Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
Expand Down Expand Up @@ -89,11 +92,19 @@ private fun CameraPreview(
val lifecycleOwner = LocalLifecycleOwner.current
val permission = android.Manifest.permission.CAMERA

// UI에서 항상 권한 최신 상태 확인
val isGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
val launcher = rememberLauncherForActivityResult(
/**
* Camera Permission Request
*/
var isGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED,
)
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
isGranted = granted

if (!granted) {
state.eventSink(OcrUiEvent.OnShowPermissionDialog)
}
Expand All @@ -102,45 +113,20 @@ private fun CameraPreview(
contract = ActivityResultContracts.StartActivityForResult(),
) { _ -> }

val cameraController = remember { LifecycleCameraController(context) }
val imageAnalyzer = remember {
ImageAnalysis.Analyzer { imageProxy ->
state.eventSink(OcrUiEvent.OnFrameReceived(imageProxy))
}
}

val systemUiController = rememberSystemUiController()

DisposableEffect(systemUiController) {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = false,
isNavigationBarContrastEnforced = false,
)

onDispose {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = true,
isNavigationBarContrastEnforced = false,
)
}
}

// 최초 진입 시 권한 요청
LaunchedEffect(Unit) {
if (!isGranted) {
state.eventSink(OcrUiEvent.OnHidePermissionDialog)
launcher.launch(permission)
permissionLauncher.launch(permission)
}
}

