diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/comment/model/CommentUiModel.kt b/core/design-system/src/main/java/com/twix/designsystem/components/comment/model/CommentUiModel.kt index 284c0bad..509df0f2 100644 --- a/core/design-system/src/main/java/com/twix/designsystem/components/comment/model/CommentUiModel.kt +++ b/core/design-system/src/main/java/com/twix/designsystem/components/comment/model/CommentUiModel.kt @@ -16,10 +16,6 @@ data class CommentUiModel( value.isNotEmpty() && hasMaxCommentLength - fun updateComment(newComment: String): CommentUiModel = copy(value = newComment) - - fun updateFocus(isFocused: Boolean) = copy(isFocused = isFocused) - companion object { const val COMMENT_COUNT = 5 } diff --git a/core/ui/src/main/java/com/twix/ui/image/ImageGenerator.kt b/core/ui/src/main/java/com/twix/ui/image/ImageGenerator.kt index 1b4d923e..ee7a5953 100644 --- a/core/ui/src/main/java/com/twix/ui/image/ImageGenerator.kt +++ b/core/ui/src/main/java/com/twix/ui/image/ImageGenerator.kt @@ -16,13 +16,13 @@ class ImageGenerator( * 주어진 [Uri]로부터 이미지를 읽어 JPEG 형식의 [ByteArray]로 변환한다. * * 내부 동작 과정: - * 1. [android.content.ContentResolver.openInputStream]으로 InputStream을 연다. - * 2. [android.graphics.BitmapFactory.decodeStream]으로 Bitmap 디코딩 - * 3. JPEG(품질 90) 압축 후 ByteArray 반환 + * 1. EXIF 메타데이터로 회전 방향 확인 + * 2. [Uri]로부터 Bitmap 디코딩 (메모리 최적화를 위해 샘플링 적용) + * 3. 필요 시 회전 처리 후 JPEG(품질 90) 압축하여 [ByteArray] 반환 * * 실패 케이스: * - InputStream 열기 실패 - * - 디코딩 실패 (손상 이미지 등) + * - 디코딩 실패 (손상된 이미지 등) * - 압축 실패 * * @param imageUri 변환할 이미지 Uri (content:// 또는 file://) @@ -40,9 +40,7 @@ class ImageGenerator( else -> bitmap } - /** - * 회전된 새로운 비트맵이 생성되었다면 원본은 즉시 해제 - * */ + // 회전된 새 비트맵이 생성된 경우 원본 즉시 해제 if (rotatedBitmap !== bitmap) bitmap.recycle() bitmapToByteArray(rotatedBitmap) } catch (e: Exception) { @@ -51,23 +49,68 @@ class ImageGenerator( } /** - * [Uri] 로부터 실제 [Bitmap] 을 디코딩한다. + * [Uri]로부터 [Bitmap]을 디코딩한다. * - * 새로운 InputStream을 열어 [BitmapFactory.decodeStream] 으로 변환한다. + * 메모리 사용량을 줄이기 위해 두 단계로 디코딩한다. + * 1. [BitmapFactory.Options.inJustDecodeBounds]로 이미지 크기만 먼저 읽기 + * 2. [calculateInSampleSize]로 샘플 크기를 계산한 뒤 실제 디코딩 + * + * @throws ImageProcessException.DecodeFailedException 디코딩 실패 시 */ - private fun uriToBitmap(imageUri: Uri): Bitmap = + private fun uriToBitmap(imageUri: Uri): Bitmap { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } contentResolver.openInputStream(imageUri)?.use { inputStream -> - BitmapFactory.decodeStream(inputStream) + BitmapFactory.decodeStream(inputStream, null, bounds) + } + + val options = + BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds, 1920, 1080) + } + + return contentResolver.openInputStream(imageUri)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, options) } ?: throw ImageProcessException.DecodeFailedException(imageUri) + } + + /** + * 목표 해상도([reqWidth] x [reqHeight])에 맞는 최적의 [BitmapFactory.Options.inSampleSize]를 계산한다. + * + * 반환값은 2의 거듭제곱이며, 디코딩된 이미지가 목표 해상도보다 작아지지 않는 최대값을 반환한다. + * + * @param options outWidth, outHeight가 채워진 [BitmapFactory.Options] + * @param reqWidth 목표 너비 (px) + * @param reqHeight 목표 높이 (px) + * @return 계산된 inSampleSize (최솟값 1) + */ + fun calculateInSampleSize( + options: BitmapFactory.Options, + reqWidth: Int, + reqHeight: Int, + ): Int { + val (height: Int, width: Int) = options.run { outHeight to outWidth } + var inSampleSize = 1 + + if (height > reqHeight || width > reqWidth) { + val halfHeight: Int = height / 2 + val halfWidth: Int = width / 2 + + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize + } /** - * [Bitmap] 을 JPEG 형식(품질 90)으로 압축하여 [ByteArray] 로 변환한다. + * [Bitmap]을 JPEG 형식(품질 90)으로 압축하여 [ByteArray]로 변환한다. * - * 압축 완료 후 메모리 절약을 위해 내부에서 [Bitmap.recycle] 을 호출한다. - * 따라서 호출 이후 전달한 Bitmap은 재사용하면 안 된다. + * 압축 완료 후 [Bitmap.recycle]을 호출하므로, 이후 해당 [Bitmap]을 재사용해선 안 된다. * * @param bitmap 압축 대상 Bitmap * @return JPEG 바이트 배열 + * @throws ImageProcessException.CompressionFailedException 압축 실패 시 */ private fun bitmapToByteArray(bitmap: Bitmap): ByteArray { val outputStream = ByteArrayOutputStream() diff --git a/feature/photolog/capture/src/main/java/com/twix/photolog/capture/PhotologCaptureViewModel.kt b/feature/photolog/capture/src/main/java/com/twix/photolog/capture/PhotologCaptureViewModel.kt index da1c8d1e..88a815e3 100644 --- a/feature/photolog/capture/src/main/java/com/twix/photolog/capture/PhotologCaptureViewModel.kt +++ b/feature/photolog/capture/src/main/java/com/twix/photolog/capture/PhotologCaptureViewModel.kt @@ -1,6 +1,7 @@ package com.twix.photolog.capture import android.net.Uri +import androidx.camera.core.CameraSelector import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.twix.designsystem.R @@ -15,6 +16,7 @@ import com.twix.photolog.capture.contract.PhotologCaptureIntent import com.twix.photolog.capture.contract.PhotologCaptureSideEffect import com.twix.photolog.capture.contract.PhotologCaptureUiState import com.twix.photolog.capture.model.CaptureStatus +import com.twix.photolog.capture.model.TorchStatus import com.twix.ui.base.BaseViewModel import com.twix.ui.image.ImageGenerator import com.twix.util.bus.GoalRefreshBus @@ -71,30 +73,42 @@ class PhotologCaptureViewModel( } private fun reducePicture(uri: Uri) { - reduce { updatePicture(uri) } + reduce { + copy( + capture = CaptureStatus.Captured(uri), + torch = TorchStatus.Off, + ) + } if (uiState.value.hasMaxCommentLength.not()) { reduceCommentFocus(true) } } private fun reduceLens() { - reduce { toggleLens() } + val newLens = + if (currentState.lens == CameraSelector.DEFAULT_BACK_CAMERA) { + CameraSelector.DEFAULT_FRONT_CAMERA + } else { + CameraSelector.DEFAULT_BACK_CAMERA + } + + reduce { copy(lens = newLens, torch = TorchStatus.Off) } } private fun reduceTorch() { - reduce { toggleTorch() } + reduce { copy(torch = TorchStatus.toggle(torch)) } } private fun setupRetake() { - reduce { removePicture() } + reduce { copy(capture = CaptureStatus.NotCaptured) } } - private fun reduceComment(comment: String) { - reduce { updateComment(comment) } + private fun reduceComment(newComment: String) { + reduce { copy(comment = comment.copy(value = newComment)) } } private fun reduceCommentFocus(isFocused: Boolean) { - reduce { updateCommentFocus(isFocused) } + reduce { copy(comment = comment.copy(isFocused = isFocused)) } } private fun handleUploadIntent() { @@ -120,9 +134,9 @@ class PhotologCaptureViewModel( private fun showValidationError() { viewModelScope.launch { if (!currentState.comment.canUpload) { - reduce { showCommentError() } + reduce { copy(showCommentError = true) } delay(ERROR_DISPLAY_DURATION_MS) - reduce { hideCommentError() } + reduce { copy(showCommentError = false) } } } } diff --git a/feature/photolog/capture/src/main/java/com/twix/photolog/capture/contract/PhotologCaptureUiState.kt b/feature/photolog/capture/src/main/java/com/twix/photolog/capture/contract/PhotologCaptureUiState.kt index 3b7dfd92..2ef9ee51 100644 --- a/feature/photolog/capture/src/main/java/com/twix/photolog/capture/contract/PhotologCaptureUiState.kt +++ b/feature/photolog/capture/src/main/java/com/twix/photolog/capture/contract/PhotologCaptureUiState.kt @@ -1,6 +1,5 @@ package com.twix.photolog.capture.contract -import android.net.Uri import androidx.camera.core.CameraSelector import androidx.compose.runtime.Immutable import com.twix.designsystem.components.comment.model.CommentUiModel @@ -24,38 +23,4 @@ data class PhotologCaptureUiState( val showTorch: Boolean get() = capture is CaptureStatus.NotCaptured && lens == CameraSelector.DEFAULT_BACK_CAMERA - - fun toggleLens(): PhotologCaptureUiState { - val newLens = - if (lens == CameraSelector.DEFAULT_BACK_CAMERA) { - CameraSelector.DEFAULT_FRONT_CAMERA - } else { - CameraSelector.DEFAULT_BACK_CAMERA - } - return copy( - lens = newLens, - torch = TorchStatus.Off, - ) - } - - fun toggleTorch(): PhotologCaptureUiState { - val newFlashMode = TorchStatus.Companion.toggle(torch) - return copy(torch = newFlashMode) - } - - fun updatePicture(uri: Uri): PhotologCaptureUiState = - copy( - capture = CaptureStatus.Captured(uri), - torch = TorchStatus.Off, - ) - - fun removePicture(): PhotologCaptureUiState = copy(capture = CaptureStatus.NotCaptured) - - fun updateComment(newComment: String) = copy(comment = comment.updateComment(newComment)) - - fun updateCommentFocus(isFocused: Boolean) = copy(comment = comment.updateFocus(isFocused)) - - fun showCommentError() = copy(showCommentError = true) - - fun hideCommentError() = copy(showCommentError = false) } diff --git a/feature/photolog/capture/src/main/java/com/twix/photolog/capture/model/camera/CaptureCamera.kt b/feature/photolog/capture/src/main/java/com/twix/photolog/capture/model/camera/CaptureCamera.kt index 77b3c955..d5dbd117 100644 --- a/feature/photolog/capture/src/main/java/com/twix/photolog/capture/model/camera/CaptureCamera.kt +++ b/feature/photolog/capture/src/main/java/com/twix/photolog/capture/model/camera/CaptureCamera.kt @@ -16,11 +16,11 @@ import androidx.camera.lifecycle.awaitInstance import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import com.twix.photolog.capture.model.TorchStatus +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.Continuation import kotlin.coroutines.resume class CaptureCamera( @@ -53,7 +53,7 @@ class CaptureCamera( lifecycleOwner: LifecycleOwner, lens: CameraSelector, ) { - val provider = ProcessCameraProvider.awaitInstance(context) + val provider = cameraProvider ?: ProcessCameraProvider.awaitInstance(context) cameraProvider = provider provider.unbindAll() @@ -102,9 +102,10 @@ class CaptureCamera( contentValues, ).build() - private fun capture(continuation: Continuation>): ImageCapture.OnImageSavedCallback = + private fun capture(continuation: CancellableContinuation>): ImageCapture.OnImageSavedCallback = object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(result: ImageCapture.OutputFileResults) { + if (continuation.isActive.not()) return val uri = result.savedUri if (uri != null) { continuation.resume(Result.success(uri)) @@ -116,12 +117,14 @@ class CaptureCamera( } override fun onError(exception: ImageCaptureException) { + if (continuation.isActive.not()) return continuation.resume(Result.failure(exception)) } } override fun unbind() { cameraProvider?.unbindAll() + _surfaceRequests.value = null } override fun toggleTorch(torch: TorchStatus) {