Skip to content

Commit b9b77b6

Browse files
committed
Merge branch 'develop' into BOOK-256-feature/#139
# Conflicts: # feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt
2 parents 12a3361 + f01d86f commit b9b77b6

File tree

11 files changed

+355
-50
lines changed

11 files changed

+355
-50
lines changed

core/ocr/build.gradle.kts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,37 @@
11
@file:Suppress("INLINE_FROM_HIGHER_PLATFORM")
22

3+
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
4+
5+
36
plugins {
47
alias(libs.plugins.booket.android.library)
8+
alias(libs.plugins.booket.android.retrofit)
59
alias(libs.plugins.booket.android.hilt)
610
}
711

812
android {
913
namespace = "com.ninecraft.booket.core.ocr"
14+
15+
defaultConfig {
16+
buildConfigField("String", "CLOUD_VISION_API_KEY", getApiKey("CLOUD_VISION_API_KEY"))
17+
}
18+
19+
buildFeatures {
20+
buildConfig = true
21+
}
1022
}
1123

1224
dependencies {
1325
implementations(
26+
projects.core.common,
27+
1428
libs.logger,
1529
libs.androidx.camera.core,
1630

1731
libs.google.mlkit.text.recognition.korean,
1832
)
1933
}
34+
35+
fun getApiKey(propertyKey: String): String {
36+
return gradleLocalProperties(rootDir, providers).getProperty(propertyKey)
37+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.ninecraft.booket.core.ocr.analyzer
2+
3+
import android.net.Uri
4+
import android.util.Base64
5+
import com.ninecraft.booket.core.common.utils.runSuspendCatching
6+
import com.ninecraft.booket.core.ocr.BuildConfig
7+
import com.ninecraft.booket.core.ocr.model.AnnotateImageRequest
8+
import com.ninecraft.booket.core.ocr.model.CloudVisionRequest
9+
import com.ninecraft.booket.core.ocr.model.CloudVisionResponse
10+
import com.ninecraft.booket.core.ocr.model.Feature
11+
import com.ninecraft.booket.core.ocr.model.ImageContext
12+
import com.ninecraft.booket.core.ocr.model.VisionImage
13+
import com.ninecraft.booket.core.ocr.service.CloudVisionService
14+
import kotlinx.coroutines.Dispatchers
15+
import kotlinx.coroutines.withContext
16+
import java.io.File
17+
import javax.inject.Inject
18+
19+
class CloudOcrRecognizer @Inject constructor(
20+
private val service: CloudVisionService,
21+
) {
22+
suspend fun recognizeText(imageUri: Uri): Result<CloudVisionResponse> = runSuspendCatching {
23+
withContext(Dispatchers.IO) {
24+
val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.")
25+
val file = File(filePath)
26+
val byte = file.readBytes()
27+
val base64Image = Base64.encodeToString(byte, Base64.NO_WRAP)
28+
29+
val request = CloudVisionRequest(
30+
requests = listOf(
31+
AnnotateImageRequest(
32+
image = VisionImage(base64Image),
33+
features = listOf(Feature(type = "TEXT_DETECTION")),
34+
imageContext = ImageContext(languageHints = null),
35+
),
36+
),
37+
)
38+
39+
service.batchAnnotateImage(
40+
apiKey = BuildConfig.CLOUD_VISION_API_KEY,
41+
body = request,
42+
)
43+
}
44+
}
45+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.ninecraft.booket.core.ocr.di
2+
3+
import com.ninecraft.booket.core.ocr.BuildConfig
4+
import com.ninecraft.booket.core.ocr.service.CloudVisionService
5+
import dagger.Module
6+
import dagger.Provides
7+
import dagger.hilt.InstallIn
8+
import dagger.hilt.components.SingletonComponent
9+
import kotlinx.serialization.json.Json
10+
import okhttp3.MediaType.Companion.toMediaType
11+
import okhttp3.OkHttpClient
12+
import okhttp3.logging.HttpLoggingInterceptor
13+
import retrofit2.Retrofit
14+
import retrofit2.converter.kotlinx.serialization.asConverterFactory
15+
import java.util.concurrent.TimeUnit
16+
import javax.inject.Singleton
17+
18+
private const val BASE_URL = "https://vision.googleapis.com/"
19+
private const val MaxTimeoutMillis = 15_000L
20+
21+
private val jsonRule = Json {
22+
// 기본값도 JSON에 포함하여 직렬화
23+
encodeDefaults = true
24+
// JSON에 정의되지 않은 키는 무시 (역직렬화 시 에러 방지)
25+
ignoreUnknownKeys = true
26+
// JSON을 보기 좋게 들여쓰기하여 포맷팅
27+
prettyPrint = true
28+
// 엄격하지 않은 파싱 (따옴표 없는 키, 후행 쉼표 등 허용)
29+
isLenient = true
30+
}
31+
32+
private val jsonConverterFactory = jsonRule.asConverterFactory("application/json".toMediaType())
33+
34+
@Module
35+
@InstallIn(SingletonComponent::class)
36+
object CloudVisionNetworkModule {
37+
38+
@Provides
39+
@Singleton
40+
@CloudVisionOkHttp
41+
fun provideOkHttp(): OkHttpClient {
42+
val log = HttpLoggingInterceptor().apply {
43+
redactHeader("X-Goog-Api-Key")
44+
level = if (BuildConfig.DEBUG) {
45+
HttpLoggingInterceptor.Level.BASIC
46+
} else {
47+
HttpLoggingInterceptor.Level.NONE
48+
}
49+
}
50+
return OkHttpClient.Builder()
51+
.addInterceptor(log)
52+
.connectTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS)
53+
.readTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS)
54+
.writeTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS)
55+
.build()
56+
}
57+
58+
@Provides
59+
@Singleton
60+
@CloudVisionRetrofit
61+
fun provideRetrofit(
62+
@CloudVisionOkHttp okHttpClient: OkHttpClient,
63+
): Retrofit {
64+
return Retrofit.Builder()
65+
.baseUrl(BASE_URL)
66+
.client(okHttpClient)
67+
.addConverterFactory(jsonConverterFactory)
68+
.build()
69+
}
70+
71+
@Provides
72+
@Singleton
73+
fun provideVisionApi(@CloudVisionRetrofit retrofit: Retrofit): CloudVisionService =
74+
retrofit.create(CloudVisionService::class.java)
75+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.ninecraft.booket.core.ocr.di
2+
3+
import javax.inject.Qualifier
4+
5+
@Qualifier
6+
@Retention(AnnotationRetention.BINARY)
7+
annotation class CloudVisionOkHttp
8+
9+
@Qualifier
10+
@Retention(AnnotationRetention.BINARY)
11+
annotation class CloudVisionRetrofit
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.ninecraft.booket.core.ocr.model
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class CloudVisionRequest(
7+
val requests: List<AnnotateImageRequest>,
8+
)
9+
10+
@Serializable
11+
data class AnnotateImageRequest(
12+
val image: VisionImage,
13+
val features: List<Feature>,
14+
val imageContext: ImageContext? = null,
15+
)
16+
17+
@Serializable
18+
data class VisionImage(
19+
val content: String,
20+
)
21+
22+
@Serializable
23+
data class Feature(
24+
val type: String = "TEXT_DETECTION",
25+
)
26+
27+
@Serializable
28+
data class ImageContext(
29+
val languageHints: List<String>? = null,
30+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.ninecraft.booket.core.ocr.model
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class CloudVisionResponse(
7+
val responses: List<AnnotateImageResponse>,
8+
)
9+
10+
@Serializable
11+
data class AnnotateImageResponse(
12+
val fullTextAnnotation: FullTextAnnotation? = null,
13+
)
14+
15+
@Serializable
16+
data class FullTextAnnotation(
17+
val text: String? = null,
18+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.ninecraft.booket.core.ocr.service
2+
3+
import com.ninecraft.booket.core.ocr.model.CloudVisionRequest
4+
import com.ninecraft.booket.core.ocr.model.CloudVisionResponse
5+
import retrofit2.http.Body
6+
import retrofit2.http.Header
7+
import retrofit2.http.POST
8+
9+
interface CloudVisionService {
10+
@POST("v1/images:annotate")
11+
suspend fun batchAnnotateImage(
12+
@Header("X-Goog-Api-Key") apiKey: String,
13+
@Body body: CloudVisionRequest,
14+
): CloudVisionResponse
15+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.ninecraft.booket.feature.record.ocr
2+
3+
import android.widget.Toast
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.ui.platform.LocalContext
6+
import com.skydoves.compose.effects.RememberedEffect
7+
8+
@Composable
9+
internal fun HandleOcrSideEffects(
10+
state: OcrUiState,
11+
) {
12+
val context = LocalContext.current
13+
14+
RememberedEffect(state.sideEffect) {
15+
when (state.sideEffect) {
16+
is OcrSideEffect.ShowToast -> {
17+
Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show()
18+
}
19+
20+
null -> {}
21+
}
22+
}
23+
}

0 commit comments

Comments
 (0)