Skip to content
Merged
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 @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ninecraft.booket.core.common.utils.MultipleEventsCutter
import com.ninecraft.booket.core.common.utils.get
Expand Down Expand Up @@ -93,6 +94,8 @@ fun ReedButton(

Text(
text = text,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = sizeStyle.textStyle.copy(
color = if (enabled) colorStyle.contentColor() else colorStyle.disabledContentColor(),
),
Expand Down
9 changes: 9 additions & 0 deletions core/designsystem/src/main/res/drawable/ic_gallery.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
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"
android:fillColor="#ffffff"/>
</vector>
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.ninecraft.booket.core.ocr.recognizer

import android.content.Context
import android.net.Uri
import android.util.Base64
import com.ninecraft.booket.core.common.utils.runSuspendCatching
import com.ninecraft.booket.core.di.ApplicationContext
import com.ninecraft.booket.core.ocr.BuildConfig
import com.ninecraft.booket.core.ocr.model.AnnotateImageRequest
import com.ninecraft.booket.core.ocr.model.CloudVisionRequest
Expand All @@ -21,13 +23,22 @@ import com.ninecraft.booket.core.di.DataScope
@SingleIn(DataScope::class)
@Inject
class CloudOcrRecognizer(
@ApplicationContext private val context: Context,
private val service: CloudVisionService,
) {
suspend fun recognizeText(imageUri: Uri): Result<CloudVisionResponse> = runSuspendCatching {
withContext(Dispatchers.IO) {
val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.")
val file = File(filePath)
val byte = file.readBytes()
val byte = when (imageUri.scheme) {
null, "file" -> {
val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.")
val file = File(filePath)
file.readBytes()
}
else -> {
context.contentResolver.openInputStream(imageUri)?.use { it.readBytes() }
?: throw IllegalArgumentException("Unable to open image input stream.")
}
}
Comment on lines +31 to +41
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

대용량 이미지 방어 로직이 필요합니다.

갤러리 이미지 도입으로 매우 큰 파일이 들어올 수 있어 readBytes()가 OOM 또는 API 제한 초과를 유발할 수 있습니다. 파일/콘텐츠 길이 사전 체크(또는 제한 스트리밍)로 상한을 두는 처리가 필요합니다.

🛠️ 제안 수정안 (사전 용량 체크 예시)
+import android.content.res.AssetFileDescriptor
+
+private companion object {
+    private const val MAX_IMAGE_BYTES = 4 * 1024 * 1024 // 예: 4MB
+}
...
-            val byte = when (imageUri.scheme) {
+            val byte = when (imageUri.scheme) {
                 null, "file" -> {
                     val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.")
                     val file = File(filePath)
+                    if (file.length() > MAX_IMAGE_BYTES) {
+                        throw IllegalArgumentException("Image is too large.")
+                    }
                     file.readBytes()
                 }
                 else -> {
+                    context.contentResolver.openAssetFileDescriptor(imageUri, "r")?.use { afd ->
+                        if (afd.length != AssetFileDescriptor.UNKNOWN_LENGTH && afd.length > MAX_IMAGE_BYTES) {
+                            throw IllegalArgumentException("Image is too large.")
+                        }
+                    } ?: throw IllegalArgumentException("Unable to open image descriptor.")
                     context.contentResolver.openInputStream(imageUri)?.use { it.readBytes() }
                         ?: throw IllegalArgumentException("Unable to open image input stream.")
                 }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val byte = when (imageUri.scheme) {
null, "file" -> {
val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.")
val file = File(filePath)
file.readBytes()
}
else -> {
context.contentResolver.openInputStream(imageUri)?.use { it.readBytes() }
?: throw IllegalArgumentException("Unable to open image input stream.")
}
}
val byte = when (imageUri.scheme) {
null, "file" -> {
val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.")
val file = File(filePath)
if (file.length() > MAX_IMAGE_BYTES) {
throw IllegalArgumentException("Image is too large.")
}
file.readBytes()
}
else -> {
context.contentResolver.openAssetFileDescriptor(imageUri, "r")?.use { afd ->
if (afd.length != AssetFileDescriptor.UNKNOWN_LENGTH && afd.length > MAX_IMAGE_BYTES) {
throw IllegalArgumentException("Image is too large.")
}
} ?: throw IllegalArgumentException("Unable to open image descriptor.")
context.contentResolver.openInputStream(imageUri)?.use { it.readBytes() }
?: throw IllegalArgumentException("Unable to open image input stream.")
}
}
🤖 Prompt for AI Agents
In
`@core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt`
around lines 31 - 41, 현재 CloudOcrRecognizer의 이미지 로드(변수 byte 생성)에서 readBytes()를
바로 호출해 대용량 이미지로 인해 OOM/제한 초과가 발생할 수 있으므로, 최대 허용 바이트(MAX_IMAGE_BYTES) 상수를 도입해
파일/콘텐트 길이를 사전 확인하거나 제한 스트리밍으로 읽도록 변경하세요: 파일 스킴일 때는 File.length()로 크기를 확인하고 초과 시
IllegalArgumentException을 던지며, content 스킴일 때는
ContentResolver.query(Images.Media.SIZE) 또는 openAssetFileDescriptor?.length를 시도해
크기를 확인하되 길이를 얻을 수 없으면 InputStream을 읽을 때는 고정 버퍼로 누적 읽기하며 누적 크기가 MAX_IMAGE_BYTES를
넘으면 즉시 중단해 에러를 던지도록 구현(관련 식별자: CloudOcrRecognizer, imageUri,
context.contentResolver, readBytes()).

val base64Image = Base64.encodeToString(byte, Base64.NO_WRAP)

val request = CloudVisionRequest(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,17 @@ fun ReedDialog(
description: String? = null,
dismissButtonText: String? = null,
onDismissRequest: () -> Unit = {},
dismissOnClickOutside: Boolean = true,
dismissOnBackPress: Boolean = true,
headerContent: @Composable (() -> Unit)? = null,
) {
Dialog(
onDismissRequest = {
onDismissRequest()
},
properties = DialogProperties(
dismissOnClickOutside = dismissOnClickOutside,
dismissOnBackPress = dismissOnBackPress,
usePlatformDefaultWidth = false,
),
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal fun HandleOcrSideEffects(
RememberedEffect(state.sideEffect) {
when (state.sideEffect) {
is OcrSideEffect.ShowToast -> {
Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show()
Toast.makeText(context, state.sideEffect.message.asString(context), Toast.LENGTH_SHORT).show()
}

null -> {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@ package com.ninecraft.booket.feature.record.ocr

import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import com.ninecraft.booket.core.common.analytics.AnalyticsHelper
import com.ninecraft.booket.core.common.utils.UiText
import com.ninecraft.booket.core.common.utils.handleException
import com.ninecraft.booket.core.ocr.recognizer.CloudOcrRecognizer
import com.ninecraft.booket.feature.record.R
import com.ninecraft.booket.feature.record.ocr.OcrSideEffect.ShowToast
import com.ninecraft.booket.feature.screens.OcrScreen
import com.ninecraft.booket.feature.screens.OcrScreen.OcrResult
import com.orhanobut.logger.Logger
import com.slack.circuit.codegen.annotations.CircuitInject
import com.slack.circuit.retained.rememberRetained
Expand All @@ -24,6 +31,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@AssistedInject
Expand All @@ -41,22 +49,65 @@ class OcrPresenter(

companion object {
private const val RECORD_OCR_SENTENCE = "record_OCR_sentence"
private const val CAMERA_MAX_FAILURES = 2
}

@Composable
override fun present(): OcrUiState {
val scope = rememberCoroutineScope()
var isLoading by rememberRetained { mutableStateOf(false) }
var currentUi by rememberRetained { mutableStateOf(OcrUi.CAMERA) }
var isPermissionDialogVisible by rememberRetained { mutableStateOf(false) }
var selectedImage by rememberRetained { mutableStateOf("") }
var sentenceList by rememberRetained { mutableStateOf(persistentListOf<String>()) }
var selectedIndices by rememberRetained { mutableStateOf(persistentSetOf<Int>()) }
var mergedSentence by rememberRetained { mutableStateOf("") }
var isTextDetectionFailed by rememberRetained { mutableStateOf(false) }
var isCameraRecognitionFailedDialogVisible by rememberRetained { mutableStateOf(false) }
var isGalleryRecognitionFailedDialogVisible by rememberRetained { mutableStateOf(false) }
var isRecaptureDialogVisible by rememberRetained { mutableStateOf(false) }
var isLoading by rememberRetained { mutableStateOf(false) }
var cameraFailureCount by rememberRetained { mutableIntStateOf(0) }
var sideEffect by rememberRetained { mutableStateOf<OcrSideEffect?>(null) }

fun recognizeText(imageUri: Uri) {
LaunchedEffect(isTextDetectionFailed) {
if (isTextDetectionFailed) {
delay(2000)
isTextDetectionFailed = false
}
}

fun handleRecognitionSuccess(text: String) {
isTextDetectionFailed = false
cameraFailureCount = 0

val sentences = text
.split("\n")
.map { it.trim() }
.filter { it.isNotEmpty() }

sentenceList = sentences.toPersistentList()
currentUi = OcrUi.RESULT
analyticsHelper.logScreenView(RECORD_OCR_SENTENCE)
}

fun handleRecognitionFailure(source: RecognizeSource) {
when (source) {
RecognizeSource.CAMERA -> {
isTextDetectionFailed = true
cameraFailureCount += 1

if (cameraFailureCount > CAMERA_MAX_FAILURES) {
isCameraRecognitionFailedDialogVisible = true
}
}

RecognizeSource.GALLERY -> {
isGalleryRecognitionFailedDialogVisible = true
}
}
}

fun recognizeText(imageUri: Uri, source: RecognizeSource) {
scope.launch {
try {
isLoading = true
Expand All @@ -65,25 +116,17 @@ class OcrPresenter(
val text = it.responses.firstOrNull()?.fullTextAnnotation?.text.orEmpty()

if (text.isNotBlank()) {
isTextDetectionFailed = false
val sentences = text
.split("\n")
.map { it.trim() }
.filter { it.isNotEmpty() }

sentenceList = sentences.toPersistentList()
currentUi = OcrUi.RESULT
analyticsHelper.logScreenView(RECORD_OCR_SENTENCE)
handleRecognitionSuccess(text)
} else {
isTextDetectionFailed = true
handleRecognitionFailure(source)
}
}
.onFailure { exception ->
isTextDetectionFailed = true
handleRecognitionFailure(source)

val handleErrorMessage = { message: String ->
Logger.e("Cloud Vision API Error: ${exception.message}")
sideEffect = OcrSideEffect.ShowToast(message)
sideEffect = ShowToast(UiText.DirectString(message))
}

handleException(
Expand Down Expand Up @@ -118,14 +161,24 @@ class OcrPresenter(

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

is OcrUiEvent.OnImageCaptured -> {
isTextDetectionFailed = false

recognizeText(event.imageUri)
recognizeText(event.imageUri, RecognizeSource.CAMERA)
}

is OcrUiEvent.OnImageSelected -> {
currentUi = OcrUi.IMAGE
selectedImage = event.imageUri
isTextDetectionFailed = false
cameraFailureCount = 0

val pareUri = selectedImage.toUri()
recognizeText(pareUri, RecognizeSource.GALLERY)
}

is OcrUiEvent.OnReCaptureButtonClick -> {
Expand All @@ -135,7 +188,7 @@ class OcrPresenter(
is OcrUiEvent.OnSelectionConfirmed -> {
mergedSentence = selectedIndices
.sorted().joinToString("") { sentenceList[it] }
navigator.pop(result = OcrScreen.OcrResult(mergedSentence))
navigator.pop(result = OcrResult(mergedSentence))
}

is OcrUiEvent.OnSentenceSelected -> {
Expand All @@ -155,6 +208,19 @@ class OcrPresenter(
is OcrUiEvent.OnRecaptureDialogDismissed -> {
isRecaptureDialogVisible = false
}

OcrUiEvent.OnImageContentClosed -> {
currentUi = OcrUi.CAMERA
}

OcrUiEvent.OnCameraRecognitionFailedDialogDismissed -> {
isCameraRecognitionFailedDialogVisible = false
cameraFailureCount = 0
}

OcrUiEvent.OnImageRecognitionFailedDialogDismissed -> {
isGalleryRecognitionFailedDialogVisible = false
}
}
}

Expand All @@ -165,9 +231,12 @@ class OcrPresenter(
return OcrUiState(
currentUi = currentUi,
isPermissionDialogVisible = isPermissionDialogVisible,
selectedImage = selectedImage,
sentenceList = sentenceList,
selectedIndices = selectedIndices,
isTextDetectionFailed = isTextDetectionFailed,
isCameraRecognitionFailedDialogVisible = isCameraRecognitionFailedDialogVisible,
isGalleryRecognitionFailedDialogVisible = isGalleryRecognitionFailedDialogVisible,
isRecaptureDialogVisible = isRecaptureDialogVisible,
isLoading = isLoading,
sideEffect = sideEffect,
Expand Down
Loading
Loading