quick wins + test cases

This commit is contained in:
Hadrian Burkhardt
2026-02-11 03:52:14 +01:00
parent c0e9b52897
commit a9bcb81207
17 changed files with 871 additions and 46 deletions
+54
View File
@@ -0,0 +1,54 @@
# Product Roadmap
## Quick Wins (1-3 days)
- [x] Duplicate UX polish
- Add subtle in-app banner/snackbar (in addition to toast) with optional "View in history".
- [x] Batch mode polish
- Add per-item copy/share in batch panel.
- Add "newest first" timestamp labels for batch captures.
- [x] History export formats
- Add CSV + JSON export options next to plain text share.
- [x] Scan result type actions
- One-tap actions for Phone/SMS/Email/Wi-Fi/Contact/Calendar where possible.
- [x] Settings for scan feedback
- Toggle haptic/beep on successful scan.
## Mid-size Features (3-7 days)
1. Import history
- Restore from CSV/JSON backup.
- Handle duplicates and merge policy.
2. Favorites / pin scans
- Pin important entries, filter by favorites.
3. Tagging
- Add tags/folders and filter chips in History.
4. Improved image scan
- Support multi-code detection from one image and let user pick result.
## Bigger Features (1-3 weeks)
1. Structured parser layer
- Parse vCard/Wi-Fi/calendar deeply and show rich result cards before actions.
2. Background camera quality controls
- Adaptive analyzer settings for tiny/low-contrast codes.
3. Security enhancement package
- Expand local URL checks (homograph/punycode heuristics, known suspicious patterns).
- Optional strict mode before open.
4. Backup/restore flow
- Full local backup with versioned schema and migration support.
## Suggested Next 2
1. Banner/snackbar duplicate feedback
2. CSV/JSON export in History
@@ -13,6 +13,7 @@ class SettingsRepository(private val context: Context) {
private object Keys { private object Keys {
val historyEnabled = booleanPreferencesKey("history_enabled") val historyEnabled = booleanPreferencesKey("history_enabled")
val warningsEnabled = booleanPreferencesKey("warnings_enabled") val warningsEnabled = booleanPreferencesKey("warnings_enabled")
val scanFeedbackEnabled = booleanPreferencesKey("scan_feedback_enabled")
} }
val historyEnabled: Flow<Boolean> = context.dataStore.data.map { prefs -> val historyEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
@@ -23,6 +24,10 @@ class SettingsRepository(private val context: Context) {
prefs[Keys.warningsEnabled] ?: true prefs[Keys.warningsEnabled] ?: true
} }
val scanFeedbackEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[Keys.scanFeedbackEnabled] ?: true
}
suspend fun setHistoryEnabled(enabled: Boolean) { suspend fun setHistoryEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.historyEnabled] = enabled } context.dataStore.edit { it[Keys.historyEnabled] = enabled }
} }
@@ -30,4 +35,8 @@ class SettingsRepository(private val context: Context) {
suspend fun setWarningsEnabled(enabled: Boolean) { suspend fun setWarningsEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.warningsEnabled] = enabled } context.dataStore.edit { it[Keys.warningsEnabled] = enabled }
} }
suspend fun setScanFeedbackEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.scanFeedbackEnabled] = enabled }
}
} }
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
data class AppUiState( data class AppUiState(
val historyEnabled: Boolean = false, val historyEnabled: Boolean = false,
val warningsEnabled: Boolean = true, val warningsEnabled: Boolean = true,
val scanFeedbackEnabled: Boolean = true,
val history: List<ScanRecord> = emptyList(), val history: List<ScanRecord> = emptyList(),
val searchQuery: String = "" val searchQuery: String = ""
) )
@@ -28,12 +29,14 @@ class AppViewModel(
val uiState: StateFlow<AppUiState> = combine( val uiState: StateFlow<AppUiState> = combine(
container.settingsRepository.historyEnabled, container.settingsRepository.historyEnabled,
container.settingsRepository.warningsEnabled, container.settingsRepository.warningsEnabled,
container.settingsRepository.scanFeedbackEnabled,
container.scanRepository.observeHistory(), container.scanRepository.observeHistory(),
query query
) { historyEnabled, warningsEnabled, history, q -> ) { historyEnabled, warningsEnabled, scanFeedbackEnabled, history, q ->
AppUiState( AppUiState(
historyEnabled = historyEnabled, historyEnabled = historyEnabled,
warningsEnabled = warningsEnabled, warningsEnabled = warningsEnabled,
scanFeedbackEnabled = scanFeedbackEnabled,
history = if (q.isBlank()) history else history.filter { history = if (q.isBlank()) history else history.filter {
it.content.contains(q, ignoreCase = true) || it.type.contains(q, ignoreCase = true) it.content.contains(q, ignoreCase = true) || it.type.contains(q, ignoreCase = true)
}, },
@@ -60,6 +63,12 @@ class AppViewModel(
} }
} }
fun setScanFeedbackEnabled(enabled: Boolean) {
viewModelScope.launch {
container.settingsRepository.setScanFeedbackEnabled(enabled)
}
}
fun deleteHistoryItem(id: Long) { fun deleteHistoryItem(id: Long) {
viewModelScope.launch { viewModelScope.launch {
container.scanRepository.deleteById(id) container.scanRepository.deleteById(id)
@@ -68,9 +68,17 @@ fun CleanScannerAppRoot(container: AppContainer) {
RootTab.Scanner -> ScannerScreen( RootTab.Scanner -> ScannerScreen(
analysisEnabled = scannerState.analysisEnabled, analysisEnabled = scannerState.analysisEnabled,
lastResult = scannerState.lastResult, lastResult = scannerState.lastResult,
batchMode = scannerState.batchMode,
batchResults = scannerState.batchResults,
duplicateFeedbackNonce = scannerState.duplicateFeedbackNonce,
scanFeedbackNonce = scannerState.scanFeedbackNonce,
warningsEnabled = appState.warningsEnabled, warningsEnabled = appState.warningsEnabled,
scanFeedbackEnabled = appState.scanFeedbackEnabled,
onScan = scannerViewModel::onScan, onScan = scannerViewModel::onScan,
onScanAgain = scannerViewModel::resumeScanning onScanAgain = scannerViewModel::resumeScanning,
onBatchModeChange = scannerViewModel::setBatchMode,
onClearBatchResults = scannerViewModel::clearBatchResults,
onOpenHistory = { activeTab = RootTab.History }
) )
RootTab.History -> HistoryScreen( RootTab.History -> HistoryScreen(
@@ -84,8 +92,10 @@ fun CleanScannerAppRoot(container: AppContainer) {
RootTab.Settings -> SettingsScreen( RootTab.Settings -> SettingsScreen(
historyEnabled = appState.historyEnabled, historyEnabled = appState.historyEnabled,
warningsEnabled = appState.warningsEnabled, warningsEnabled = appState.warningsEnabled,
scanFeedbackEnabled = appState.scanFeedbackEnabled,
onHistoryToggle = appViewModel::setHistoryEnabled, onHistoryToggle = appViewModel::setHistoryEnabled,
onWarningsToggle = appViewModel::setWarningsEnabled onWarningsToggle = appViewModel::setWarningsEnabled,
onScanFeedbackToggle = appViewModel::setScanFeedbackEnabled
) )
} }
} }
@@ -10,32 +10,67 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class BatchScanRecord(
val result: ScanResult,
val timestamp: Long
)
data class ScannerUiState( data class ScannerUiState(
val lastResult: ScanResult? = null, val lastResult: ScanResult? = null,
val analysisEnabled: Boolean = true, val analysisEnabled: Boolean = true,
val lastScanTimestamp: Long = 0L val lastScanTimestamp: Long = 0L,
val batchMode: Boolean = false,
val batchResults: List<BatchScanRecord> = emptyList(),
val recentScanKeys: List<String> = emptyList(),
val duplicateFeedbackNonce: Int = 0,
val scanFeedbackNonce: Int = 0
) )
class ScannerViewModel( class ScannerViewModel(
private val container: AppContainer private val saveScan: suspend (content: String, type: String) -> Unit,
private val nowProvider: () -> Long = { System.currentTimeMillis() }
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ScannerUiState()) private val _uiState = MutableStateFlow(ScannerUiState())
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow() val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
fun onScan(result: ScanResult) { fun onScan(result: ScanResult) {
val now = System.currentTimeMillis() val now = nowProvider()
val current = _uiState.value val current = _uiState.value
if (!current.analysisEnabled) return if (!current.analysisEnabled) return
if (now - current.lastScanTimestamp < 800) return if (now - current.lastScanTimestamp < 800) return
_uiState.value = current.copy( val key = "${result.type}|${result.content}"
lastResult = result, val isDuplicate = current.recentScanKeys.contains(key)
analysisEnabled = false, val updatedRecent = (listOf(key) + current.recentScanKeys).distinct().take(200)
lastScanTimestamp = now
) _uiState.value = if (current.batchMode) {
val updatedBatch = if (current.batchResults.any { "${it.result.type}|${it.result.content}" == key }) {
current.batchResults
} else {
(listOf(BatchScanRecord(result = result, timestamp = now)) + current.batchResults).take(100)
}
current.copy(
lastResult = null,
analysisEnabled = true,
lastScanTimestamp = now,
batchResults = updatedBatch,
recentScanKeys = updatedRecent,
duplicateFeedbackNonce = if (isDuplicate) current.duplicateFeedbackNonce + 1 else current.duplicateFeedbackNonce,
scanFeedbackNonce = current.scanFeedbackNonce + 1
)
} else {
current.copy(
lastResult = result,
analysisEnabled = false,
lastScanTimestamp = now,
recentScanKeys = updatedRecent,
duplicateFeedbackNonce = if (isDuplicate) current.duplicateFeedbackNonce + 1 else current.duplicateFeedbackNonce,
scanFeedbackNonce = current.scanFeedbackNonce + 1
)
}
viewModelScope.launch { viewModelScope.launch {
container.scanRepository.maybeSaveScan(result.content, result.type) saveScan(result.content, result.type)
} }
} }
@@ -43,10 +78,24 @@ class ScannerViewModel(
_uiState.value = _uiState.value.copy(analysisEnabled = true, lastResult = null) _uiState.value = _uiState.value.copy(analysisEnabled = true, lastResult = null)
} }
fun setBatchMode(enabled: Boolean) {
_uiState.value = _uiState.value.copy(
batchMode = enabled,
analysisEnabled = true,
lastResult = null
)
}
fun clearBatchResults() {
_uiState.value = _uiState.value.copy(batchResults = emptyList())
}
class Factory(private val container: AppContainer) : ViewModelProvider.Factory { class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ScannerViewModel(container) as T return ScannerViewModel(
saveScan = { content, type -> container.scanRepository.maybeSaveScan(content, type) }
) as T
} }
} }
} }
@@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clean.scanner.R import com.clean.scanner.R
import com.clean.scanner.domain.ScanRecord import com.clean.scanner.domain.ScanRecord
import com.clean.scanner.util.HistoryExportFormatter
import com.clean.scanner.util.Intents import com.clean.scanner.util.Intents
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@@ -91,12 +92,30 @@ fun HistoryScreen(
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
TextButton( TextButton(
onClick = { onClick = {
val exportText = buildHistoryExportText(history) val exportText = HistoryExportFormatter.formatText(history)
Intents.shareText(context, exportText) Intents.shareContent(context, exportText, "text/plain")
}, },
enabled = history.isNotEmpty() enabled = history.isNotEmpty()
) { ) {
Text(stringResource(R.string.share_history)) Text(stringResource(R.string.share_txt))
}
TextButton(
onClick = {
val exportCsv = HistoryExportFormatter.formatCsv(history)
Intents.shareContent(context, exportCsv, "text/csv")
},
enabled = history.isNotEmpty()
) {
Text(stringResource(R.string.share_csv))
}
TextButton(
onClick = {
val exportJson = HistoryExportFormatter.formatJson(history)
Intents.shareContent(context, exportJson, "application/json")
},
enabled = history.isNotEmpty()
) {
Text(stringResource(R.string.share_json))
} }
TextButton(onClick = { showDeleteAll.value = true }) { TextButton(onClick = { showDeleteAll.value = true }) {
Text(stringResource(R.string.delete_all)) Text(stringResource(R.string.delete_all))
@@ -148,12 +167,3 @@ private fun HistoryRow(
} }
) )
} }
private fun buildHistoryExportText(history: List<ScanRecord>): String {
if (history.isEmpty()) return ""
val formatter = DateFormat.getDateTimeInstance()
return history.joinToString(separator = "\n\n") { item ->
val time = formatter.format(Date(item.timestamp))
"$time\n${item.type}\n${item.content}"
}
}
@@ -3,12 +3,16 @@ package com.clean.scanner.ui.screens
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.media.AudioManager
import android.media.ToneGenerator
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -17,7 +21,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -29,10 +32,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -40,10 +47,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -51,21 +61,40 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clean.scanner.R import com.clean.scanner.R
import com.clean.scanner.domain.ScanResult import com.clean.scanner.domain.ScanResult
import com.clean.scanner.ui.BatchScanRecord
import com.clean.scanner.ui.components.CameraPreview import com.clean.scanner.ui.components.CameraPreview
import com.clean.scanner.util.ClipboardUtil import com.clean.scanner.util.ClipboardUtil
import com.clean.scanner.util.Intents import com.clean.scanner.util.Intents
import com.clean.scanner.util.ScanContentParsers
import com.clean.scanner.util.UrlRiskScorer import com.clean.scanner.util.UrlRiskScorer
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.text.DateFormat
import java.util.Date
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ScannerScreen( fun ScannerScreen(
analysisEnabled: Boolean, analysisEnabled: Boolean,
lastResult: ScanResult?, lastResult: ScanResult?,
batchMode: Boolean,
batchResults: List<BatchScanRecord>,
duplicateFeedbackNonce: Int,
scanFeedbackNonce: Int,
warningsEnabled: Boolean, warningsEnabled: Boolean,
scanFeedbackEnabled: Boolean,
onScan: (ScanResult) -> Unit, onScan: (ScanResult) -> Unit,
onScanAgain: () -> Unit onScanAgain: () -> Unit,
onBatchModeChange: (Boolean) -> Unit,
onClearBatchResults: () -> Unit,
onOpenHistory: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val duplicateSnackbarHostState = remember { SnackbarHostState() }
val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) }
var cameraGranted by remember { var cameraGranted by remember {
mutableStateOf( mutableStateOf(
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
@@ -79,9 +108,12 @@ fun ScannerScreen(
var torchAvailable by remember { mutableStateOf(false) } var torchAvailable by remember { mutableStateOf(false) }
var showRiskWarning by remember { mutableStateOf(false) } var showRiskWarning by remember { mutableStateOf(false) }
var pendingOpenUrl by remember { mutableStateOf<String?>(null) } var pendingOpenUrl by remember { mutableStateOf<String?>(null) }
var showImageScanFailed by remember { mutableStateOf(false) }
var showNoCodeInImage by remember { mutableStateOf(false) }
val activity = context as? Activity val activity = context as? Activity
val imageScanner = remember { BarcodeScanning.getClient() }
val launcher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
) { granted -> ) { granted ->
cameraGranted = granted cameraGranted = granted
@@ -93,8 +125,66 @@ fun ScannerScreen(
} }
} }
val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
val image = try {
InputImage.fromFilePath(context, uri)
} catch (_: Exception) {
showImageScanFailed = true
return@rememberLauncherForActivityResult
}
imageScanner.process(image)
.addOnSuccessListener { barcodes ->
val first = barcodes.firstOrNull()
val raw = first?.rawValue
if (raw.isNullOrBlank()) {
showNoCodeInImage = true
} else {
onScan(ScanResult(content = raw, type = first.valueType.toHumanType()))
}
}
.addOnFailureListener {
showImageScanFailed = true
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!cameraGranted) launcher.launch(Manifest.permission.CAMERA) if (!cameraGranted) permissionLauncher.launch(Manifest.permission.CAMERA)
}
LaunchedEffect(duplicateFeedbackNonce) {
if (duplicateFeedbackNonce > 0) {
Toast.makeText(
context,
context.getString(R.string.already_scanned),
Toast.LENGTH_SHORT
).show()
val result = duplicateSnackbarHostState.showSnackbar(
message = context.getString(R.string.already_scanned),
actionLabel = context.getString(R.string.view_history),
withDismissAction = true
)
if (result == SnackbarResult.ActionPerformed) {
onOpenHistory()
}
}
}
LaunchedEffect(scanFeedbackNonce, scanFeedbackEnabled) {
if (scanFeedbackEnabled && scanFeedbackNonce > 0) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP, 120)
}
}
DisposableEffect(Unit) {
onDispose {
imageScanner.close()
toneGenerator.release()
}
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -133,13 +223,14 @@ fun ScannerScreen(
center = androidx.compose.ui.geometry.Offset(cx, cy) center = androidx.compose.ui.geometry.Offset(cx, cy)
) )
} }
Text( Text(
text = stringResource(R.string.pinch_to_zoom_hint), text = stringResource(R.string.pinch_to_zoom_hint),
color = Color.White, color = Color.White,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 40.dp) .padding(bottom = if (batchMode) 190.dp else 56.dp)
.background( .background(
color = Color.Black.copy(alpha = 0.35f), color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(18.dp) shape = RoundedCornerShape(18.dp)
@@ -147,17 +238,62 @@ fun ScannerScreen(
.padding(horizontal = 14.dp, vertical = 8.dp) .padding(horizontal = 14.dp, vertical = 8.dp)
) )
if (torchAvailable) { IconButton(
RowTopToggle( onClick = { imagePicker.launch("image/*") },
checked = torchEnabled, modifier = Modifier
onCheckedChange = { torchEnabled = it }, .align(Alignment.TopEnd)
label = stringResource(R.string.flashlight) .padding(top = 12.dp, end = 12.dp)
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(14.dp)
)
) {
Icon(
painter = painterResource(id = android.R.drawable.ic_menu_gallery),
contentDescription = stringResource(R.string.scan_from_image),
tint = Color.White
) )
} }
Column(
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (torchAvailable) {
OverlayToggle(
checked = torchEnabled,
onCheckedChange = { torchEnabled = it },
label = stringResource(R.string.flashlight)
)
}
OverlayToggle(
checked = batchMode,
onCheckedChange = onBatchModeChange,
label = stringResource(R.string.batch_mode)
)
}
if (batchMode) {
Box(modifier = Modifier.align(Alignment.BottomCenter)) {
BatchResultsPanel(
results = batchResults,
onClear = onClearBatchResults
)
}
}
SnackbarHost(
hostState = duplicateSnackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = if (batchMode) 12.dp else 80.dp)
)
} else { } else {
PermissionContent( PermissionContent(
showSettingsHint = showSettingsHint, showSettingsHint = showSettingsHint,
onRequestPermission = { launcher.launch(Manifest.permission.CAMERA) }, onRequestPermission = { permissionLauncher.launch(Manifest.permission.CAMERA) },
onOpenSettings = { onOpenSettings = {
val intent = Intent( val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
@@ -168,7 +304,7 @@ fun ScannerScreen(
) )
} }
if (lastResult != null) { if (lastResult != null && !batchMode) {
ModalBottomSheet(onDismissRequest = onScanAgain) { ModalBottomSheet(onDismissRequest = onScanAgain) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -211,6 +347,51 @@ fun ScannerScreen(
) )
} }
} }
when (lastResult.type) {
"Phone" -> {
Button(onClick = {
Intents.dialPhone(context, ScanContentParsers.extractPhoneNumber(lastResult.content))
}) {
Text(stringResource(R.string.call_number))
}
}
"SMS" -> {
Button(onClick = {
val smsData = ScanContentParsers.parseSms(lastResult.content)
Intents.sendSms(context, smsData.first, smsData.second)
}) {
Text(stringResource(R.string.send_sms))
}
}
"Email" -> {
Button(onClick = {
Intents.sendEmail(context, ScanContentParsers.extractEmail(lastResult.content), null)
}) {
Text(stringResource(R.string.send_email))
}
}
"WiFi" -> {
Button(onClick = { Intents.openWifiSettings(context) }) {
Text(stringResource(R.string.open_wifi_settings))
}
}
"Contact" -> {
Button(onClick = { Intents.addContact(context, lastResult.content) }) {
Text(stringResource(R.string.add_contact))
}
}
"Calendar" -> {
Button(onClick = { Intents.addCalendarEvent(context, lastResult.content) }) {
Text(stringResource(R.string.add_calendar_event))
}
}
}
} }
} }
} }
@@ -232,28 +413,138 @@ fun ScannerScreen(
} }
) )
} }
if (showNoCodeInImage) {
AlertDialog(
onDismissRequest = { showNoCodeInImage = false },
text = { Text(stringResource(R.string.no_code_found_in_image)) },
confirmButton = {
TextButton(onClick = { showNoCodeInImage = false }) {
Text(stringResource(R.string.confirm))
}
}
)
}
if (showImageScanFailed) {
AlertDialog(
onDismissRequest = { showImageScanFailed = false },
text = { Text(stringResource(R.string.image_scan_failed)) },
confirmButton = {
TextButton(onClick = { showImageScanFailed = false }) {
Text(stringResource(R.string.confirm))
}
}
)
}
} }
} }
@Composable @Composable
private fun RowTopToggle( private fun OverlayToggle(
checked: Boolean, checked: Boolean,
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
label: String label: String
) { ) {
Column(
modifier = Modifier
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Text(text = label, color = Color.White)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun BatchResultsPanel(
results: List<BatchScanRecord>,
onClear: () -> Unit
) {
val context = LocalContext.current
val timeFormat = remember { DateFormat.getTimeInstance(DateFormat.SHORT) }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp), .padding(horizontal = 12.dp, vertical = 12.dp),
contentAlignment = Alignment.TopStart contentAlignment = Alignment.BottomCenter
) { ) {
Column { Column(
Text(text = label, color = Color.White) modifier = Modifier
Switch(checked = checked, onCheckedChange = onCheckedChange) .fillMaxWidth()
.background(
color = Color.Black.copy(alpha = 0.42f),
shape = RoundedCornerShape(14.dp)
)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.batch_captures_count, results.size),
color = Color.White
)
results.take(3).forEach { item ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "${item.result.type}: ${item.result.content}",
color = Color.White.copy(alpha = 0.92f),
maxLines = 1
)
Text(
text = timeFormat.format(Date(item.timestamp)),
color = Color.White.copy(alpha = 0.7f)
)
}
Row {
IconButton(onClick = { ClipboardUtil.copy(context, item.result.content) }) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.copy),
tint = Color.White
)
}
IconButton(onClick = { Intents.shareText(context, item.result.content) }) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = stringResource(R.string.share),
tint = Color.White
)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onClear, enabled = results.isNotEmpty()) {
Text(stringResource(R.string.clear_batch))
}
TextButton(
onClick = { Intents.shareText(context, buildBatchExport(results)) },
enabled = results.isNotEmpty()
) {
Text(stringResource(R.string.share_batch))
}
}
} }
} }
} }
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}"
}
}
@Composable @Composable
private fun PermissionContent( private fun PermissionContent(
showSettingsHint: Boolean, showSettingsHint: Boolean,
@@ -278,3 +569,19 @@ private fun PermissionContent(
} }
} }
} }
private fun Int.toHumanType(): String = when (this) {
Barcode.TYPE_CONTACT_INFO -> "Contact"
Barcode.TYPE_EMAIL -> "Email"
Barcode.TYPE_ISBN -> "ISBN"
Barcode.TYPE_PHONE -> "Phone"
Barcode.TYPE_PRODUCT -> "Product"
Barcode.TYPE_SMS -> "SMS"
Barcode.TYPE_TEXT -> "Text"
Barcode.TYPE_URL -> "URL"
Barcode.TYPE_WIFI -> "WiFi"
Barcode.TYPE_GEO -> "Geo"
Barcode.TYPE_CALENDAR_EVENT -> "Calendar"
Barcode.TYPE_DRIVER_LICENSE -> "Driver license"
else -> "Unknown"
}
@@ -22,8 +22,10 @@ import com.clean.scanner.R
fun SettingsScreen( fun SettingsScreen(
historyEnabled: Boolean, historyEnabled: Boolean,
warningsEnabled: Boolean, warningsEnabled: Boolean,
scanFeedbackEnabled: Boolean,
onHistoryToggle: (Boolean, Boolean) -> Unit, onHistoryToggle: (Boolean, Boolean) -> Unit,
onWarningsToggle: (Boolean) -> Unit onWarningsToggle: (Boolean) -> Unit,
onScanFeedbackToggle: (Boolean) -> Unit
) { ) {
val showDeleteConfirm = remember { mutableStateOf(false) } val showDeleteConfirm = remember { mutableStateOf(false) }
@@ -70,6 +72,11 @@ fun SettingsScreen(
Text(text = stringResource(R.string.security_warnings)) Text(text = stringResource(R.string.security_warnings))
Switch(checked = warningsEnabled, onCheckedChange = onWarningsToggle) Switch(checked = warningsEnabled, onCheckedChange = onWarningsToggle)
Spacer(modifier = Modifier.height(16.dp))
Text(text = stringResource(R.string.scan_feedback))
Switch(checked = scanFeedbackEnabled, onCheckedChange = onScanFeedbackToggle)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text(text = stringResource(R.string.about)) Text(text = stringResource(R.string.about))
Text(text = stringResource(R.string.version)) Text(text = stringResource(R.string.version))
@@ -0,0 +1,50 @@
package com.clean.scanner.util
import com.clean.scanner.domain.ScanRecord
import java.text.DateFormat
import java.util.Date
object HistoryExportFormatter {
fun formatText(history: List<ScanRecord>): String {
if (history.isEmpty()) return ""
val formatter = DateFormat.getDateTimeInstance()
return history.joinToString(separator = "\n\n") { item ->
val time = formatter.format(Date(item.timestamp))
"$time\n${item.type}\n${item.content}"
}
}
fun formatCsv(history: List<ScanRecord>): String {
if (history.isEmpty()) return ""
val formatter = DateFormat.getDateTimeInstance()
val header = "timestamp,type,content"
val rows = history.joinToString(separator = "\n") { item ->
val ts = csvEscape(formatter.format(Date(item.timestamp)))
val type = csvEscape(item.type)
val content = csvEscape(item.content)
"$ts,$type,$content"
}
return "$header\n$rows"
}
fun formatJson(history: List<ScanRecord>): String {
if (history.isEmpty()) return "[]"
return history.joinToString(prefix = "[\n", postfix = "\n]", separator = ",\n") { item ->
val escapedType = jsonEscape(item.type)
val escapedContent = jsonEscape(item.content)
" {\"id\":${item.id},\"timestamp\":${item.timestamp},\"type\":\"$escapedType\",\"content\":\"$escapedContent\"}"
}
}
private fun csvEscape(value: String): String {
val escaped = value.replace("\"", "\"\"")
return "\"$escaped\""
}
private fun jsonEscape(value: String): String {
return value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
}
}
@@ -3,6 +3,9 @@ package com.clean.scanner.util
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.provider.Settings
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
object Intents { object Intents {
@@ -12,11 +15,64 @@ object Intents {
} }
fun shareText(context: Context, text: String) { fun shareText(context: Context, text: String) {
shareContent(context, text, "text/plain")
}
fun shareContent(context: Context, text: String, mimeType: String) {
val intent = Intent(Intent.ACTION_SEND) val intent = Intent(Intent.ACTION_SEND)
.setType("text/plain") .setType(mimeType)
.putExtra(Intent.EXTRA_TEXT, text) .putExtra(Intent.EXTRA_TEXT, text)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val chooser = Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val chooser = Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(context, chooser, null) startActivity(context, chooser, null)
} }
fun dialPhone(context: Context, number: String) {
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${number.trim()}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(context, intent, null)
}
fun sendSms(context: Context, number: String, message: String?) {
val base = number.trim()
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:$base"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (!message.isNullOrBlank()) {
intent.putExtra("sms_body", message)
}
startActivity(context, intent, null)
}
fun sendEmail(context: Context, email: String, subject: String?) {
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${email.trim()}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (!subject.isNullOrBlank()) {
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
}
startActivity(context, intent, null)
}
fun openWifiSettings(context: Context) {
val intent = Intent(Settings.ACTION_WIFI_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(context, intent, null)
}
fun addContact(context: Context, rawContent: String) {
val intent = Intent(Intent.ACTION_INSERT).apply {
type = ContactsContract.Contacts.CONTENT_TYPE
putExtra(ContactsContract.Intents.Insert.NOTES, rawContent)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(context, intent, null)
}
fun addCalendarEvent(context: Context, rawContent: String) {
val intent = Intent(Intent.ACTION_INSERT).apply {
data = CalendarContract.Events.CONTENT_URI
putExtra(CalendarContract.Events.TITLE, "Scanned event")
putExtra(CalendarContract.Events.DESCRIPTION, rawContent)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(context, intent, null)
}
} }
@@ -0,0 +1,31 @@
package com.clean.scanner.util
object ScanContentParsers {
fun extractPhoneNumber(raw: String): String {
return raw.substringAfter("tel:", raw)
.substringBefore('?')
.trim()
}
fun parseSms(raw: String): Pair<String, String?> {
val cleaned = raw.trim()
return if (cleaned.startsWith("SMSTO:", ignoreCase = true)) {
val parts = cleaned.split(':', limit = 3)
val number = parts.getOrNull(1).orEmpty()
val body = parts.getOrNull(2)
number to body
} else {
cleaned to null
}
}
fun extractEmail(raw: String): String {
val cleaned = raw.trim()
if (cleaned.startsWith("mailto:", ignoreCase = true)) {
return cleaned.substringAfter("mailto:").substringBefore('?').trim()
}
val match = Regex("[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+")
.find(cleaned)
return match?.value ?: cleaned
}
}
+19
View File
@@ -8,6 +8,7 @@
<string name="privacy">Datenschutz</string> <string name="privacy">Datenschutz</string>
<string name="privacy_text">Keine Datenübertragung, keine Werbung, kein Tracking.</string> <string name="privacy_text">Keine Datenübertragung, keine Werbung, kein Tracking.</string>
<string name="security_warnings">Sicherheitswarnungen</string> <string name="security_warnings">Sicherheitswarnungen</string>
<string name="scan_feedback">Scan-Feedback (Ton + Vibration)</string>
<string name="about">Über</string> <string name="about">Über</string>
<string name="copy">Kopieren</string> <string name="copy">Kopieren</string>
<string name="share">Teilen</string> <string name="share">Teilen</string>
@@ -33,4 +34,22 @@
<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="share_history">Historie teilen</string> <string name="share_history">Historie teilen</string>
<string name="share_txt">TXT</string>
<string name="share_csv">CSV</string>
<string name="share_json">JSON</string>
<string name="scan_from_image">Aus Bild scannen</string>
<string name="batch_mode">Stapelmodus</string>
<string name="batch_captures_count">Stapel-Scans: %1$d</string>
<string name="clear_batch">Stapel leeren</string>
<string name="share_batch">Stapel teilen</string>
<string name="no_code_found_in_image">Im gewählten Bild wurde kein QR- oder Barcode gefunden.</string>
<string name="image_scan_failed">Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen.</string>
<string name="already_scanned">Bereits gescannt</string>
<string name="view_history">Historie anzeigen</string>
<string name="call_number">Nummer anrufen</string>
<string name="send_sms">SMS senden</string>
<string name="send_email">E-Mail senden</string>
<string name="open_wifi_settings">WLAN-Einstellungen öffnen</string>
<string name="add_contact">Kontakt hinzufügen</string>
<string name="add_calendar_event">Kalendereintrag hinzufügen</string>
</resources> </resources>
+19
View File
@@ -8,6 +8,7 @@
<string name="privacy">Privacy</string> <string name="privacy">Privacy</string>
<string name="privacy_text">No data transfer, no ads, no tracking.</string> <string name="privacy_text">No data transfer, no ads, no tracking.</string>
<string name="security_warnings">Security warnings</string> <string name="security_warnings">Security warnings</string>
<string name="scan_feedback">Scan feedback (beep + haptic)</string>
<string name="about">About</string> <string name="about">About</string>
<string name="copy">Copy</string> <string name="copy">Copy</string>
<string name="share">Share</string> <string name="share">Share</string>
@@ -33,4 +34,22 @@
<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="share_history">Share history</string> <string name="share_history">Share history</string>
<string name="share_txt">TXT</string>
<string name="share_csv">CSV</string>
<string name="share_json">JSON</string>
<string name="scan_from_image">Scan from image</string>
<string name="batch_mode">Batch mode</string>
<string name="batch_captures_count">Batch captures: %1$d</string>
<string name="clear_batch">Clear batch</string>
<string name="share_batch">Share batch</string>
<string name="no_code_found_in_image">No QR or barcode found in the selected image.</string>
<string name="image_scan_failed">Could not read this image. Try another one.</string>
<string name="already_scanned">Already scanned</string>
<string name="view_history">View history</string>
<string name="call_number">Call number</string>
<string name="send_sms">Send SMS</string>
<string name="send_email">Send email</string>
<string name="open_wifi_settings">Open Wi-Fi settings</string>
<string name="add_contact">Add contact</string>
<string name="add_calendar_event">Add calendar event</string>
</resources> </resources>
@@ -0,0 +1,23 @@
package com.clean.scanner.testutil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
@@ -0,0 +1,95 @@
package com.clean.scanner.ui
import com.clean.scanner.domain.ScanResult
import com.clean.scanner.testutil.MainDispatcherRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ScannerViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun onScan_firstSingleScan_updatesStateAndSaves() = runTest {
val saved = mutableListOf<Pair<String, String>>()
var now = 1_000L
val viewModel = ScannerViewModel(
saveScan = { content, type -> saved += content to type },
nowProvider = { now }
)
viewModel.onScan(ScanResult(content = "https://example.com", type = "URL"))
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals("https://example.com", state.lastResult?.content)
assertEquals(false, state.analysisEnabled)
assertEquals(1, state.scanFeedbackNonce)
assertEquals(0, state.duplicateFeedbackNonce)
assertEquals(listOf("URL|https://example.com"), state.recentScanKeys)
assertEquals(listOf("https://example.com" to "URL"), saved)
}
@Test
fun duplicateScan_afterResume_incrementsDuplicateFeedback() = runTest {
val saved = mutableListOf<Pair<String, String>>()
var now = 1_000L
val viewModel = ScannerViewModel(
saveScan = { content, type -> saved += content to type },
nowProvider = { now }
)
val result = ScanResult(content = "ABC", type = "Text")
viewModel.onScan(result)
viewModel.resumeScanning()
now = 2_000L
viewModel.onScan(result)
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(2, state.scanFeedbackNonce)
assertEquals(1, state.duplicateFeedbackNonce)
assertEquals(2, saved.size)
}
@Test
fun batchMode_addsUniqueKeepsNewestFirst_andClearWorks() = runTest {
val saved = mutableListOf<Pair<String, String>>()
var now = 1_000L
val viewModel = ScannerViewModel(
saveScan = { content, type -> saved += content to type },
nowProvider = { now }
)
viewModel.setBatchMode(true)
viewModel.onScan(ScanResult(content = "A", type = "Text"))
now = 2_000L
viewModel.onScan(ScanResult(content = "B", type = "Text"))
now = 3_000L
viewModel.onScan(ScanResult(content = "A", type = "Text"))
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(2, state.batchResults.size)
assertEquals("B", state.batchResults[0].result.content)
assertEquals(2_000L, state.batchResults[0].timestamp)
assertEquals("A", state.batchResults[1].result.content)
assertEquals(1, state.duplicateFeedbackNonce)
assertEquals(3, state.scanFeedbackNonce)
assertTrue(state.analysisEnabled)
viewModel.clearBatchResults()
assertTrue(viewModel.uiState.value.batchResults.isEmpty())
assertEquals(3, saved.size)
}
}
@@ -0,0 +1,51 @@
package com.clean.scanner.util
import com.clean.scanner.domain.ScanRecord
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class HistoryExportFormatterTest {
@Test
fun formatText_empty_returnsEmptyString() {
assertEquals("", HistoryExportFormatter.formatText(emptyList()))
}
@Test
fun formatCsv_quotesAndEscapesFields() {
val history = listOf(
ScanRecord(
id = 1,
timestamp = 0L,
type = "Te,xt",
content = "hello \"world\""
)
)
val csv = HistoryExportFormatter.formatCsv(history)
assertTrue(csv.startsWith("timestamp,type,content\n"))
assertTrue(csv.contains("\"Te,xt\""))
assertTrue(csv.contains("\"hello \"\"world\"\"\""))
}
@Test
fun formatJson_escapesQuotesBackslashAndNewline() {
val history = listOf(
ScanRecord(
id = 7,
timestamp = 123L,
type = "Type\"A",
content = "line1\\line2\nnext"
)
)
val json = HistoryExportFormatter.formatJson(history)
assertTrue(json.contains("\"id\":7"))
assertTrue(json.contains("\"timestamp\":123"))
assertTrue(json.contains("\"type\":\"Type\\\"A\""))
assertTrue(json.contains("\"content\":\"line1\\\\line2\\nnext\""))
}
}
@@ -0,0 +1,26 @@
package com.clean.scanner.util
import org.junit.Assert.assertEquals
import org.junit.Test
class ScanContentParsersTest {
@Test
fun extractPhoneNumber_handlesTelUri() {
val phone = ScanContentParsers.extractPhoneNumber("tel:+123456789?foo=bar")
assertEquals("+123456789", phone)
}
@Test
fun parseSms_handlesSmstoWithBody() {
val (number, body) = ScanContentParsers.parseSms("SMSTO:+49123456:hello there")
assertEquals("+49123456", number)
assertEquals("hello there", body)
}
@Test
fun extractEmail_handlesMailtoAndPlainText() {
assertEquals("x@y.com", ScanContentParsers.extractEmail("mailto:x@y.com?subject=hi"))
assertEquals("a.b+c@d.dev", ScanContentParsers.extractEmail("contact me at a.b+c@d.dev now"))
}
}