Harden Android privacy and network flow
This commit is contained in:
@@ -10,6 +10,15 @@
|
|||||||
- Проверка `:app:testDebugUnitTest :app:assembleDebug` завершилась `BUILD SUCCESSFUL`.
|
- Проверка `:app:testDebugUnitTest :app:assembleDebug` завершилась `BUILD SUCCESSFUL`.
|
||||||
- Версия Android-приложения повышена до `0.1.4`.
|
- Версия 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
|
## 2026-08-28
|
||||||
|
|
||||||
### Вопрос
|
### Вопрос
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "ru.obdai.receipt"
|
applicationId = "ru.obdai.receipt"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 5
|
versionCode = 6
|
||||||
versionName = "0.1.4"
|
versionName = "0.1.5"
|
||||||
|
|
||||||
val apiToken = providers.environmentVariable("RECEIPT_API_TOKEN").orNull ?: ""
|
val apiToken = providers.environmentVariable("RECEIPT_API_TOKEN").orNull ?: ""
|
||||||
buildConfigField("String", "RECEIPT_API_TOKEN", "\"${apiToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
buildConfigField("String", "RECEIPT_API_TOKEN", "\"${apiToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ private fun CameraScreen(
|
|||||||
else Text(if (bounds == null) "Найти и распознать препараты" else "Распознать crop")
|
else Text(if (bounds == null) "Найти и распознать препараты" else "Распознать crop")
|
||||||
}
|
}
|
||||||
if (state is UiState.Error) Text(state.message, color = Color.Red)
|
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 (state is UiState.Result) Text(state.text, color = Color.Red, modifier = Modifier.padding(24.dp))
|
||||||
}
|
}
|
||||||
if (capturedBitmap != null && bounds != null) {
|
if (capturedBitmap != null && bounds != null) {
|
||||||
@@ -181,11 +184,7 @@ private fun CameraScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state is UiState.Result) {
|
if (state is UiState.Result) ResultOverlay(state.bitmap, state.text)
|
||||||
ResultOverlay(state.bitmap, state.text)
|
|
||||||
} else if (bitmap != null) {
|
|
||||||
ResultOverlay(bitmap, "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,25 +7,45 @@ import com.google.mlkit.vision.text.TextRecognition
|
|||||||
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
|
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlin.coroutines.resume
|
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 {
|
class MedicationZoneDetector {
|
||||||
private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
|
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))
|
recognizer.process(InputImage.fromBitmap(bitmap, 0))
|
||||||
.addOnSuccessListener { result ->
|
.addOnSuccessListener { result ->
|
||||||
|
val text = result.text
|
||||||
|
if (containsPatientData(text)) {
|
||||||
|
continuation.resume(ZoneDetection.PatientDataDetected)
|
||||||
|
return@addOnSuccessListener
|
||||||
|
}
|
||||||
val blocks = result.textBlocks
|
val blocks = result.textBlocks
|
||||||
.map { it.boundingBox }
|
.map { it.boundingBox }
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.filter { it.top > bitmap.height / 5 }
|
.filter { it.top > bitmap.height / 5 }
|
||||||
continuation.resume(blocks.reduceOrNull { first, next ->
|
val bounds = blocks.reduceOrNull { first, next ->
|
||||||
Rect(first).apply { union(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() {
|
fun close() {
|
||||||
recognizer.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.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ReceiptResponse(val text: String? = null, val usage: Usage? = null)
|
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(
|
class ApiClient(
|
||||||
private val endpoint: String,
|
private val endpoint: String,
|
||||||
private val token: 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 }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.obdai.receipt.crop.MedicationZoneDetector
|
import ru.obdai.receipt.crop.MedicationZoneDetector
|
||||||
|
import ru.obdai.receipt.crop.ZoneDetection
|
||||||
import ru.obdai.receipt.network.ApiClient
|
import ru.obdai.receipt.network.ApiClient
|
||||||
|
|
||||||
sealed interface UiState {
|
sealed interface UiState {
|
||||||
data object Idle : UiState
|
data object Idle : UiState
|
||||||
data object Analyzing : UiState
|
data object Analyzing : UiState
|
||||||
|
data object PrivacyBlocked : UiState
|
||||||
data class Result(val bitmap: Bitmap, val text: String) : UiState
|
data class Result(val bitmap: Bitmap, val text: String) : UiState
|
||||||
data class Error(val message: String) : UiState
|
data class Error(val message: String) : UiState
|
||||||
}
|
}
|
||||||
@@ -41,7 +43,14 @@ class ReceiptViewModel(
|
|||||||
|
|
||||||
fun detectZone(bitmap: Bitmap, onDetected: (android.graphics.Rect?) -> Unit) {
|
fun detectZone(bitmap: Bitmap, onDetected: (android.graphics.Rect?) -> Unit) {
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user