better performance

This commit is contained in:
Hadrian Burkhardt
2026-02-12 23:29:43 +01:00
parent a9bcb81207
commit f8aa3a7bc0
16 changed files with 250 additions and 59 deletions
@@ -4,20 +4,30 @@ import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.clean.scanner.domain.ScanResult
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
class MlKitBarcodeAnalyzer(
private val isAnalysisEnabled: () -> Boolean = { true },
private val onDetected: (ScanResult) -> Unit
) : ImageAnalysis.Analyzer {
) : ImageAnalysis.Analyzer, AutoCloseable {
private val scanner = BarcodeScanning.getClient()
@Volatile
private var processing = false
override fun analyze(imageProxy: ImageProxy) {
if (!isAnalysisEnabled() || processing) {
imageProxy.close()
return
}
val mediaImage = imageProxy.image ?: run {
imageProxy.close()
return
}
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
processing = true
scanner.process(image)
.addOnSuccessListener { barcodes ->
@@ -26,22 +36,29 @@ class MlKitBarcodeAnalyzer(
val type = first.valueType.toHumanType()
onDetected(ScanResult(content = raw, type = type))
}
.addOnCompleteListener { imageProxy.close() }
.addOnCompleteListener {
processing = false
imageProxy.close()
}
}
private fun Int.toHumanType(): String = when (this) {
1 -> "Contact"
2 -> "Email"
3 -> "ISBN"
4 -> "Phone"
5 -> "Product"
6 -> "SMS"
7 -> "Text"
8 -> "URL"
9 -> "WiFi"
10 -> "Geo"
11 -> "Calendar"
12 -> "Driver license"
Barcode.TYPE_CONTACT_INFO -> "Contact"
Barcode.TYPE_EMAIL -> "Email"
Barcode.TYPE_ISBN -> "ISBN"
Barcode.TYPE_PHONE -> "Phone"
Barcode.TYPE_PRODUCT -> "Product"
Barcode.TYPE_SMS -> "SMS"
Barcode.TYPE_TEXT -> "Text"
Barcode.TYPE_URL -> "URL"
Barcode.TYPE_WIFI -> "WiFi"
Barcode.TYPE_GEO -> "Geo"
Barcode.TYPE_CALENDAR_EVENT -> "Calendar"
Barcode.TYPE_DRIVER_LICENSE -> "Driver license"
else -> "Unknown"
}
override fun close() {
scanner.close()
}
}
@@ -32,6 +32,8 @@ class ScannerViewModel(
) : ViewModel() {
private val _uiState = MutableStateFlow(ScannerUiState())
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
private val recentScanKeySet = LinkedHashSet<String>(200)
private val batchKeySet = LinkedHashSet<String>(100)
fun onScan(result: ScanResult) {
val now = nowProvider()
@@ -40,14 +42,25 @@ class ScannerViewModel(
if (now - current.lastScanTimestamp < 800) return
val key = "${result.type}|${result.content}"
val isDuplicate = current.recentScanKeys.contains(key)
val updatedRecent = (listOf(key) + current.recentScanKeys).distinct().take(200)
val isDuplicate = key in recentScanKeySet
recentScanKeySet.remove(key)
recentScanKeySet.add(key)
while (recentScanKeySet.size > 200) {
recentScanKeySet.remove(recentScanKeySet.first())
}
val updatedRecent = recentScanKeySet.toList().asReversed()
_uiState.value = if (current.batchMode) {
val updatedBatch = if (current.batchResults.any { "${it.result.type}|${it.result.content}" == key }) {
val updatedBatch = if (key in batchKeySet) {
current.batchResults
} else {
(listOf(BatchScanRecord(result = result, timestamp = now)) + current.batchResults).take(100)
batchKeySet.add(key)
val nextBatch = (listOf(BatchScanRecord(result = result, timestamp = now)) + current.batchResults)
.take(100)
while (batchKeySet.size > 100) {
batchKeySet.remove(batchKeySet.first())
}
nextBatch
}
current.copy(
lastResult = null,
@@ -87,6 +100,7 @@ class ScannerViewModel(
}
fun clearBatchResults() {
batchKeySet.clear()
_uiState.value = _uiState.value.copy(batchResults = emptyList())
}
@@ -13,6 +13,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -39,6 +40,15 @@ fun CameraPreview(
val previewView = remember { PreviewView(context) }
val cameraRef = remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
val zoomRatio = remember { mutableStateOf(1f) }
val latestAnalysisEnabled = rememberUpdatedState(analysisEnabled)
val latestOnScan = rememberUpdatedState(onScan)
val analyzer = remember {
MlKitBarcodeAnalyzer(
isAnalysisEnabled = { latestAnalysisEnabled.value },
onDetected = { result -> latestOnScan.value(result.content, result.type) }
)
}
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
val scaleGestureDetector = remember {
ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
@@ -56,12 +66,15 @@ fun CameraPreview(
DisposableEffect(Unit) {
onDispose {
cameraProviderRef.value?.unbindAll()
analyzer.close()
cameraExecutor.shutdown()
}
}
LaunchedEffect(analysisEnabled) {
LaunchedEffect(lifecycleOwner) {
val provider = ProcessCameraProvider.getInstance(context).get()
cameraProviderRef.value = provider
provider.unbindAll()
val preview = Preview.Builder().build().apply {
@@ -71,13 +84,7 @@ fun CameraPreview(
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build().apply {
if (analysisEnabled) {
setAnalyzer(cameraExecutor, MlKitBarcodeAnalyzer { result ->
onScan(result.content, result.type)
})
} else {
clearAnalyzer()
}
setAnalyzer(cameraExecutor, analyzer)
}
val camera = provider.bindToLifecycle(
@@ -1,5 +1,6 @@
package com.clean.scanner.ui.screens
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -14,9 +16,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
import com.clean.scanner.util.Intents
@Composable
fun SettingsScreen(
@@ -27,7 +31,10 @@ fun SettingsScreen(
onWarningsToggle: (Boolean) -> Unit,
onScanFeedbackToggle: (Boolean) -> Unit
) {
val context = LocalContext.current
val showDeleteConfirm = remember { mutableStateOf(false) }
val showFeatureRequestForm = remember { mutableStateOf(false) }
val requesterNeed = remember { mutableStateOf("") }
if (showDeleteConfirm.value) {
AlertDialog(
@@ -49,6 +56,51 @@ fun SettingsScreen(
)
}
if (showFeatureRequestForm.value) {
AlertDialog(
onDismissRequest = { showFeatureRequestForm.value = false },
title = { Text(stringResource(R.string.feature_request_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = requesterNeed.value,
onValueChange = { requesterNeed.value = it },
label = { Text(stringResource(R.string.feature_request_details)) }
)
}
},
confirmButton = {
TextButton(
onClick = {
val body = buildString {
appendLine("Request:")
append(requesterNeed.value.trim())
}
Intents.sendEmail(
context = context,
email = context.getString(R.string.support_email),
subject = context.getString(R.string.feature_request_subject),
body = body
)
showFeatureRequestForm.value = false
requesterNeed.value = ""
Toast.makeText(
context,
context.getString(R.string.feature_request_sent),
Toast.LENGTH_SHORT
).show()
},
enabled = requesterNeed.value.isNotBlank()
) { Text(stringResource(R.string.send_request)) }
},
dismissButton = {
TextButton(onClick = { showFeatureRequestForm.value = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -82,5 +134,9 @@ fun SettingsScreen(
Text(text = stringResource(R.string.version))
Text(text = stringResource(R.string.licenses))
Text(text = stringResource(R.string.contact))
Spacer(modifier = Modifier.height(12.dp))
TextButton(onClick = { showFeatureRequestForm.value = true }) {
Text(text = stringResource(R.string.feature_request))
}
}
}
@@ -43,12 +43,15 @@ object Intents {
startActivity(context, intent, null)
}
fun sendEmail(context: Context, email: String, subject: String?) {
fun sendEmail(context: Context, email: String, subject: String?, body: String? = null) {
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${email.trim()}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (!subject.isNullOrBlank()) {
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
}
if (!body.isNullOrBlank()) {
intent.putExtra(Intent.EXTRA_TEXT, body)
}
startActivity(context, intent, null)
}