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