// 앱이 포그라운드로 북귀할 때 OS 권한 체크
// 앱이 포그라운드로 북귀할 때 OS 권한 동기화
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
val currentGrant = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
if (currentGrant) {
isGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
if (isGranted) {
state.eventSink(OcrUiEvent.OnHidePermissionDialog)
} else {
state.eventSink(OcrUiEvent.OnShowPermissionDialog)
Expand All @@ -151,18 +137,52 @@ private fun CameraPreview(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}

DisposableEffect(lifecycleOwner, cameraController) {
cameraController.bindToLifecycle(lifecycleOwner)
cameraController.setImageAnalysisAnalyzer(
ContextCompat.getMainExecutor(context),
imageAnalyzer,
)
/**
* Camera Controller & ImageAnalyzer
*/
val cameraController = remember { LifecycleCameraController(context) }
val imageAnalyzer = remember {
ImageAnalysis.Analyzer { imageProxy ->
state.eventSink(OcrUiEvent.OnFrameReceived(imageProxy))
}
}
Comment on lines +144 to +148
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Analyzer에서 imageProxy.close() 보장 필요 + eventSink 참조 최신화

  • close 누락 시 프레임 정체/메모리 누수 위험이 큽니다. 소비 측(이벤트 핸들러)에서 반드시 close를 보장하는지 확인해 주세요.
  • 또한 Analyzer를 remember(state.eventSink)로 생성하면 sink 변경 시 최신 참조를 사용합니다.

권장 diff(참고: 비동기 처리라면 finally에서 close하지 말고, 소비 측에서 완료 시 close):

-    val imageAnalyzer = remember {
-        ImageAnalysis.Analyzer { imageProxy ->
-            state.eventSink(OcrUiEvent.OnFrameReceived(imageProxy))
-        }
-    }
+    val imageAnalyzer = remember(state.eventSink) {
+        ImageAnalysis.Analyzer { imageProxy ->
+            try {
+                state.eventSink(OcrUiEvent.OnFrameReceived(imageProxy))
+            } finally {
+                // 동기 처리일 때만 사용. 비동기 처리라면 소비 측에서 close 책임.
+                // imageProxy.close()
+            }
+        }
+    }

검증 스크립트(소비 측에서 close 호출 여부 확인):


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "OnFrameReceived 처리부 검색"
rg -n "OnFrameReceived\\(" -A 50

echo
echo "imageProxy.close() 호출 위치 검색"
rg -n "imageProxy\\.close\\(\\)" -A 2

Length of output: 9435


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1. OcrPresenter.kt에서 OnFrameReceived 처리부 검색"
rg -n "OnFrameReceived" -A 20 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt

echo
echo "2. Presenter에서 imageProxy.close() 호출 여부 확인"
rg -n "close(" -A 2 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt

echo
echo "3. LiveTextAnalyzer, StillTextAnalyzer 사용 위치 검색"
rg -n "LiveTextAnalyzer" -A 10 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt
rg -n "StillTextAnalyzer" -A 10 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt

echo
echo "4. Analyzer.invoke/analyze 호출 검색"
rg -n "analyz" -A 5 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt

Length of output: 1372


Analyzer에서 close 처리 확인 및 remember(key) 반영 요청

  • core/ocr 모듈의 StillTextAnalyzer/LiveTextAnalyzer에서 imageProxy.close()를 호출해 리소스를 해제하고 있으므로, UI 레이어의 Analyzer에서 별도 close 호출은 불필요합니다.
  • 다만 remember 블록에 state.eventSink를 키로 포함하지 않으면, eventSink가 변경되어도 람다에 최신 참조가 반영되지 않습니다. 아래 예시처럼 수정해 주세요.
-    val imageAnalyzer = remember {
+    val imageAnalyzer = remember(state.eventSink) {
         ImageAnalysis.Analyzer { imageProxy ->
             state.eventSink(OcrUiEvent.OnFrameReceived(imageProxy))
         }
     }
📝 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 imageAnalyzer = remember {
ImageAnalysis.Analyzer { imageProxy ->
state.eventSink(OcrUiEvent.OnFrameReceived(imageProxy))
}
}
val imageAnalyzer = remember(state.eventSink) {
ImageAnalysis.Analyzer { imageProxy ->
state.eventSink(OcrUiEvent.OnFrameReceived(imageProxy))
}
}
🤖 Prompt for AI Agents
In
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt
around lines 144 to 148, the ImageAnalysis.Analyzer does not need to call
imageProxy.close() because resource management is handled in the core/ocr
module. However, the remember block should include state.eventSink as a key to
ensure the lambda captures the latest reference when eventSink changes. Update
the remember call to remember(state.eventSink) and remove any explicit
imageProxy.close() calls in this analyzer.


DisposableEffect(isGranted, lifecycleOwner, cameraController) {
if (isGranted) {
cameraController.bindToLifecycle(lifecycleOwner)
cameraController.setImageAnalysisAnalyzer(
ContextCompat.getMainExecutor(context),
imageAnalyzer,
)
}
Comment on lines +150 to +157
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

이미지 분석을 메인 스레드가 아닌 전용 Executor에서 실행하세요

현재 MainExecutor로 분석하면 OCR 로직이 UI 스레드를 점유해 프레임 드랍 가능성이 큽니다. 전용 싱글 스레드 Executor 사용을 권장합니다. 이 변경으로 권한 허용 직후에도 안정적으로 프리뷰+분석이 동작합니다.

적용 diff(실행 스레드 전환):

@@
-    DisposableEffect(isGranted, lifecycleOwner, cameraController) {
+    DisposableEffect(isGranted, lifecycleOwner, cameraController) {
         if (isGranted) {
-            cameraController.bindToLifecycle(lifecycleOwner)
-            cameraController.setImageAnalysisAnalyzer(
-                ContextCompat.getMainExecutor(context),
-                imageAnalyzer,
-            )
+            cameraController.bindToLifecycle(lifecycleOwner)
+            // 전용 Executor 사용 권장 (아래 cameraExecutor 참고)
+            cameraController.setImageAnalysisAnalyzer(
+                cameraExecutor,
+                imageAnalyzer,
+            )
         }

추가 코드(해당 블록 위/아래에 배치):

// 상단 import 추가
import java.util.concurrent.Executors

// controller 바로 아래에 배치
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }

// onDispose 블록 내 자원 정리
onDispose {
    cameraController.unbind()
    cameraController.clearImageAnalysisAnalyzer()
    cameraExecutor.shutdown()
}

원하시면 위 변경을 반영한 전체 패치를 준비해드리겠습니다.

🤖 Prompt for AI Agents
In
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt
around lines 150 to 157, the image analysis is currently executed on the main
thread using ContextCompat.getMainExecutor, which can cause UI frame drops. To
fix this, create a dedicated single-threaded executor using
Executors.newSingleThreadExecutor() and use it instead of the main executor for
image analysis. Also, add proper resource cleanup by shutting down this executor
in the onDispose block along with unbinding the cameraController and clearing
the image analysis analyzer.


onDispose {
cameraController.unbind()
cameraController.clearImageAnalysisAnalyzer()
}
}

/**
* SystemStatusBar Color
*/
val systemUiController = rememberSystemUiController()

DisposableEffect(systemUiController) {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = false,
isNavigationBarContrastEnforced = false,
)

onDispose {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = true,
isNavigationBarContrastEnforced = false,
)
}
}

ReedScaffold(
modifier = modifier.fillMaxSize(),
containerColor = Neutral950,
Expand Down
Loading