Harden Android privacy and network flow

This commit is contained in:
“Naeel”
2026-08-29 16:13:49 +03:00
parent c88224104e
commit 3d846d0be5
7 changed files with 56 additions and 13 deletions
+9
View File
@@ -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
### Вопрос
+2 -2
View File
@@ -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("\"", "\\\"")}\"")
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
@@ -168,6 +168,9 @@ private fun CameraScreen(
else Text(if (bounds == null) "Найти и распознать препараты" else "Распознать crop")
}
if (state is UiState.Error) Text(state.message, color = Color.Red)
if (state is UiState.PrivacyBlocked) {
Text("Обнаружены данные пациента. Отправка заблокирована.", color = Color.Red)
}
if (state is UiState.Result) Text(state.text, color = Color.Red, modifier = Modifier.padding(24.dp))
}
if (capturedBitmap != null && bounds != null) {
@@ -181,11 +184,7 @@ private fun CameraScreen(
)
)
}
if (state is UiState.Result) {
ResultOverlay(state.bitmap, state.text)
} else if (bitmap != null) {
ResultOverlay(bitmap, "")
}
if (state is UiState.Result) ResultOverlay(state.bitmap, state.text)
}
}
@@ -7,25 +7,45 @@ import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import java.util.regex.Pattern
sealed interface ZoneDetection {
data class Found(val bounds: Rect) : ZoneDetection
data object NoText : ZoneDetection
data object PatientDataDetected : ZoneDetection
}
class MedicationZoneDetector {
private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
suspend fun detect(bitmap: Bitmap): Rect? = suspendCancellableCoroutine { continuation ->
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)
}
}
@@ -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 }
@@ -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)
}
}
}