diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..9cae2d3 --- /dev/null +++ b/ROADMAP.md @@ -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 diff --git a/app/src/main/java/com/clean/scanner/settings/SettingsRepository.kt b/app/src/main/java/com/clean/scanner/settings/SettingsRepository.kt index fa90b52..2d2dcd5 100644 --- a/app/src/main/java/com/clean/scanner/settings/SettingsRepository.kt +++ b/app/src/main/java/com/clean/scanner/settings/SettingsRepository.kt @@ -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 = context.dataStore.data.map { prefs -> @@ -23,6 +24,10 @@ class SettingsRepository(private val context: Context) { prefs[Keys.warningsEnabled] ?: true } + val scanFeedbackEnabled: Flow = 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 } + } } diff --git a/app/src/main/java/com/clean/scanner/ui/AppViewModel.kt b/app/src/main/java/com/clean/scanner/ui/AppViewModel.kt index 7470cb1..5827a32 100644 --- a/app/src/main/java/com/clean/scanner/ui/AppViewModel.kt +++ b/app/src/main/java/com/clean/scanner/ui/AppViewModel.kt @@ -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 = emptyList(), val searchQuery: String = "" ) @@ -28,12 +29,14 @@ class AppViewModel( val uiState: StateFlow = 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) diff --git a/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt b/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt index 8f5bee0..155763d 100644 --- a/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt +++ b/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt @@ -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 ) } } diff --git a/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt b/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt index a974b50..d6bb727 100644 --- a/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt +++ b/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt @@ -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 = emptyList(), + val recentScanKeys: List = 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 = _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 create(modelClass: Class): T { - return ScannerViewModel(container) as T + return ScannerViewModel( + saveScan = { content, type -> container.scanRepository.maybeSaveScan(content, type) } + ) as T } } } diff --git a/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt index 8876dca..4cfe656 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt @@ -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): 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}" - } -} diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt index e84c4d3..3655ecd 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt @@ -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, + 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(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, + 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): 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" +} diff --git a/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt index eabcab1..fbe4adc 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt @@ -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)) diff --git a/app/src/main/java/com/clean/scanner/util/HistoryExportFormatter.kt b/app/src/main/java/com/clean/scanner/util/HistoryExportFormatter.kt new file mode 100644 index 0000000..c8def5b --- /dev/null +++ b/app/src/main/java/com/clean/scanner/util/HistoryExportFormatter.kt @@ -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): 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): 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): 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") + } +} diff --git a/app/src/main/java/com/clean/scanner/util/Intents.kt b/app/src/main/java/com/clean/scanner/util/Intents.kt index 4abbde1..e0283a7 100644 --- a/app/src/main/java/com/clean/scanner/util/Intents.kt +++ b/app/src/main/java/com/clean/scanner/util/Intents.kt @@ -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) + } } diff --git a/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt b/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt new file mode 100644 index 0000000..6761e7b --- /dev/null +++ b/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt @@ -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 { + 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 + } +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ce74424..8818854 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -8,6 +8,7 @@ Datenschutz Keine Datenübertragung, keine Werbung, kein Tracking. Sicherheitswarnungen + Scan-Feedback (Ton + Vibration) Über Kopieren Teilen @@ -33,4 +34,22 @@ Kamera erlauben Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen Historie teilen + TXT + CSV + JSON + Aus Bild scannen + Stapelmodus + Stapel-Scans: %1$d + Stapel leeren + Stapel teilen + Im gewählten Bild wurde kein QR- oder Barcode gefunden. + Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen. + Bereits gescannt + Historie anzeigen + Nummer anrufen + SMS senden + E-Mail senden + WLAN-Einstellungen öffnen + Kontakt hinzufügen + Kalendereintrag hinzufügen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0cbbb7..fcd1e5e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,7 @@ Privacy No data transfer, no ads, no tracking. Security warnings + Scan feedback (beep + haptic) About Copy Share @@ -33,4 +34,22 @@ Allow camera Pinch to zoom for small codes Share history + TXT + CSV + JSON + Scan from image + Batch mode + Batch captures: %1$d + Clear batch + Share batch + No QR or barcode found in the selected image. + Could not read this image. Try another one. + Already scanned + View history + Call number + Send SMS + Send email + Open Wi-Fi settings + Add contact + Add calendar event diff --git a/app/src/test/java/com/clean/scanner/testutil/MainDispatcherRule.kt b/app/src/test/java/com/clean/scanner/testutil/MainDispatcherRule.kt new file mode 100644 index 0000000..8bb1c25 --- /dev/null +++ b/app/src/test/java/com/clean/scanner/testutil/MainDispatcherRule.kt @@ -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() + } +} diff --git a/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt b/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt new file mode 100644 index 0000000..5c1ea68 --- /dev/null +++ b/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt @@ -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>() + 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>() + 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>() + 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) + } +} diff --git a/app/src/test/java/com/clean/scanner/util/HistoryExportFormatterTest.kt b/app/src/test/java/com/clean/scanner/util/HistoryExportFormatterTest.kt new file mode 100644 index 0000000..2e88794 --- /dev/null +++ b/app/src/test/java/com/clean/scanner/util/HistoryExportFormatterTest.kt @@ -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\"")) + } +} diff --git a/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt b/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt new file mode 100644 index 0000000..867eb1d --- /dev/null +++ b/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt @@ -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")) + } +}