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
+53
View File
@@ -0,0 +1,53 @@
# Clean Scanner Use Cases
## 1. Everyday Personal Use
- Scan restaurant menus, product QR labels, and website links quickly.
- Copy/share scanned values to chat apps or notes.
- Open links directly with local risk warning support.
## 2. Event & Ticketing
- Scan tickets at venues and quickly validate repeated entries.
- Use batch mode to process multiple attendees without leaving the camera.
- Share batch captures to organizers for quick reconciliation.
## 3. Inventory & Operations
- Scan product barcodes in stock rooms.
- Use batch mode for continuous scanning of many items.
- Export and share history (TXT/CSV/JSON) for downstream reporting.
## 4. Field Work & Service Teams
- Scan device labels/serials on-site.
- Save local history for audit trails when enabled.
- Share captured codes with support teams in real time.
## 5. Office & Admin Workflows
- Scan contact QR/vCard and add to contacts.
- Scan Wi-Fi setup QR and jump to Wi-Fi settings.
- Scan calendar/event data and create calendar entries.
## 6. Communication Shortcuts
- Scan phone/SMS/email QR data.
- One-tap actions: call, send SMS, send email.
- Reduce manual entry errors for phone numbers and addresses.
## 7. Security-Conscious Browsing
- Scan URL QR codes and get local warning prompts for suspicious patterns.
- Decide whether to open risky links with explicit confirmation.
- Keep scanning offline-first without backend calls.
## 8. Offline / Low-Connectivity Scenarios
- Use the scanner with no internet dependency for core scanning.
- Keep data local-first and share outputs when connectivity returns.
- Useful for travel, warehouses, and remote job sites.
## 9. Accessibility & Speed
- Launch directly into camera for 0-click scan flow.
- Pinch-to-zoom for small or distant QR/barcodes.
- Friendly scanner guide with immediate feedback on successful scans.
## 10. Team Handover & Data Transfer
- Export scan history in multiple formats:
- TXT for human-readable logs
- CSV for spreadsheets/BI tools
- JSON for system integrations
- Share exports to teammates via native Android share sheet.
+26 -28
View File
@@ -1,17 +1,17 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.clean.scanner"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "com.clean.scanner"
minSdk = 24
targetSdk = 35
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
buildConfigField("boolean", "FEATURE_PAYWALL_ENABLED", "false")
@@ -40,19 +40,11 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -60,41 +52,47 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("androidx.activity:activity-compose:1.12.3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.navigation:navigation-compose:2.8.2")
implementation("com.google.android.material:material:1.13.0")
implementation("androidx.navigation:navigation-compose:2.9.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.camera:camera-core:1.3.4")
implementation("androidx.camera:camera-camera2:1.3.4")
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
implementation("androidx.camera:camera-core:1.5.3")
implementation("androidx.camera:camera-camera2:1.5.3")
implementation("androidx.camera:camera-lifecycle:1.5.3")
implementation("androidx.camera:camera-view:1.5.3")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.room:room-runtime:2.8.4")
implementation("androidx.room:room-ktx:2.8.4")
ksp("androidx.room:room-compiler:2.8.4")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.datastore:datastore-preferences:1.2.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
@@ -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)
}
+9
View File
@@ -52,4 +52,13 @@
<string name="open_wifi_settings">WLAN-Einstellungen öffnen</string>
<string name="add_contact">Kontakt hinzufügen</string>
<string name="add_calendar_event">Kalendereintrag hinzufügen</string>
<string name="support_email">support@example.com</string>
<string name="feature_request">Feature-Request-Formular</string>
<string name="feature_request_title">Feature-Request</string>
<string name="feature_request_name">Dein Name</string>
<string name="feature_request_email">Deine E-Mail</string>
<string name="feature_request_details">Was brauchst du?</string>
<string name="feature_request_subject">Feature-Request von App-Nutzer</string>
<string name="send_request">Anfrage senden</string>
<string name="feature_request_sent">E-Mail-App wird geöffnet...</string>
</resources>
+9
View File
@@ -52,4 +52,13 @@
<string name="open_wifi_settings">Open Wi-Fi settings</string>
<string name="add_contact">Add contact</string>
<string name="add_calendar_event">Add calendar event</string>
<string name="support_email">support@example.com</string>
<string name="feature_request">Feature request form</string>
<string name="feature_request_title">Feature request</string>
<string name="feature_request_name">Your name</string>
<string name="feature_request_email">Your email</string>
<string name="feature_request_details">What do you need?</string>
<string name="feature_request_subject">Feature request from app user</string>
<string name="send_request">Send request</string>
<string name="feature_request_sent">Opening email app...</string>
</resources>
+3 -3
View File
@@ -1,5 +1,5 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false
id("com.android.application") version "9.0.0" apply false
id("com.google.devtools.ksp") version "2.3.5" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false
}
+9
View File
@@ -2,3 +2,12 @@ org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.defaults.buildfeatures.resvalues=false
android.sdk.defaultTargetSdkToCompileSdkIfUnset=true
android.usesSdkInManifest.disallowed=true
android.builtInKotlin=true
android.r8.optimizedResourceShrinking=true
android.newDsl=true
+13
View File
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/658299a896470fbb3103ba3a430ee227/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/23adb857f3cb3cbe28750bc7faa7abc0/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+3
View File
@@ -5,6 +5,9 @@ pluginManagement {
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)