Skip to content

Conversation

@seoyoon513
Copy link
Contributor

@seoyoon513 seoyoon513 commented Jan 21, 2026

🔗 관련 이슈

📙 작업 설명

화면 UI 구조 리팩토링

image

OcrUi에서 각 content를 별도 패키지 및 파일로 분리

@TraceRecomposition
@CircuitInject(OcrScreen::class, AppScope::class)
@Composable
internal fun OcrUi(
    state: OcrUiState,
    modifier: Modifier = Modifier,
) {
    HandleOcrSideEffects(state = state)

    when (state.currentUi) {
        OcrUi.CAMERA -> OcrCameraContent(state = state, modifier = modifier)
        OcrUi.IMAGE -> OcrImageContent(state = state, modifier = modifier)
        OcrUi.RESULT -> OcrResultContent(state = state, modifier = modifier)
    }
}

갤러리에서 선택한 이미지 인식 기능 추가

텍스트 인식 실패 케이스 추가

📸 스크린샷 또는 시연 영상

갤러리 이미지 인식

Screen_recording_20260121_223114.mp4

카메라 촬영 이미지 인식 실패

Screen_recording_20260121_223140.mp4

갤러리 이미지 인식 실패

Screen_recording_20260121_223219.mp4

💬 추가 설명 or 리뷰 포인트

  • 작업이 늦어져서 죄송합니다 🙇

Summary by CodeRabbit

  • 새로운 기능

    • 갤러리 아이콘 추가
    • 카메라·이미지·결과 기반의 OCR 화면과 하단 카메라 바, 결과 선택 UI 추가
    • 이미지 스캔 화면 및 점 애니메이션 로더 추가
    • 이미지 선택용 스캐닝 문자열 리소스 추가
  • 버그 수정

    • 다양한 URI 스킴에서의 이미지 읽기 안정성 향상
    • OCR 인식 실패 시 재시도·다이얼로그 흐름 개선
  • 개선사항

    • 버튼 텍스트 공간 제약 시 말줄임 표시 적용
    • 다이얼로그의 외부 클릭·뒤로가기 취소 동작 제어 옵션 추가

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 21, 2026

Caution

Review failed

An error occurred during the review process. Please try again later.

Walkthrough

갤러리 이미지 선택을 OCR 인식 플로우에 추가하고, 카메라/갤러리별 UI·오류 흐름을 분리·관리하며 CloudOcrRecognizer에 Context 기반 URI 바이트 읽기 로직을 도입했습니다.

Changes

