Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1a2c913
feat: 피드 이미지 API 명세 구현
junseo511 May 23, 2025
3e4acd0
feat: 피드 목록 아이템 이미지 추가
junseo511 May 23, 2025
4984321
feat: 피드 상세 이미지 추가
junseo511 May 23, 2025
fc09e57
feat: 사진 갯수 제한을 위한 커스텀 ActivityResultContract 구현
junseo511 May 23, 2025
0a3caa0
feat: 사진 압축을 위한 ImageCompressor 구현
junseo511 May 23, 2025
e86a397
feat: 싱글 이벤트 처리를 위한 유틸 클래스 구현
junseo511 May 23, 2025
2a0c62d
feat: 이미지 첨부 기능 구현
junseo511 May 23, 2025
e189a2b
feat: 멀티파트 API 전송 적용
junseo511 May 23, 2025
23d112e
feat: 이미지 확대창 구현
junseo511 May 23, 2025
951396e
refactor: 컴포즈 뷰 성능 개선
junseo511 May 23, 2025
05e169a
refactor: 최대 업로드 이미지 수 변경
junseo511 May 23, 2025
9fece35
feat: 피드 수정 구현
junseo511 May 24, 2025
9107fe9
refactor: 의존성 주입 방식 변경
junseo511 May 24, 2025
98d31fa
refactor: 피드 목록 이미지 여백 수정
junseo511 May 24, 2025
1a3d12b
refactor: 멀티파트 변환 객체 이동
junseo511 May 24, 2025
8435a8a
refactor: 이미지 삭제 방식 변경
junseo511 May 24, 2025
7d6ab44
refactor: 이미지 최대 사이즈 변경
junseo511 May 25, 2025
f818cc9
refactor: stream 안정성 개선
junseo511 May 31, 2025
f1696b0
refactor: PhotoPicker 안정성 개선
junseo511 May 31, 2025
fc288b6
refactor: 불필요한 임포트 변수 제거
junseo511 May 31, 2025
b8145ec
refactor: 유틸 객체 싱글톤으로 변경
junseo511 May 31, 2025
5919fb6
refactor: 이벤트 핸들러 사용 방식 변경
junseo511 May 31, 2025
adeddc6
refactor: 기존 이미지 불러오는 순서 보장
junseo511 May 31, 2025
4e10589
refactor: 압축 예외 처리 추가
junseo511 May 31, 2025
ca130c2
refactor: 이미지 최대 장수 변경
junseo511 May 31, 2025
df3d358
refactor: 하드코딩된 변수 상수화
junseo511 Jun 1, 2025
844ca3a
refactor: 이미지 압축시 사용하는 Dispatchers 변경
junseo511 Jun 2, 2025
dea9e73
refactor: 이미지 압축시 사용하는 Dispatchers 롤백
junseo511 Jun 2, 2025
44e910b
refactor: SharedFlow를 활용하도록 이전
junseo511 Jun 2, 2025
f76797a
refactor: .asSharedFlow() 함수 활용
junseo511 Jun 2, 2025
6f4cbe0
refactor: 멀티파트 변환 함수 제네릭 적용
junseo511 Jun 2, 2025
b6ee10b
refactor: Flow collect 확장함수 적용
junseo511 Jun 2, 2025
f7dc4de
refactor: 사용하지 않는 함수 제거
junseo511 Jun 2, 2025
afdcb2f
refactor: 이미지 다운로드 병렬처리 추가
junseo511 Jun 2, 2025
1b406cc
refactor: connection 연결 차단 함수 추가
junseo511 Jun 2, 2025
eea4a8c
refactor: ExpandedFeedImageBackground 위치 이동
junseo511 Jun 2, 2025
ad6467d
refactor: 인덱스 안정성 개선
junseo511 Jun 2, 2025
5f0cd64
refactor: 피드 이미지 불러오기 로직 개선
junseo511 Jun 24, 2025
d57f570
feat: 피드 이미지 개수 표시
junseo511 Jun 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@
android:name=".ui.notificationSetting.NotificationSettingActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".ui.expandedFeedImage.ExpandedFeedImageActivity"
android:exported="false"
android:screenOrientation="portrait" />
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ object BindingAdapter {
isVectorImage: Boolean?,
isCircularImage: Boolean?,
) {
if (imageUrl.isNullOrEmpty()) {
view.setImageResource(img_loading_thumbnail)
return
}
view.load(imageUrl) {
crossfade(true)
if (isVectorImage == true) decoderFactory(SvgDecoder.Factory())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.into.websoso.core.common.util

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.activity.result.contract.ActivityResultContract
import com.into.websoso.core.common.util.DynamicLimitPhotoPicker.Input

/**
* Websoso 프로젝트에서 사용하는 커스텀 이미지 선택기입니다.
*
* 이 클래스는 이미지 여러 장을 선택할 수 있으며,
* 선택 가능한 최대 개수(maxSelectable)를 런타임에서 동적으로 지정할 수 있습니다.
*
* Android 13(API 33) 이상에서는 시스템의 PhotoPicker([MediaStore.ACTION_PICK_IMAGES])를 사용하며,
* `MediaStore.EXTRA_PICK_IMAGES_MAX`를 통해 선택 가능 개수를 제한할 수 있습니다.
*
* Android 13 미만에서는 [Intent.ACTION_OPEN_DOCUMENT]를 사용하여
* 여러 장 선택이 가능하지만 선택 개수 제한은 직접 처리해주셔야 합니다.
*
* 사용 예시:
* ```
* val pickerLauncher = registerForActivityResult(DynamicLimitPhotoPicker()) { uris ->
* // 선택된 이미지 URI 목록을 처리합니다
* }
*
* pickerLauncher.launch(DynamicLimitPhotoPicker.Input(maxSelectable = 3))
* ```
*
* @return 선택된 이미지들의 [Uri] 목록을 반환합니다.
* @see Input 선택 가능한 최대 개수를 설정할 수 있는 입력 파라미터입니다.
*/
class DynamicLimitPhotoPicker : ActivityResultContract<Input, List<Uri>>() {
data class Input(
val maxSelectable: Int,
)
Comment on lines +37 to +39
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a: 추후에 프로퍼티가 추가될 수 있어서 data class가 필요한건가요?

Copy link
Copy Markdown
Member Author

@junseo511 junseo511 May 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a: 추후에 프로퍼티가 추가될 수 있어서 data class가 필요한건가요?

커스텀된 객체임을 감안하면 남겨두는게 좋을 것 같아요 👍


override fun createIntent(
context: Context,
input: Input,
): Intent =
if (Build.VERSION.SDK_INT >= 33) {
Intent(MediaStore.ACTION_PICK_IMAGES).apply {
type = MINE_TYPE
putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, input.maxSelectable)
}
} else {
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = MINE_TYPE
addCategory(Intent.CATEGORY_OPENABLE)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
}

override fun parseResult(
resultCode: Int,
intent: Intent?,
): List<Uri> {
if (resultCode != Activity.RESULT_OK || intent == null) return emptyList()

val uris = mutableListOf<Uri>()

intent.clipData?.let { clip ->
for (i in 0 until clip.itemCount) {
clip.getItemAt(i).uri?.let { uris.add(it) }
}
}

intent.data?.let { uris.add(it) }

return uris
}

companion object {
private const val MINE_TYPE: String = "image/*"
}
}
17 changes: 17 additions & 0 deletions app/src/main/java/com/into/websoso/core/common/util/Event.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.into.websoso.core.common.util

open class Event<out T>(
Comment thread
junseo511 marked this conversation as resolved.
Outdated
private val content: T,
) {
private var hasBeenHandled = false

fun getContentIfNotHandled(): T? =
if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}

fun peekContent(): T = content
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.into.websoso.core.common.util

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ImageCompressor
Comment thread
junseo511 marked this conversation as resolved.
@Inject
constructor(
@ApplicationContext private val context: Context,
) {
suspend fun compressUris(
uris: List<Uri>,
size: Double = DEFAULT_MAX_IMAGE_SIZE,
): List<Uri> =
withContext(Dispatchers.IO) {
uris.mapNotNull { uri ->
runCatching {
val inputStream = context.contentResolver.openInputStream(uri)
val bitmap = inputStream?.use { BitmapFactory.decodeStream(it) } ?: return@runCatching null

var quality = INITIAL_QUALITY
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)

while (outputStream.size() > (size * MB) && quality > QUALITY_DECREMENT_STEP) {
quality -= QUALITY_DECREMENT_STEP
outputStream.reset()
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
}

val compressedFile = File.createTempFile("compressed_", ".jpg", context.cacheDir)
FileOutputStream(compressedFile).use {
it.write(outputStream.toByteArray())
}

outputStream.close()
Uri.fromFile(compressedFile)
}.onFailure {
it.printStackTrace()
}.getOrNull()
}
}

companion object {
private const val INITIAL_QUALITY: Int = 100
private const val QUALITY_DECREMENT_STEP: Int = 5
private const val DEFAULT_MAX_IMAGE_SIZE: Double = 0.25
private const val MB: Double = (1024 * 1024).toDouble()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.into.websoso.core.common.util

import kotlinx.coroutines.flow.MutableStateFlow

class MutableSingleStateFlow<T>(
Comment thread
junseo511 marked this conversation as resolved.
Outdated
private val _stateFlow: MutableStateFlow<Event<T>?> = MutableStateFlow(null),
) : SingleStateFlow<T> by _stateFlow {
fun emit(value: T) {
_stateFlow.value = Event(value)
}

fun clear() {
_stateFlow.value = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.into.websoso.core.common.util

import kotlinx.coroutines.flow.StateFlow

typealias SingleStateFlow<T> = StateFlow<Event<T>?>
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ package com.into.websoso.data.di

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.into.websoso.data.remote.api.FeedApi
import com.into.websoso.data.remote.api.NovelApi
import com.into.websoso.data.remote.api.PushMessageApi
import com.into.websoso.data.remote.api.UserApi
import com.into.websoso.data.remote.api.VersionApi
import com.into.websoso.data.repository.AuthRepository
import com.into.websoso.data.repository.FeedRepository
import com.into.websoso.data.repository.NovelRepository
import com.into.websoso.data.repository.PushMessageRepository
import com.into.websoso.data.repository.UserRepository
Expand All @@ -22,10 +20,6 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
@Provides
@Singleton
fun provideFeedRepository(feedApi: FeedApi): FeedRepository = FeedRepository(feedApi)

@Provides
@Singleton
fun provideUserRepository(
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/into/websoso/data/mapper/FeedMapper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ fun FeedResponseDto.toData(): FeedEntity =
isSpoiler = isSpoiler,
isMyFeed = isMyFeed,
isPublic = isPublic,
images = thumbnailUrl?.let { listOf(it) } ?: emptyList(),
imageCount = imageCount,
novel = NovelEntity(
id = novelId,
title = title,
Expand Down Expand Up @@ -90,6 +92,8 @@ fun FeedDetailResponseDto.toData(): FeedEntity =
isSpoiler = isSpoiler,
isMyFeed = isMyFeed,
isPublic = isPublic,
images = images,
imageCount = images.size,
novel = NovelEntity(
id = novelId,
title = title,
Expand Down
37 changes: 37 additions & 0 deletions app/src/main/java/com/into/websoso/data/mapper/MultiPartMapper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.into.websoso.data.mapper

import android.content.Context
import android.net.Uri
import com.into.websoso.data.remote.request.FeedRequestDto
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream
import javax.inject.Inject

class MultiPartMapper
@Inject
constructor(
@ApplicationContext private val context: Context,
) {
fun formatToMultipart(feedRequestDto: FeedRequestDto): MultipartBody.Part {
Comment thread
junseo511 marked this conversation as resolved.
Outdated
val json = Json.encodeToString(feedRequestDto)
val requestBody = json.toRequestBody("application/json".toMediaType())
return MultipartBody.Part.createFormData("feed", "feed.json", requestBody)
}

fun formatToMultipart(uri: Uri): MultipartBody.Part {
val inputStream: InputStream = context.contentResolver.openInputStream(uri)
?: throw IllegalArgumentException("유효하지 않은 URI: $uri")

return inputStream.use { stream ->
val bytes = stream.readBytes()
val fileName = uri.lastPathSegment ?: "image.jpg"
val requestBody = bytes.toRequestBody("image/*".toMediaType())
MultipartBody.Part.createFormData("images", fileName, requestBody)
}
}
}
2 changes: 2 additions & 0 deletions app/src/main/java/com/into/websoso/data/model/FeedEntity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ data class FeedEntity(
val isMyFeed: Boolean,
val isPublic: Boolean,
val novel: NovelEntity,
val images: List<String>,
val imageCount: Int,
) {
data class UserEntity(
val id: Long,
Expand Down
12 changes: 9 additions & 3 deletions app/src/main/java/com/into/websoso/data/remote/api/FeedApi.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
package com.into.websoso.data.remote.api

import com.into.websoso.data.remote.request.CommentRequestDto
import com.into.websoso.data.remote.request.FeedRequestDto
import com.into.websoso.data.remote.response.CommentsResponseDto
import com.into.websoso.data.remote.response.FeedDetailResponseDto
import com.into.websoso.data.remote.response.FeedsResponseDto
import com.into.websoso.data.remote.response.PopularFeedsResponseDto
import com.into.websoso.data.remote.response.UserInterestFeedsResponseDto
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query

Expand All @@ -23,15 +25,19 @@ interface FeedApi {
@Query("size") size: Int,
): FeedsResponseDto

@Multipart
@POST("feeds")
suspend fun postFeed(
@Body feedRequestDto: FeedRequestDto,
@Part feedRequestDto: MultipartBody.Part,
@Part images: List<MultipartBody.Part>?,
)

@Multipart
@PUT("feeds/{feedId}")
suspend fun putFeed(
@Path("feedId") feedId: Long,
@Body feedRequestDto: FeedRequestDto,
@Part feedRequestDto: MultipartBody.Part,
@Part images: List<MultipartBody.Part>?,
)

@GET("feeds/{feedId}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ data class FeedDetailResponseDto(
val isMyFeed: Boolean,
@SerialName("isPublic")
val isPublic: Boolean,
@SerialName("images")
val images: List<String>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,8 @@ data class FeedResponseDto(
val isMyFeed: Boolean,
@SerialName("isPublic")
val isPublic: Boolean,
@SerialName("thumbnailUrl")
val thumbnailUrl: String?,
@SerialName("imageCount")
val imageCount: Int,
)
Loading
Loading