Add Android receipt camera MVP scaffold
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.obdai.receipt"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.obdai.receipt"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
buildConfigField("String", "RECEIPT_API_TOKEN", "\"\"")
|
||||
buildConfigField("String", "RECEIPT_API_URL", "\"https://obdai.ru/receipt\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.activity:activity-compose:1.10.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
implementation("androidx.camera:camera-camera2:1.4.1")
|
||||
implementation("androidx.camera:camera-lifecycle:1.4.1")
|
||||
implementation("androidx.camera:camera-view:1.4.1")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# App-specific R8 rules.
|
||||
@@ -0,0 +1,19 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="Receipt Camera"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ReceiptCamera"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,109 @@
|
||||
package ru.obdai.receipt
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.graphics.Bitmap
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import ru.obdai.receipt.camera.CameraManager
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var latestBitmap by mutableStateOf<Bitmap?>(null)
|
||||
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
private val cameraManager = CameraManager()
|
||||
private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) previewView?.let(::startCamera)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent { CameraScreen(latestBitmap, ::onPreviewReady) }
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPreviewReady(view: PreviewView) {
|
||||
if (previewView === view) return
|
||||
previewView = view
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
startCamera(view)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCamera(view: PreviewView) {
|
||||
val providerFuture = ProcessCameraProvider.getInstance(this)
|
||||
providerFuture.addListener({
|
||||
val provider = providerFuture.get()
|
||||
val preview = Preview.Builder().build()
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
analysis.setAnalyzer(cameraExecutor, cameraManager.analyzer { bitmap -> latestBitmap = bitmap })
|
||||
provider.unbindAll()
|
||||
preview.setSurfaceProvider(view.surfaceProvider)
|
||||
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
private var previewView: PreviewView? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
latestBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||
cameraExecutor.shutdown()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraScreen(bitmap: Bitmap?, onPreviewReady: (PreviewView) -> Unit = {}) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
factory = { context -> PreviewView(context).also(onPreviewReady) },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(onClick = { /* Crop and recognize are wired by ReceiptViewModel. */ }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Распознать")
|
||||
}
|
||||
}
|
||||
if (bitmap != null) ResultOverlay(bitmap, "")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultOverlay(bitmap: Bitmap, text: String) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
drawContext.canvas.nativeCanvas.drawBitmap(bitmap, null, size.toRect(), null)
|
||||
drawContext.canvas.nativeCanvas.drawText(text, 24f, 48f, android.graphics.Paint().apply { color = android.graphics.Color.RED; textSize = 32f })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.obdai.receipt.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageFormat
|
||||
import android.graphics.Rect
|
||||
import android.graphics.YuvImage
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class CameraManager {
|
||||
fun analyzer(onFrame: (Bitmap) -> Unit): ImageAnalysis.Analyzer = ImageAnalysis.Analyzer { image ->
|
||||
try {
|
||||
image.toBitmap()?.let(onFrame)
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ImageProxy.toBitmap(): Bitmap? {
|
||||
if (format != ImageFormat.YUV_420_888 || planes.size < 3) return null
|
||||
val y = planes[0].buffer
|
||||
val u = planes[1].buffer
|
||||
val v = planes[2].buffer
|
||||
val ySize = y.remaining()
|
||||
val uSize = u.remaining()
|
||||
val vSize = v.remaining()
|
||||
val nv21 = ByteArray(ySize + uSize + vSize)
|
||||
y.get(nv21, 0, ySize)
|
||||
v.get(nv21, ySize, vSize)
|
||||
u.get(nv21, ySize + vSize, uSize)
|
||||
val jpeg = ByteArrayOutputStream()
|
||||
YuvImage(nv21, ImageFormat.NV21, width, height, null)
|
||||
.compressToJpeg(Rect(0, 0, width, height), 92, jpeg)
|
||||
return BitmapFactory.decodeByteArray(jpeg.toByteArray(), 0, jpeg.size())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.obdai.receipt.crop
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
|
||||
object CropHelper {
|
||||
fun crop(source: Bitmap, bounds: Rect): Bitmap {
|
||||
require(!source.isRecycled) { "Source bitmap is recycled" }
|
||||
val left = bounds.left.coerceIn(0, source.width)
|
||||
val top = bounds.top.coerceIn(0, source.height)
|
||||
val right = bounds.right.coerceIn(left, source.width)
|
||||
val bottom = bounds.bottom.coerceIn(top, source.height)
|
||||
require(right > left && bottom > top) { "Crop bounds are empty" }
|
||||
return Bitmap.createBitmap(source, left, top, right - left, bottom - top)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package ru.obdai.receipt.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
@Serializable
|
||||
data class ReceiptResponse(val text: String? = null, val usage: Usage? = null)
|
||||
|
||||
@Serializable
|
||||
data class Usage(val promptTokens: Int? = null, val candidatesTokens: Int? = null, val totalTokens: Int? = null)
|
||||
|
||||
class ApiClient(
|
||||
private val endpoint: String,
|
||||
private val token: String,
|
||||
private val client: OkHttpClient = OkHttpClient()
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun recognize(imageBytes: ByteArray, prompt: String): ReceiptResponse {
|
||||
require(token.isNotBlank()) { "RECEIPT_API_TOKEN is not configured" }
|
||||
val imageBody = imageBytes.toRequestBody("image/jpeg".toMediaType())
|
||||
val body = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("image", "medications.jpg", imageBody)
|
||||
.addFormDataPart("prompt", prompt)
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
.url(endpoint)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.post(body)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
check(response.isSuccessful) { "Receipt API returned HTTP ${response.code}" }
|
||||
return json.decodeFromString(response.body?.string().orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ru.obdai.receipt.viewmodel
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.obdai.receipt.network.ApiClient
|
||||
|
||||
sealed interface UiState {
|
||||
data object Idle : UiState
|
||||
data object Analyzing : UiState
|
||||
data class Result(val bitmap: Bitmap, val text: String) : UiState
|
||||
data class Error(val message: String) : UiState
|
||||
}
|
||||
|
||||
class ReceiptViewModel(private val apiClient: ApiClient) : ViewModel() {
|
||||
private val _state = MutableStateFlow<UiState>(UiState.Idle)
|
||||
val state: StateFlow<UiState> = _state
|
||||
|
||||
fun recognize(crop: Bitmap, prompt: String) {
|
||||
require(!crop.isRecycled) { "Crop bitmap is recycled" }
|
||||
_state.value = UiState.Analyzing
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val bytes = crop.toJpegBytes()
|
||||
apiClient.recognize(bytes, prompt)
|
||||
}.onSuccess { response ->
|
||||
_state.value = UiState.Result(crop, response.text.orEmpty())
|
||||
}.onFailure { error ->
|
||||
_state.value = UiState.Error(error.message ?: "Recognition failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
(_state.value as? UiState.Result)?.bitmap?.let { bitmap ->
|
||||
if (!bitmap.isRecycled) bitmap.recycle()
|
||||
}
|
||||
_state.value = UiState.Idle
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun Bitmap.toJpegBytes(): ByteArray {
|
||||
val output = java.io.ByteArrayOutputStream()
|
||||
compress(Bitmap.CompressFormat.JPEG, 92, output)
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<style name="Theme.ReceiptCamera" parent="android:style/Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "ReceiptCamera"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user