diff --git a/core/ocr/build.gradle.kts b/core/ocr/build.gradle.kts index e3a83b9a..4d70a87e 100644 --- a/core/ocr/build.gradle.kts +++ b/core/ocr/build.gradle.kts @@ -1,19 +1,37 @@ @file:Suppress("INLINE_FROM_HIGHER_PLATFORM") +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + + plugins { alias(libs.plugins.booket.android.library) + alias(libs.plugins.booket.android.retrofit) alias(libs.plugins.booket.android.hilt) } android { namespace = "com.ninecraft.booket.core.ocr" + + defaultConfig { + buildConfigField("String", "CLOUD_VISION_API_KEY", getApiKey("CLOUD_VISION_API_KEY")) + } + + buildFeatures { + buildConfig = true + } } dependencies { implementations( + projects.core.common, + libs.logger, libs.androidx.camera.core, libs.google.mlkit.text.recognition.korean, ) } + +fun getApiKey(propertyKey: String): String { + return gradleLocalProperties(rootDir, providers).getProperty(propertyKey) +} diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt new file mode 100644 index 00000000..538095db --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt @@ -0,0 +1,45 @@ +package com.ninecraft.booket.core.ocr.analyzer + +import android.net.Uri +import android.util.Base64 +import com.ninecraft.booket.core.common.utils.runSuspendCatching +import com.ninecraft.booket.core.ocr.BuildConfig +import com.ninecraft.booket.core.ocr.model.AnnotateImageRequest +import com.ninecraft.booket.core.ocr.model.CloudVisionRequest +import com.ninecraft.booket.core.ocr.model.CloudVisionResponse +import com.ninecraft.booket.core.ocr.model.Feature +import com.ninecraft.booket.core.ocr.model.ImageContext +import com.ninecraft.booket.core.ocr.model.VisionImage +import com.ninecraft.booket.core.ocr.service.CloudVisionService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +class CloudOcrRecognizer @Inject constructor( + private val service: CloudVisionService, +) { + suspend fun recognizeText(imageUri: Uri): Result = 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, + ) + } + } +} diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt new file mode 100644 index 00000000..c38717b5 --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt @@ -0,0 +1,75 @@ +package com.ninecraft.booket.core.ocr.di + +import com.ninecraft.booket.core.ocr.BuildConfig +import com.ninecraft.booket.core.ocr.service.CloudVisionService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +private const val BASE_URL = "https://vision.googleapis.com/" +private const val MaxTimeoutMillis = 15_000L + +private val jsonRule = Json { + // 기본값도 JSON에 포함하여 직렬화 + encodeDefaults = true + // JSON에 정의되지 않은 키는 무시 (역직렬화 시 에러 방지) + ignoreUnknownKeys = true + // JSON을 보기 좋게 들여쓰기하여 포맷팅 + prettyPrint = true + // 엄격하지 않은 파싱 (따옴표 없는 키, 후행 쉼표 등 허용) + isLenient = true +} + +private val jsonConverterFactory = jsonRule.asConverterFactory("application/json".toMediaType()) + +@Module +@InstallIn(SingletonComponent::class) +object CloudVisionNetworkModule { + + @Provides + @Singleton + @CloudVisionOkHttp + fun provideOkHttp(): OkHttpClient { + val log = HttpLoggingInterceptor().apply { + redactHeader("X-Goog-Api-Key") + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BASIC + } else { + HttpLoggingInterceptor.Level.NONE + } + } + return OkHttpClient.Builder() + .addInterceptor(log) + .connectTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) + .writeTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) + .build() + } + + @Provides + @Singleton + @CloudVisionRetrofit + fun provideRetrofit( + @CloudVisionOkHttp okHttpClient: OkHttpClient, + ): Retrofit { + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(jsonConverterFactory) + .build() + } + + @Provides + @Singleton + fun provideVisionApi(@CloudVisionRetrofit retrofit: Retrofit): CloudVisionService = + retrofit.create(CloudVisionService::class.java) +} diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkQualifier.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkQualifier.kt new file mode 100644 index 00000000..b79d4ea1 --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkQualifier.kt @@ -0,0 +1,11 @@ +package com.ninecraft.booket.core.ocr.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CloudVisionOkHttp + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CloudVisionRetrofit diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionRequest.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionRequest.kt new file mode 100644 index 00000000..b3d0c730 --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionRequest.kt @@ -0,0 +1,30 @@ +package com.ninecraft.booket.core.ocr.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CloudVisionRequest( + val requests: List, +) + +@Serializable +data class AnnotateImageRequest( + val image: VisionImage, + val features: List, + val imageContext: ImageContext? = null, +) + +@Serializable +data class VisionImage( + val content: String, +) + +@Serializable +data class Feature( + val type: String = "TEXT_DETECTION", +) + +@Serializable +data class ImageContext( + val languageHints: List? = null, +) diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionResponse.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionResponse.kt new file mode 100644 index 00000000..71c5f52f --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionResponse.kt @@ -0,0 +1,18 @@ +package com.ninecraft.booket.core.ocr.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CloudVisionResponse( + val responses: List, +) + +@Serializable +data class AnnotateImageResponse( + val fullTextAnnotation: FullTextAnnotation? = null, +) + +@Serializable +data class FullTextAnnotation( + val text: String? = null, +) diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt new file mode 100644 index 00000000..3ee87c14 --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt @@ -0,0 +1,15 @@ +package com.ninecraft.booket.core.ocr.service + +import com.ninecraft.booket.core.ocr.model.CloudVisionRequest +import com.ninecraft.booket.core.ocr.model.CloudVisionResponse +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST + +interface CloudVisionService { + @POST("v1/images:annotate") + suspend fun batchAnnotateImage( + @Header("X-Goog-Api-Key") apiKey: String, + @Body body: CloudVisionRequest, + ): CloudVisionResponse +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt new file mode 100644 index 00000000..8b9e8569 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt @@ -0,0 +1,23 @@ +package com.ninecraft.booket.feature.record.ocr + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.skydoves.compose.effects.RememberedEffect + +@Composable +internal fun HandleOcrSideEffects( + state: OcrUiState, +) { + val context = LocalContext.current + + RememberedEffect(state.sideEffect) { + when (state.sideEffect) { + is OcrSideEffect.ShowToast -> { + Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() + } + + null -> {} + } + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt index 0105759a..9d69eac0 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt @@ -1,12 +1,15 @@ package com.ninecraft.booket.feature.record.ocr +import android.net.Uri import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import com.ninecraft.booket.core.ocr.analyzer.LiveTextAnalyzer +import com.ninecraft.booket.core.common.utils.handleException +import com.ninecraft.booket.core.ocr.analyzer.CloudOcrRecognizer import com.ninecraft.booket.feature.screens.OcrScreen +import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator @@ -17,34 +20,66 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.components.ActivityRetainedComponent import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch class OcrPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, - private val liveTextAnalyzer: LiveTextAnalyzer.Factory, + private val recognizer: CloudOcrRecognizer, ) : Presenter { @Composable override fun present(): OcrUiState { + val scope = rememberCoroutineScope() var currentUi by rememberRetained { mutableStateOf(OcrUi.CAMERA) } var isPermissionDialogVisible by rememberRetained { mutableStateOf(false) } - var sentenceList by rememberRetained { mutableStateOf(emptyList().toPersistentList()) } + var sentenceList by rememberRetained { mutableStateOf(persistentListOf()) } var recognizedText by rememberRetained { mutableStateOf("") } var selectedIndices by rememberRetained { mutableStateOf(setOf()) } var mergedSentence by rememberRetained { mutableStateOf("") } var isTextDetectionFailed by rememberRetained { mutableStateOf(false) } var isRecaptureDialogVisible by rememberRetained { mutableStateOf(false) } - - val analyzer = rememberRetained { - liveTextAnalyzer.create( - onTextDetected = { text -> - recognizedText = text - }, - ) - } - - DisposableEffect(Unit) { - onDispose { - analyzer.cancel() + var isLoading by rememberRetained { mutableStateOf(false) } + var sideEffect by rememberRetained { mutableStateOf(null) } + + fun recognizeText(imageUri: Uri) { + scope.launch { + try { + isLoading = true + recognizer.recognizeText(imageUri) + .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 = sentences.toPersistentList() + currentUi = OcrUi.RESULT + } else { + isTextDetectionFailed = true + } + } + .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 = {}, + ) + } + } finally { + isLoading = false + } } } @@ -62,24 +97,20 @@ class OcrPresenter @AssistedInject constructor( isPermissionDialogVisible = false } - is OcrUiEvent.OnFrameReceived -> { - analyzer.analyze(event.imageProxy) + is OcrUiEvent.OnCaptureStart -> { + isLoading = true } - is OcrUiEvent.OnCaptureButtonClick -> { - if (recognizedText.isEmpty()) { - isTextDetectionFailed = true - } else { - isTextDetectionFailed = false + is OcrUiEvent.OnCaptureFailed -> { + isLoading = false + sideEffect = OcrSideEffect.ShowToast("이미지 캡처에 실패했어요") + Logger.e("ImageCaptureException: ${event.exception.message}") + } - val sentences = recognizedText - .split("\n") - .map { it.trim() } - .filter { it.isNotEmpty() } - sentenceList = persistentListOf(*sentences.toTypedArray()) + is OcrUiEvent.OnImageCaptured -> { + isTextDetectionFailed = false - currentUi = OcrUi.RESULT - } + recognizeText(event.imageUri) } is OcrUiEvent.OnReCaptureButtonClick -> { @@ -88,7 +119,7 @@ class OcrPresenter @AssistedInject constructor( is OcrUiEvent.OnSelectionConfirmed -> { mergedSentence = selectedIndices - .sorted().joinToString(" ") { sentenceList[it] } + .sorted().joinToString("") { sentenceList[it] } navigator.pop(result = OcrScreen.OcrResult(mergedSentence)) } @@ -119,6 +150,8 @@ class OcrPresenter @AssistedInject constructor( selectedIndices = selectedIndices, isTextDetectionFailed = isTextDetectionFailed, isRecaptureDialogVisible = isRecaptureDialogVisible, + isLoading = isLoading, + sideEffect = sideEffect, eventSink = ::handleEvent, ) } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt index 4d5e0857..1cdee8da 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt @@ -8,7 +8,8 @@ import android.view.ViewGroup import android.widget.LinearLayout import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException import androidx.camera.view.LifecycleCameraController import androidx.camera.view.PreviewView import androidx.compose.foundation.background @@ -29,6 +30,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -49,6 +51,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -69,6 +72,7 @@ import com.ninecraft.booket.feature.screens.OcrScreen import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiController +import java.io.File import com.ninecraft.booket.core.designsystem.R as designR @CircuitInject(OcrScreen::class, ActivityRetainedComponent::class) @@ -77,6 +81,8 @@ internal fun OcrUi( state: OcrUiState, modifier: Modifier = Modifier, ) { + HandleOcrSideEffects(state = state) + when (state.currentUi) { OcrUi.CAMERA -> CameraPreview(state = state, modifier = modifier) OcrUi.RESULT -> TextScanResult(state = state, modifier = modifier) @@ -138,27 +144,17 @@ private fun CameraPreview( } /** - * Camera Controller & ImageAnalyzer + * Camera Controller */ val cameraController = remember { LifecycleCameraController(context) } - val imageAnalyzer = remember { - ImageAnalysis.Analyzer { imageProxy -> - state.eventSink(OcrUiEvent.OnFrameReceived(imageProxy)) - } - } DisposableEffect(isGranted, lifecycleOwner, cameraController) { if (isGranted) { cameraController.bindToLifecycle(lifecycleOwner) - cameraController.setImageAnalysisAnalyzer( - ContextCompat.getMainExecutor(context), - imageAnalyzer, - ) } onDispose { cameraController.unbind() - cameraController.clearImageAnalysisAnalyzer() } } @@ -253,8 +249,27 @@ private fun CameraPreview( } Button( + 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)) + } + }, + ) }, modifier = Modifier.size(72.dp), shape = CircleShape, @@ -272,6 +287,15 @@ private fun CameraPreview( } Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) } + + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ReedTheme.colors.contentBrand) + } + } } } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt index 2a86e6d6..812fc57e 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt @@ -1,27 +1,40 @@ package com.ninecraft.booket.feature.record.ocr -import androidx.camera.core.ImageProxy +import android.net.Uri +import androidx.compose.runtime.Immutable import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID data class OcrUiState( val currentUi: OcrUi = OcrUi.CAMERA, val isPermissionDialogVisible: Boolean = false, - val sentenceList: ImmutableList = emptyList().toPersistentList(), + val sentenceList: ImmutableList = persistentListOf(), val selectedIndices: Set = emptySet(), val isTextDetectionFailed: Boolean = false, val isRecaptureDialogVisible: Boolean = false, + val isLoading: Boolean = false, + val sideEffect: OcrSideEffect? = null, val eventSink: (OcrUiEvent) -> Unit, ) : CircuitUiState +@Immutable +sealed interface OcrSideEffect { + data class ShowToast( + val message: String, + private val key: String = UUID.randomUUID().toString(), + ) : OcrSideEffect +} + 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 data object OnSelectionConfirmed : OcrUiEvent data object OnRecaptureDialogConfirmed : OcrUiEvent