base64 enocding + display

This commit is contained in:
Hadrian Burkhardt
2026-05-10 00:13:43 +02:00
parent 1b610f6c4d
commit cd73c35c4d
14 changed files with 313 additions and 79 deletions
@@ -5,7 +5,7 @@ import android.os.SystemClock
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import de.softwareapp_hb.privateqrscanner.domain.ScanResult 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.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
@@ -117,7 +117,7 @@ class MlKitBarcodeAnalyzer(
scanner.process(image) scanner.process(image)
.addOnSuccessListener { barcodes -> .addOnSuccessListener { barcodes ->
val readable = barcodes.firstOrNull { it.readablePayload() != null } val readable = barcodes.firstOrNull { it.readableBarcodePayload() != null }
val boxes = barcodes.mapNotNull { barcode -> val boxes = barcodes.mapNotNull { barcode ->
val bounds = barcode.boundingBox ?: return@mapNotNull null val bounds = barcode.boundingBox ?: return@mapNotNull null
val normalized = normalizeBoundingBox( val normalized = normalizeBoundingBox(
@@ -144,6 +144,7 @@ class MlKitBarcodeAnalyzer(
sourceHeight = sourceHeight sourceHeight = sourceHeight
) )
if (readable != null) { if (readable != null) {
val payload = readable.readableBarcodePayload() ?: return@addOnSuccessListener
val readableBox = readable.boundingBox?.let { bounds -> val readableBox = readable.boundingBox?.let { bounds ->
val normalized = normalizeBoundingBox( val normalized = normalizeBoundingBox(
rect = bounds, rect = bounds,
@@ -165,8 +166,9 @@ class MlKitBarcodeAnalyzer(
} }
onDetected( onDetected(
ScanResult( ScanResult(
content = readable.readablePayload().orEmpty(), content = payload.content,
type = readable.valueType.toHumanType() type = readable.valueType.toHumanType(),
isBase64Encoded = payload.isBase64Encoded
), ),
gatedReadableBox, gatedReadableBox,
sourceWidth, sourceWidth,
@@ -2,5 +2,13 @@ package de.softwareapp_hb.privateqrscanner.domain
data class ScanResult( data class ScanResult(
val content: String, 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
}
}
@@ -60,7 +60,7 @@ class ScannerViewModel(
val current = _uiState.value val current = _uiState.value
if (!current.analysisEnabled) return 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 (now - current.lastScanTimestamp < GENERAL_DEBOUNCE_MS) return
if (key == lastAcceptedKey && now - lastAcceptedTimestamp < SAME_CODE_HOLDOFF_MS) return if (key == lastAcceptedKey && now - lastAcceptedTimestamp < SAME_CODE_HOLDOFF_MS) return
@@ -107,7 +107,7 @@ class ScannerViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
saveScan(result.content, result.type) saveScan(result.content, result.displayType)
} }
} }
@@ -133,13 +133,13 @@ class ScannerViewModel(
fun auditDuplicateTicketScan(result: ScanResult) { fun auditDuplicateTicketScan(result: ScanResult) {
viewModelScope.launch { viewModelScope.launch {
saveScan(result.content, "Duplicate ticket (${result.type})") saveScan(result.content, "Duplicate ticket (${result.displayType})")
} }
} }
fun auditUnregisteredTicketScan(result: ScanResult) { fun auditUnregisteredTicketScan(result: ScanResult) {
viewModelScope.launch { 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 { fun evaluateEventTicketScan(result: ScanResult): EventTicketScanDecision {
val key = "${result.type}|${result.content}" val key = "${result.displayType}|${result.content}"
val normalizedContent = normalizeWhitelistId(result.content) val normalizedContent = normalizeWhitelistId(result.content)
val now = nowProvider() val now = nowProvider()
if (eventTicketWhitelistIds.isNotEmpty() && normalizedContent !in eventTicketWhitelistIds) { if (eventTicketWhitelistIds.isNotEmpty() && normalizedContent !in eventTicketWhitelistIds) {
@@ -26,6 +26,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox
import de.softwareapp_hb.privateqrscanner.data.scanner.MlKitBarcodeAnalyzer import de.softwareapp_hb.privateqrscanner.data.scanner.MlKitBarcodeAnalyzer
import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.max import kotlin.math.max
@@ -45,7 +46,7 @@ fun CameraPreview(
sourceWidth: Int, sourceWidth: Int,
sourceHeight: Int sourceHeight: Int
) -> Unit = { _, _, _, _, _ -> }, ) -> Unit = { _, _, _, _, _ -> },
onScan: (String, String, DetectionBox?, Int, Int) -> Unit onScan: (ScanResult, DetectionBox?, Int, Int) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -72,7 +73,7 @@ fun CameraPreview(
} }
}, },
onDetected = { result, readableBox, sourceWidth, sourceHeight -> onDetected = { result, readableBox, sourceWidth, sourceHeight ->
latestOnScan.value(result.content, result.type, readableBox, sourceWidth, sourceHeight) latestOnScan.value(result, readableBox, sourceWidth, sourceHeight)
} }
) )
} }
@@ -68,10 +68,20 @@ fun HistoryScreen(
val detail = selectedItem.value val detail = selectedItem.value
if (detail != null) { if (detail != null) {
val detailIsBase64 = detail.isBase64Encoded()
AlertDialog( AlertDialog(
onDismissRequest = { selectedItem.value = null }, onDismissRequest = { selectedItem.value = null },
title = { Text(text = detail.type) }, 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 = { confirmButton = {
TextButton(onClick = { selectedItem.value = null }) { TextButton(onClick = { selectedItem.value = null }) {
Text(text = stringResource(R.string.confirm)) Text(text = stringResource(R.string.confirm))
@@ -167,9 +177,20 @@ private fun HistoryRow(
.clickable { onOpenDetails() } .clickable { onOpenDetails() }
.padding(vertical = 12.dp)) { .padding(vertical = 12.dp)) {
Text(text = item.type) 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))) Text(text = DateFormat.getDateTimeInstance().format(Date(item.timestamp)))
} }
} }
) )
} }
private fun ScanRecord.isBase64Encoded(): Boolean {
return type.contains("base64", ignoreCase = true)
}
@@ -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.DetectionBox
import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint
import de.softwareapp_hb.privateqrscanner.domain.ScanResult 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.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScannerOptions
@@ -175,7 +175,7 @@ internal fun GalleryScanPreviewDialog(
} }
val live = barcodes.mapNotNull { barcode -> 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 normalizedBox = barcode.boundingBox?.let { bounds ->
val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f) val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f)
val topN = ((bounds.top + cropTop) / imgH).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) DetectionBox(leftN, topN, rightN, bottomN, corners)
} }
GalleryScanCandidate( GalleryScanCandidate(
result = ScanResult(content = raw, type = barcode.valueType.toHumanType()), result = ScanResult(
content = payload.content,
type = barcode.valueType.toHumanType(),
isBase64Encoded = payload.isBase64Encoded
),
box = normalizedBox box = normalizedBox
) )
}.distinctBy { "${it.result.type}|${it.result.content}" } }.distinctBy { "${it.result.displayType}|${it.result.content}" }
liveCandidates = live liveCandidates = live
} }
@@ -329,12 +333,16 @@ internal fun GalleryScanPreviewDialog(
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Text( Text(
text = "${index + 1}. ${candidate.result.type}", text = "${index + 1}. ${candidate.result.displayType}",
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Text( 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, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
@@ -102,6 +102,11 @@ internal fun BatchResultsPanel(
color = Color.White color = Color.White
) )
results.take(3).forEach { item -> 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -109,7 +114,7 @@ internal fun BatchResultsPanel(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "${item.result.type}: ${item.result.content}", text = "${item.result.displayType}: $contentText",
color = Color.White.copy(alpha = 0.92f), color = Color.White.copy(alpha = 0.92f),
maxLines = 1 maxLines = 1
) )
@@ -159,7 +164,8 @@ private fun buildBatchExport(results: List<BatchScanRecord>): String {
if (results.isEmpty()) return "" if (results.isEmpty()) return ""
val formatter = DateFormat.getDateTimeInstance() val formatter = DateFormat.getDateTimeInstance()
return results.joinToString(separator = "\n\n") { item -> 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}"
} }
} }
@@ -22,9 +22,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.domain.ScanResult import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import de.softwareapp_hb.privateqrscanner.util.ParsedContact import de.softwareapp_hb.privateqrscanner.util.ParsedContact
import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers
@@ -43,8 +45,10 @@ internal fun ResultVisualCard(
onOpenUrl: ((String) -> Unit)? = null, onOpenUrl: ((String) -> Unit)? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val contact = remember(result.content) { ScanContentParsers.parseContact(result.content) } val contact = remember(result) {
if (contact != null || result.type == "Contact") { if (result.isBase64Encoded) null else ScanContentParsers.parseContact(result.content)
}
if (!result.isBase64Encoded && (contact != null || result.type == "Contact")) {
ContactVisualCard( ContactVisualCard(
contact = contact, contact = contact,
rawContent = result.content, rawContent = result.content,
@@ -63,7 +67,7 @@ internal fun ResultVisualCard(
modifier = Modifier.padding(14.dp), modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (result.type == "WiFi") { if (!result.isBase64Encoded && result.type == "WiFi") {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -80,10 +84,17 @@ internal fun ResultVisualCard(
} }
} else { } else {
Text( Text(
text = result.type, text = result.displayType,
style = MaterialTheme.typography.titleMedium 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()) { if (fields.isEmpty()) {
Text( Text(
text = result.content, text = result.content,
@@ -280,6 +291,9 @@ private fun selectContactTemplate(contact: ParsedContact?, rawContent: String):
} }
private fun buildResultFields(result: ScanResult): List<ResultField> { private fun buildResultFields(result: ScanResult): List<ResultField> {
if (result.isBase64Encoded) {
return listOf(ResultField("Encoded data", result.content))
}
return when (result.type) { return when (result.type) {
"Contact" -> { "Contact" -> {
val contact = ScanContentParsers.parseContact(result.content) val contact = ScanContentParsers.parseContact(result.content)
@@ -85,7 +85,7 @@ import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil
import de.softwareapp_hb.privateqrscanner.util.Intents import de.softwareapp_hb.privateqrscanner.util.Intents
import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers
import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer 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.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
@@ -175,6 +175,13 @@ fun ScannerScreen(
.build() .build()
) )
} }
fun ScanResult.visibleAlertContent(): String {
return if (isBase64Encoded) {
context.getString(R.string.base64_encoded_inline, content)
} else {
content
}
}
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
@@ -204,7 +211,7 @@ fun ScannerScreen(
imageScanner.process(image) imageScanner.process(image)
.addOnSuccessListener { barcodes -> .addOnSuccessListener { barcodes ->
val candidates = barcodes.mapNotNull { barcode -> 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 normalizedBox = barcode.boundingBox?.let { bounds ->
val corners = barcode.cornerPoints?.map { p -> val corners = barcode.cornerPoints?.map { p ->
DetectionPoint( DetectionPoint(
@@ -221,10 +228,14 @@ fun ScannerScreen(
) )
} }
GalleryScanCandidate( GalleryScanCandidate(
result = ScanResult(content = raw, type = barcode.valueType.toHumanType()), result = ScanResult(
content = payload.content,
type = barcode.valueType.toHumanType(),
isBase64Encoded = payload.isBase64Encoded
),
box = normalizedBox box = normalizedBox
) )
}.distinctBy { "${it.result.type}|${it.result.content}" } }.distinctBy { "${it.result.displayType}|${it.result.content}" }
imageScanCandidates = candidates imageScanCandidates = candidates
} }
.addOnFailureListener { .addOnFailureListener {
@@ -332,7 +343,7 @@ fun ScannerScreen(
detectionSourceWidth = sourceWidth detectionSourceWidth = sourceWidth
detectionSourceHeight = sourceHeight detectionSourceHeight = sourceHeight
}, },
onScan = { content, type, readableBox, sourceWidth, sourceHeight -> onScan = { scanResult, readableBox, sourceWidth, sourceHeight ->
val box = readableBox ?: return@CameraPreview val box = readableBox ?: return@CameraPreview
if (sourceWidth <= 0 || sourceHeight <= 0 || viewW <= 0f || viewH <= 0f) { if (sourceWidth <= 0 || sourceHeight <= 0 || viewW <= 0f || viewH <= 0f) {
return@CameraPreview return@CameraPreview
@@ -352,18 +363,17 @@ fun ScannerScreen(
if (!insideAim) return@CameraPreview if (!insideAim) return@CameraPreview
if (forceBatchMode) { if (forceBatchMode) {
val scanResult = ScanResult(content = content, type = type)
when (onEvaluateEventTicketScan(scanResult)) { when (onEvaluateEventTicketScan(scanResult)) {
EventTicketScanDecision.Accept -> Unit EventTicketScanDecision.Accept -> Unit
EventTicketScanDecision.Unregistered -> { EventTicketScanDecision.Unregistered -> {
onAuditUnregisteredTicket(scanResult) onAuditUnregisteredTicket(scanResult)
unregisteredTicketAlertContent = content unregisteredTicketAlertContent = scanResult.visibleAlertContent()
showUnregisteredTicketAlert = true showUnregisteredTicketAlert = true
return@CameraPreview return@CameraPreview
} }
EventTicketScanDecision.DuplicateAlert -> { EventTicketScanDecision.DuplicateAlert -> {
onAuditDuplicateTicket(scanResult) onAuditDuplicateTicket(scanResult)
duplicateTicketAlertContent = content duplicateTicketAlertContent = scanResult.visibleAlertContent()
showDuplicateTicketAlert = true showDuplicateTicketAlert = true
return@CameraPreview 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) { if (lastResult != null && !isBatchModeActive) {
val parsedContact = remember(lastResult.content) { ScanContentParsers.parseContact(lastResult.content) } val parsedContact = remember(lastResult) {
val parsedEvent = remember(lastResult.content) { ScanContentParsers.parseCalendarEvent(lastResult.content) } 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) { ModalBottomSheet(onDismissRequest = onScanAgain) {
Column( Column(
@@ -659,6 +673,7 @@ fun ScannerScreen(
} }
} }
if (!lastResult.isBase64Encoded) {
when (lastResult.type) { when (lastResult.type) {
"Phone" -> { "Phone" -> {
if (capabilities.allowDialPhone) { if (capabilities.allowDialPhone) {
@@ -712,6 +727,7 @@ fun ScannerScreen(
} }
} }
} }
}
if (showRiskWarning && pendingOpenUrl != null) { if (showRiskWarning && pendingOpenUrl != null) {
AlertDialog( AlertDialog(
@@ -1,20 +1,86 @@
package de.softwareapp_hb.privateqrscanner.util package de.softwareapp_hb.privateqrscanner.util
import com.google.mlkit.vision.barcode.common.Barcode 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? { fun Barcode.readablePayload(): String? {
rawValue?.trim()?.takeIf { it.isNotBlank() }?.let { return it } return readableBarcodePayload()?.content
displayValue?.trim()?.takeIf { it.isNotBlank() }?.let { return it } }
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() 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 { private fun String.isLikelyHumanReadable(): Boolean {
if (isBlank()) return false if (isBlank()) return false
val controlChars = count { it.isISOControl() && it != '\n' && it != '\r' && it != '\t' } var index = 0
return controlChars == 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
} }
+2
View File
@@ -31,6 +31,8 @@
<string name="contact">Kontakt: softwareapp.hb@gmail.com</string> <string name="contact">Kontakt: softwareapp.hb@gmail.com</string>
<string name="content_type">Typ</string> <string name="content_type">Typ</string>
<string name="content_value">Inhalt</string> <string name="content_value">Inhalt</string>
<string name="base64_encoded_notice">Als Base64 angezeigt, weil die gescannten Daten nicht als Text dargestellt werden können.</string>
<string name="base64_encoded_inline">Base64-codiert: %1$s</string>
<string name="request_camera">Kamera erlauben</string> <string name="request_camera">Kamera erlauben</string>
<string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string> <string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string>
<string name="aim_center_hint">Code im mittleren Rahmen ausrichten.</string> <string name="aim_center_hint">Code im mittleren Rahmen ausrichten.</string>
+2
View File
@@ -31,6 +31,8 @@
<string name="contact">Contact: softwareapp.hb@gmail.com</string> <string name="contact">Contact: softwareapp.hb@gmail.com</string>
<string name="content_type">Type</string> <string name="content_type">Type</string>
<string name="content_value">Content</string> <string name="content_value">Content</string>
<string name="base64_encoded_notice">Displayed as Base64 because the scanned data cannot be shown as text.</string>
<string name="base64_encoded_inline">Base64 encoded: %1$s</string>
<string name="request_camera">Allow camera</string> <string name="request_camera">Allow camera</string>
<string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string> <string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string>
<string name="aim_center_hint">Aim the code inside the center frame.</string> <string name="aim_center_hint">Aim the code inside the center frame.</string>
@@ -115,4 +115,21 @@ class ScannerViewModelTest {
assertEquals(1, saved.size) assertEquals(1, saved.size)
assertEquals(1, state.batchResults.size) assertEquals(1, state.batchResults.size)
} }
@Test
fun onScan_base64EncodedScan_savesTypeWithBase64Marker() = runTest {
val saved = mutableListOf<Pair<String, String>>()
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)
}
} }
@@ -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)
}
}