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 @@ + +