base64 enocding + display
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user