Cohort / File(s) 변경 사항 요약
UI 상태 및 이벤트 / 모델
feature/record/src/main/kotlin/.../OcrUiState.kt
selectedImage, isCameraRecognitionFailedDialogVisible, isGalleryRecognitionFailedDialogVisible, OnImageSelected, OnImageContentClosed, 다이얼로그 이벤트, IMAGE/RecognizeSource enum 추가; ShowToast 메시지 타입을 UiText로 변경
프레젠터 / 흐름 제어
feature/record/src/main/kotlin/.../OcrPresenter.kt
카메라/갤러리 인식 흐름 통합·중앙화, 성공/실패 핸들러 추가, 문장 분할 및 상태 전파, 선택된 이미지 추적, CAMERA_MAX_FAILURES 상수 도입, navigator.pop 반환형에 OcrResult 사용
OCR 인식(백엔드)
core/ocr/src/main/kotlin/.../CloudOcrRecognizer.kt
@ApplicationContext 주입 추가; URI 스킴(file/content/null)별로 파일/ContentResolver에서 바이트 읽는 로직 도입 및 실패 처리 추가
UI 진입점 / 레이아웃 분리
feature/record/src/main/kotlin/.../OcrUi.kt
내부 CameraPreview/TextScanResult 제거, OcrCameraContent / OcrImageContent / OcrResultContent로 위임
카메라 캡처 화면
feature/record/src/main/kotlin/.../content/OcrCameraContent.kt
권한 처리, LifecycleCameraController 바인딩, 임시 파일 캡처 저장, 갤러리 호출, 인식 실패 다이얼로그 연결
이미지 처리 화면(갤러리)
feature/record/src/main/kotlin/.../content/OcrImageContent.kt
PickVisualMedia 연동, 선택 이미지 처리 및 로더 표시, 갤러리 인식 실패 다이얼로그와 재시도 흐름
결과 화면
feature/record/src/main/kotlin/.../content/OcrResultContent.kt
문장 리스트/선택/확정 UI, 재촬영 확인 다이얼로그, 이벤트 전파
카메라 제어 컴포넌트
feature/record/src/main/kotlin/.../component/CameraBottomBar.kt
갤러리·촬영 버튼 제공(내부/프리뷰 포함)
로딩 인디케이터
feature/record/src/main/kotlin/.../component/ImageProcessingLoader.kt
3점 스태거드 애니메이션 로더 컴포넌트 추가
디자인 시스템 / 리소스
core/designsystem/src/main/kotlin/.../ReedButton.kt, core/designsystem/src/main/res/drawable/ic_gallery.xml, feature/record/src/main/res/values/strings.xml
버튼 텍스트에 TextOverflow.Ellipsis 적용(한 줄, 말줄임), 갤러리 벡터 드로어블 추가, OCR 문자열 리소스 추가
다이얼로그 구성 변경
core/ui/src/main/kotlin/.../ReedDialog.kt
dismissOnClickOutsidedismissOnBackPress 파라미터 추가 및 DialogProperties에 전달
사이드 이펙트 처리
feature/record/src/main/kotlin/.../HandleOcrSideEffects.kt
ShowToast에서 UiText.asString(context) 호출로 변경

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CameraUI as OcrCameraContent
    participant Gallery as PhotoPicker
    participant Presenter as OcrPresenter
    participant OCR as CloudOcrRecognizer
    participant ImageUI as OcrImageContent
    participant Result as OcrResultContent

    User->>CameraUI: 화면 진입 / 권한 승인
    CameraUI->>User: 갤러리 버튼, 캡처 버튼 표시

    alt 카메라 캡처
        User->>CameraUI: 캡처
        CameraUI->>CameraUI: 임시 파일에 저장
        CameraUI->>Presenter: OnImageCaptured(fileUri, CAMERA)
        Presenter->>OCR: recognizeText(fileUri, CAMERA)
    else 갤러리 선택
        User->>CameraUI: 갤러리 버튼 클릭
        CameraUI->>Gallery: 사진 선택기 실행
        Gallery->>User: 이미지 선택
        Gallery->>Presenter: OnImageSelected(contentUri)
        Presenter->>OCR: recognizeText(contentUri, GALLERY)
    end

    alt 인식 성공
        OCR->>Presenter: 텍스트 결과
        Presenter->>Presenter: 문장 분할, 상태 업데이트
        Presenter->>Result: 상태 전파 (문장 목록)
        Result->>User: 문장 선택 UI 제공
    else 인식 실패
        OCR->>Presenter: 오류
        alt 출처 == CAMERA
            Presenter->>CameraUI: 카메라 실패 다이얼로그 표시
        else 출처 == GALLERY
            Presenter->>ImageUI: 갤러리 실패 다이얼로그 표시
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • easyhooon

Poem

🐰 갤러리에 빛을 골라 담고
카메라로도 한 장 남겨두네
실패해도 다시 고르며
문장들을 살며시 쪼개어
책의 말들을 불러오네 📸✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 풀 리퀘스트의 주요 변경사항을 정확하게 설명합니다. 갤러리 이미지 선택 기능이 추가되었으며, 제목은 이를 명확하게 반영합니다.
Linked Issues check ✅ Passed 연결된 이슈 #251의 모든 코딩 요구사항이 충족되었습니다. 갤러리 이미지 입력 소스 지원, OCR 인식 기능, UI 흐름 통합이 모두 구현되었습니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 연결된 이슈의 범위 내에 있습니다. ReedButton 텍스트 오버플로우, 갤러리 아이콘 리소스, 그리고 OCR 구조 리팩토링은 모두 의도된 기능과 관련이 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (1)

