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.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
}
+2
View File
@@ -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>
+2
View File
@@ -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)
}
}