Skip to content

Commit c46d2ae

Browse files
authored
Merge pull request #120 from YAPP-Github/feat/#119-photolog-upload-modifiy
인증샷 샘플링 로직 추가
2 parents 7841567 + 662f4cf commit c46d2ae

File tree

5 files changed

+86
-65
lines changed

5 files changed

+86
-65
lines changed

core/design-system/src/main/java/com/twix/designsystem/components/comment/model/CommentUiModel.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@ data class CommentUiModel(
1616
value.isNotEmpty() &&
1717
hasMaxCommentLength
1818

19-
fun updateComment(newComment: String): CommentUiModel = copy(value = newComment)
20-
21-
fun updateFocus(isFocused: Boolean) = copy(isFocused = isFocused)
22-
2319
companion object {
2420
const val COMMENT_COUNT = 5
2521
}

core/ui/src/main/java/com/twix/ui/image/ImageGenerator.kt

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ class ImageGenerator(
1616
* 주어진 [Uri]로부터 이미지를 읽어 JPEG 형식의 [ByteArray]로 변환한다.
1717
*
1818
* 내부 동작 과정:
19-
* 1. [android.content.ContentResolver.openInputStream]으로 InputStream을 연다.
20-
* 2. [android.graphics.BitmapFactory.decodeStream]으로 Bitmap 디코딩
21-
* 3. JPEG(품질 90) 압축 후 ByteArray 반환
19+
* 1. EXIF 메타데이터로 회전 방향 확인
20+
* 2. [Uri]로부터 Bitmap 디코딩 (메모리 최적화를 위해 샘플링 적용)
21+
* 3. 필요 시 회전 처리 후 JPEG(품질 90) 압축하여 [ByteArray] 반환
2222
*
2323
* 실패 케이스:
2424
* - InputStream 열기 실패
25-
* - 디코딩 실패 (손상 이미지 등)
25+
* - 디코딩 실패 (손상된 이미지 등)
2626
* - 압축 실패
2727
*
2828
* @param imageUri 변환할 이미지 Uri (content:// 또는 file://)
@@ -40,9 +40,7 @@ class ImageGenerator(
4040
else -> bitmap
4141
}
4242

43-
/**
44-
* 회전된 새로운 비트맵이 생성되었다면 원본은 즉시 해제
45-
* */
43+
// 회전된 새 비트맵이 생성된 경우 원본 즉시 해제
4644
if (rotatedBitmap !== bitmap) bitmap.recycle()
4745
bitmapToByteArray(rotatedBitmap)
4846
} catch (e: Exception) {
@@ -51,23 +49,68 @@ class ImageGenerator(
5149
}
5250

5351
/**
54-
* [Uri] 로부터 실제 [Bitmap] 을 디코딩한다.
52+
* [Uri]로부터 [Bitmap]을 디코딩한다.
5553
*
56-
* 새로운 InputStream을 열어 [BitmapFactory.decodeStream] 으로 변환한다.
54+
* 메모리 사용량을 줄이기 위해 두 단계로 디코딩한다.
55+
* 1. [BitmapFactory.Options.inJustDecodeBounds]로 이미지 크기만 먼저 읽기
56+
* 2. [calculateInSampleSize]로 샘플 크기를 계산한 뒤 실제 디코딩
57+
*
58+
* @throws ImageProcessException.DecodeFailedException 디코딩 실패 시
5759
*/
58-
private fun uriToBitmap(imageUri: Uri): Bitmap =
60+
private fun uriToBitmap(imageUri: Uri): Bitmap {
61+
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
5962
contentResolver.openInputStream(imageUri)?.use { inputStream ->
60-
BitmapFactory.decodeStream(inputStream)
63+
BitmapFactory.decodeStream(inputStream, null, bounds)
64+
}
65+
66+
val options =
67+
BitmapFactory.Options().apply {
68+
inSampleSize = calculateInSampleSize(bounds, 1920, 1080)
69+
}
70+
71+
return contentResolver.openInputStream(imageUri)?.use { inputStream ->
72+
BitmapFactory.decodeStream(inputStream, null, options)
6173
} ?: throw ImageProcessException.DecodeFailedException(imageUri)
74+
}
75+
76+
/**
77+
* 목표 해상도([reqWidth] x [reqHeight])에 맞는 최적의 [BitmapFactory.Options.inSampleSize]를 계산한다.
78+
*
79+
* 반환값은 2의 거듭제곱이며, 디코딩된 이미지가 목표 해상도보다 작아지지 않는 최대값을 반환한다.
80+
*
81+
* @param options outWidth, outHeight가 채워진 [BitmapFactory.Options]
82+
* @param reqWidth 목표 너비 (px)
83+
* @param reqHeight 목표 높이 (px)
84+
* @return 계산된 inSampleSize (최솟값 1)
85+
*/
86+
fun calculateInSampleSize(
87+
options: BitmapFactory.Options,
88+
reqWidth: Int,
89+
reqHeight: Int,
90+
): Int {
91+
val (height: Int, width: Int) = options.run { outHeight to outWidth }
92+
var inSampleSize = 1
93+
94+
if (height > reqHeight || width > reqWidth) {
95+
val halfHeight: Int = height / 2
96+
val halfWidth: Int = width / 2
97+
98+
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
99+
inSampleSize *= 2
100+
}
101+
}
102+
103+
return inSampleSize
104+
}
62105

63106
/**
64-
* [Bitmap] 을 JPEG 형식(품질 90)으로 압축하여 [ByteArray] 로 변환한다.
107+
* [Bitmap]을 JPEG 형식(품질 90)으로 압축하여 [ByteArray]로 변환한다.
65108
*
66-
* 압축 완료 후 메모리 절약을 위해 내부에서 [Bitmap.recycle] 을 호출한다.
67-
* 따라서 호출 이후 전달한 Bitmap은 재사용하면 안 된다.
109+
* 압축 완료 후 [Bitmap.recycle]을 호출하므로, 이후 해당 [Bitmap]을 재사용해선 안 된다.
68110
*
69111
* @param bitmap 압축 대상 Bitmap
70112
* @return JPEG 바이트 배열
113+
* @throws ImageProcessException.CompressionFailedException 압축 실패 시
71114
*/
72115
private fun bitmapToByteArray(bitmap: Bitmap): ByteArray {
73116
val outputStream = ByteArrayOutputStream()

feature/photolog/capture/src/main/java/com/twix/photolog/capture/PhotologCaptureViewModel.kt

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.twix.photolog.capture
22

33
import android.net.Uri
4+
import androidx.camera.core.CameraSelector
45
import androidx.lifecycle.SavedStateHandle
56
import androidx.lifecycle.viewModelScope
67
import com.twix.designsystem.R
@@ -15,6 +16,7 @@ import com.twix.photolog.capture.contract.PhotologCaptureIntent
1516
import com.twix.photolog.capture.contract.PhotologCaptureSideEffect
1617
import com.twix.photolog.capture.contract.PhotologCaptureUiState
1718
import com.twix.photolog.capture.model.CaptureStatus
19+
import com.twix.photolog.capture.model.TorchStatus
1820
import com.twix.ui.base.BaseViewModel
1921
import com.twix.ui.image.ImageGenerator
2022
import com.twix.util.bus.GoalRefreshBus
@@ -71,30 +73,42 @@ class PhotologCaptureViewModel(
7173
}
7274

7375
private fun reducePicture(uri: Uri) {
74-
reduce { updatePicture(uri) }
76+
reduce {
77+
copy(
78+
capture = CaptureStatus.Captured(uri),
79+
torch = TorchStatus.Off,
80+
)
81+
}
7582
if (uiState.value.hasMaxCommentLength.not()) {
7683
reduceCommentFocus(true)
7784
}
7885
}
7986

8087
private fun reduceLens() {
81-
reduce { toggleLens() }
88+
val newLens =
89+
if (currentState.lens == CameraSelector.DEFAULT_BACK_CAMERA) {
90+
CameraSelector.DEFAULT_FRONT_CAMERA
91+
} else {
92+
CameraSelector.DEFAULT_BACK_CAMERA
93+
}
94+
95+
reduce { copy(lens = newLens, torch = TorchStatus.Off) }
8296
}
8397

8498
private fun reduceTorch() {
85-
reduce { toggleTorch() }
99+
reduce { copy(torch = TorchStatus.toggle(torch)) }
86100
}
87101

88102
private fun setupRetake() {
89-
reduce { removePicture() }
103+
reduce { copy(capture = CaptureStatus.NotCaptured) }
90104
}
91105

92-
private fun reduceComment(comment: String) {
93-
reduce { updateComment(comment) }
106+
private fun reduceComment(newComment: String) {
107+
reduce { copy(comment = comment.copy(value = newComment)) }
94108
}
95109

96110
private fun reduceCommentFocus(isFocused: Boolean) {
97-
reduce { updateCommentFocus(isFocused) }
111+
reduce { copy(comment = comment.copy(isFocused = isFocused)) }
98112
}
99113

100114
private fun handleUploadIntent() {
@@ -120,9 +134,9 @@ class PhotologCaptureViewModel(
120134
private fun showValidationError() {
121135
viewModelScope.launch {
122136
if (!currentState.comment.canUpload) {
123-
reduce { showCommentError() }
137+
reduce { copy(showCommentError = true) }
124138
delay(ERROR_DISPLAY_DURATION_MS)
125-
reduce { hideCommentError() }
139+
reduce { copy(showCommentError = false) }
126140
}
127141
}
128142
}
Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.twix.photolog.capture.contract
22

3-
import android.net.Uri
43
import androidx.camera.core.CameraSelector
54
import androidx.compose.runtime.Immutable
65
import com.twix.designsystem.components.comment.model.CommentUiModel
@@ -24,38 +23,4 @@ data class PhotologCaptureUiState(
2423

2524
val showTorch: Boolean
2625
get() = capture is CaptureStatus.NotCaptured && lens == CameraSelector.DEFAULT_BACK_CAMERA
27-
28-
fun toggleLens(): PhotologCaptureUiState {
29-
val newLens =
30-
if (lens == CameraSelector.DEFAULT_BACK_CAMERA) {
31-
CameraSelector.DEFAULT_FRONT_CAMERA
32-
} else {
33-
CameraSelector.DEFAULT_BACK_CAMERA
34-
}
35-
return copy(
36-
lens = newLens,
37-
torch = TorchStatus.Off,
38-
)
39-
}
40-
41-
fun toggleTorch(): PhotologCaptureUiState {
42-
val newFlashMode = TorchStatus.Companion.toggle(torch)
43-
return copy(torch = newFlashMode)
44-
}
45-
46-
fun updatePicture(uri: Uri): PhotologCaptureUiState =
47-
copy(
48-
capture = CaptureStatus.Captured(uri),
49-
torch = TorchStatus.Off,
50-
)
51-
52-
fun removePicture(): PhotologCaptureUiState = copy(capture = CaptureStatus.NotCaptured)
53-
54-
fun updateComment(newComment: String) = copy(comment = comment.updateComment(newComment))
55-
56-
fun updateCommentFocus(isFocused: Boolean) = copy(comment = comment.updateFocus(isFocused))
57-
58-
fun showCommentError() = copy(showCommentError = true)
59-
60-
fun hideCommentError() = copy(showCommentError = false)
6126
}

feature/photolog/capture/src/main/java/com/twix/photolog/capture/model/camera/CaptureCamera.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ import androidx.camera.lifecycle.awaitInstance
1616
import androidx.core.content.ContextCompat
1717
import androidx.lifecycle.LifecycleOwner
1818
import com.twix.photolog.capture.model.TorchStatus
19+
import kotlinx.coroutines.CancellableContinuation
1920
import kotlinx.coroutines.flow.MutableStateFlow
2021
import kotlinx.coroutines.flow.StateFlow
2122
import kotlinx.coroutines.flow.asStateFlow
2223
import kotlinx.coroutines.suspendCancellableCoroutine
23-
import kotlin.coroutines.Continuation
2424
import kotlin.coroutines.resume
2525

2626
class CaptureCamera(
@@ -53,7 +53,7 @@ class CaptureCamera(
5353
lifecycleOwner: LifecycleOwner,
5454
lens: CameraSelector,
5555
) {
56-
val provider = ProcessCameraProvider.awaitInstance(context)
56+
val provider = cameraProvider ?: ProcessCameraProvider.awaitInstance(context)
5757
cameraProvider = provider
5858

5959
provider.unbindAll()
@@ -102,9 +102,10 @@ class CaptureCamera(
102102
contentValues,
103103
).build()
104104

105-
private fun capture(continuation: Continuation<Result<Uri>>): ImageCapture.OnImageSavedCallback =
105+
private fun capture(continuation: CancellableContinuation<Result<Uri>>): ImageCapture.OnImageSavedCallback =
106106
object : ImageCapture.OnImageSavedCallback {
107107
override fun onImageSaved(result: ImageCapture.OutputFileResults) {
108+
if (continuation.isActive.not()) return
108109
val uri = result.savedUri
109110
if (uri != null) {
110111
continuation.resume(Result.success(uri))
@@ -116,12 +117,14 @@ class CaptureCamera(
116117
}
117118

118119
override fun onError(exception: ImageCaptureException) {
120+
if (continuation.isActive.not()) return
119121
continuation.resume(Result.failure(exception))
120122
}
121123
}
122124

123125
override fun unbind() {
124126
cameraProvider?.unbindAll()
127+
_surfaceRequests.value = null
125128
}
126129

127130
override fun toggleTorch(torch: TorchStatus) {

0 commit comments

Comments
 (0)