108-138: 동시 인식 요청 시 결과 덮어쓰기 가능
연속 캡처/재선택이 발생하면 늦게 완료된 이전 요청이 최신 결과를 덮어쓸 수 있습니다. 요청 ID 가드 또는 이전 Job 취소를 권장합니다.

수정 제안
+    var recognitionRequestId by rememberRetained { mutableStateOf(0) }

     fun recognizeText(imageUri: Uri, source: RecognizeSource) {
+        recognitionRequestId += 1
+        val requestId = recognitionRequestId
         scope.launch {
             try {
                 isLoading = true
                 recognizer.recognizeText(imageUri)
                     .onSuccess {
+                        if (requestId != recognitionRequestId) return@onSuccess
                         val text = it.responses.firstOrNull()?.fullTextAnnotation?.text.orEmpty()
                         if (text.isNotBlank()) {
                             handleRecognitionSuccess(text)
                         } else {
                             handleRecognitionFailure(source)
                         }
                     }
                     .onFailure { exception ->
+                        if (requestId != recognitionRequestId) return@onFailure
                         handleRecognitionFailure(source)
                         val handleErrorMessage = { message: String ->
                             Logger.e("Cloud Vision API Error: ${exception.message}")
                             sideEffect = OcrSideEffect.ShowToast(message)
                         }
                         handleException(
                             exception = exception,
                             onError = handleErrorMessage,
                             onLoginRequired = {},
                         )
                     }
             } finally {
-                isLoading = false
+                if (requestId == recognitionRequestId) {
+                    isLoading = false
+                }
             }
         }
     }
🤖 Fix all issues with AI agents
In
`@core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt`:
- Around line 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()).

In
`@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraBottomBar.kt`:
- Around line 51-55: In CameraBottomBar replace hardcoded English
contentDescription values on the Icon composables (the one using
ImageVector.vectorResource(designR.drawable.ic_gallery) and the scan Icon) with
localized string resources; add ocr_gallery_icon and ocr_scan_icon to
strings.xml and use the Compose stringResource(R.string.ocr_gallery_icon) and
stringResource(R.string.ocr_scan_icon) for the contentDescription in the
CameraBottomBar composable so accessibility strings are localizable.

In
`@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt`:
- Around line 238-265: Before calling cameraController.takePicture inside the
CameraBottomBar onCaptureClick lambda, check camera permission and camera
binding state and short-circuit if not available: verify permission (e.g., using
ContextCompat.checkSelfPermission or your existing permission state) and whether
the cameraController is ready, and if permission is missing call the permission
request flow or show a dialog instead of proceeding; keep existing eventSink
calls for success/failure (OcrUiEvent.OnCaptureStart, OnImageCaptured,
OnCaptureFailed) but do not invoke takePicture when permission is denied or
camera isn't bound.

In
`@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt`:
- Around line 94-108: The ReedDialog currently allows outside taps/back button
to trigger its onDismissRequest which reopens the gallery; update the ReedDialog
invocation to set DialogProperties(dismissOnClickOutside = false) (or otherwise
disable outside dismiss) so only explicit dismiss button clicks call the
onDismissRequest that runs
state.eventSink(OcrUiEvent.OnImageRecognitionFailedDialogDismissed) and
photoPickerLauncher.launch(PickVisualMediaRequest(...)); ensure ReedDialog
retains its confirm button handler (OcrUiEvent.OnCloseClick) and that the
gallery reopen logic remains only in the explicit dismiss button handler.

In
`@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt`:
- Around line 91-105: The bug is that cameraFailureCount is never reset, so the
"2 consecutive failures" policy is broken; update the logic to reset
cameraFailureCount to 0 whenever the camera-failure dialog is dismissed (the
handler that sets isCameraRecognitionFailedDialogVisible to false) and whenever
the app switches away from camera to gallery (the code that sets
isGalleryRecognitionFailedDialogVisible / toggles camera/gallery mode), and also
reset cameraFailureCount when switching back to camera to start a fresh session;
locate references to cameraFailureCount, handleRecognitionFailure,
isCameraRecognitionFailedDialogVisible and
isGalleryRecognitionFailedDialogVisible and add cameraFailureCount = 0 in those
transition/close handlers.
🧹 Nitpick comments (3)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt (1)

