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.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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BatchScanRecord>): 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}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ResultField> {
|
||||
if (result.isBase64Encoded) {
|
||||
return listOf(ResultField("Encoded data", result.content))
|
||||
}
|
||||
return when (result.type) {
|
||||
"Contact" -> {
|
||||
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.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
<string name="contact">Kontakt: softwareapp.hb@gmail.com</string>
|
||||
<string name="content_type">Typ</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="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>
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
<string name="contact">Contact: softwareapp.hb@gmail.com</string>
|
||||
<string name="content_type">Type</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="pinch_to_zoom_hint">Pinch to zoom for small codes</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, 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