From a0646273bc7c186f56700575d73ee6269da001c9 Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Tue, 3 Mar 2026 16:11:28 +0100 Subject: [PATCH] event ticketing whitelist --- .../clean/scanner/ui/CleanScannerAppRoot.kt | 5 + .../com/clean/scanner/ui/ScannerViewModel.kt | 65 +++++++ .../clean/scanner/ui/screens/ScannerScreen.kt | 178 +++++++++++++++++- app/src/main/res/values-de/strings.xml | 10 + app/src/main/res/values/strings.xml | 10 + 5 files changed, 259 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt b/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt index 268945c..9ea905e 100644 --- a/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt +++ b/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt @@ -70,12 +70,17 @@ fun CleanScannerAppRoot(container: AppContainer) { lastResult = scannerState.lastResult, batchMode = scannerState.batchMode, batchResults = scannerState.batchResults, + eventTicketWhitelistCount = scannerState.eventTicketWhitelistCount, duplicateFeedbackNonce = scannerState.duplicateFeedbackNonce, scanFeedbackNonce = scannerState.scanFeedbackNonce, warningsEnabled = appState.warningsEnabled, scanFeedbackEnabled = appState.scanFeedbackEnabled, useCaseView = appState.useCaseView, onScan = scannerViewModel::onScan, + onEvaluateEventTicketScan = scannerViewModel::evaluateEventTicketScan, + onAuditDuplicateTicket = scannerViewModel::auditDuplicateTicketScan, + onAuditUnregisteredTicket = scannerViewModel::auditUnregisteredTicketScan, + onReplaceEventTicketWhitelist = scannerViewModel::replaceEventTicketWhitelist, onScanAgain = scannerViewModel::resumeScanning, onBatchModeChange = scannerViewModel::setBatchMode, onClearBatchResults = scannerViewModel::clearBatchResults, diff --git a/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt b/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt index ea4d435..53b64c3 100644 --- a/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt +++ b/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt @@ -21,11 +21,19 @@ data class ScannerUiState( val lastScanTimestamp: Long = 0L, val batchMode: Boolean = false, val batchResults: List = emptyList(), + val eventTicketWhitelistCount: Int = 0, val recentScanKeys: List = emptyList(), val duplicateFeedbackNonce: Int = 0, val scanFeedbackNonce: Int = 0 ) +enum class EventTicketScanDecision { + Accept, + Unregistered, + DuplicateAlert, + Ignore +} + class ScannerViewModel( private val saveScan: suspend (content: String, type: String) -> Unit, private val nowProvider: () -> Long = { System.currentTimeMillis() } @@ -33,12 +41,17 @@ class ScannerViewModel( private companion object { const val GENERAL_DEBOUNCE_MS = 800L const val SAME_CODE_HOLDOFF_MS = 2500L + const val EVENT_TICKET_HOLDOFF_MS = 30_000L } private val _uiState = MutableStateFlow(ScannerUiState()) val uiState: StateFlow = _uiState.asStateFlow() private val recentScanKeySet = LinkedHashSet(200) private val batchKeySet = LinkedHashSet(100) + private val eventTicketSeenKeys = HashSet() + private val eventTicketWhitelistIds = HashSet() + private val eventTicketDuplicateCooldowns = HashMap() + private val eventTicketRecentlyAccepted = HashMap() private var lastAcceptedKey: String? = null private var lastAcceptedTimestamp: Long = 0L @@ -112,9 +125,61 @@ class ScannerViewModel( fun clearBatchResults() { batchKeySet.clear() + eventTicketSeenKeys.clear() + eventTicketDuplicateCooldowns.clear() + eventTicketRecentlyAccepted.clear() _uiState.value = _uiState.value.copy(batchResults = emptyList()) } + fun auditDuplicateTicketScan(result: ScanResult) { + viewModelScope.launch { + saveScan(result.content, "Duplicate ticket (${result.type})") + } + } + + fun auditUnregisteredTicketScan(result: ScanResult) { + viewModelScope.launch { + saveScan(result.content, "Unregistered ticket (${result.type})") + } + } + + fun replaceEventTicketWhitelist(ids: Set) { + eventTicketWhitelistIds.clear() + eventTicketWhitelistIds.addAll(ids.map(::normalizeWhitelistId).filter { it.isNotBlank() }) + _uiState.value = _uiState.value.copy(eventTicketWhitelistCount = eventTicketWhitelistIds.size) + } + + fun evaluateEventTicketScan(result: ScanResult): EventTicketScanDecision { + val key = "${result.type}|${result.content}" + val normalizedContent = normalizeWhitelistId(result.content) + val now = nowProvider() + if (eventTicketWhitelistIds.isNotEmpty() && normalizedContent !in eventTicketWhitelistIds) { + return EventTicketScanDecision.Unregistered + } + if (key !in eventTicketSeenKeys) { + eventTicketSeenKeys.add(key) + eventTicketRecentlyAccepted[key] = now + return EventTicketScanDecision.Accept + } + + val acceptedAt = eventTicketRecentlyAccepted[key] ?: 0L + if (now - acceptedAt < EVENT_TICKET_HOLDOFF_MS) { + return EventTicketScanDecision.Ignore + } + + val cooldownUntil = eventTicketDuplicateCooldowns[key] ?: 0L + if (now < cooldownUntil) { + return EventTicketScanDecision.Ignore + } + + eventTicketDuplicateCooldowns[key] = now + EVENT_TICKET_HOLDOFF_MS + return EventTicketScanDecision.DuplicateAlert + } + + private fun normalizeWhitelistId(value: String): String { + return value.trim().lowercase() + } + class Factory(private val container: AppContainer) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt index b0b4687..2a04ad0 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.FlashOff import androidx.compose.material.icons.filled.FlashOn import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material.icons.filled.ViewModule import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -49,6 +50,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -75,6 +77,7 @@ import com.clean.scanner.data.scanner.DetectionBox import com.clean.scanner.data.scanner.DetectionPoint import com.clean.scanner.domain.ScanResult import com.clean.scanner.ui.BatchScanRecord +import com.clean.scanner.ui.EventTicketScanDecision import com.clean.scanner.ui.UseCaseView import com.clean.scanner.ui.components.CameraPreview import com.clean.scanner.ui.capabilities @@ -87,6 +90,9 @@ import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlin.math.max internal data class GalleryScanCandidate( @@ -101,12 +107,17 @@ fun ScannerScreen( lastResult: ScanResult?, batchMode: Boolean, batchResults: List, + eventTicketWhitelistCount: Int, duplicateFeedbackNonce: Int, scanFeedbackNonce: Int, warningsEnabled: Boolean, scanFeedbackEnabled: Boolean, useCaseView: UseCaseView, onScan: (ScanResult) -> Unit, + onEvaluateEventTicketScan: (ScanResult) -> EventTicketScanDecision, + onAuditDuplicateTicket: (ScanResult) -> Unit, + onAuditUnregisteredTicket: (ScanResult) -> Unit, + onReplaceEventTicketWhitelist: (Set) -> Unit, onScanAgain: () -> Unit, onBatchModeChange: (Boolean) -> Unit, onClearBatchResults: () -> Unit, @@ -115,9 +126,9 @@ fun ScannerScreen( val context = LocalContext.current val haptic = LocalHapticFeedback.current val capabilities = remember(useCaseView) { useCaseView.capabilities() } + val forceBatchMode = useCaseView == UseCaseView.EventTicketing val showBatchModeToggle = remember(useCaseView) { when (useCaseView) { - UseCaseView.EventTicketing, UseCaseView.InventoryOperations, UseCaseView.FieldWorkServiceTeams, UseCaseView.OfflineLowConnectivity, @@ -125,12 +136,14 @@ fun ScannerScreen( else -> false } } + val isBatchModeActive = forceBatchMode || batchMode val duplicateSnackbarHostState = remember { SnackbarHostState() } val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) } - LaunchedEffect(showBatchModeToggle, batchMode) { - if (!showBatchModeToggle && batchMode) { - onBatchModeChange(false) + LaunchedEffect(forceBatchMode, showBatchModeToggle, batchMode) { + when { + forceBatchMode && !batchMode -> onBatchModeChange(true) + !forceBatchMode && !showBatchModeToggle && batchMode -> onBatchModeChange(false) } } @@ -147,6 +160,10 @@ fun ScannerScreen( var torchAvailable by remember { mutableStateOf(false) } var showRiskWarning by remember { mutableStateOf(false) } var pendingOpenUrl by remember { mutableStateOf(null) } + var showDuplicateTicketAlert by remember { mutableStateOf(false) } + var showUnregisteredTicketAlert by remember { mutableStateOf(false) } + var duplicateTicketAlertContent by remember { mutableStateOf(null) } + var unregisteredTicketAlertContent by remember { mutableStateOf(null) } var showImageScanFailed by remember { mutableStateOf(false) } var imageScanCandidates by remember { mutableStateOf>(emptyList()) } var imageScanPreviewUri by remember { mutableStateOf(null) } @@ -155,6 +172,8 @@ fun ScannerScreen( var detectionBoxes by remember { mutableStateOf>(emptyList()) } var detectionSourceWidth by remember { mutableIntStateOf(0) } var detectionSourceHeight by remember { mutableIntStateOf(0) } + var lastHandledScanFeedbackNonce by remember { mutableIntStateOf(scanFeedbackNonce) } + val scope = rememberCoroutineScope() val activity = context as? Activity val imageScanner = remember { BarcodeScanning.getClient( @@ -220,6 +239,34 @@ fun ScannerScreen( showImageScanFailed = true } } + val whitelistPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + val ids = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { reader -> + parseWhitelistIds(reader.readText()) + } ?: emptySet() + }.getOrElse { emptySet() } + } + if (ids.isEmpty()) { + Toast.makeText( + context, + context.getString(R.string.whitelist_import_empty), + Toast.LENGTH_SHORT + ).show() + } else { + onReplaceEventTicketWhitelist(ids) + Toast.makeText( + context, + context.getString(R.string.whitelist_import_success, ids.size), + Toast.LENGTH_SHORT + ).show() + } + } + } LaunchedEffect(Unit) { if (!cameraGranted) permissionLauncher.launch(Manifest.permission.CAMERA) @@ -227,6 +274,8 @@ fun ScannerScreen( LaunchedEffect(duplicateFeedbackNonce) { if (duplicateFeedbackNonce > 0) { + if (useCaseView == UseCaseView.EventTicketing) return@LaunchedEffect + Toast.makeText( context, context.getString(R.string.already_scanned), @@ -244,10 +293,13 @@ fun ScannerScreen( } LaunchedEffect(scanFeedbackNonce, scanFeedbackEnabled) { - if (scanFeedbackEnabled && scanFeedbackNonce > 0) { + if (scanFeedbackEnabled && scanFeedbackNonce > lastHandledScanFeedbackNonce) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP, 120) } + if (scanFeedbackNonce > lastHandledScanFeedbackNonce) { + lastHandledScanFeedbackNonce = scanFeedbackNonce + } } DisposableEffect(Unit) { @@ -307,6 +359,26 @@ fun ScannerScreen( val insideAim = centerX in aimLeft..aimRight && centerY in aimTop..aimBottom if (!insideAim) return@CameraPreview + if (forceBatchMode) { + val scanResult = ScanResult(content = content, type = type) + when (onEvaluateEventTicketScan(scanResult)) { + EventTicketScanDecision.Accept -> Unit + EventTicketScanDecision.Unregistered -> { + onAuditUnregisteredTicket(scanResult) + unregisteredTicketAlertContent = content + showUnregisteredTicketAlert = true + return@CameraPreview + } + EventTicketScanDecision.DuplicateAlert -> { + onAuditDuplicateTicket(scanResult) + duplicateTicketAlertContent = content + showDuplicateTicketAlert = true + return@CameraPreview + } + EventTicketScanDecision.Ignore -> return@CameraPreview + } + } + onScan(ScanResult(content = content, type = type)) } ) @@ -394,7 +466,7 @@ fun ScannerScreen( textAlign = TextAlign.Center, modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = if (batchMode) 190.dp else 56.dp) + .padding(bottom = if (isBatchModeActive) 190.dp else 56.dp) .background( color = Color.Black.copy(alpha = 0.35f), shape = RoundedCornerShape(18.dp) @@ -420,6 +492,24 @@ fun ScannerScreen( ) } } + if (useCaseView == UseCaseView.EventTicketing) { + IconButton( + onClick = { whitelistPicker.launch("*/*") }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 12.dp, end = if (capabilities.allowScanFromImage) 64.dp else 12.dp) + .background( + color = Color.Black.copy(alpha = 0.35f), + shape = RoundedCornerShape(14.dp) + ) + ) { + Icon( + imageVector = Icons.Default.UploadFile, + contentDescription = stringResource(R.string.import_whitelist), + tint = Color.White + ) + } + } Text( text = stringResource(useCaseView.titleRes), @@ -434,6 +524,21 @@ fun ScannerScreen( ) .padding(horizontal = 12.dp, vertical = 6.dp) ) + if (useCaseView == UseCaseView.EventTicketing) { + Text( + text = stringResource(R.string.whitelist_loaded_count, eventTicketWhitelistCount), + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 56.dp, start = 64.dp, end = 64.dp) + .background( + color = Color.Black.copy(alpha = 0.35f), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) + } Column( modifier = Modifier @@ -462,7 +567,7 @@ fun ScannerScreen( } } - if (batchMode && showBatchModeToggle) { + if (isBatchModeActive && (showBatchModeToggle || forceBatchMode)) { Box(modifier = Modifier.align(Alignment.BottomCenter)) { BatchResultsPanel( results = batchResults, @@ -476,7 +581,7 @@ fun ScannerScreen( hostState = duplicateSnackbarHostState, modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = if (batchMode) 12.dp else 80.dp) + .padding(bottom = if (isBatchModeActive) 12.dp else 80.dp) ) } else if (!galleryOpen) { PermissionContent( @@ -498,7 +603,7 @@ fun ScannerScreen( ) } - if (lastResult != null && !batchMode) { + if (lastResult != null && !isBatchModeActive) { val parsedContact = remember(lastResult.content) { ScanContentParsers.parseContact(lastResult.content) } val parsedEvent = remember(lastResult.content) { ScanContentParsers.parseCalendarEvent(lastResult.content) } @@ -634,6 +739,51 @@ fun ScannerScreen( ) } + if (showDuplicateTicketAlert && useCaseView == UseCaseView.EventTicketing) { + AlertDialog( + onDismissRequest = { showDuplicateTicketAlert = false }, + title = { Text(text = stringResource(R.string.duplicate_ticket_alert_title), color = Color(0xFFB00020)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(R.string.duplicate_ticket_alert_message)) + if (!duplicateTicketAlertContent.isNullOrBlank()) { + Text( + text = stringResource(R.string.duplicate_ticket_alert_code, duplicateTicketAlertContent!!), + color = Color(0xFFB00020) + ) + } + } + }, + confirmButton = { + TextButton(onClick = { showDuplicateTicketAlert = false }) { + Text(stringResource(R.string.confirm)) + } + } + ) + } + if (showUnregisteredTicketAlert && useCaseView == UseCaseView.EventTicketing) { + AlertDialog( + onDismissRequest = { showUnregisteredTicketAlert = false }, + title = { Text(text = stringResource(R.string.unregistered_ticket_alert_title), color = Color(0xFFB00020)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(R.string.unregistered_ticket_alert_message)) + if (!unregisteredTicketAlertContent.isNullOrBlank()) { + Text( + text = stringResource(R.string.unregistered_ticket_alert_code, unregisteredTicketAlertContent!!), + color = Color(0xFFB00020) + ) + } + } + }, + confirmButton = { + TextButton(onClick = { showUnregisteredTicketAlert = false }) { + Text(stringResource(R.string.confirm)) + } + } + ) + } + if (imageScanPreviewUri != null) { GalleryScanPreviewDialog( imageUri = imageScanPreviewUri, @@ -663,3 +813,13 @@ fun ScannerScreen( } } } + +private fun parseWhitelistIds(raw: String): Set { + if (raw.isBlank()) return emptySet() + return raw + .split('\n', '\r', ',', ';', '\t') + .asSequence() + .map { it.trim().lowercase() } + .filter { it.isNotBlank() } + .toSet() +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 15715ce..2702685 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -41,6 +41,10 @@ CSV JSON Aus Bild scannen + Whitelist importieren + Geladene registrierte IDs: %1$d + %1$d IDs importiert. + IDs konnten aus dieser Datei nicht importiert werden. Stapelmodus Stapel-Scans: %1$d Stapel leeren @@ -50,6 +54,12 @@ Wähle ein Ergebnis aus: Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen. Bereits gescannt + Doppeltes Ticket erkannt + Dieser Ticket-/Code wurde bereits gescannt. Bitte Eintritt sofort prüfen, um Betrug zu verhindern. + Code: %1$s + Nicht registriertes Ticket erkannt + Dieser Ticket-/Code ist nicht in der importierten Whitelist. Bitte Registrierung prüfen. + Code: %1$s Historie anzeigen Nummer anrufen SMS senden diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2763dc0..b0f4cad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,10 @@ CSV JSON Scan from image + Import whitelist + Registered IDs loaded: %1$d + Imported %1$d IDs. + Could not import IDs from this file. Batch mode Batch captures: %1$d Clear batch @@ -50,6 +54,12 @@ Choose a result to use: Could not read this image. Try another one. Already scanned + Duplicate ticket detected + This ticket/code was scanned before. Verify entry immediately to prevent fraud. + Code: %1$s + Unregistered ticket detected + This ticket/code is not in the imported whitelist. Verify attendee registration. + Code: %1$s View history Call number Send SMS