event ticketing whitelist

This commit is contained in:
Hadrian Burkhardt
2026-03-03 16:11:28 +01:00
parent 3d7620954f
commit a0646273bc
5 changed files with 259 additions and 9 deletions
@@ -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,
@@ -21,11 +21,19 @@ data class ScannerUiState(
val lastScanTimestamp: Long = 0L,
val batchMode: Boolean = false,
val batchResults: List<BatchScanRecord> = emptyList(),
val eventTicketWhitelistCount: Int = 0,
val recentScanKeys: List<String> = 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<ScannerUiState> = _uiState.asStateFlow()
private val recentScanKeySet = LinkedHashSet<String>(200)
private val batchKeySet = LinkedHashSet<String>(100)
private val eventTicketSeenKeys = HashSet<String>()
private val eventTicketWhitelistIds = HashSet<String>()
private val eventTicketDuplicateCooldowns = HashMap<String, Long>()
private val eventTicketRecentlyAccepted = HashMap<String, Long>()
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<String>) {
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 <T : ViewModel> create(modelClass: Class<T>): T {
@@ -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<BatchScanRecord>,
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<String>) -> 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<String?>(null) }
var showDuplicateTicketAlert by remember { mutableStateOf(false) }
var showUnregisteredTicketAlert by remember { mutableStateOf(false) }
var duplicateTicketAlertContent by remember { mutableStateOf<String?>(null) }
var unregisteredTicketAlertContent by remember { mutableStateOf<String?>(null) }
var showImageScanFailed by remember { mutableStateOf(false) }
var imageScanCandidates by remember { mutableStateOf<List<GalleryScanCandidate>>(emptyList()) }
var imageScanPreviewUri by remember { mutableStateOf<Uri?>(null) }
@@ -155,6 +172,8 @@ fun ScannerScreen(
var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(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<String> {
if (raw.isBlank()) return emptySet()
return raw
.split('\n', '\r', ',', ';', '\t')
.asSequence()
.map { it.trim().lowercase() }
.filter { it.isNotBlank() }
.toSet()
}
+10
View File
@@ -41,6 +41,10 @@
<string name="share_csv">CSV</string>
<string name="share_json">JSON</string>
<string name="scan_from_image">Aus Bild scannen</string>
<string name="import_whitelist">Whitelist importieren</string>
<string name="whitelist_loaded_count">Geladene registrierte IDs: %1$d</string>
<string name="whitelist_import_success">%1$d IDs importiert.</string>
<string name="whitelist_import_empty">IDs konnten aus dieser Datei nicht importiert werden.</string>
<string name="batch_mode">Stapelmodus</string>
<string name="batch_captures_count">Stapel-Scans: %1$d</string>
<string name="clear_batch">Stapel leeren</string>
@@ -50,6 +54,12 @@
<string name="image_scan_pick_subtitle">Wähle ein Ergebnis aus:</string>
<string name="image_scan_failed">Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen.</string>
<string name="already_scanned">Bereits gescannt</string>
<string name="duplicate_ticket_alert_title">Doppeltes Ticket erkannt</string>
<string name="duplicate_ticket_alert_message">Dieser Ticket-/Code wurde bereits gescannt. Bitte Eintritt sofort prüfen, um Betrug zu verhindern.</string>
<string name="duplicate_ticket_alert_code">Code: %1$s</string>
<string name="unregistered_ticket_alert_title">Nicht registriertes Ticket erkannt</string>
<string name="unregistered_ticket_alert_message">Dieser Ticket-/Code ist nicht in der importierten Whitelist. Bitte Registrierung prüfen.</string>
<string name="unregistered_ticket_alert_code">Code: %1$s</string>
<string name="view_history">Historie anzeigen</string>
<string name="call_number">Nummer anrufen</string>
<string name="send_sms">SMS senden</string>
+10
View File
@@ -41,6 +41,10 @@
<string name="share_csv">CSV</string>
<string name="share_json">JSON</string>
<string name="scan_from_image">Scan from image</string>
<string name="import_whitelist">Import whitelist</string>
<string name="whitelist_loaded_count">Registered IDs loaded: %1$d</string>
<string name="whitelist_import_success">Imported %1$d IDs.</string>
<string name="whitelist_import_empty">Could not import IDs from this file.</string>
<string name="batch_mode">Batch mode</string>
<string name="batch_captures_count">Batch captures: %1$d</string>
<string name="clear_batch">Clear batch</string>
@@ -50,6 +54,12 @@
<string name="image_scan_pick_subtitle">Choose a result to use:</string>
<string name="image_scan_failed">Could not read this image. Try another one.</string>
<string name="already_scanned">Already scanned</string>
<string name="duplicate_ticket_alert_title">Duplicate ticket detected</string>
<string name="duplicate_ticket_alert_message">This ticket/code was scanned before. Verify entry immediately to prevent fraud.</string>
<string name="duplicate_ticket_alert_code">Code: %1$s</string>
<string name="unregistered_ticket_alert_title">Unregistered ticket detected</string>
<string name="unregistered_ticket_alert_message">This ticket/code is not in the imported whitelist. Verify attendee registration.</string>
<string name="unregistered_ticket_alert_code">Code: %1$s</string>
<string name="view_history">View history</string>
<string name="call_number">Call number</string>
<string name="send_sms">Send SMS</string>