Skip to content

Commit 4f07099

Browse files
authored
Merge pull request #257 from YAPP-Github/BOOK-491-feature/#251
feat: OCR 인식에 갤러리 이미지 선택 기능 추가
2 parents c70b2e3 + c576319 commit 4f07099

14 files changed

Lines changed: 896 additions & 436 deletions

File tree

core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedButton.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
2626
import androidx.compose.ui.Alignment
2727
import androidx.compose.ui.Modifier
2828
import androidx.compose.ui.graphics.graphicsLayer
29+
import androidx.compose.ui.text.style.TextOverflow
2930
import androidx.compose.ui.unit.dp
3031
import com.ninecraft.booket.core.common.utils.MultipleEventsCutter
3132
import com.ninecraft.booket.core.common.utils.get
@@ -93,6 +94,8 @@ fun ReedButton(
9394

9495
Text(
9596
text = text,
97+
overflow = TextOverflow.Ellipsis,
98+
maxLines = 1,
9699
style = sizeStyle.textStyle.copy(
97100
color = if (enabled) colorStyle.contentColor() else colorStyle.disabledContentColor(),
98101
),
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24">
6+
<path
7+
android:pathData="M19,2.25C19.729,2.25 20.429,2.54 20.944,3.056C21.46,3.571 21.75,4.271 21.75,5V19C21.75,19.729 21.46,20.429 20.944,20.944C20.429,21.46 19.729,21.75 19,21.75H5C4.271,21.75 3.571,21.46 3.056,20.944C2.54,20.429 2.25,19.729 2.25,19V5C2.25,4.271 2.54,3.571 3.056,3.056C3.571,2.54 4.271,2.25 5,2.25H19ZM8.395,13.096C8.302,13.022 8.17,13.023 8.079,13.099L3.75,16.706V19C3.75,19.331 3.882,19.649 4.116,19.884C4.351,20.118 4.668,20.25 5,20.25H19C19.331,20.25 19.649,20.118 19.884,19.884C20.118,19.649 20.25,19.331 20.25,19V18.713L16.799,15.492C16.705,15.405 16.559,15.403 16.463,15.488L15.161,16.646C14.524,17.212 13.571,17.237 12.905,16.704L8.395,13.096ZM5,3.75C4.668,3.75 4.351,3.882 4.116,4.116C3.882,4.351 3.75,4.668 3.75,5V14.753L7.118,11.946C7.757,11.414 8.683,11.405 9.332,11.925L13.842,15.532C13.937,15.608 14.073,15.605 14.164,15.524L15.466,14.367C16.141,13.767 17.163,13.779 17.823,14.396L20.25,16.66V5C20.25,4.668 20.118,4.351 19.884,4.116C19.649,3.882 19.331,3.75 19,3.75H5ZM16,7C17.105,7 18,7.895 18,9C18,10.105 17.105,11 16,11C14.895,11 14,10.105 14,9C14,7.895 14.895,7 16,7Z"
8+
android:fillColor="#ffffff"/>
9+
</vector>

core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.ninecraft.booket.core.ocr.recognizer
22

3+
import android.content.Context
34
import android.net.Uri
45
import android.util.Base64
56
import com.ninecraft.booket.core.common.utils.runSuspendCatching
7+
import com.ninecraft.booket.core.di.ApplicationContext
68
import com.ninecraft.booket.core.ocr.BuildConfig
79
import com.ninecraft.booket.core.ocr.model.AnnotateImageRequest
810
import com.ninecraft.booket.core.ocr.model.CloudVisionRequest
@@ -21,13 +23,22 @@ import com.ninecraft.booket.core.di.DataScope
2123
@SingleIn(DataScope::class)
2224
@Inject
2325
class CloudOcrRecognizer(
26+
@ApplicationContext private val context: Context,
2427
private val service: CloudVisionService,
2528
) {
2629
suspend fun recognizeText(imageUri: Uri): Result<CloudVisionResponse> = runSuspendCatching {
2730
withContext(Dispatchers.IO) {
28-
val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.")
29-
val file = File(filePath)
30-
val byte = file.readBytes()
31+
val byte = when (imageUri.scheme) {
32+
null, "file" -> {
33+
val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.")
34+
val file = File(filePath)
35+
file.readBytes()
36+
}
37+
else -> {
38+
context.contentResolver.openInputStream(imageUri)?.use { it.readBytes() }
39+
?: throw IllegalArgumentException("Unable to open image input stream.")
40+
}
41+
}
3142
val base64Image = Base64.encodeToString(byte, Base64.NO_WRAP)
3243

3344
val request = CloudVisionRequest(

core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,17 @@ fun ReedDialog(
3232
description: String? = null,
3333
dismissButtonText: String? = null,
3434
onDismissRequest: () -> Unit = {},
35+
dismissOnClickOutside: Boolean = true,
36+
dismissOnBackPress: Boolean = true,
3537
headerContent: @Composable (() -> Unit)? = null,
3638
) {
3739
Dialog(
3840
onDismissRequest = {
3941
onDismissRequest()
4042
},
4143
properties = DialogProperties(
44+
dismissOnClickOutside = dismissOnClickOutside,
45+
dismissOnBackPress = dismissOnBackPress,
4246
usePlatformDefaultWidth = false,
4347
),
4448
) {

feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ internal fun HandleOcrSideEffects(
1414
RememberedEffect(state.sideEffect) {
1515
when (state.sideEffect) {
1616
is OcrSideEffect.ShowToast -> {
17-
Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show()
17+
Toast.makeText(context, state.sideEffect.message.asString(context), Toast.LENGTH_SHORT).show()
1818
}
1919

2020
null -> {}

feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,21 @@ package com.ninecraft.booket.feature.record.ocr
22

33
import android.net.Uri
44
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.LaunchedEffect
56
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.mutableIntStateOf
68
import androidx.compose.runtime.mutableStateOf
79
import androidx.compose.runtime.rememberCoroutineScope
810
import androidx.compose.runtime.setValue
11+
import androidx.core.net.toUri
912
import com.ninecraft.booket.core.common.analytics.AnalyticsHelper
13+
import com.ninecraft.booket.core.common.utils.UiText
1014
import com.ninecraft.booket.core.common.utils.handleException
1115
import com.ninecraft.booket.core.ocr.recognizer.CloudOcrRecognizer
16+
import com.ninecraft.booket.feature.record.R
17+
import com.ninecraft.booket.feature.record.ocr.OcrSideEffect.ShowToast
1218
import com.ninecraft.booket.feature.screens.OcrScreen
19+
import com.ninecraft.booket.feature.screens.OcrScreen.OcrResult
1320
import com.orhanobut.logger.Logger
1421
import com.slack.circuit.codegen.annotations.CircuitInject
1522
import com.slack.circuit.retained.rememberRetained
@@ -24,6 +31,7 @@ import kotlinx.collections.immutable.persistentListOf
2431
import kotlinx.collections.immutable.persistentSetOf
2532
import kotlinx.collections.immutable.toPersistentList
2633
import kotlinx.collections.immutable.toPersistentSet
34+
import kotlinx.coroutines.delay
2735
import kotlinx.coroutines.launch
2836

2937
@AssistedInject
@@ -41,22 +49,65 @@ class OcrPresenter(
4149

4250
companion object {
4351
private const val RECORD_OCR_SENTENCE = "record_OCR_sentence"
52+
private const val CAMERA_MAX_FAILURES = 2
4453
}
4554

4655
@Composable
4756
override fun present(): OcrUiState {
4857
val scope = rememberCoroutineScope()
58+
var isLoading by rememberRetained { mutableStateOf(false) }
4959
var currentUi by rememberRetained { mutableStateOf(OcrUi.CAMERA) }
5060
var isPermissionDialogVisible by rememberRetained { mutableStateOf(false) }
61+
var selectedImage by rememberRetained { mutableStateOf("") }
5162
var sentenceList by rememberRetained { mutableStateOf(persistentListOf<String>()) }
5263
var selectedIndices by rememberRetained { mutableStateOf(persistentSetOf<Int>()) }
5364
var mergedSentence by rememberRetained { mutableStateOf("") }
5465
var isTextDetectionFailed by rememberRetained { mutableStateOf(false) }
66+
var isCameraRecognitionFailedDialogVisible by rememberRetained { mutableStateOf(false) }
67+
var isGalleryRecognitionFailedDialogVisible by rememberRetained { mutableStateOf(false) }
5568
var isRecaptureDialogVisible by rememberRetained { mutableStateOf(false) }
56-
var isLoading by rememberRetained { mutableStateOf(false) }
69+
var cameraFailureCount by rememberRetained { mutableIntStateOf(0) }
5770
var sideEffect by rememberRetained { mutableStateOf<OcrSideEffect?>(null) }
5871

59-
fun recognizeText(imageUri: Uri) {
72+
LaunchedEffect(isTextDetectionFailed) {
73+
if (isTextDetectionFailed) {
74+
delay(2000)
75+
isTextDetectionFailed = false
76+
}
77+
}
78+
79+
fun handleRecognitionSuccess(text: String) {
80+
isTextDetectionFailed = false
81+
cameraFailureCount = 0
82+
83+
val sentences = text
84+
.split("\n")
85+
.map { it.trim() }
86+
.filter { it.isNotEmpty() }
87+
88+
sentenceList = sentences.toPersistentList()
89+
currentUi = OcrUi.RESULT
90+
analyticsHelper.logScreenView(RECORD_OCR_SENTENCE)
91+
}
92+
93+
fun handleRecognitionFailure(source: RecognizeSource) {
94+
when (source) {
95+
RecognizeSource.CAMERA -> {
96+
isTextDetectionFailed = true
97+
cameraFailureCount += 1
98+
99+
if (cameraFailureCount > CAMERA_MAX_FAILURES) {
100+
isCameraRecognitionFailedDialogVisible = true
101+
}
102+
}
103+
104+
RecognizeSource.GALLERY -> {
105+
isGalleryRecognitionFailedDialogVisible = true
106+
}
107+
}
108+
}
109+
110+
fun recognizeText(imageUri: Uri, source: RecognizeSource) {
60111
scope.launch {
61112
try {
62113
isLoading = true
@@ -65,25 +116,17 @@ class OcrPresenter(
65116
val text = it.responses.firstOrNull()?.fullTextAnnotation?.text.orEmpty()
66117

67118
if (text.isNotBlank()) {
68-
isTextDetectionFailed = false
69-
val sentences = text
70-
.split("\n")
71-
.map { it.trim() }
72-
.filter { it.isNotEmpty() }
73-
74-
sentenceList = sentences.toPersistentList()
75-
currentUi = OcrUi.RESULT
76-
analyticsHelper.logScreenView(RECORD_OCR_SENTENCE)
119+
handleRecognitionSuccess(text)
77120
} else {
78-
isTextDetectionFailed = true
121+
handleRecognitionFailure(source)
79122
}
80123
}
81124
.onFailure { exception ->
82-
isTextDetectionFailed = true
125+
handleRecognitionFailure(source)
83126

84127
val handleErrorMessage = { message: String ->
85128
Logger.e("Cloud Vision API Error: ${exception.message}")
86-
sideEffect = OcrSideEffect.ShowToast(message)
129+
sideEffect = ShowToast(UiText.DirectString(message))
87130
}
88131

89132
handleException(
@@ -118,14 +161,24 @@ class OcrPresenter(
118161

119162
is OcrUiEvent.OnCaptureFailed -> {
120163
isLoading = false
121-
sideEffect = OcrSideEffect.ShowToast("이미지 캡처에 실패했어요")
164+
sideEffect = ShowToast(UiText.StringResource(R.string.ocr_capture_failed))
122165
Logger.e("ImageCaptureException: ${event.exception.message}")
123166
}
124167

125168
is OcrUiEvent.OnImageCaptured -> {
126169
isTextDetectionFailed = false
127170

128-
recognizeText(event.imageUri)
171+
recognizeText(event.imageUri, RecognizeSource.CAMERA)
172+
}
173+
174+
is OcrUiEvent.OnImageSelected -> {
175+
currentUi = OcrUi.IMAGE
176+
selectedImage = event.imageUri
177+
isTextDetectionFailed = false
178+
cameraFailureCount = 0
179+
180+
val pareUri = selectedImage.toUri()
181+
recognizeText(pareUri, RecognizeSource.GALLERY)
129182
}
130183

131184
is OcrUiEvent.OnReCaptureButtonClick -> {
@@ -135,7 +188,7 @@ class OcrPresenter(
135188
is OcrUiEvent.OnSelectionConfirmed -> {
136189
mergedSentence = selectedIndices
137190
.sorted().joinToString("") { sentenceList[it] }
138-
navigator.pop(result = OcrScreen.OcrResult(mergedSentence))
191+
navigator.pop(result = OcrResult(mergedSentence))
139192
}
140193

141194
is OcrUiEvent.OnSentenceSelected -> {
@@ -155,6 +208,19 @@ class OcrPresenter(
155208
is OcrUiEvent.OnRecaptureDialogDismissed -> {
156209
isRecaptureDialogVisible = false
157210
}
211+
212+
OcrUiEvent.OnImageContentClosed -> {
213+
currentUi = OcrUi.CAMERA
214+
}
215+
216+
OcrUiEvent.OnCameraRecognitionFailedDialogDismissed -> {
217+
isCameraRecognitionFailedDialogVisible = false
218+
cameraFailureCount = 0
219+
}
220+
221+
OcrUiEvent.OnImageRecognitionFailedDialogDismissed -> {
222+
isGalleryRecognitionFailedDialogVisible = false
223+
}
158224
}
159225
}
160226

@@ -165,9 +231,12 @@ class OcrPresenter(
165231
return OcrUiState(
166232
currentUi = currentUi,
167233
isPermissionDialogVisible = isPermissionDialogVisible,
234+
selectedImage = selectedImage,
168235
sentenceList = sentenceList,
169236
selectedIndices = selectedIndices,
170237
isTextDetectionFailed = isTextDetectionFailed,
238+
isCameraRecognitionFailedDialogVisible = isCameraRecognitionFailedDialogVisible,
239+
isGalleryRecognitionFailedDialogVisible = isGalleryRecognitionFailedDialogVisible,
171240
isRecaptureDialogVisible = isRecaptureDialogVisible,
172241
isLoading = isLoading,
173242
sideEffect = sideEffect,

0 commit comments

Comments
 (0)