feat: OCR 인식률 개선을 위해 Cloud Vision API로 마이그레이션#155
Conversation
WalkthroughCloud Vision 기반 OCR 통합을 추가했다. Gradle에서 로컬 API 키를 BuildConfig로 주입하고, 네트워크 DI(OkHttp/Retrofit/qualifiers), 요청·응답 모델, Retrofit 서비스, CloudOcrRecognizer를 도입했으며 레코드 기능은 프레임 분석을 제거하고 캡처→클라우드 OCR 흐름으로 전환했다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User as 사용자
participant UI as OcrUi
participant Presenter as OcrPresenter
participant Recognizer as CloudOcrRecognizer
participant Service as CloudVisionService
participant GCV as Google Vision API
User->>UI: 캡처 버튼 탭
UI->>UI: ImageCapture.takePicture() → 임시 파일/URI 생성
UI-->>Presenter: OnImageCaptured(imageUri)
Presenter->>Presenter: isLoading = true
Presenter->>Recognizer: recognizeText(imageUri)
Recognizer->>Service: POST v1/images:annotate (X-Goog-Api-Key, JSON)
Service->>GCV: 네트워크 요청
GCV-->>Service: CloudVisionResponse
Service-->>Recognizer: 응답 반환
Recognizer-->>Presenter: Result(success/failure)
Presenter->>UI: state 업데이트 (isLoading=false, 결과/sideEffect)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Assessment against linked issues
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
|
Pricing 정보 알 수 있을까요 |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (19)
core/ocr/build.gradle.kts (2)
35-37: 로컬 속성 조회 시 예외 메시지/대체 경로 부재
- 내부 API(com.android.build.gradle.internal….) 의존은 AGP 업데이트 시 호환성 리스크입니다.
- 키가 누락되면 NPE/불친절한 오류로 이어질 수 있어, 명시적 예외와 대체 소스(Gradle 속성, 환경 변수) 조회를 권장합니다.
-fun getApiKey(propertyKey: String): String { - return gradleLocalProperties(rootDir, providers).getProperty(propertyKey) -} +fun getApiKey(propertyKey: String): String { + val local = gradleLocalProperties(rootDir, providers).getProperty(propertyKey) + val gradleProp = providers.gradleProperty(propertyKey).orNull + val env = providers.environmentVariable(propertyKey).orNull + val value = local ?: gradleProp ?: env + return value ?: throw GradleException( + "Missing '$propertyKey'. Please add it to local.properties, " + + "or set a Gradle property/environment variable with the same name." + ) +}대안(내부 API 회피)이 필요하면 Properties를 직접 로드하는 방식으로 교체할 수 있습니다. 원하시면 해당 방식으로 패치를 제안하겠습니다.
15-17: 보안 강화: API Key 노출 방지 및 헤더 전달 권장모바일 앱에 API Key를 직접 포함하면 바이너리 역공학을 통해 추출될 위험이 있으므로, 아래 보호 조치를 권장드립니다.
- GCP Console에서
• Android 앱 제한(패키지명 + SHA-1) 설정
• API 제한(Cloud Vision API만 허용) 설정- HTTP 쿼리 파라미터(
?key=…) 대신 HTTP 헤더X-Goog-Api-Key로 API Key 전달 (공식 지원)- OkHttp 사용 시
• 쿼리스트링 대신 헤더 방식으로 Key 전달
• Interceptor에서redactHeader("X-Goog-Api-Key")호출로 로그 마스킹 적용- 더욱 강화된 보안 필요 시
• 앱 → 서버 프록시 → Cloud Vision 호출 구조 전환
• 모바일 앱에는 토큰/서명 기반 요청만 전달원하시면 curl, Python, Node.js 등 환경별 예제 코드나 OkHttp Interceptor 패치 샘플을 제공해 드리겠습니다.
Tags:
core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkQualifier.kt (1)
5-11: Qualifier 자체는 적절함 + KDoc 보완 제안두 Qualifier 분리는 명확하고 Hilt 적용에도 적합합니다. 간단한 KDoc을 추가해 사용처에서 의도를 바로 파악할 수 있게 하면 더 좋습니다.
-@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class CloudVisionOkHttp +/** Cloud Vision 전용 OkHttpClient 주입 식별자 */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CloudVisionOkHttp @@ -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class CloudVisionRetrofit +/** Cloud Vision 전용 Retrofit 인스턴스 주입 식별자 */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CloudVisionRetrofitcore/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionResponse.kt (1)
11-13: 응답 내 per-request 에러 정보(Status) 미모델링 → 오류와 “인식 실패”의 구분 어려움Cloud Vision은 responses[i].error(google.rpc.Status)를 채워 반환하는 경우가 많습니다. 이를 모델링하지 않으면 “오류”를 “빈 결과”로 취급하게 되어 UX/로깅이 흐려집니다. 최소 Status를 반영해 에러/빈결과를 구분하세요.
@Serializable data class AnnotateImageResponse( - val fullTextAnnotation: FullTextAnnotation? = null, + val fullTextAnnotation: FullTextAnnotation? = null, + val error: Status? = null, )추가 타입:
@Serializable data class Status( val code: Int? = null, val message: String? = null // val details: List<JsonElement>? = null // 필요 시 확장 )core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionRequest.kt (2)
24-26: Feature.type의 문자열 매직값 제거 및 기본값 ‘DOCUMENT_TEXT_DETECTION’ 권장문자열 상수는 오타 리스크가 있고, 도큐먼트(도서/영수증 등)에는 DOCUMENT_TEXT_DETECTION이 더 적합한 경우가 많습니다. enum으로 타이프 세이프티를 확보하고 기본값을 DOCUMENT_TEXT_DETECTION으로 바꾸는 것을 제안합니다.
@Serializable data class Feature( - val type: String = "TEXT_DETECTION", + val type: FeatureType = FeatureType.DOCUMENT_TEXT_DETECTION, )추가 enum:
@Serializable enum class FeatureType { TEXT_DETECTION, DOCUMENT_TEXT_DETECTION }추가로, 한국어 인식률 향상을 위해 필요시 ImageContext.languageHints에 ["ko"]를 전달하도록 상위 레이어까지 매개변수를 끌어올리는 것도 고려해주세요.
18-21: 대용량 Base64 전송 비용 고려(성능/요금/타임아웃)Camera 원본을 그대로 Base64로 올리면 페이로드가 커집니다. 업로드 전 리사이즈/크롭(문서 영역), JPEG 품질 조정 등을 적용하면 호출 지연/실패율을 줄일 수 있습니다. 필요시 가이드/유틸을 제안하겠습니다.
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt (2)
3-7: 서드파티 RememberedEffect 대신 Compose 표준 LaunchedEffect 권장(의존성 최소화)동일한 동작은 LaunchedEffect로 구현 가능하며, 추가 라이브러리 의존성을 줄일 수 있습니다.
-import com.skydoves.compose.effects.RememberedEffect +import androidx.compose.runtime.LaunchedEffect
14-22: 이벤트 소비(once-only) 보장 및 표준 Effect로 교체
- 현재는 sideEffect를 소비하지 않아(널로 되돌리거나 토글하지 않아) 동일 인스턴스가 재발행될 경우 재표시 여부가 구현 의존적입니다.
- LaunchedEffect로 교체하고, 필요 시 onSideEffectConsumed 콜백을 노출해 1회성 이벤트 소비를 보장하세요.
- RememberedEffect(state.sideEffect) { - when (state.sideEffect) { - is OcrSideEffect.ShowToast -> { - Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() - } - - null -> {} - } - } + LaunchedEffect(state.sideEffect) { + when (val effect = state.sideEffect) { + is OcrSideEffect.ShowToast -> { + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + // onSideEffectConsumed?.invoke() // 콜백 추가 시 소비 + } + null -> Unit + } + }선택 사항: 시그니처를
internal fun HandleOcrSideEffects(state: OcrUiState, onSideEffectConsumed: () -> Unit)로 확장하고, ViewModel에서sideEffect = null로 되돌리도록 하면 중복 토스트를 확실히 방지할 수 있습니다.core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt (1)
10-14: 불필요 필드 최소화(Partial Response)로 페이로드 축소 고려Vision API는
fields쿼리로 Partial Response를 지원합니다. 예:responses(fullTextAnnotation/text, textAnnotations/description)등. 대용량 base64를 업로드하는 만큼 응답도 가벼울수록 유리합니다. 필요 시 파라미터를 선택적으로 추가하는 오버로드/디폴트 파라미터를 고려해보세요.예시 시그니처(선택사항):
@POST("v1/images:annotate") suspend fun batchAnnotateImage( @Header("X-Goog-Api-Key") apiKey: String, @Body body: CloudVisionRequest, @Query("fields") fields: String? = null, ): CloudVisionResponsecore/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt (2)
21-24: 문서 스캔에 최적화된 Feature/언어 힌트 적용 제안책/문서 성격상
TEXT_DETECTION보다DOCUMENT_TEXT_DETECTION이 인식 품질이 더 안정적인 편입니다. 또한 한국어/영어 혼용을 고려해languageHints = listOf("ko","en")를 주면 품질이 개선됩니다.- features = listOf(Feature(type = "TEXT_DETECTION")), - imageContext = ImageContext(languageHints = null), + features = listOf(Feature(type = "DOCUMENT_TEXT_DETECTION")), + imageContext = ImageContext(languageHints = listOf("ko", "en")),
27-32: 200 응답 내 per-response error 처리 누락 가능성Vision API는 HTTP 200이더라도
responses[0].error에 오류가 담겨오는 케이스가 있습니다. 이 경우도Result.failure로 매핑해 상위(UI)에 명확히 전달하는 편이 좋습니다.예시(응답 모델에
error가 있다면):val resp = service.batchAnnotateImage(apiKey = ..., body = request) val error = resp.responses.firstOrNull()?.error return if (error != null) Result.failure(IllegalStateException("Vision error: ${error.message} (${error.code})")) else Result.success(resp)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt (3)
51-55: 의미 없는 no-op 인터셉터 제거요청을 재구성만 하고 아무 것도 하지 않는 인터셉터는 불필요하며 호출 오버헤드만 추가합니다.
- .addInterceptor { chain -> - val req = chain.request().newBuilder() - .build() - chain.proceed(req) - }
21-30: 네트워크 전송 최적화: prettyPrint 비활성화
prettyPrint = true는 요청 본문을 보기 좋게 들여쓰기하여 전송 바이트를 늘립니다. 네트워크 페이로드를 줄이기 위해 false를 권장합니다.- // JSON을 보기 좋게 들여쓰기하여 포맷팅 - prettyPrint = true + // 전송 크기 최적화 + prettyPrint = false
19-20: 상수 네이밍 컨벤션 통일
MaxTimeoutMillis→MAX_TIMEOUT_MILLIS로 변경하면 다른 상수(BASE_URL)와 컨벤션 일치합니다.-private const val MaxTimeoutMillis = 15_000L +private const val MAX_TIMEOUT_MILLIS = 15_000L @@ - .connectTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) - .readTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) - .writeTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) + .connectTimeout(MAX_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + .readTimeout(MAX_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + .writeTimeout(MAX_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)Also applies to: 56-58
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt (1)
34-35: ByteArray 이벤트 페이로드는 메모리 압박 가능 → URI/파일 경로 전달로 교체 고려대용량 이미지를 ByteArray로 메모리에 올려 전달하면 GC 압박과 힙 사용량이 커집니다. 이벤트에는
Uri또는 파일 경로를 전달하고, 실제 바이트 변환/리사이즈/압축은 Presenter/UseCase(백그라운드)에서 처리하는 구조를 권장합니다. 이 변경은 향후 리트라이/재처리에도 유리합니다.대안 예시:
data class OnCaptureButtonClick(val photoUri: Uri) : OcrUiEventfeature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (4)
69-72: 로그에는 사용자 메시지뿐 아니라 원인 파악에 필요한 최소 컨텍스트를 포함하세요현재는 exception.message만 로깅합니다. 요청 ID/HTTP 상태/엔드포인트 등 디버깅에 유용한 정보를 추가하거나, 내부 빌드에서만 스택트레이스를 남기는 전략을 고려해 주세요. (구체 API는 팀 로깅 유틸 정책에 따르세요.)
55-61: 불필요한 배열 생성 제거 — toPersistentList() 사용 권장varargs로 toTypedArray()를 거쳐 persistentListOf로 변환하는 대신, 컬렉션 확장인 toPersistentList()가 더 간결하고 할당이 적습니다. 위의 통합 diff에 반영되어 있습니다.
101-105: 중복 클릭으로 중복 요청 방지isLoading 동안에는 새로운 캡처 요청을 무시해 네트워크 중복 호출/레이스 컨디션을 피하세요.
is OcrUiEvent.OnCaptureButtonClick -> { - isTextDetectionFailed = false + if (isLoading) return + isTextDetectionFailed = false val base64Image = Base64.encodeToString(event.imageData, Base64.NO_WRAP) recognizeText(base64Image) }
103-105: 요금/지연 최적화: 업로드 전에 리사이즈·압축 고려원본 이미지를 그대로 Base64로 올리면 트래픽·요금·지연이 증가합니다. 캡처 직후 장치 내에서:
- 긴 변 기준 ~1280px 리사이즈
- JPEG 품질 80~85로 압축
- EXIF 제거
를 수행한 뒤 인코딩하는 것을 권장합니다. UI에서는 isLoading과 함께 버튼 비활성화/인디케이터로 사용자 피드백을 유지하세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (11)
core/ocr/build.gradle.kts(1 hunks)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt(1 hunks)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt(1 hunks)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkQualifier.kt(1 hunks)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionRequest.kt(1 hunks)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionResponse.kt(1 hunks)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt(1 hunks)feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt(1 hunks)feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt(5 hunks)feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt(7 hunks)feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt(2 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#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과 팀은 한국어 주석을 선호하며, 한국어 주석을 영어로 번역하라는 제안을 하지 않아야 함
🧬 Code graph analysis (3)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt (1)
HandleOcrSideEffects(8-23)
core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt (1)
core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/RunSuspendCatching.kt (1)
runSuspendCatching(16-30)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (1)
core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt (1)
handleException(19-46)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: ci-build
🔇 Additional comments (11)
core/ocr/build.gradle.kts (2)
31-32: 현재 ML Kit 한국어 텍스트 인식 의존성은 여전히 사용 중입니다OcrModule에서
TextRecognition.getClient(KoreanTextRecognizerOptions.Builder().build())로 텍스트 인식 클라이언트를 제공하고, StillTextAnalyzer 및 LiveTextAnalyzer에서TextRecognizer와InputImage를 사용하고 있습니다.
따라서core/ocr/build.gradle.kts의- libs.google.mlkit.text.recognition.korean,의존성은 아직 제거할 수 없습니다.
– 의존성을 제거하려면
•OcrModule.kt의 provider 메서드
•StillTextAnalyzer.kt와LiveTextAnalyzer.kt의 ML Kit 사용부
를 모두 삭제 또는 대체해야 합니다.
추후 온디바이스 OCR 기능 자체를 완전히 제거한 다음에 의존성을 정리해주세요.Likely an incorrect or invalid review comment.
8-8: ignoreUnknownKeys 설정이 이미 Retrofit 컨버터에 적용되어 있습니다.아래 파일들에서 Json 구성 객체에
ignoreUnknownKeys = true가 명시되어 있으며, 해당 객체를 ConverterFactory로 등록하고 있습니다. 추가 조치가 필요하지 않습니다.
core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt
• 21–27행:private val jsonRule = Json { // 기본값도 JSON에 포함하여 직렬화 encodeDefaults = true // JSON에 정의되지 않은 키는 무시 (역직렬화 시 에러 방지) ignoreUnknownKeys = true // JSON을 보기 좋게 들여쓰기하여 포맷팅 prettyPrint = true }• 71행:
.addConverterFactory(jsonConverterFactory)로jsonRule기반 Converter 등록됨
core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkModule.kt
• 26–32행:private val jsonRule = Json { encodeDefaults = true // JSON에 정의되지 않은 키는 무시 (역직렬화 시 에러 방지) ignoreUnknownKeys = true prettyPrint = true }• 120행:
.addConverterFactory(jsonConverterFactory)로jsonRule기반 Converter 등록됨core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionResponse.kt (1)
5-8: LGTM: 최소 스키마만 모델링하고 불필요한 필드 배제응답의 핵심 필드만 모델링한 점은 좋습니다. 단, Retrofit/kotlinx.serialization 쪽에서 ignoreUnknownKeys가 설정되어 있어야 안전합니다(빌드 설정 코멘트 참고).
core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt (1)
9-15: 엔드포인트/모델 매핑은 적절합니다Retrofit 인터페이스, suspend 반환, Request/Response 직렬화 전제 모두 자연스럽습니다. 위 보안 포인트만 해결되면 서비스 자체는 깔끔합니다.
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt (2)
84-85: 사이드 이펙트 처리 훅 연결 👍UI에서 토스트 등 일회성 이펙트를 안전하게 처리하도록 분리한 점 좋습니다.
290-297: 로딩 오버레이 적용 적절로딩 중 캡처 버튼 비활성화와 풀스크린 인디케이터로 상태 전달이 명확합니다.
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt (2)
17-19: 로딩/사이드이펙트 상태 추가 적절UI/처리 흐름이 명확해졌습니다. 사이드 이펙트 분리로 재조합성/테스트 용이성도 좋아집니다.
22-28: 토스트용 사이드이펙트 키 전략 👍UUID 키로 동일 메시지 연속 노출 시에도 구분되는 점이 좋습니다.
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (3)
9-12: Cloud OCR로의 전환 및 DI 주입 방향성 좋습니다
- 앱 네트워크와 분리된 core:ocr 레이어에서 인식기를 주입받는 구조가 향후 교체/제거에 유리합니다.
Also applies to: 27-28
103-103: Base64.NO_WRAP 사용 적절Cloud Vision에 전달할 이미지 바이트는 줄바꿈 없이 Base64 인코딩하는 것이 맞습니다.
44-82: 예외 미처리로 인해 코루틴 크래시/침묵 실패 가능성 — catch 추가, 401 처리, 문장 리스트 변환 개선 제안
- recognizeText가 Result 외에 예외를 직접 throw하면 onFailure가 실행되지 않고 코루틴 핸들러로 전파됩니다. 현재는 finally에서 로딩만 끄고 사용자 피드백이 사라질 수 있습니다.
- 401(HttpException)일 때 onLoginRequired를 비워두면 사용자에게 어떤 메시지도 전달되지 않습니다. Cloud Vision의 인증 실패/키 문제는 명시적인 안내가 필요합니다.
- 문장 분할은 lines() + toPersistentList()가 간결하고 할당을 줄입니다.
- 텍스트 미검출(빈 문자열)일 때도 사용자에게 안내 메시지를 주는 편이 UX에 좋습니다.
아래처럼 보완을 권장합니다.
fun recognizeText(base64Image: String) { scope.launch { - try { + try { isLoading = true recognizer.recognizeText(base64Image) .onSuccess { val text = it.responses.firstOrNull()?.fullTextAnnotation?.text.orEmpty() recognizedText = text if (text.isNotBlank()) { isTextDetectionFailed = false - val sentences = text - .split("\n") - .map { it.trim() } - .filter { it.isNotEmpty() } - - sentenceList = persistentListOf(*sentences.toTypedArray()) + val sentences = text + .lines() + .map { it.trim() } + .filter { it.isNotEmpty() } + sentenceList = sentences.toPersistentList() currentUi = OcrUi.RESULT } else { isTextDetectionFailed = true + sideEffect = OcrSideEffect.ShowToast("텍스트를 인식하지 못했어요. 더 밝은 곳에서 다시 촬영해 주세요.") } } .onFailure { exception -> isTextDetectionFailed = true - - val handleErrorMessage = { message: String -> - Logger.e("Cloud Vision API Error: ${exception.message}") - sideEffect = OcrSideEffect.ShowToast(message) - } - handleException( - exception = exception, - onError = handleErrorMessage, - onLoginRequired = {}, + exception = exception, + onError = { message -> + Logger.e("Cloud Vision API Error: ${exception.message}") + sideEffect = OcrSideEffect.ShowToast(message) + }, + onLoginRequired = { + sideEffect = OcrSideEffect.ShowToast("OCR 서비스 인증 오류가 발생했습니다. 잠시 후 다시 시도해주세요.") + }, ) } - } finally { + } catch (e: Throwable) { + // 필요 시 CancellationException은 재전파해 취소语의를 보존하세요. + // if (e is CancellationException) throw e + isTextDetectionFailed = true + handleException( + exception = e, + onError = { message -> + Logger.e("Cloud Vision API Error: ${e.message}") + sideEffect = OcrSideEffect.ShowToast(message) + }, + onLoginRequired = { + sideEffect = OcrSideEffect.ShowToast("OCR 서비스 인증 오류가 발생했습니다. 잠시 후 다시 시도해주세요.") + }, + ) + } finally { isLoading = false } } }추가로 CancellationException을 재전파하려면 아래 임포트를 파일 상단에 추가하세요:
import kotlinx.coroutines.CancellationException⛔ Skipped due to learnings
Learnt from: seoyoon513 PR: YAPP-Github/Reed-Android#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 PR: YAPP-Github/Reed-Android#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, try-catch, invokeOnCompletion을 통해 모든 예외 상황을 다루므로 presenter 레벨에서 추가 에러 처리가 불필요함
| defaultConfig { | ||
| buildConfigField("String", "CLOUD_VISION_API_KEY", getApiKey("CLOUD_VISION_API_KEY")) | ||
| } |
There was a problem hiding this comment.
buildConfigField에 따옴표 누락 → 빌드 실패/런타임 상수 생성 실패
Gradle의 buildConfigField는 세 번째 인자로 “코드 리터럴 문자열”을 기대합니다. 현재는 실제 키 값이 그대로 들어가 빌드시 문법 오류가 나거나 BuildConfig 상수가 잘못 생성될 수 있습니다. 문자열을 이스케이프하여 전달해주세요.
- buildConfigField("String", "CLOUD_VISION_API_KEY", getApiKey("CLOUD_VISION_API_KEY"))
+ buildConfigField(
+ "String",
+ "CLOUD_VISION_API_KEY",
+ "\"${getApiKey("CLOUD_VISION_API_KEY")}\""
+ )📝 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.
| defaultConfig { | |
| buildConfigField("String", "CLOUD_VISION_API_KEY", getApiKey("CLOUD_VISION_API_KEY")) | |
| } | |
| defaultConfig { | |
| buildConfigField( | |
| "String", | |
| "CLOUD_VISION_API_KEY", | |
| "\"${getApiKey("CLOUD_VISION_API_KEY")}\"" | |
| ) | |
| } |
🤖 Prompt for AI Agents
In core/ocr/build.gradle.kts around lines 15 to 17, the buildConfigField call
passes the raw API key value as the third argument which must be a code literal
string; wrap the returned key value in escaped double quotes so the third
parameter is a proper quoted string literal (i.e., ensure the key is supplied as
a string literal with escaped quotes appropriate for the Kotlin Gradle DSL) to
prevent build-time syntax errors and ensure BuildConfig generates the correct
constant.
| suspend fun recognizeText(base64Image: String): Result<CloudVisionResponse> = runSuspendCatching { | ||
| val request = CloudVisionRequest( | ||
| requests = listOf( | ||
| AnnotateImageRequest( | ||
| image = VisionImage(base64Image), | ||
| features = listOf(Feature(type = "TEXT_DETECTION")), | ||
| imageContext = ImageContext(languageHints = null), | ||
| ), | ||
| ), | ||
| ) | ||
|
|
||
| service.batchAnnotateImage( | ||
| key = BuildConfig.CLOUD_VISION_API_KEY, | ||
| body = request, | ||
| ) | ||
| } |
There was a problem hiding this comment.
suspend 컨텍스트에서 non-suspend 람다(runSuspendCatching)에 suspend 호출을 넣어 컴파일 오류 발생
제공된 runSuspendCatching은 block: () -> T (non-suspend) 시그니처입니다. 그 안에서 service.batchAnnotateImage(...)(suspend)를 호출할 수 없어 컴파일되지 않습니다. 간단히 try/catch로 감싸서 Result를 만들거나, runSuspendCatching을 suspend로 오버로드해야 합니다. 또한 서비스 시그니처를 헤더로 바꾸면 인자명도 변경해야 합니다.
아래는 Recognizer 쪽에서 try/catch로 정리하는 최소 변경안입니다:
-class CloudOcrRecognizer @Inject constructor(
- private val service: CloudVisionService,
-) {
- suspend fun recognizeText(base64Image: String): Result<CloudVisionResponse> = runSuspendCatching {
- val request = CloudVisionRequest(
+class CloudOcrRecognizer @Inject constructor(
+ private val service: CloudVisionService,
+) {
+ suspend fun recognizeText(base64Image: String): Result<CloudVisionResponse> {
+ val request = CloudVisionRequest(
requests = listOf(
AnnotateImageRequest(
image = VisionImage(base64Image),
- features = listOf(Feature(type = "TEXT_DETECTION")),
- imageContext = ImageContext(languageHints = null),
+ features = listOf(Feature(type = "TEXT_DETECTION")),
+ imageContext = ImageContext(languageHints = null),
),
),
)
-
- service.batchAnnotateImage(
- key = BuildConfig.CLOUD_VISION_API_KEY,
- body = request,
- )
- }
+ return try {
+ val response = service.batchAnnotateImage(
+ apiKey = BuildConfig.CLOUD_VISION_API_KEY,
+ body = request,
+ )
+ Result.success(response)
+ } catch (rethrown: CancellationException) {
+ throw rethrown
+ } catch (t: Throwable) {
+ Result.failure(t)
+ }
+ }
}대안: 공용 유틸 runSuspendCatching에 suspend 오버로드를 추가하는 방향도 가능합니다.
📝 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.
| suspend fun recognizeText(base64Image: String): Result<CloudVisionResponse> = runSuspendCatching { | |
| val request = CloudVisionRequest( | |
| requests = listOf( | |
| AnnotateImageRequest( | |
| image = VisionImage(base64Image), | |
| features = listOf(Feature(type = "TEXT_DETECTION")), | |
| imageContext = ImageContext(languageHints = null), | |
| ), | |
| ), | |
| ) | |
| service.batchAnnotateImage( | |
| key = BuildConfig.CLOUD_VISION_API_KEY, | |
| body = request, | |
| ) | |
| } | |
| class CloudOcrRecognizer @Inject constructor( | |
| private val service: CloudVisionService, | |
| ) { | |
| suspend fun recognizeText(base64Image: String): Result<CloudVisionResponse> { | |
| val request = CloudVisionRequest( | |
| requests = listOf( | |
| AnnotateImageRequest( | |
| image = VisionImage(base64Image), | |
| features = listOf(Feature(type = "TEXT_DETECTION")), | |
| imageContext = ImageContext(languageHints = null), | |
| ), | |
| ), | |
| ) | |
| return try { | |
| val response = service.batchAnnotateImage( | |
| apiKey = BuildConfig.CLOUD_VISION_API_KEY, | |
| body = request, | |
| ) | |
| Result.success(response) | |
| } catch (rethrown: CancellationException) { | |
| throw rethrown | |
| } catch (t: Throwable) { | |
| Result.failure(t) | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
In
core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt
around lines 17–32, the code calls the suspend function
service.batchAnnotateImage(...) from inside runSuspendCatching which has a
non-suspend signature, causing a compile error; wrap the suspend call in a
proper suspend-aware construct by replacing runSuspendCatching with an explicit
try/catch in the suspend function and return Result.success(response) or
Result.failure(exception), or alternatively add a suspend overload
runSuspendCatching(block: suspend () -> T) in the shared util; also update the
service call argument names if you change to header-style signature (e.g., use
appropriate header parameter name instead of key) so the call matches the
service interface.
| var isLoading by rememberRetained { mutableStateOf(false) } | ||
| var sideEffect by rememberRetained { mutableStateOf<OcrSideEffect?>(null) } | ||
|
|
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
sideEffect는 1회성으로 소비되도록 클리어 경로를 보장하세요
UI에서 토스트를 표시한 뒤 sideEffect를 null로 되돌리지 않으면, 재구성 시 중복 표시될 수 있습니다. OcrUi에서 이미 소비 후 null 처리(또는 별도 이벤트로 클리어)를 하고 있는지 확인 부탁드립니다. 없다면 OnSideEffectConsumed 같은 이벤트를 추가해 here에서 sideEffect = null을 수행하세요.
다음 스크립트로 소비 경로가 존재하는지 확인할 수 있습니다:
Also applies to: 144-145
🏁 Script executed:
#!/bin/bash
# sideEffect null 처리 여부
rg -nP 'sideEffect\s*=\s*null' -C2
# 소비 이벤트/헬퍼 존재 여부
rg -nP 'OnSideEffect|SideEffectConsumed|HandleOcrSideEffects' -C3
# 토스트 트리거 사용처 파악
rg -nP 'OcrSideEffect\.ShowToast\(' -C3Length of output: 3975
🏁 Script executed:
#!/bin/bash
# Inspect side-effect handler to verify clearing logic
sed -n '1,200p' feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.ktLength of output: 715
OcrSideEffect 소비 후 클리어 경로 추가 필요
현재 HandleOcrSideEffects 컴포저블에서는 state.sideEffect 값을 읽어 Toast만 표시할 뿐, Presenter 쪽 sideEffect 변수를 null로 되돌려 주지 않아 이후 재구성 시 재발동되지 않습니다. BookDetail/LibrayPresenter처럼 소비 후 null 처리 로직이 필요합니다.
• 파일 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt
– sideEffect 초기 선언부(41–43줄)
– 에러 처리 시 ShowToast 할당 부(약 71줄)
→ 소비 후 sideEffect = null 호출 메서드(예: clearSideEffect()) 추가
• 파일 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt
– HandleOcrSideEffects 호출부에 onSideEffectConsumed: () -> Unit 파라미터 추가
– RememberedEffect 블록 끝에 onSideEffectConsumed() 호출
예시 diff:
// OcrPresenter.kt
@@ class OcrPresenter {
var sideEffect by rememberRetained { mutableStateOf<OcrSideEffect?>(null) }
+ private fun clearSideEffect() {
+ sideEffect = null
+ }
private fun handleError(message: String) {
sideEffect = OcrSideEffect.ShowToast(message)
+ // 바로 클리어 호출
+ clearSideEffect()
}
}
// OcrUi.kt
@@ @Composable fun OcrUi(…) {
- HandleOcrSideEffects(state = state)
+ HandleOcrSideEffects(
+ state = state,
+ onSideEffectConsumed = { viewModel.clearSideEffect() }
+ )
}
// HandleOcrSideEffects.kt
@@ Composable fun HandleOcrSideEffects(
- RememberedEffect(state.sideEffect) {
+ RememberedEffect(state.sideEffect) {
when (state.sideEffect) {
is OcrSideEffect.ShowToast -> {
Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show()
+ onSideEffectConsumed()
}
null -> {}
}
}이렇게 하면 토스트 표시 후 반드시 sideEffect 가 null 로 클리어되어, 재구성 시 중복 실행을 방지할 수 있습니다.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt
around lines 41–43 and the error-assignment site near line ~71, the presenter
sets sideEffect but never clears it after the UI consumes it; add a method on
the presenter (e.g., clearSideEffect()) that sets sideEffect = null and call it
wherever the presenter assigns ShowToast on error so the effect can be cleared
from the presenter side. Also update
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt
where HandleOcrSideEffects is invoked: add an onSideEffectConsumed: () -> Unit
parameter, call that callback at the end of the rememberedEffect block after
showing the toast, and wire that callback to presenter.clearSideEffect() so the
sideEffect is nulled immediately after consumption.
| handleException( | ||
| exception = exception, | ||
| onError = handleErrorMessage, | ||
| onLoginRequired = {}, | ||
| ) | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
401 케이스가 사용자에게 전달되지 않습니다 — onLoginRequired에서 안내 토스트 표시
Cloud Vision 인증 실패(예: API 키 문제) 시 현재는 아무 피드백이 없습니다. onLoginRequired에서 사용자 안내 토스트를 띄우도록 했습니다(상단 통합 diff 참고). 서비스 정책에 따라 설정 화면 이동 등 추가 액션을 연결해도 좋습니다.
🤖 Prompt for AI Agents
In
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt
around lines 74 to 79, the onLoginRequired lambda passed to handleException is
empty so 401/auth failures give no user feedback; replace the empty lambda with
code that displays a user-facing toast (or invokes the presenter/view method
that shows a login-required toast) and optionally routes the user to the
settings/login screen. Specifically, change onLoginRequired = {} to call the
existing UI-facing function (e.g., showLoginRequiredToast() or
view.showToast(...)) or trigger navigation to the credential/settings screen so
authentication errors produce an immediate toast and actionable navigation.
| enabled = !state.isLoading, | ||
| onClick = { | ||
| state.eventSink(OcrUiEvent.OnCaptureButtonClick) | ||
| 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) { | ||
| val bytes = photoFile.readBytes() | ||
| state.eventSink(OcrUiEvent.OnCaptureButtonClick(bytes)) | ||
| } | ||
|
|
||
| override fun onError(exception: ImageCaptureException) { | ||
| Logger.e("ImageCaptureException: ${exception.message}") | ||
| } | ||
| } | ||
| ) |
There was a problem hiding this comment.
🛠️ Refactor suggestion
대용량 이미지 파일을 메인 스레드에서 읽고 있어 UI 프리즈 위험 + 캐시 파일 삭제 누락
ContextCompat.getMainExecutor(context)로 콜백이 메인에서 실행됩니다. photoFile.readBytes()는 수 MB 이상일 수 있어 UI를 블로킹합니다. 또한 임시 파일을 삭제하지 않아 캐시 누적 우려가 있습니다. 콜백 내부에서 IO 디스패처로 파일을 읽고, 결과만 메인으로 전송 후 파일을 삭제하세요.
아래 최소 변경안(주석 포함):
- val executor = ContextCompat.getMainExecutor(context)
+ 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) {
- val bytes = photoFile.readBytes()
- state.eventSink(OcrUiEvent.OnCaptureButtonClick(bytes))
+ // 큰 파일 읽기는 백그라운드에서 처리
+ scope.launch(Dispatchers.IO) {
+ runCatching { photoFile.readBytes() }
+ .onSuccess { bytes ->
+ withContext(Dispatchers.Main) {
+ state.eventSink(OcrUiEvent.OnCaptureButtonClick(bytes))
+ }
+ }
+ .onFailure { e ->
+ Logger.e("Read captured file failed: ${'$'}{e.message}")
+ }
+ // 캐시 정리
+ photoFile.delete()
+ }
}
override fun onError(exception: ImageCaptureException) {
Logger.e("ImageCaptureException: ${exception.message}")
}
}
)추가로, 아래 보조 변경이 필요합니다.
CameraPreview컴포저블 상단 근처에val scope = rememberCoroutineScope()를 선언- import 추가:
import androidx.compose.runtime.rememberCoroutineScope,import kotlinx.coroutines.Dispatchers,import kotlinx.coroutines.launch,import kotlinx.coroutines.withContext
📝 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.
| enabled = !state.isLoading, | |
| onClick = { | |
| state.eventSink(OcrUiEvent.OnCaptureButtonClick) | |
| 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) { | |
| val bytes = photoFile.readBytes() | |
| state.eventSink(OcrUiEvent.OnCaptureButtonClick(bytes)) | |
| } | |
| override fun onError(exception: ImageCaptureException) { | |
| Logger.e("ImageCaptureException: ${exception.message}") | |
| } | |
| } | |
| ) | |
| enabled = !state.isLoading, | |
| onClick = { | |
| 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) { | |
| // 큰 파일 읽기는 백그라운드에서 처리 | |
| scope.launch(Dispatchers.IO) { | |
| runCatching { photoFile.readBytes() } | |
| .onSuccess { bytes -> | |
| withContext(Dispatchers.Main) { | |
| state.eventSink(OcrUiEvent.OnCaptureButtonClick(bytes)) | |
| } | |
| } | |
| .onFailure { e -> | |
| Logger.e("Read captured file failed: ${e.message}") | |
| } | |
| // 캐시 정리 | |
| photoFile.delete() | |
| } | |
| } | |
| override fun onError(exception: ImageCaptureException) { | |
| Logger.e("ImageCaptureException: ${exception.message}") | |
| } | |
| } | |
| ) |
🤖 Prompt for AI Agents
In
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt
around lines 252-271, the ImageCapture callback reads the temp file on the main
executor (blocking UI) and never deletes the temp file; change the callback to
offload file IO to the IO dispatcher and only send the result back on the main
thread, then delete the temp file. Concretely: ensure a rememberCoroutineScope()
is available near CameraPreview, keep the callback on the main executor but
inside it launch a coroutine that uses withContext(Dispatchers.IO) to read
photoFile.readBytes() and delete(photoFile), then switch to Main (or use the
original scope) to call state.eventSink(OcrUiEvent.OnCaptureButtonClick(bytes)).
Also add the imports androidx.compose.runtime.rememberCoroutineScope,
kotlinx.coroutines.Dispatchers, kotlinx.coroutines.launch, and
kotlinx.coroutines.withContext.
| return OkHttpClient.Builder() | ||
| .addInterceptor(log) | ||
| .addInterceptor { chain -> | ||
| val req = chain.request().newBuilder() |
| .map { it.trim() } | ||
| .filter { it.isNotEmpty() } | ||
|
|
||
| sentenceList = persistentListOf(*sentences.toTypedArray()) |
There was a problem hiding this comment.
여기 그냥 toPersistentList()로 변환할수있으면 그 방식 부탁합니다
| val selectedIndices: Set<Int> = emptySet(), | ||
| val isTextDetectionFailed: Boolean = false, | ||
| val isRecaptureDialogVisible: Boolean = false, | ||
| val isLoading: Boolean = false, |
There was a problem hiding this comment.
그 이번 변경 사항은 아닌데 윗줄에 sentence persistentListOf()로 초기화 해두는게 리스트 변환 비용을 줄일수있어 변경 부탁드림다
easyhooon
left a comment
There was a problem hiding this comment.
1000건이면 나름 넉넉하네요 다행
인식률 개선하느라 수고하셨습니다!
There was a problem hiding this comment.
Actionable comments posted: 3
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)
86-144: sideEffect 소비 이벤트 처리 추가UI에서
OnSideEffectConsumed를 보낼 수 있도록 처리해 주세요.fun handleEvent(event: OcrUiEvent) { when (event) { @@ is OcrUiEvent.OnSentenceSelected -> { selectedIndices = if (selectedIndices.contains(event.index)) { selectedIndices - event.index } else { selectedIndices + event.index } } + is OcrUiEvent.OnSideEffectConsumed -> { + sideEffect = null + }
♻️ Duplicate comments (4)
core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt (1)
22-44: 컴파일 불가: suspend 호출을 non-suspend 블록(runSuspendCatching) 안에서 수행하고 있습니다현재 runSuspendCatching의 시그니처는
block: () -> T(non-suspend)인데, 블록 내부에서withContext및service.batchAnnotateImage(...)(suspend)를 호출하여 컴파일되지 않습니다. 아래처럼 try/catch로 감싸는 방향이 가장 간단합니다. 또한 이 기회에 파일 삭제 누락도 같이 보완했습니다.- 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 base64Image = Base64.encodeToString(byte, Base64.NO_WRAP) - - val request = CloudVisionRequest( - requests = listOf( - AnnotateImageRequest( - image = VisionImage(base64Image), - features = listOf(Feature(type = "TEXT_DETECTION")), - imageContext = ImageContext(languageHints = null), - ), - ), - ) - - service.batchAnnotateImage( - apiKey = BuildConfig.CLOUD_VISION_API_KEY, - body = request, - ) - } - } + suspend fun recognizeText(imageUri: Uri): Result<CloudVisionResponse> { + return try { + val response = withContext(Dispatchers.IO) { + val filePath = imageUri.path ?: error("URI does not have a valid path.") + val file = File(filePath) + val bytes = file.readBytes() + val base64Image = Base64.encodeToString(bytes, Base64.NO_WRAP) + // 읽기 이후 즉시 삭제하여 캐시 누적 방지 + runCatching { if (imageUri.scheme == "file") file.delete() } + + val request = CloudVisionRequest( + requests = listOf( + AnnotateImageRequest( + image = VisionImage(base64Image), + features = listOf(Feature(type = "TEXT_DETECTION")), + imageContext = ImageContext(languageHints = null), + ), + ), + ) + + service.batchAnnotateImage( + apiKey = BuildConfig.CLOUD_VISION_API_KEY, + body = request, + ) + } + Result.success(response) + } catch (rethrown: CancellationException) { + throw rethrown + } catch (t: Throwable) { + Result.failure(t) + } + }feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt (1)
85-86: sideEffect는 소비 후 반드시 클리어 — 콜백 연동으로 중복 토스트 방지토스트 표시 후
sideEffect를 null로 되돌리는 소비 경로가 없습니다. 아래처럼 소비 콜백을 받아 Presenter로 이벤트를 전달해 즉시 클리어하는 패턴을 권장합니다.- HandleOcrSideEffects(state = state) + HandleOcrSideEffects( + state = state, + onSideEffectConsumed = { state.eventSink(OcrUiEvent.OnSideEffectConsumed) }, + )추가 변경(파일 외 보조 수정):
- HandleOcrSideEffects.kt:
onSideEffectConsumed: () -> Unit파라미터 추가 후 토스트 표시 직후 호출- OcrUiState.kt:
OnSideEffectConsumed이벤트 추가- OcrPresenter.kt:
OnSideEffectConsumed수신 시sideEffect = nullfeature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt (1)
23-29: 토스트 재발동 방지 키 전략은 좋지만, 소비 후 클리어 경로가 필요
ShowToast에 랜덤 키를 포함해 동등성 문제를 회피한 접근은 합리적입니다. 다만 Presenter에서sideEffect = null로 되돌리는 경로가 없으므로 이벤트 기반 소비를 추가해 주세요(아래 이벤트 추가 코멘트 참고).feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (1)
74-79: 401/인증 실패 사용자 피드백 누락 — onLoginRequired 처리Cloud Vision API 키 문제 등 401 상황에서 사용자에게 아무 안내가 없습니다. 간단히 토스트라도 노출해 주세요.
- handleException( + handleException( exception = exception, onError = handleErrorMessage, - onLoginRequired = {}, + onLoginRequired = { + sideEffect = OcrSideEffect.ShowToast("인증이 필요해요. Vision API 키를 확인해 주세요.") + }, )
🧹 Nitpick comments (4)
core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt (2)
24-27: file:// 전제 가정 완화 제안 — content:// URI 대응 또는 API 변경 검토현재
Uri.path로File을 직접 읽는 구조는 file 스킴에만 안전합니다. 향후 갤러리/SAF 등에서 넘어오는content://URI를 고려하면,
- (선호) Recognizer의 입력을
ByteArray로 변경하여 상위(프레젠터/Ui)에서ContentResolver로 안전하게 바이트를 준비하거나,- (대안) Recognizer에
ContentResolver(또는Context)를 주입해 스킴에 따라 분기 처리
를 권장합니다.
26-27: 네이밍 정리
val byte = file.readBytes()는 복수형 데이터이므로bytes가 더 자연스럽습니다. 위의 메인 수정안(diff)에 포함했습니다.feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (2)
44-83: 인식 완료 후 임시 파일 정리(성공/실패와 무관) 추가현재 Recognizer에서 읽은 직후 삭제하도록 제안했지만, 이 레이어에서 한 번 더 안전망을 두면 누수가 줄어듭니다. 또한
isLoading을finally에서 false로 내리고 있어 적절합니다. 아래처럼finally에 파일 삭제를 덧붙이는 것을 권장합니다.fun recognizeText(imageUri: Uri) { scope.launch { try { isLoading = true recognizer.recognizeText(imageUri) .onSuccess { @@ } .onFailure { exception -> isTextDetectionFailed = true @@ handleException( exception = exception, onError = handleErrorMessage, - onLoginRequired = {}, + onLoginRequired = {}, ) } } finally { isLoading = false + // 파일 스킴인 경우 잔여 임시 파일 정리(Recognizer에서 실패했을 때 대비) + if (imageUri.scheme == "file") { + runCatching { File(requireNotNull(imageUri.path)).delete() } + } } } }
50-64: 응답 파싱 보강 제안(선택)
responses.firstOrNull()?.fullTextAnnotation?.text만 확인하면 Vision이 개별 response에error를 채운 경우를 놓칠 수 있습니다. 에러 필드가 있는지 먼저 검사하고, 그 경우onFailure로 넘기거나 사용자에게 안내하는 로직을 추가해 두면 디버깅이 쉬워집니다.원하시면 응답 모델(CloudVisionResponse)에 맞춘 안전한 파싱 유틸을 생성해 드리겠습니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (6)
core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt(1 hunks)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt(1 hunks)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt(1 hunks)feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt(4 hunks)feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt(8 hunks)feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt
- core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#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과 팀은 한국어 주석을 선호하며, 한국어 주석을 영어로 번역하라는 제안을 하지 않아야 함
🧬 Code graph analysis (3)
core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt (1)
core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/RunSuspendCatching.kt (1)
runSuspendCatching(16-30)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (2)
core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt (1)
handleException(19-46)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt (1)
recognizeText(22-44)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt (1)
HandleOcrSideEffects(8-23)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: ci-build
🔇 Additional comments (2)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt (1)
292-299: 로딩 오버레이 UX 적합촬영/인식 중 이탈 방지를 위해 버튼 비활성화 + 중앙 로딩 오버레이 구성 좋습니다.
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt (1)
14-15: 불변 리스트 초기값과 로딩/사이드이펙트 상태 추가 👍
persistentListOf()초기화로 불필요한 복사가 줄어들고,isLoading/sideEffect도입으로 상태 기민성이 좋아졌습니다.Also applies to: 18-20
| features = listOf(Feature(type = "TEXT_DETECTION")), | ||
| imageContext = ImageContext(languageHints = null), | ||
| ), |
There was a problem hiding this comment.
🛠️ Refactor suggestion
인식률 목적에 맞춘 기능 타입 전환: DOCUMENT_TEXT_DETECTION 권장
Cloud Vision에서 일반 텍스트가 아닌 문서 단위의 OCR 정밀도를 원하시면 TEXT_DETECTION 대신 DOCUMENT_TEXT_DETECTION을 사용하세요. 본 PR의 목표(“인식률 개선”)에 직접적인 기여가 큽니다.
- features = listOf(Feature(type = "TEXT_DETECTION")),
- imageContext = ImageContext(languageHints = null),
+ features = listOf(Feature(type = "DOCUMENT_TEXT_DETECTION")),
+ imageContext = ImageContext(languageHints = listOf("ko", "en")),추가로 언어 힌트(languageHints)를 사용 가능한 주 사용 언어(ko, en 등)로 제공하면 품질 향상에 도움이 됩니다.
📝 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.
| features = listOf(Feature(type = "TEXT_DETECTION")), | |
| imageContext = ImageContext(languageHints = null), | |
| ), | |
| @@ core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt | |
| val request = CloudVisionRequest( | |
| requests = listOf( | |
| AnnotateImageRequest( | |
| image = VisionImage(base64Image)), | |
| - features = listOf(Feature(type = "TEXT_DETECTION")), | |
| features = listOf(Feature(type = "DOCUMENT_TEXT_DETECTION")), | |
| imageContext = ImageContext(languageHints = listOf("ko", "en")), | |
| ), | |
| ), | |
| ) |
🤖 Prompt for AI Agents
In
core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt
around lines 33-35, the feature currently uses "TEXT_DETECTION" and
imageContext.languageHints is null; change the feature type to
"DOCUMENT_TEXT_DETECTION" to improve document-level OCR accuracy and set
imageContext.languageHints to a list of primary languages (e.g., "ko", "en") or
a configurable value so the Vision API can use language hints for better
results.
| enabled = !state.isLoading, | ||
| onClick = { | ||
| state.eventSink(OcrUiEvent.OnCaptureButtonClick) | ||
| 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)) | ||
| } | ||
| }, | ||
| ) | ||
| }, |
There was a problem hiding this comment.
🛠️ Refactor suggestion
임시 사진 파일 삭제 누락(에러 경로) — 캐시 누적 방지
촬영 실패 시 생성된 임시 파일이 삭제되지 않아 캐시가 누적됩니다. 성공 경로는 Recognizer에서 읽은 직후 삭제하도록 제안했고, 실패 경로는 여기서 삭제해 주세요.
override fun onError(exception: ImageCaptureException) {
- state.eventSink(OcrUiEvent.OnCaptureFailed(exception))
+ state.eventSink(OcrUiEvent.OnCaptureFailed(exception))
+ // 실패 시 임시 파일 정리
+ runCatching { photoFile.delete() }
}📝 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.
| enabled = !state.isLoading, | |
| onClick = { | |
| state.eventSink(OcrUiEvent.OnCaptureButtonClick) | |
| 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)) | |
| } | |
| }, | |
| ) | |
| }, | |
| enabled = !state.isLoading, | |
| onClick = { | |
| 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)) | |
| // 실패 시 임시 파일 정리 | |
| runCatching { photoFile.delete() } | |
| } | |
| }, | |
| ) | |
| }, |
| sealed interface OcrUiEvent : CircuitUiEvent { | ||
| data object OnCloseClick : OcrUiEvent | ||
| data object OnShowPermissionDialog : OcrUiEvent | ||
| data object OnHidePermissionDialog : OcrUiEvent | ||
| data class OnFrameReceived(val imageProxy: ImageProxy) : OcrUiEvent | ||
| data object OnCaptureButtonClick : OcrUiEvent | ||
| data object OnCaptureStart : OcrUiEvent | ||
| data class OnCaptureFailed(val exception: Exception) : OcrUiEvent | ||
| data class OnImageCaptured(val imageUri: Uri) : OcrUiEvent | ||
| data object OnReCaptureButtonClick : OcrUiEvent |
There was a problem hiding this comment.
🛠️ Refactor suggestion
side-effect 소비용 이벤트 추가 제안
UI에서 토스트를 띄운 뒤 Presenter로 소비 사실을 전달할 수 있도록 전용 이벤트를 추가하세요.
sealed interface OcrUiEvent : CircuitUiEvent {
@@
- data class OnImageCaptured(val imageUri: Uri) : OcrUiEvent
+ data class OnImageCaptured(val imageUri: Uri) : OcrUiEvent
+ data object OnSideEffectConsumed : OcrUiEvent📝 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.
| sealed interface OcrUiEvent : CircuitUiEvent { | |
| data object OnCloseClick : OcrUiEvent | |
| data object OnShowPermissionDialog : OcrUiEvent | |
| data object OnHidePermissionDialog : OcrUiEvent | |
| data class OnFrameReceived(val imageProxy: ImageProxy) : OcrUiEvent | |
| data object OnCaptureButtonClick : OcrUiEvent | |
| data object OnCaptureStart : OcrUiEvent | |
| data class OnCaptureFailed(val exception: Exception) : OcrUiEvent | |
| data class OnImageCaptured(val imageUri: Uri) : OcrUiEvent | |
| data object OnReCaptureButtonClick : OcrUiEvent | |
| sealed interface OcrUiEvent : CircuitUiEvent { | |
| data object OnCloseClick : OcrUiEvent | |
| data object OnShowPermissionDialog : OcrUiEvent | |
| data object OnHidePermissionDialog : OcrUiEvent | |
| data object OnCaptureStart : OcrUiEvent | |
| data class OnCaptureFailed(val exception: Exception) : OcrUiEvent | |
| data class OnImageCaptured(val imageUri: Uri) : OcrUiEvent | |
| data object OnSideEffectConsumed : OcrUiEvent | |
| data object OnReCaptureButtonClick : OcrUiEvent | |
| } |
🤖 Prompt for AI Agents
In
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt
around lines 31 to 38, add a new UI event to the OcrUiEvent sealed interface
that signals the UI has shown/consumed a one-time side-effect (toast) so the
Presenter can mark it handled; implement this as an additional event (e.g.,
OnToastConsumed or OnConsumableEventConsumed) and include an optional
identifier/payload if multiple toast types must be distinguished, then emit that
event from the UI after showing the toast so the Presenter can update
state/clear the side-effect.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (2)
41-43: sideEffect 소비 후 null 클리어 경로가 없습니다 — 재구성 시 토스트 재발동 위험UI에서 토스트를 표시한 뒤 sideEffect가 null로 되돌려지지 않아 재구성 시 중복 표시될 수 있습니다. 이전 리뷰에서도 동일 지적이 있었어요. Presenter에서 소비 이벤트를 처리해 sideEffect를 명시적으로 null로 초기화하는 경로를 추가해 주세요.
아래처럼 OcrUiEvent에 소비 이벤트를 추가하고, when 블록에 핸들러를 추가하는 방식을 권장합니다.
@@ fun handleEvent(event: OcrUiEvent) { when (event) { + is OcrUiEvent.OnSideEffectConsumed -> { + sideEffect = null + }OcrUiEvent 정의(다른 파일)에는 다음을 추가해야 합니다:
// OcrUiEvent.kt (예시) sealed interface OcrUiEvent { // ... data object OnSideEffectConsumed : OcrUiEvent }그리고 HandleOcrSideEffects/OcrUi 쪽에서 토스트 표시 후 OnSideEffectConsumed 이벤트를 흘려보내 주세요. 원하시면 해당 파일들까지 포함한 패치를 만들어 드리겠습니다.
Also applies to: 69-73, 104-108, 140-144
74-79: 401 인증 실패 시 사용자 피드백 없음 — onLoginRequired 처리 추가 필요Cloud Vision 인증 오류(401) 발생 시 현재는 사용자에게 아무 안내가 없습니다. 최소한 토스트를 통해 액션을 안내해 주세요. 필요시 설정 화면 이동도 연결할 수 있습니다. (이전 리뷰와 동일 지적)
- handleException( - exception = exception, - onError = handleErrorMessage, - onLoginRequired = {}, - ) + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + Logger.w("Cloud Vision 401 Unauthorized. Check API key/quota.") + sideEffect = OcrSideEffect.ShowToast("인증이 만료되었어요. 설정에서 API 키를 확인해주세요.") + }, + )
🧹 Nitpick comments (5)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (5)
55-61: 줄바꿈 파싱은 lineSequence()/lines() 사용 권장 — CRLF/플랫폼 차이 안전성문자열을 직접 "\n"로 split하면 CRLF 등 다양한 줄바꿈 시퀀스에서 깨질 수 있습니다. 표준 라이브러리의 lineSequence()를 사용하면 플랫폼/개행 차이에 안전합니다.
- val sentences = text - .split("\n") - .map { it.trim() } - .filter { it.isNotEmpty() } - - sentenceList = sentences.toPersistentList() + val sentences = text + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .toList() + + sentenceList = sentences.toPersistentList()
47-49: isLoading 토글 중복 — 단일 진입/종료 지점으로 정리 권장OnCaptureStart에서 true로 설정하고 recognizeText의 finally에서 false로 해제하면 충분해 보입니다. recognizeText 진입부의 isLoading = true는 중복이며 불필요한 재조합/플리커를 유발할 수 있습니다. 다만 OnCaptureStart 없이 바로 OnImageCaptured가 들어올 수 있는 경로가 없다면 제거하세요.
- isLoading = true검증 포인트:
- OnImageCaptured 이벤트는 항상 OnCaptureStart 이후에만 발생하는지?
- 캡처 실패 플로우에서는 OnCaptureFailed가 false로 내리는지(이미 구현됨).
Also applies to: 100-103
121-123: 선택 문장 결합 시 구분자 확인 필요 — 현재 공백 없이 모두 붙습니다현재 joinToString("")로 공백 없이 결합됩니다. UX 측면에서 공백(또는 줄바꿈) 구분을 쓰는 것이 자연스러울 수 있습니다. 기획 의도가 “붙여쓰기”가 아니라면 아래처럼 변경을 제안합니다.
- mergedSentence = selectedIndices - .sorted().joinToString("") { sentenceList[it] } + mergedSentence = selectedIndices + .sorted() + .joinToString(separator = " ") { sentenceList[it] }비고: 인덱스 범위는 UI에서 보장되겠지만, 혹시 모를 범위 오류를 막으려면 getOrNull을 사용한 방어적 접근도 고려할 수 있습니다.
36-36: recognizedText가 UI 상태에 노출되지 않습니다 — 미사용이면 정리, 사용 예정이면 상태에 포함recognizedText를 계산해 두지만 OcrUiState로는 전달하지 않습니다. 실제 UI에서 사용 계획이 없다면 제거해 단순화하고, 사용 계획이 있다면 OcrUiState에 포함해 단방향 데이터 흐름을 유지하세요.
Also applies to: 146-155
104-108: 캡처 실패 메시지 국제화/재사용화하드코딩된 문자열("이미지 캡처에 실패했어요")은 strings 리소스로 이동하면 재사용과 번역이 용이합니다. Compose 환경에서도 stringResource로 손쉽게 대체 가능합니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt(5 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#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과 팀은 한국어 주석을 선호하며, 한국어 주석을 영어로 번역하라는 제안을 하지 않아야 함
🧬 Code graph analysis (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (2)
core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt (1)
handleException(19-46)core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt (1)
recognizeText(22-44)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: ci-build
🔇 Additional comments (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt (1)
23-23: toPersistentList() 적용 LGTM팀 코멘트(easyhooon)의 요청대로 불변 리스트 변환을 일관되게 적용하신 점 좋습니다. 불변성 유지로 인해 accidental mutation을 방지할 수 있어요.
Also applies to: 35-36, 60-61
| recognizer.recognizeText(imageUri) | ||
| .onSuccess { |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
CloudOcrRecognizer의 Uri 처리 전략 확인 — content:// URI 호환성
현재 Recognizer는 Uri.path를 File로 읽는 구현으로 보입니다(core/ocr/.../CloudOcrRecognizer.kt 21–43줄 인용). CameraX/갤러리 등에서 전달되는 content:// URI에서는 path가 유효하지 않아 실패할 수 있습니다. ContentResolver.openInputStream() 기반으로 바꾸는 것을 권장합니다. 실패 시 현재 핸들링(토스트)만으로 끝나 UX 저하가 있을 수 있으니 사전에 호환성을 확보해 주세요.
검증용 스크립트(레포 루트에서 실행):
예상: Uri.path 의존만 있고 openInputStream 사용이 없다면 content:// 대응이 필요합니다.
🏁 Script executed:
#!/bin/bash
# Recognizer에서 content:// 대응 여부 점검
rg -nP --type=kt -C2 'Uri\.path|openInputStream\(|ContentResolver\b|DocumentsContract\b'Length of output: 117
rg가 .kt 파일 타입을 인식하지 못해 결과가 출력되지 않았습니다. 아래처럼 glob 옵션을 추가하여 다시 한 번 content:// URI 처리 관련 코드를 검색해 주시겠어요?
🏁 Script executed:
#!/bin/bash
# Recognizer에서 content:// 대응 여부 점검 (glob으로 .kt 파일 지정)
rg -n --glob '*.kt' -C2 'Uri\.path|openInputStream\(|ContentResolver\b|DocumentsContract\b'Length of output: 861
CloudOcrRecognizer에서 content:// URI 지원 보강 필요
현재 CloudOcrRecognizer.recognizeText(imageUri)(core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt:24–26) 구현부는 다음과 같이 Uri.path로만 파일을 읽고 있습니다:
val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.")
val file = File(filePath)
val byte = file.readBytes()이 방식은 cameraX나 갤러리에서 전달되는 content:// URI에서는 path가 null이거나 실제 파일 시스템 경로가 아니어서 예외가 발생하거나 제대로 동작하지 않습니다.
아래 작업을 반영해 주세요:
- ContentResolver.openInputStream(imageUri)를 이용해 InputStream을 얻고 바이트로 읽도록 변경
- File 기반 읽기가 실패할 경우에도 ContentResolver 방식을 재시도하거나, 사용자에게 권한 요청/재시도 안내 흐름을 추가
- 단순 토스트 처리 대신, 재시도 로직 혹은 적절한 에러 화면을 통해 UX를 개선
위 내용을 적용 후, file://, content:// 등 다양한 URI 스킴에서 정상 동작하는지 검증해 주세요.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt (1)
251-273: 촬영 실패(onError) 시 임시 파일 삭제 누락 — 캐시 누적 방지 필요촬영 직전
File.createTempFile로 캐시 파일을 생성하지만,onError에서 파일을 정리하지 않아 실패 반복 시 캐시가 누적될 수 있습니다. 에러 경로에서 삭제를 보장해 주세요.아래 최소 수정안:
override fun onError(exception: ImageCaptureException) { state.eventSink(OcrUiEvent.OnCaptureFailed(exception)) + // 실패 시 임시 파일 정리(존재 여부 무관, 예외 무시) + runCatching { photoFile.delete() } }
🧹 Nitpick comments (2)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt (2)
256-266: 업로드/비용/성능 최적화를 위한 캡처 이미지 정책 제안클라우드 OCR로 전환되었으므로 전송 바이트를 줄이는 이득이 큽니다. 아래 정책을 고려해 주세요.
- 전송 전 리사이즈(긴 변 1280
1600px), JPEG 품질 8085로 압축.- EXIF 회전 보정 후 업로드(서버에서 추가 처리 불필요).
- 메타데이터 제거(PII 최소화).
원한다면 Recognizer 쪽에 비파괴(preview용)·업로드용 두 가지 파이프라인 템플릿을 제안 드릴게요.
291-298: 로딩 오버레이가 입력을 차단하지 않음 — 오작동 예방을 위해 클릭 소모(sink) 추가 권장오버레이 Box는 시각적으로만 덮고 있어 터치 이벤트가 하위 뷰로 전달될 수 있습니다(버튼은 비활성화했지만 닫기 등 다른 터치 가능 요소도 존재). 로딩 중에는 모든 입력을 차단하는 편이 안전합니다.
간단한 입력 차단 방법(추가 임포트:
MutableInteractionSource,clickable):- if (state.isLoading) { - Box( - modifier = Modifier.fillMaxSize(), + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { /* swallow clicks while loading */ }, contentAlignment = Alignment.Center, ) { CircularProgressIndicator(color = ReedTheme.colors.contentBrand) } }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt(8 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#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과 팀은 한국어 주석을 선호하며, 한국어 주석을 영어로 번역하라는 제안을 하지 않아야 함
📚 Learning: 2025-07-31T23:30:37.547Z
Learnt from: easyhooon
PR: YAPP-Github/Reed-Android#88
File: feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt:18-47
Timestamp: 2025-07-31T23:30:37.547Z
Learning: In Circuit architecture, screen parameters are not manually passed to presenter constructors. The CircuitInject annotation automatically handles the injection of the entire Screen object, making screen parameters accessible without explicitly adding them to the constructor or factory methods.
Applied to files:
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt
🧬 Code graph analysis (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt (1)
HandleOcrSideEffects(8-23)
🔇 Additional comments (4)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt (4)
11-13: 신규 의존성/임포트 추가 적절 — 사용처 일치 확인됨ImageCapture/Exception, CircularProgressIndicator, toUri, File 모두 본문에서 활용되고 있어 불필요 임포트 없음. 별도 액션 필요 없습니다.
Also applies to: 33-33, 54-54, 75-75
84-85: 사이드 이펙트 처리 분리(LGTM)UI 파일에서 토스트/부수효과를 분리해 조합성·테스트 용이성 모두 좋아졌습니다.
264-266: OnImageCaptured 핸들러 및 임시 파일 삭제 로직 재검증 필요ripgrep
--type=kt옵션으로는 Kotlin 파일이 인식되지 않아 검색이 실패했습니다. 아래와 같이 Kotlin 파일 패턴을 지정해 다시 한 번 삭제 호출 여부를 확인해 주세요.• OnImageCaptured 핸들러 위치 확인
rg -nP -C3 '\bOnImageCaptured\s*\(' -g '*.kt'• OCR 관련 파일 삭제 호출(추정) 위치 확인
rg -nP -C2 '\bdelete\s*\(' -g '*.kt' | rg -nP 'ocr_|cacheDir|cache' || true• 캡처된
Uri를 읽는 경로(업로드/디코딩 등) 확인rg -nP -C3 '\b(openInputStream|decodeStream|asRequestBody|toRequestBody|File\(\s*uri\.path)' -g '*.kt' | rg -nP '(Ocr|Vision|Recognizer)' || true— 만약 삭제 호출이 downstream(예:
CloudOcrRecognizer/Presenter)에도 없다면,
- Recognizer 내부에서 파일 읽기 후 반드시
finally블록으로 삭제하거나- OCR 완료 시 UI로 “OnImageConsumed(uri)” 같은 이벤트를 보내 여기서 삭제
위 두 가지 방안 중 하나를 선택해 삭제 책임을 일원화해 주세요.
256-263: LifecycleCameraController의 ImageCapture 기본 활성화 확인검증 결과, CameraX
LifecycleCameraController(1.1.0 이상, CameraX 1.3~1.5 계열)에서는Preview외에도IMAGE_CAPTURE와IMAGE_ANALYSIS가 기본으로 활성화되어 있습니다. 따라서setEnabledUseCases를 호출하지 않아도takePicture(OutputFileOptions, Executor, OnImageSavedCallback)는 정상 동작하며, 리그레션 리스크가 없습니다. 별도 조치 없이 현재 구현을 유지하셔도 안전합니다.

🔗 관련 이슈
📙 작업 설명
📸 스크린샷 또는 시연 영상
Reed_OCR_CloudVision_.mp4
💬 추가 설명 or 리뷰 포인트
Summary by CodeRabbit