From 3d846d0be59a2a437f75d9d64a9dd0b81d55f793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 29 Aug 2026 16:13:49 +0300 Subject: [PATCH] Harden Android privacy and network flow --- HISTORY/2026-08-28-obdai-receipt.md | 9 ++++++ android-app/app/build.gradle.kts | 4 +-- android-app/app/src/main/AndroidManifest.xml | 1 + .../java/ru/obdai/receipt/MainActivity.kt | 9 +++--- .../receipt/crop/MedicationZoneDetector.kt | 28 ++++++++++++++++--- .../ru/obdai/receipt/network/ApiClient.kt | 7 ++++- .../receipt/viewmodel/ReceiptViewModel.kt | 11 +++++++- 7 files changed, 56 insertions(+), 13 deletions(-) diff --git a/HISTORY/2026-08-28-obdai-receipt.md b/HISTORY/2026-08-28-obdai-receipt.md index 2094d00..6be4fbf 100644 --- a/HISTORY/2026-08-28-obdai-receipt.md +++ b/HISTORY/2026-08-28-obdai-receipt.md @@ -10,6 +10,15 @@ - Проверка `:app:testDebugUnitTest :app:assembleDebug` завершилась `BUILD SUCCESSFUL`. - Версия Android-приложения повышена до `0.1.4`. +## 2026-08-29: Code review fixes + +- Добавлено разрешение `INTERNET`; для OkHttp заданы connect/read/call timeouts. +- Аналитические Bitmap больше не рисуются поверх live preview на каждом кадре. +- YUV-конвертация учитывает `rowStride`, `pixelStride` и rotation; старые live-кадры освобождаются. +- Добавлен локальный privacy gate: признаки ФИО, пациента или даты блокируют отправку. +- ML Kit различает найденную зону, отсутствие текста и `PrivacyBlocked`. +- Unit-тесты и `assembleDebug` для версии `0.1.5` завершились успешно. + ## 2026-08-28 ### Вопрос diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 65b6169..35d4e3a 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "ru.obdai.receipt" minSdk = 26 targetSdk = 35 - versionCode = 5 - versionName = "0.1.4" + versionCode = 6 + versionName = "0.1.5" val apiToken = providers.environmentVariable("RECEIPT_API_TOKEN").orNull ?: "" buildConfigField("String", "RECEIPT_API_TOKEN", "\"${apiToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"") diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index bb2540c..09d076b 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + + suspend fun detect(bitmap: Bitmap): ZoneDetection = suspendCancellableCoroutine { continuation -> recognizer.process(InputImage.fromBitmap(bitmap, 0)) .addOnSuccessListener { result -> + val text = result.text + if (containsPatientData(text)) { + continuation.resume(ZoneDetection.PatientDataDetected) + return@addOnSuccessListener + } val blocks = result.textBlocks .map { it.boundingBox } .filterNotNull() .filter { it.top > bitmap.height / 5 } - continuation.resume(blocks.reduceOrNull { first, next -> + val bounds = blocks.reduceOrNull { first, next -> Rect(first).apply { union(next) } - }) + } + continuation.resume(bounds?.let(ZoneDetection::Found) ?: ZoneDetection.NoText) } - .addOnFailureListener { continuation.resume(null) } + .addOnFailureListener { continuation.resume(ZoneDetection.NoText) } } fun close() { recognizer.close() } + + private fun containsPatientData(text: String): Boolean { + val normalized = text.lowercase() + val date = Pattern.compile("""\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b""").matcher(normalized).find() + val labels = listOf("ф.и.о", "фамилия", "имя", "отчество", "дата рождения", "пациент") + return date || labels.any(normalized::contains) + } } \ No newline at end of file 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 index c3db27b..b2f4b31 100644 --- 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 @@ -7,6 +7,7 @@ import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit @Serializable data class ReceiptResponse(val text: String? = null, val usage: Usage? = null) @@ -17,7 +18,11 @@ data class Usage(val promptTokens: Int? = null, val candidatesTokens: Int? = nul class ApiClient( private val endpoint: String, private val token: String, - private val client: OkHttpClient = OkHttpClient() + private val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .callTimeout(150, TimeUnit.SECONDS) + .build() ) { private val json = Json { ignoreUnknownKeys = true } 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 index 211f032..fc12e81 100644 --- 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 @@ -8,11 +8,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import ru.obdai.receipt.crop.MedicationZoneDetector +import ru.obdai.receipt.crop.ZoneDetection import ru.obdai.receipt.network.ApiClient sealed interface UiState { data object Idle : UiState data object Analyzing : UiState + data object PrivacyBlocked : UiState data class Result(val bitmap: Bitmap, val text: String) : UiState data class Error(val message: String) : UiState } @@ -41,7 +43,14 @@ class ReceiptViewModel( fun detectZone(bitmap: Bitmap, onDetected: (android.graphics.Rect?) -> Unit) { viewModelScope.launch(Dispatchers.Default) { - onDetected(zoneDetector.detect(bitmap)) + when (val detection = zoneDetector.detect(bitmap)) { + is ZoneDetection.Found -> onDetected(detection.bounds) + ZoneDetection.PatientDataDetected -> { + _state.value = UiState.PrivacyBlocked + onDetected(null) + } + ZoneDetection.NoText -> onDetected(null) + } } }