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 {
val historyEnabled = booleanPreferencesKey("history_enabled")
val warningsEnabled = booleanPreferencesKey("warnings_enabled")
val scanFeedbackEnabled = booleanPreferencesKey("scan_feedback_enabled")
}
val historyEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
@@ -23,6 +24,10 @@ class SettingsRepository(private val context: Context) {
prefs[Keys.warningsEnabled] ?: true
}
val scanFeedbackEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[Keys.scanFeedbackEnabled] ?: true
}
suspend fun setHistoryEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.historyEnabled] = enabled }
}
@@ -30,4 +35,8 @@ class SettingsRepository(private val context: Context) {
suspend fun setWarningsEnabled(enabled: Boolean) {
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(
val historyEnabled: Boolean = false,
val warningsEnabled: Boolean = true,
val scanFeedbackEnabled: Boolean = true,
val history: List<ScanRecord> = emptyList(),
val searchQuery: String = ""
)
@@ -28,12 +29,14 @@ class AppViewModel(
val uiState: StateFlow<AppUiState> = combine(
container.settingsRepository.historyEnabled,
container.settingsRepository.warningsEnabled,
container.settingsRepository.scanFeedbackEnabled,
container.scanRepository.observeHistory(),
query
) { historyEnabled, warningsEnabled, history, q ->
) { historyEnabled, warningsEnabled, scanFeedbackEnabled, history, q ->
AppUiState(
historyEnabled = historyEnabled,
warningsEnabled = warningsEnabled,
scanFeedbackEnabled = scanFeedbackEnabled,
history = if (q.isBlank()) history else history.filter {
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) {
viewModelScope.launch {
container.scanRepository.deleteById(id)
@@ -68,9 +68,17 @@ fun CleanScannerAppRoot(container: AppContainer) {
RootTab.Scanner -> ScannerScreen(
analysisEnabled = scannerState.analysisEnabled,
lastResult = scannerState.lastResult,
batchMode = scannerState.batchMode,
batchResults = scannerState.batchResults,
duplicateFeedbackNonce = scannerState.duplicateFeedbackNonce,
scanFeedbackNonce = scannerState.scanFeedbackNonce,
warningsEnabled = appState.warningsEnabled,
scanFeedbackEnabled = appState.scanFeedbackEnabled,
onScan = scannerViewModel::onScan,
onScanAgain = scannerViewModel::resumeScanning
onScanAgain = scannerViewModel::resumeScanning,
onBatchModeChange = scannerViewModel::setBatchMode,
onClearBatchResults = scannerViewModel::clearBatchResults,
onOpenHistory = { activeTab = RootTab.History }
)
RootTab.History -> HistoryScreen(
@@ -84,8 +92,10 @@ fun CleanScannerAppRoot(container: AppContainer) {
RootTab.Settings -> SettingsScreen(
historyEnabled = appState.historyEnabled,
warningsEnabled = appState.warningsEnabled,
scanFeedbackEnabled = appState.scanFeedbackEnabled,
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.launch
data class BatchScanRecord(
val result: ScanResult,
val timestamp: Long
)
data class ScannerUiState(
val lastResult: ScanResult? = null,
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(
private val container: AppContainer
private val saveScan: suspend (content: String, type: String) -> Unit,
private val nowProvider: () -> Long = { System.currentTimeMillis() }
) : ViewModel() {
private val _uiState = MutableStateFlow(ScannerUiState())
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
fun onScan(result: ScanResult) {
val now = System.currentTimeMillis()
val now = nowProvider()
val current = _uiState.value
if (!current.analysisEnabled) return
if (now - current.lastScanTimestamp < 800) return
_uiState.value = current.copy(
lastResult = result,
analysisEnabled = false,
lastScanTimestamp = now
)
val key = "${result.type}|${result.content}"
val isDuplicate = current.recentScanKeys.contains(key)
val updatedRecent = (listOf(key) + current.recentScanKeys).distinct().take(200)
_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 {
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)
}
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 {
@Suppress("UNCHECKED_CAST")
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 com.clean.scanner.R
import com.clean.scanner.domain.ScanRecord
import com.clean.scanner.util.HistoryExportFormatter
import com.clean.scanner.util.Intents
import java.text.DateFormat
import java.util.Date
@@ -91,12 +92,30 @@ fun HistoryScreen(
Row(modifier = Modifier.fillMaxWidth()) {
TextButton(
onClick = {
val exportText = buildHistoryExportText(history)
Intents.shareText(context, exportText)
val exportText = HistoryExportFormatter.formatText(history)
Intents.shareContent(context, exportText, "text/plain")
},
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 }) {
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.app.Activity
import android.content.Intent
import android.media.AudioManager
import android.media.ToneGenerator
import android.net.Uri
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -29,10 +32,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -40,10 +47,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -51,21 +61,40 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.clean.scanner.R
import com.clean.scanner.domain.ScanResult
import com.clean.scanner.ui.BatchScanRecord
import com.clean.scanner.ui.components.CameraPreview
import com.clean.scanner.util.ClipboardUtil
import com.clean.scanner.util.Intents
import com.clean.scanner.util.ScanContentParsers
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)
@Composable
fun ScannerScreen(
analysisEnabled: Boolean,
lastResult: ScanResult?,
batchMode: Boolean,
batchResults: List<BatchScanRecord>,
duplicateFeedbackNonce: Int,
scanFeedbackNonce: Int,
warningsEnabled: Boolean,
scanFeedbackEnabled: Boolean,
onScan: (ScanResult) -> Unit,
onScanAgain: () -> Unit
onScanAgain: () -> Unit,
onBatchModeChange: (Boolean) -> Unit,
onClearBatchResults: () -> Unit,
onOpenHistory: () -> Unit
) {
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val duplicateSnackbarHostState = remember { SnackbarHostState() }
val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) }
var cameraGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
@@ -79,9 +108,12 @@ fun ScannerScreen(
var torchAvailable by remember { mutableStateOf(false) }
var showRiskWarning by remember { mutableStateOf(false) }
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 imageScanner = remember { BarcodeScanning.getClient() }
val launcher = rememberLauncherForActivityResult(
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { 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) {
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()) {
@@ -133,13 +223,14 @@ fun ScannerScreen(
center = androidx.compose.ui.geometry.Offset(cx, cy)
)
}
Text(
text = stringResource(R.string.pinch_to_zoom_hint),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 40.dp)
.padding(bottom = if (batchMode) 190.dp else 56.dp)
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(18.dp)
@@ -147,17 +238,62 @@ fun ScannerScreen(
.padding(horizontal = 14.dp, vertical = 8.dp)
)
if (torchAvailable) {
RowTopToggle(
checked = torchEnabled,
onCheckedChange = { torchEnabled = it },
label = stringResource(R.string.flashlight)
IconButton(
onClick = { imagePicker.launch("image/*") },
modifier = Modifier
.align(Alignment.TopEnd)
.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 {
PermissionContent(
showSettingsHint = showSettingsHint,
onRequestPermission = { launcher.launch(Manifest.permission.CAMERA) },
onRequestPermission = { permissionLauncher.launch(Manifest.permission.CAMERA) },
onOpenSettings = {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
@@ -168,7 +304,7 @@ fun ScannerScreen(
)
}
if (lastResult != null) {
if (lastResult != null && !batchMode) {
ModalBottomSheet(onDismissRequest = onScanAgain) {
Column(
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
private fun RowTopToggle(
private fun OverlayToggle(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
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(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
contentAlignment = Alignment.TopStart
.padding(horizontal = 12.dp, vertical = 12.dp),
contentAlignment = Alignment.BottomCenter
) {
Column {
Text(text = label, color = Color.White)
Switch(checked = checked, onCheckedChange = onCheckedChange)
Column(
modifier = Modifier
.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
private fun PermissionContent(
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(
historyEnabled: Boolean,
warningsEnabled: Boolean,
scanFeedbackEnabled: Boolean,
onHistoryToggle: (Boolean, Boolean) -> Unit,
onWarningsToggle: (Boolean) -> Unit
onWarningsToggle: (Boolean) -> Unit,
onScanFeedbackToggle: (Boolean) -> Unit
) {
val showDeleteConfirm = remember { mutableStateOf(false) }
@@ -70,6 +72,11 @@ fun SettingsScreen(
Text(text = stringResource(R.string.security_warnings))
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))
Text(text = stringResource(R.string.about))
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.Intent
import android.net.Uri
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.provider.Settings
import androidx.core.content.ContextCompat.startActivity
object Intents {
@@ -12,11 +15,64 @@ object Intents {
}
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)
.setType("text/plain")
.setType(mimeType)
.putExtra(Intent.EXTRA_TEXT, text)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val chooser = Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
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_text">Keine Datenübertragung, keine Werbung, kein Tracking.</string>
<string name="security_warnings">Sicherheitswarnungen</string>
<string name="scan_feedback">Scan-Feedback (Ton + Vibration)</string>
<string name="about">Über</string>
<string name="copy">Kopieren</string>
<string name="share">Teilen</string>
@@ -33,4 +34,22 @@
<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="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>
+19
View File
@@ -8,6 +8,7 @@
<string name="privacy">Privacy</string>
<string name="privacy_text">No data transfer, no ads, no tracking.</string>
<string name="security_warnings">Security warnings</string>
<string name="scan_feedback">Scan feedback (beep + haptic)</string>
<string name="about">About</string>
<string name="copy">Copy</string>
<string name="share">Share</string>
@@ -33,4 +34,22 @@
<string name="request_camera">Allow camera</string>
<string name="pinch_to_zoom_hint">Pinch to zoom for small codes</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>
@@ -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"))
}
}