13-21: 이미지 URI 타입을 일관되게 통일하세요.

OnImageCapturedUri인데 selectedImage/OnImageSelectedString이라 타입 혼용으로 변환/파싱 오류가 생기기 쉽습니다. UI 상태와 이벤트를 Uri(또는 모두 String)로 통일하는 편이 안전합니다.

♻️ 제안 변경 (Uri로 통일)
-    val selectedImage: String = "",
+    val selectedImage: Uri? = null,
...
-    data class OnImageSelected(val imageUri: String) : OcrUiEvent
+    data class OnImageSelected(val imageUri: Uri) : OcrUiEvent

Also applies to: 42-45

core/designsystem/src/main/res/drawable/ic_gallery.xml (1)

1-8: 라이트/다크 모드 아이콘 분리 여부 확인 필요
기본 drawable에 #ffffff 고정 색상이라 라이트 배경에서 사용 시 보이지 않을 수 있습니다. 이 아이콘이 다크 배경 전용이 아니라면 drawable-night 등 모드별 리소스 분리 적용 여부를 확인해 주세요. Based on learnings, 모드별 별도 아이콘 파일 방식을 선호합니다.

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

180-181: 변수명 오타 정리 제안
pareUri는 오타로 보입니다. 의미 전달을 위해 parsedUri 같은 이름이 더 명확합니다.

수정 제안
-    val pareUri = selectedImage.toUri()
-    recognizeText(pareUri, RecognizeSource.GALLERY)
+    val parsedUri = selectedImage.toUri()
+    recognizeText(parsedUri, RecognizeSource.GALLERY)

Comment on lines +31 to +41
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.")
}
}
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()).

