quick wins + test cases
This commit is contained in:
+54
@@ -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(
|
||||
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
|
||||
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)
|
||||
)
|
||||
|
||||
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) {
|
||||
RowTopToggle(
|
||||
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,26 +413,136 @@ 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
|
||||
) {
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.TopStart
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.35f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
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(horizontal = 12.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user