Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions core/ocr/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"))
}
Comment on lines +15 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

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.

Suggested change
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.


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)
}
Original file line number Diff line number Diff line change
@@ -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<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),
),
Comment on lines +33 to +35
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

인식률 목적에 맞춘 기능 타입 전환: 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.

Suggested change
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.

),
)

service.batchAnnotateImage(
apiKey = BuildConfig.CLOUD_VISION_API_KEY,
body = request,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.ninecraft.booket.core.ocr.model

import kotlinx.serialization.Serializable

@Serializable
data class CloudVisionRequest(
val requests: List<AnnotateImageRequest>,
)

@Serializable
data class AnnotateImageRequest(
val image: VisionImage,
val features: List<Feature>,
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<String>? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.ninecraft.booket.core.ocr.model

import kotlinx.serialization.Serializable

@Serializable
data class CloudVisionResponse(
val responses: List<AnnotateImageResponse>,
)

@Serializable
data class AnnotateImageResponse(
val fullTextAnnotation: FullTextAnnotation? = null,
)

@Serializable
data class FullTextAnnotation(
val text: String? = null,
)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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 -> {}
}
}
}
Loading
Loading