diff --git a/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt b/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt index bf1c227..0e6b3d0 100644 --- a/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt +++ b/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt @@ -5,7 +5,7 @@ import android.os.SystemClock import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import de.softwareapp_hb.privateqrscanner.domain.ScanResult -import de.softwareapp_hb.privateqrscanner.util.readablePayload +import de.softwareapp_hb.privateqrscanner.util.readableBarcodePayload import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.common.Barcode @@ -117,7 +117,7 @@ class MlKitBarcodeAnalyzer( scanner.process(image) .addOnSuccessListener { barcodes -> - val readable = barcodes.firstOrNull { it.readablePayload() != null } + val readable = barcodes.firstOrNull { it.readableBarcodePayload() != null } val boxes = barcodes.mapNotNull { barcode -> val bounds = barcode.boundingBox ?: return@mapNotNull null val normalized = normalizeBoundingBox( @@ -144,6 +144,7 @@ class MlKitBarcodeAnalyzer( sourceHeight = sourceHeight ) if (readable != null) { + val payload = readable.readableBarcodePayload() ?: return@addOnSuccessListener val readableBox = readable.boundingBox?.let { bounds -> val normalized = normalizeBoundingBox( rect = bounds, @@ -165,8 +166,9 @@ class MlKitBarcodeAnalyzer( } onDetected( ScanResult( - content = readable.readablePayload().orEmpty(), - type = readable.valueType.toHumanType() + content = payload.content, + type = readable.valueType.toHumanType(), + isBase64Encoded = payload.isBase64Encoded ), gatedReadableBox, sourceWidth, diff --git a/app/src/main/java/com/clean/scanner/domain/ScanResult.kt b/app/src/main/java/com/clean/scanner/domain/ScanResult.kt index aea58f0..2fc2a2e 100644 --- a/app/src/main/java/com/clean/scanner/domain/ScanResult.kt +++ b/app/src/main/java/com/clean/scanner/domain/ScanResult.kt @@ -2,5 +2,13 @@ package de.softwareapp_hb.privateqrscanner.domain data class ScanResult( val content: String, - val type: String -) + val type: String, + val isBase64Encoded: Boolean = false +) { + val displayType: String + get() = if (isBase64Encoded && !type.contains("base64", ignoreCase = true)) { + "$type (Base64)" + } else { + type + } +} 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 a96a34a..abc0300 100644 --- a/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt +++ b/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt @@ -60,7 +60,7 @@ class ScannerViewModel( val current = _uiState.value if (!current.analysisEnabled) return - val key = "${result.type}|${result.content}" + val key = "${result.displayType}|${result.content}" if (now - current.lastScanTimestamp < GENERAL_DEBOUNCE_MS) return if (key == lastAcceptedKey && now - lastAcceptedTimestamp < SAME_CODE_HOLDOFF_MS) return @@ -107,7 +107,7 @@ class ScannerViewModel( } viewModelScope.launch { - saveScan(result.content, result.type) + saveScan(result.content, result.displayType) } } @@ -133,13 +133,13 @@ class ScannerViewModel( fun auditDuplicateTicketScan(result: ScanResult) { viewModelScope.launch { - saveScan(result.content, "Duplicate ticket (${result.type})") + saveScan(result.content, "Duplicate ticket (${result.displayType})") } } fun auditUnregisteredTicketScan(result: ScanResult) { viewModelScope.launch { - saveScan(result.content, "Unregistered ticket (${result.type})") + saveScan(result.content, "Unregistered ticket (${result.displayType})") } } @@ -150,7 +150,7 @@ class ScannerViewModel( } fun evaluateEventTicketScan(result: ScanResult): EventTicketScanDecision { - val key = "${result.type}|${result.content}" + val key = "${result.displayType}|${result.content}" val normalizedContent = normalizeWhitelistId(result.content) val now = nowProvider() if (eventTicketWhitelistIds.isNotEmpty() && normalizedContent !in eventTicketWhitelistIds) { diff --git a/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt b/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt index e93376b..ca63bfd 100644 --- a/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt +++ b/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox import de.softwareapp_hb.privateqrscanner.data.scanner.MlKitBarcodeAnalyzer +import de.softwareapp_hb.privateqrscanner.domain.ScanResult import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import kotlin.math.max @@ -45,7 +46,7 @@ fun CameraPreview( sourceWidth: Int, sourceHeight: Int ) -> Unit = { _, _, _, _, _ -> }, - onScan: (String, String, DetectionBox?, Int, Int) -> Unit + onScan: (ScanResult, DetectionBox?, Int, Int) -> Unit ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -72,7 +73,7 @@ fun CameraPreview( } }, onDetected = { result, readableBox, sourceWidth, sourceHeight -> - latestOnScan.value(result.content, result.type, readableBox, sourceWidth, sourceHeight) + latestOnScan.value(result, readableBox, sourceWidth, sourceHeight) } ) } diff --git a/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt index 7a2472b..933d722 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt @@ -68,10 +68,20 @@ fun HistoryScreen( val detail = selectedItem.value if (detail != null) { + val detailIsBase64 = detail.isBase64Encoded() AlertDialog( onDismissRequest = { selectedItem.value = null }, title = { Text(text = detail.type) }, - text = { Text(text = detail.content) }, + text = { + if (detailIsBase64) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(R.string.base64_encoded_notice)) + Text(text = detail.content) + } + } else { + Text(text = detail.content) + } + }, confirmButton = { TextButton(onClick = { selectedItem.value = null }) { Text(text = stringResource(R.string.confirm)) @@ -167,9 +177,20 @@ private fun HistoryRow( .clickable { onOpenDetails() } .padding(vertical = 12.dp)) { Text(text = item.type) - Text(text = item.content, maxLines = 2) + Text( + text = if (item.isBase64Encoded()) { + stringResource(R.string.base64_encoded_inline, item.content) + } else { + item.content + }, + maxLines = 2 + ) Text(text = DateFormat.getDateTimeInstance().format(Date(item.timestamp))) } } ) } + +private fun ScanRecord.isBase64Encoded(): Boolean { + return type.contains("base64", ignoreCase = true) +} diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt index bd06191..0073425 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt @@ -54,7 +54,7 @@ import de.softwareapp_hb.privateqrscanner.R import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint import de.softwareapp_hb.privateqrscanner.domain.ScanResult -import de.softwareapp_hb.privateqrscanner.util.readablePayload +import de.softwareapp_hb.privateqrscanner.util.readableBarcodePayload import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScannerOptions @@ -175,7 +175,7 @@ internal fun GalleryScanPreviewDialog( } val live = barcodes.mapNotNull { barcode -> - val raw = barcode.readablePayload() ?: return@mapNotNull null + val payload = barcode.readableBarcodePayload() ?: return@mapNotNull null val normalizedBox = barcode.boundingBox?.let { bounds -> val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f) val topN = ((bounds.top + cropTop) / imgH).coerceIn(0f, 1f) @@ -190,10 +190,14 @@ internal fun GalleryScanPreviewDialog( DetectionBox(leftN, topN, rightN, bottomN, corners) } GalleryScanCandidate( - result = ScanResult(content = raw, type = barcode.valueType.toHumanType()), + result = ScanResult( + content = payload.content, + type = barcode.valueType.toHumanType(), + isBase64Encoded = payload.isBase64Encoded + ), box = normalizedBox ) - }.distinctBy { "${it.result.type}|${it.result.content}" } + }.distinctBy { "${it.result.displayType}|${it.result.content}" } liveCandidates = live } @@ -329,12 +333,16 @@ internal fun GalleryScanPreviewDialog( ) { Column(modifier = Modifier.fillMaxWidth()) { Text( - text = "${index + 1}. ${candidate.result.type}", + text = "${index + 1}. ${candidate.result.displayType}", textAlign = TextAlign.Start, modifier = Modifier.fillMaxWidth() ) Text( - text = candidate.result.content, + text = if (candidate.result.isBase64Encoded) { + stringResource(R.string.base64_encoded_inline, candidate.result.content) + } else { + candidate.result.content + }, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Start, diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerOverlayComponents.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerOverlayComponents.kt index 82dc2db..0e0c8a3 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerOverlayComponents.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerOverlayComponents.kt @@ -102,6 +102,11 @@ internal fun BatchResultsPanel( color = Color.White ) results.take(3).forEach { item -> + val contentText = if (item.result.isBase64Encoded) { + stringResource(R.string.base64_encoded_inline, item.result.content) + } else { + item.result.content + } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -109,7 +114,7 @@ internal fun BatchResultsPanel( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "${item.result.type}: ${item.result.content}", + text = "${item.result.displayType}: $contentText", color = Color.White.copy(alpha = 0.92f), maxLines = 1 ) @@ -159,7 +164,8 @@ private fun buildBatchExport(results: List): String { if (results.isEmpty()) return "" val formatter = DateFormat.getDateTimeInstance() return results.joinToString(separator = "\n\n") { item -> - "${formatter.format(Date(item.timestamp))}\n${item.result.type}\n${item.result.content}" + val encoding = if (item.result.isBase64Encoded) "\nEncoding: Base64" else "" + "${formatter.format(Date(item.timestamp))}\n${item.result.displayType}$encoding\n${item.result.content}" } } diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerResultCards.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerResultCards.kt index 1b54142..d923532 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerResultCards.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerResultCards.kt @@ -22,9 +22,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import de.softwareapp_hb.privateqrscanner.R import de.softwareapp_hb.privateqrscanner.domain.ScanResult import de.softwareapp_hb.privateqrscanner.util.ParsedContact import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers @@ -43,8 +45,10 @@ internal fun ResultVisualCard( onOpenUrl: ((String) -> Unit)? = null, modifier: Modifier = Modifier ) { - val contact = remember(result.content) { ScanContentParsers.parseContact(result.content) } - if (contact != null || result.type == "Contact") { + val contact = remember(result) { + if (result.isBase64Encoded) null else ScanContentParsers.parseContact(result.content) + } + if (!result.isBase64Encoded && (contact != null || result.type == "Contact")) { ContactVisualCard( contact = contact, rawContent = result.content, @@ -63,7 +67,7 @@ internal fun ResultVisualCard( modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - if (result.type == "WiFi") { + if (!result.isBase64Encoded && result.type == "WiFi") { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically @@ -80,10 +84,17 @@ internal fun ResultVisualCard( } } else { Text( - text = result.type, + text = result.displayType, style = MaterialTheme.typography.titleMedium ) } + if (result.isBase64Encoded) { + Text( + text = stringResource(R.string.base64_encoded_notice), + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF4F6277) + ) + } if (fields.isEmpty()) { Text( text = result.content, @@ -280,6 +291,9 @@ private fun selectContactTemplate(contact: ParsedContact?, rawContent: String): } private fun buildResultFields(result: ScanResult): List { + if (result.isBase64Encoded) { + return listOf(ResultField("Encoded data", result.content)) + } return when (result.type) { "Contact" -> { val contact = ScanContentParsers.parseContact(result.content) 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 7eaf361..41353f3 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 @@ -85,7 +85,7 @@ import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil import de.softwareapp_hb.privateqrscanner.util.Intents import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer -import de.softwareapp_hb.privateqrscanner.util.readablePayload +import de.softwareapp_hb.privateqrscanner.util.readableBarcodePayload import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.common.Barcode @@ -175,6 +175,13 @@ fun ScannerScreen( .build() ) } + fun ScanResult.visibleAlertContent(): String { + return if (isBase64Encoded) { + context.getString(R.string.base64_encoded_inline, content) + } else { + content + } + } val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() @@ -204,7 +211,7 @@ fun ScannerScreen( imageScanner.process(image) .addOnSuccessListener { barcodes -> val candidates = barcodes.mapNotNull { barcode -> - val raw = barcode.readablePayload() ?: return@mapNotNull null + val payload = barcode.readableBarcodePayload() ?: return@mapNotNull null val normalizedBox = barcode.boundingBox?.let { bounds -> val corners = barcode.cornerPoints?.map { p -> DetectionPoint( @@ -221,10 +228,14 @@ fun ScannerScreen( ) } GalleryScanCandidate( - result = ScanResult(content = raw, type = barcode.valueType.toHumanType()), + result = ScanResult( + content = payload.content, + type = barcode.valueType.toHumanType(), + isBase64Encoded = payload.isBase64Encoded + ), box = normalizedBox ) - }.distinctBy { "${it.result.type}|${it.result.content}" } + }.distinctBy { "${it.result.displayType}|${it.result.content}" } imageScanCandidates = candidates } .addOnFailureListener { @@ -332,7 +343,7 @@ fun ScannerScreen( detectionSourceWidth = sourceWidth detectionSourceHeight = sourceHeight }, - onScan = { content, type, readableBox, sourceWidth, sourceHeight -> + onScan = { scanResult, readableBox, sourceWidth, sourceHeight -> val box = readableBox ?: return@CameraPreview if (sourceWidth <= 0 || sourceHeight <= 0 || viewW <= 0f || viewH <= 0f) { return@CameraPreview @@ -352,18 +363,17 @@ fun ScannerScreen( 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 + unregisteredTicketAlertContent = scanResult.visibleAlertContent() showUnregisteredTicketAlert = true return@CameraPreview } EventTicketScanDecision.DuplicateAlert -> { onAuditDuplicateTicket(scanResult) - duplicateTicketAlertContent = content + duplicateTicketAlertContent = scanResult.visibleAlertContent() showDuplicateTicketAlert = true return@CameraPreview } @@ -371,7 +381,7 @@ fun ScannerScreen( } } - onScan(ScanResult(content = content, type = type)) + onScan(scanResult) } ) @@ -596,8 +606,12 @@ fun ScannerScreen( } if (lastResult != null && !isBatchModeActive) { - val parsedContact = remember(lastResult.content) { ScanContentParsers.parseContact(lastResult.content) } - val parsedEvent = remember(lastResult.content) { ScanContentParsers.parseCalendarEvent(lastResult.content) } + val parsedContact = remember(lastResult) { + if (lastResult.isBase64Encoded) null else ScanContentParsers.parseContact(lastResult.content) + } + val parsedEvent = remember(lastResult) { + if (lastResult.isBase64Encoded) null else ScanContentParsers.parseCalendarEvent(lastResult.content) + } ModalBottomSheet(onDismissRequest = onScanAgain) { Column( @@ -659,52 +673,54 @@ fun ScannerScreen( } } - when (lastResult.type) { - "Phone" -> { - if (capabilities.allowDialPhone) { - Button(onClick = { - Intents.dialPhone(context, ScanContentParsers.extractPhoneNumber(lastResult.content)) - }) { - Text(stringResource(R.string.call_number)) + if (!lastResult.isBase64Encoded) { + when (lastResult.type) { + "Phone" -> { + if (capabilities.allowDialPhone) { + Button(onClick = { + Intents.dialPhone(context, ScanContentParsers.extractPhoneNumber(lastResult.content)) + }) { + Text(stringResource(R.string.call_number)) + } } } - } - "SMS" -> { - if (capabilities.allowSendSms) { - Button(onClick = { - val smsData = ScanContentParsers.parseSms(lastResult.content) - Intents.sendSms(context, smsData.first, smsData.second) - }) { - Text(stringResource(R.string.send_sms)) + "SMS" -> { + if (capabilities.allowSendSms) { + Button(onClick = { + val smsData = ScanContentParsers.parseSms(lastResult.content) + Intents.sendSms(context, smsData.first, smsData.second) + }) { + Text(stringResource(R.string.send_sms)) + } } } - } - "Email" -> { - if (capabilities.allowSendEmail) { - Button(onClick = { - Intents.sendEmail(context, ScanContentParsers.extractEmail(lastResult.content), null) - }) { - Text(stringResource(R.string.send_email)) + "Email" -> { + if (capabilities.allowSendEmail) { + Button(onClick = { + Intents.sendEmail(context, ScanContentParsers.extractEmail(lastResult.content), null) + }) { + Text(stringResource(R.string.send_email)) + } } } - } - "WiFi" -> { - if (capabilities.allowOpenWifiSettings) { - Button(onClick = { Intents.openWifiSettings(context) }) { - Text(stringResource(R.string.open_wifi_settings)) + "WiFi" -> { + if (capabilities.allowOpenWifiSettings) { + Button(onClick = { Intents.openWifiSettings(context) }) { + Text(stringResource(R.string.open_wifi_settings)) + } } } - } - "Calendar" -> { - if (capabilities.allowAddCalendarEvent) { - Button(onClick = { - Intents.addCalendarEvent(context, parsedEvent, lastResult.content) - }) { - Text(stringResource(R.string.add_calendar_event)) + "Calendar" -> { + if (capabilities.allowAddCalendarEvent) { + Button(onClick = { + Intents.addCalendarEvent(context, parsedEvent, lastResult.content) + }) { + Text(stringResource(R.string.add_calendar_event)) + } } } } diff --git a/app/src/main/java/com/clean/scanner/util/BarcodePayload.kt b/app/src/main/java/com/clean/scanner/util/BarcodePayload.kt index 8571cfc..a224700 100644 --- a/app/src/main/java/com/clean/scanner/util/BarcodePayload.kt +++ b/app/src/main/java/com/clean/scanner/util/BarcodePayload.kt @@ -1,20 +1,86 @@ package de.softwareapp_hb.privateqrscanner.util import com.google.mlkit.vision.barcode.common.Barcode +import java.util.Base64 + +internal data class ReadableBarcodePayload( + val content: String, + val isBase64Encoded: Boolean +) fun Barcode.readablePayload(): String? { - rawValue?.trim()?.takeIf { it.isNotBlank() }?.let { return it } - displayValue?.trim()?.takeIf { it.isNotBlank() }?.let { return it } + return readableBarcodePayload()?.content +} - val bytes = rawBytes?.takeIf { it.isNotEmpty() } ?: return null +internal fun Barcode.readableBarcodePayload(): ReadableBarcodePayload? { + return readablePayload(rawValue, displayValue, rawBytes) +} + +internal fun readablePayload( + rawValue: String?, + displayValue: String?, + rawBytes: ByteArray? +): ReadableBarcodePayload? { + val bytes = rawBytes?.takeIf { it.isNotEmpty() } + rawValue?.trim()?.takeIf { it.isNotBlank() }?.let { value -> + return if (value.isLikelyHumanReadable()) { + ReadableBarcodePayload(content = value, isBase64Encoded = false) + } else { + value.asBase64Payload(bytes) + } + } + displayValue?.trim()?.takeIf { it.isNotBlank() }?.let { value -> + return if (value.isLikelyHumanReadable()) { + ReadableBarcodePayload(content = value, isBase64Encoded = false) + } else { + value.asBase64Payload(bytes) + } + } + + bytes ?: return null val utf8 = bytes.toString(Charsets.UTF_8).trim() - if (utf8.isLikelyHumanReadable()) return utf8 + if (utf8.isLikelyHumanReadable()) { + return ReadableBarcodePayload(content = utf8, isBase64Encoded = false) + } - return bytes.joinToString(separator = "") { byte -> "%02X".format(byte) } + return bytes.asBase64Payload() } private fun String.isLikelyHumanReadable(): Boolean { if (isBlank()) return false - val controlChars = count { it.isISOControl() && it != '\n' && it != '\r' && it != '\t' } - return controlChars == 0 + var index = 0 + while (index < length) { + val codePoint = codePointAt(index) + val charCount = Character.charCount(codePoint) + if ( + Character.isISOControl(codePoint) && codePoint != '\n'.code && + codePoint != '\r'.code && codePoint != '\t'.code + ) { + return false + } + if ( + codePoint == '\uFFFD'.code || + (this[index].isSurrogate() && charCount == 1) || + codePoint.isNonCharacter() + ) { + return false + } + index += charCount + } + return true +} + +private fun String.asBase64Payload(rawBytes: ByteArray?): ReadableBarcodePayload { + return rawBytes?.asBase64Payload() ?: toByteArray(Charsets.UTF_8).asBase64Payload() +} + +private fun ByteArray.asBase64Payload(): ReadableBarcodePayload { + return ReadableBarcodePayload( + content = Base64.getEncoder().encodeToString(this), + isBase64Encoded = true + ) +} + +private fun Int.isNonCharacter(): Boolean { + return this in 0xFDD0..0xFDEF || (this and 0xFFFE) == 0xFFFE } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6fbca45..5734ba3 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -31,6 +31,8 @@ Kontakt: softwareapp.hb@gmail.com Typ Inhalt + Als Base64 angezeigt, weil die gescannten Daten nicht als Text dargestellt werden können. + Base64-codiert: %1$s Kamera erlauben Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen Code im mittleren Rahmen ausrichten. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ede482d..3f9a03d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,6 +31,8 @@ Contact: softwareapp.hb@gmail.com Type Content + Displayed as Base64 because the scanned data cannot be shown as text. + Base64 encoded: %1$s Allow camera Pinch to zoom for small codes Aim the code inside the center frame. diff --git a/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt b/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt index e1d1e98..60092f7 100644 --- a/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt +++ b/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt @@ -115,4 +115,21 @@ class ScannerViewModelTest { assertEquals(1, saved.size) assertEquals(1, state.batchResults.size) } + + @Test + fun onScan_base64EncodedScan_savesTypeWithBase64Marker() = runTest { + val saved = mutableListOf>() + val viewModel = ScannerViewModel( + saveScan = { content, type -> saved += content to type }, + nowProvider = { 1_000L } + ) + + viewModel.onScan(ScanResult(content = "AAEC", type = "Text", isBase64Encoded = true)) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals("Text (Base64)", state.lastResult?.displayType) + assertEquals(listOf("Text (Base64)|AAEC"), state.recentScanKeys) + assertEquals(listOf("AAEC" to "Text (Base64)"), saved) + } } diff --git a/app/src/test/java/com/clean/scanner/util/BarcodePayloadTest.kt b/app/src/test/java/com/clean/scanner/util/BarcodePayloadTest.kt new file mode 100644 index 0000000..04c3774 --- /dev/null +++ b/app/src/test/java/com/clean/scanner/util/BarcodePayloadTest.kt @@ -0,0 +1,71 @@ +package de.softwareapp_hb.privateqrscanner.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BarcodePayloadTest { + + @Test + fun readablePayload_prefersDisplayableRawValue() { + val payload = readablePayload( + rawValue = " hello world ", + displayValue = null, + rawBytes = null + ) + + assertEquals("hello world", payload?.content) + assertFalse(payload?.isBase64Encoded ?: true) + } + + @Test + fun readablePayload_keepsDisplayableUtf8Bytes() { + val text = "Ticket \uD83D\uDE00" + + val payload = readablePayload( + rawValue = null, + displayValue = null, + rawBytes = text.toByteArray(Charsets.UTF_8) + ) + + assertEquals(text, payload?.content) + assertFalse(payload?.isBase64Encoded ?: true) + } + + @Test + fun readablePayload_base64EncodesBinaryBytes() { + val payload = readablePayload( + rawValue = null, + displayValue = null, + rawBytes = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0xFF.toByte()) + ) + + assertEquals("AAECA/8=", payload?.content) + assertTrue(payload?.isBase64Encoded ?: false) + } + + @Test + fun readablePayload_base64EncodesRawBytesWhenRawValueContainsReplacementCharacter() { + val payload = readablePayload( + rawValue = "broken \uFFFD", + displayValue = null, + rawBytes = byteArrayOf(0x00, 0x01, 0x02) + ) + + assertEquals("AAEC", payload?.content) + assertTrue(payload?.isBase64Encoded ?: false) + } + + @Test + fun readablePayload_base64EncodesRawValueWhenNoBytesAreAvailable() { + val payload = readablePayload( + rawValue = "A\u0000B", + displayValue = null, + rawBytes = null + ) + + assertEquals("QQBC", payload?.content) + assertTrue(payload?.isBase64Encoded ?: false) + } +}