Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
71 changes: 57 additions & 14 deletions core/ui/src/main/java/com/twix/ui/image/ImageGenerator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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://)
Expand All @@ -40,9 +40,7 @@ class ImageGenerator(
else -> bitmap
}

/**
* 회전된 새로운 비트맵이 생성되었다면 원본은 즉시 해제
* */
// 회전된 새 비트맵이 생성된 경우 원본 즉시 해제
if (rotatedBitmap !== bitmap) bitmap.recycle()
bitmapToByteArray(rotatedBitmap)
} catch (e: Exception) {
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -102,9 +102,10 @@ class CaptureCamera(
contentValues,
).build()

private fun capture(continuation: Continuation<Result<Uri>>): ImageCapture.OnImageSavedCallback =
private fun capture(continuation: CancellableContinuation<Result<Uri>>): 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))
Expand All @@ -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) {
Expand Down
Loading