diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts
new file mode 100644
index 0000000..c04fe09
--- /dev/null
+++ b/android-app/app/build.gradle.kts
@@ -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")
+}
diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro
new file mode 100644
index 0000000..195ab12
--- /dev/null
+++ b/android-app/app/proguard-rules.pro
@@ -0,0 +1 @@
+# App-specific R8 rules.
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bb2540c
--- /dev/null
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt b/android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt
new file mode 100644
index 0000000..1ec6ba6
--- /dev/null
+++ b/android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt
@@ -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(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 })
+ }
+}
diff --git a/android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt b/android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt
new file mode 100644
index 0000000..4c42484
--- /dev/null
+++ b/android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt
@@ -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())
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/ru/obdai/receipt/crop/CropHelper.kt b/android-app/app/src/main/java/ru/obdai/receipt/crop/CropHelper.kt
new file mode 100644
index 0000000..ac639ef
--- /dev/null
+++ b/android-app/app/src/main/java/ru/obdai/receipt/crop/CropHelper.kt
@@ -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)
+ }
+}
diff --git a/android-app/app/src/main/java/ru/obdai/receipt/network/ApiClient.kt b/android-app/app/src/main/java/ru/obdai/receipt/network/ApiClient.kt
new file mode 100644
index 0000000..c3db27b
--- /dev/null
+++ b/android-app/app/src/main/java/ru/obdai/receipt/network/ApiClient.kt
@@ -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())
+ }
+ }
+}
diff --git a/android-app/app/src/main/java/ru/obdai/receipt/viewmodel/ReceiptViewModel.kt b/android-app/app/src/main/java/ru/obdai/receipt/viewmodel/ReceiptViewModel.kt
new file mode 100644
index 0000000..a164ff2
--- /dev/null
+++ b/android-app/app/src/main/java/ru/obdai/receipt/viewmodel/ReceiptViewModel.kt
@@ -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.Idle)
+ val state: StateFlow = _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()
+ }
+}
diff --git a/android-app/app/src/main/res/values/styles.xml b/android-app/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..3655d9d
--- /dev/null
+++ b/android-app/app/src/main/res/values/styles.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts
new file mode 100644
index 0000000..1b4481a
--- /dev/null
+++ b/android-app/build.gradle.kts
@@ -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
+}
diff --git a/android-app/gradle.properties b/android-app/gradle.properties
new file mode 100644
index 0000000..e696167
--- /dev/null
+++ b/android-app/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
diff --git a/android-app/settings.gradle.kts b/android-app/settings.gradle.kts
new file mode 100644
index 0000000..e0919a2
--- /dev/null
+++ b/android-app/settings.gradle.kts
@@ -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")