Add Android receipt camera MVP scaffold

This commit is contained in:
“Naeel”
2026-08-29 13:50:03 +03:00
parent 5cfbab3eba
commit d3e6ec3a9b
12 changed files with 365 additions and 0 deletions
+60
View File
@@ -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")
}
+1
View File
@@ -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>
+5
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
+18
View File
@@ -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")