Comment on lines +51 to +55
Icon(
imageVector = ImageVector.vectorResource(designR.drawable.ic_gallery),
contentDescription = "Gallery Icon",
modifier = Modifier
.size(ReedTheme.spacing.spacing6),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

접근성 설명 문자열을 리소스로 분리하세요.

contentDescription이 하드코딩 영문이라 로컬라이징이 불가합니다. 문자열 리소스로 분리해 접근성/다국어 대응을 해주세요.

🔧 제안 수정안
@@
-import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
@@
-import com.ninecraft.booket.core.designsystem.R as designR
+import com.ninecraft.booket.core.designsystem.R as designR
+import com.ninecraft.booket.feature.record.R
@@
-            Icon(
+            Icon(
                 imageVector = ImageVector.vectorResource(designR.drawable.ic_gallery),
-                contentDescription = "Gallery Icon",
+                contentDescription = stringResource(R.string.ocr_gallery_icon),
                 modifier = Modifier
                     .size(ReedTheme.spacing.spacing6),
             )
@@
-            Icon(
+            Icon(
                 imageVector = ImageVector.vectorResource(designR.drawable.ic_maximize),
-                contentDescription = "Scan Icon",
+                contentDescription = stringResource(R.string.ocr_scan_icon),
                 modifier = Modifier.size(ReedTheme.spacing.spacing8),
             )

strings.xmlocr_gallery_icon, ocr_scan_icon 추가가 필요합니다.

Also applies to: 71-74

🤖 Prompt for AI Agents
In
`@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraBottomBar.kt`
around lines 51 - 55, In CameraBottomBar replace hardcoded English
contentDescription values on the Icon composables (the one using
ImageVector.vectorResource(designR.drawable.ic_gallery) and the scan Icon) with
localized string resources; add ocr_gallery_icon and ocr_scan_icon to
strings.xml and use the Compose stringResource(R.string.ocr_gallery_icon) and
stringResource(R.string.ocr_scan_icon) for the contentDescription in the
CameraBottomBar composable so accessibility strings are localizable.

Comment on lines +238 to +265
CameraBottomBar(
onGalleryClick = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
onCaptureClick = {
state.eventSink(OcrUiEvent.OnCaptureStart)

val executor = ContextCompat.getMainExecutor(context)
val photoFile = File.createTempFile("ocr_", ".jpg", context.cacheDir)
val output = ImageCapture.OutputFileOptions.Builder(photoFile).build()

cameraController.takePicture(
output,
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
state.eventSink(OcrUiEvent.OnImageCaptured(photoFile.toUri()))
}

override fun onError(exception: ImageCaptureException) {
state.eventSink(OcrUiEvent.OnCaptureFailed(exception))
}
},
)
},
buttonEnabled = !state.isLoading,
Copy link

@coderabbitai coderabbitai bot Jan 21, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

권한 미허용 상태에서 촬영 시도를 차단하세요.

현재는 권한이 없는 상태에서도 촬영 버튼 클릭 시 takePicture가 호출됩니다. 카메라가 바인딩되지 않은 상태라 실패하거나 예외가 발생할 수 있습니다. 권한을 먼저 확인하고, 미허용 시 다이얼로그/권한 요청으로 분기하세요.

🔧 제안 수정안
@@
                 CameraBottomBar(
@@
-                    onCaptureClick = {
-                        state.eventSink(OcrUiEvent.OnCaptureStart)
-
-                        val executor = ContextCompat.getMainExecutor(context)
-                        val photoFile = File.createTempFile("ocr_", ".jpg", context.cacheDir)
-                        val output = ImageCapture.OutputFileOptions.Builder(photoFile).build()
-
-                        cameraController.takePicture(
-                            output,
-                            executor,
-                            object : ImageCapture.OnImageSavedCallback {
-                                override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
-                                    state.eventSink(OcrUiEvent.OnImageCaptured(photoFile.toUri()))
-                                }
-
-                                override fun onError(exception: ImageCaptureException) {
-                                    state.eventSink(OcrUiEvent.OnCaptureFailed(exception))
-                                }
-                            },
-                        )
-                    },
+                    onCaptureClick = {
+                        if (!isGranted) {
+                            state.eventSink(OcrUiEvent.OnShowPermissionDialog)
+                            permissionLauncher.launch(permission)
+                        } else {
+                            state.eventSink(OcrUiEvent.OnCaptureStart)
+
+                            val executor = ContextCompat.getMainExecutor(context)
+                            val photoFile = File.createTempFile("ocr_", ".jpg", context.cacheDir)
+                            val output = ImageCapture.OutputFileOptions.Builder(photoFile).build()
+
+                            cameraController.takePicture(
+                                output,
+                                executor,
+                                object : ImageCapture.OnImageSavedCallback {
+                                    override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
+                                        state.eventSink(OcrUiEvent.OnImageCaptured(photoFile.toUri()))
+                                    }
+
+                                    override fun onError(exception: ImageCaptureException) {
+                                        state.eventSink(OcrUiEvent.OnCaptureFailed(exception))
+                                    }
+                                },
+                            )
+                        }
+                    },
🤖 Prompt for AI Agents
In
`@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt`
around lines 238 - 265, Before calling cameraController.takePicture inside the
CameraBottomBar onCaptureClick lambda, check camera permission and camera
binding state and short-circuit if not available: verify permission (e.g., using
ContextCompat.checkSelfPermission or your existing permission state) and whether
the cameraController is ready, and if permission is missing call the permission
request flow or show a dialog instead of proceeding; keep existing eventSink
calls for success/failure (OcrUiEvent.OnCaptureStart, OnImageCaptured,
OnCaptureFailed) but do not invoke takePicture when permission is denied or
camera isn't bound.

Copy link
Contributor

Choose a reason for hiding this comment

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

카메라 권한이 없는 경우엔, '카메라 권한이 필요해요' 다이얼로그가 올라오고, 이게 바깥 영역의 터치를 막기 때문에, onCaptureClick Event가 발생할 경우는 없을 것 같아

Copy link

Choose a reason for hiding this comment

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

@easyhooon, 설명 감사합니다! 말씀하신 대로 권한 다이얼로그가 모달로 표시되어 하위 콘텐츠의 터치를 차단한다면, onCaptureClick이 권한 미허용 상태에서 발생하지 않는 것이 맞네요. UI 레이어에서 이미 방어되고 있다는 점 확인했습니다. 👍


🧠 Learnings used
Learnt from: seoyoon513
Repo: YAPP-Github/Reed-Android PR: 175
File: feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt:103-124
Timestamp: 2025-09-01T11:05:37.684Z
Learning: seoyoon513이 확인한 바로는 produceState 기반의 권한 상태 관리가 올바르게 동작한다. permissionLauncher 콜백과 ON_RESUME lifecycle 이벤트 조합으로 모든 권한 변경 시나리오가 적절히 처리됨

Comment on lines 91 to 105
fun handleRecognitionFailure(source: RecognizeSource) {
when (source) {
RecognizeSource.CAMERA -> {
isTextDetectionFailed = true
cameraFailureCount += 1

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

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

@coderabbitai coderabbitai bot Jan 21, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "OcrPresenter.kt" | head -5

Repository: YAPP-Github/Reed-Android

Length of output: 156


🏁 Script executed:

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

Repository: YAPP-Github/Reed-Android

Length of output: 158


🏁 Script executed:

cat -n feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt | sed -n '85,110p'

Repository: YAPP-Github/Reed-Android

Length of output: 1099


🏁 Script executed:

cat -n feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt | sed -n '210,225p'

Repository: YAPP-Github/Reed-Android

Length of output: 657


🏁 Script executed:

rg "cameraFailureCount" feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt -n

Repository: YAPP-Github/Reed-Android

Length of output: 300


🏁 Script executed:

cat -n feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt | sed -n '70,85p'

Repository: YAPP-Github/Reed-Android

Length of output: 640


🏁 Script executed:

rg "OnImageSelected" feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt -n -B 2 -A 5

Repository: YAPP-Github/Reed-Android

Length of output: 383


🏁 Script executed:

rg "CAMERA_MAX_FAILURES" feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/ -n

Repository: YAPP-Github/Reed-Android

Length of output: 366


🏁 Script executed:

cat -n feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt | sed -n '174,180p'

Repository: YAPP-Github/Reed-Android

Length of output: 437


🏁 Script executed:

rg "OcrUiEvent\." feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt | head -20

Repository: YAPP-Github/Reed-Android

Length of output: 924


카메라 인식 실패 횟수 리셋 누락으로 인한 논리 오류

cameraFailureCount가 다이얼로그 닫기(라인 216-218)와 갤러리 모드로 전환(라인 174-178) 시에 리셋되지 않아 의도된 "연속 2회 실패" 정책이 깨집니다. 현재 로직에서는:

  1. 카메라에서 2회 연속 실패 → 다이얼로그 표시 (count=2)
  2. 다이얼로그 닫음 → count는 여전히 2
  3. 1회 더 실패 → 다이얼로그 즉시 표시 (count=3 ≥ 2)

이는 사용자가 다이얼로그를 닫은 후 새로운 시도를 하려 할 때 예상치 못한 조기 실패 다이얼로그 노출로 이어집니다.

또한 갤러리 모드로 전환 후 다시 카메라로 돌아올 때도 카운트가 유지되어 새 세션에서 1회 실패만으로 다이얼로그가 나타날 수 있습니다.

수정 제안
 OcrUiEvent.OnCameraRecognitionFailedDialogDismissed -> {
     isCameraRecognitionFailedDialogVisible = false
+    cameraFailureCount = 0
 }

 is OcrUiEvent.OnImageSelected -> {
     currentUi = OcrUi.IMAGE
     selectedImage = event.imageUri
     isTextDetectionFailed = false
     isGalleryRecognitionFailedDialogVisible = false
+    cameraFailureCount = 0
 }
🤖 Prompt for AI Agents
In
`@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt`
around lines 91 - 105, The bug is that cameraFailureCount is never reset, so the
"2 consecutive failures" policy is broken; update the logic to reset
cameraFailureCount to 0 whenever the camera-failure dialog is dismissed (the
handler that sets isCameraRecognitionFailedDialogVisible to false) and whenever
the app switches away from camera to gallery (the code that sets
isGalleryRecognitionFailedDialogVisible / toggles camera/gallery mode), and also
reset cameraFailureCount when switching back to camera to start a fresh session;
locate references to cameraFailureCount, handleRecognitionFailure,
isCameraRecognitionFailedDialogVisible and
isGalleryRecognitionFailedDialogVisible and add cameraFailureCount = 0 in those
transition/close handlers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

정책 내용이 아래와 같은데, 연속 2회의 경우에 띄우는건지 2회 이상 실패에 띄우는건지 확인해보고 작업할게

OCR 인식 오류 <다이얼로그>
OCR 인식 지연 메시지 2번 띄운 후에도 인식 안되는 경우 → 직접 입력 유도

Copy link

Choose a reason for hiding this comment

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

@seoyoon513, 정책 확인하시는 것 좋습니다!

현재 코드는 cameraFailureCount >= CAMERA_MAX_FAILURES (Line 98)를 사용하고 있어서, 2회차 실패부터 다이얼로그가 표시됩니다. 만약 정책이 "2번 띄운 후" (즉, 3회차에 다이얼로그)라면 > 조건으로 변경하시면 됩니다.

다만 제가 지적한 핵심 이슈는 카운트 리셋 누락입니다:

  • 다이얼로그를 닫아도 cameraFailureCount가 유지되어, 다음 실패 시 즉시 다이얼로그가 재노출됨
  • 갤러리 모드로 전환했다가 카메라로 돌아와도 이전 카운트가 남아있음

정책 확인 후 임계값(>= vs >)과 함께 카운트 리셋 로직도 추가해 주시면 좋을 것 같습니다. 확인 후 작업 진행하시면 됩니다! 👍


🧠 Learnings used
Learnt from: seoyoon513
Repo: YAPP-Github/Reed-Android PR: 72
File: feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt:70-70
Timestamp: 2025-07-28T18:22:00.618Z
Learning: LiveTextAnalyzer는 ML Kit 에러를 addOnFailureListener로 처리하지만 로그만 남기고 presenter에 실패를 알리지 않아서, UI에서 텍스트 인식 실패 상태를 표시할 수 없는 문제가 있음. StillTextAnalyzer처럼 실패 콜백을 추가하는 것이 더 나은 해결책임

Learnt from: seoyoon513
Repo: YAPP-Github/Reed-Android PR: 46
File: feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/InfiniteLazyColumn.kt:83-95
Timestamp: 2025-07-14T00:46:03.843Z
Learning: seoyoon513과 팀은 한국어 주석을 선호하며, 한국어 주석을 영어로 번역하라는 제안을 하지 않아야 함

@easyhooon
Copy link
Contributor

깔끔하네요

@easyhooon
Copy link
Contributor

도서 상세화면 진입시 앱 크래시 발생하는건 제 작업 브랜치 merge하면 해결될것으로 예상됩니다.

import com.ninecraft.booket.core.designsystem.theme.ReedTheme

@Composable
internal fun ImageProcessingLoader(
Copy link
Contributor

@easyhooon easyhooon Jan 22, 2026

Choose a reason for hiding this comment

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

로띠로 처리할것을 예상했는데 직접 구현했군요ㄷ 좋슴다.

 - mutableStateOf<Int>: 제네릭이라 int → Integer 박싱 필요
 - mutableIntStateOf: int를 직접 저장하여 박싱 불필요
Copy link
Contributor

@easyhooon easyhooon left a comment

Choose a reason for hiding this comment

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

수정 필요 부분 간단한 작업이라 제가 처리했구, 동작에도 문제 없어서 merge 하면 될 것 같습니다~ 수고하셨습니다!

리팩토링 굿굿

@seoyoon513
Copy link
Contributor Author

@easyhooon 수정 감사합니다~!! OCR 인식 실패 정책 반영했고 테스트 후 문제 없어서 머지 진행할게요!

@seoyoon513 seoyoon513 merged commit 4f07099 into develop Jan 23, 2026
4 checks passed
@seoyoon513 seoyoon513 deleted the BOOK-491-feature/#251 branch January 23, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BOOK-491/feat] OCR 인식에 갤러리 이미지 선택 기능 추가

3 participants