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 {
|
private object Keys {
|
||||||
val historyEnabled = booleanPreferencesKey("history_enabled")
|
val historyEnabled = booleanPreferencesKey("history_enabled")
|
||||||
val warningsEnabled = booleanPreferencesKey("warnings_enabled")
|
val warningsEnabled = booleanPreferencesKey("warnings_enabled")
|
||||||
|
val scanFeedbackEnabled = booleanPreferencesKey("scan_feedback_enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
val historyEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
val historyEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||||
@@ -23,6 +24,10 @@ class SettingsRepository(private val context: Context) {
|
|||||||
prefs[Keys.warningsEnabled] ?: true
|
prefs[Keys.warningsEnabled] ?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scanFeedbackEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[Keys.scanFeedbackEnabled] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setHistoryEnabled(enabled: Boolean) {
|
suspend fun setHistoryEnabled(enabled: Boolean) {
|
||||||
context.dataStore.edit { it[Keys.historyEnabled] = enabled }
|
context.dataStore.edit { it[Keys.historyEnabled] = enabled }
|
||||||
}
|
}
|
||||||
@@ -30,4 +35,8 @@ class SettingsRepository(private val context: Context) {
|
|||||||
suspend fun setWarningsEnabled(enabled: Boolean) {
|
suspend fun setWarningsEnabled(enabled: Boolean) {
|
||||||
context.dataStore.edit { it[Keys.warningsEnabled] = enabled }
|
context.dataStore.edit { it[Keys.warningsEnabled] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setScanFeedbackEnabled(enabled: Boolean) {
|
||||||
|
context.dataStore.edit { it[Keys.scanFeedbackEnabled] = enabled }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
|
|||||||
data class AppUiState(
|
data class AppUiState(
|
||||||
val historyEnabled: Boolean = false,
|
val historyEnabled: Boolean = false,
|
||||||
val warningsEnabled: Boolean = true,
|
val warningsEnabled: Boolean = true,
|
||||||
|
val scanFeedbackEnabled: Boolean = true,
|
||||||
val history: List<ScanRecord> = emptyList(),
|
val history: List<ScanRecord> = emptyList(),
|
||||||
val searchQuery: String = ""
|
val searchQuery: String = ""
|
||||||
)
|
)
|
||||||
@@ -28,12 +29,14 @@ class AppViewModel(
|
|||||||
val uiState: StateFlow<AppUiState> = combine(
|
val uiState: StateFlow<AppUiState> = combine(
|
||||||
container.settingsRepository.historyEnabled,
|
container.settingsRepository.historyEnabled,
|
||||||
container.settingsRepository.warningsEnabled,
|
container.settingsRepository.warningsEnabled,
|
||||||
|
container.settingsRepository.scanFeedbackEnabled,
|
||||||
container.scanRepository.observeHistory(),
|
container.scanRepository.observeHistory(),
|
||||||
query
|
query
|
||||||
) { historyEnabled, warningsEnabled, history, q ->
|
) { historyEnabled, warningsEnabled, scanFeedbackEnabled, history, q ->
|
||||||
AppUiState(
|
AppUiState(
|
||||||
historyEnabled = historyEnabled,
|
historyEnabled = historyEnabled,
|
||||||
warningsEnabled = warningsEnabled,
|
warningsEnabled = warningsEnabled,
|
||||||
|
scanFeedbackEnabled = scanFeedbackEnabled,
|
||||||
history = if (q.isBlank()) history else history.filter {
|
history = if (q.isBlank()) history else history.filter {
|
||||||
it.content.contains(q, ignoreCase = true) || it.type.contains(q, ignoreCase = true)
|
it.content.contains(q, ignoreCase = true) || it.type.contains(q, ignoreCase = true)
|
||||||
},
|
},
|
||||||
@@ -60,6 +63,12 @@ class AppViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setScanFeedbackEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
container.settingsRepository.setScanFeedbackEnabled(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteHistoryItem(id: Long) {
|
fun deleteHistoryItem(id: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
container.scanRepository.deleteById(id)
|
container.scanRepository.deleteById(id)
|
||||||
|
|||||||
@@ -68,9 +68,17 @@ fun CleanScannerAppRoot(container: AppContainer) {
|
|||||||
RootTab.Scanner -> ScannerScreen(
|
RootTab.Scanner -> ScannerScreen(
|
||||||
analysisEnabled = scannerState.analysisEnabled,
|
analysisEnabled = scannerState.analysisEnabled,
|
||||||
lastResult = scannerState.lastResult,
|
lastResult = scannerState.lastResult,
|
||||||
|
batchMode = scannerState.batchMode,
|
||||||
|
batchResults = scannerState.batchResults,
|
||||||
|
duplicateFeedbackNonce = scannerState.duplicateFeedbackNonce,
|
||||||
|
scanFeedbackNonce = scannerState.scanFeedbackNonce,
|
||||||
warningsEnabled = appState.warningsEnabled,
|
warningsEnabled = appState.warningsEnabled,
|
||||||
|
scanFeedbackEnabled = appState.scanFeedbackEnabled,
|
||||||
onScan = scannerViewModel::onScan,
|
onScan = scannerViewModel::onScan,
|
||||||
onScanAgain = scannerViewModel::resumeScanning
|
onScanAgain = scannerViewModel::resumeScanning,
|
||||||
|
onBatchModeChange = scannerViewModel::setBatchMode,
|
||||||
|
onClearBatchResults = scannerViewModel::clearBatchResults,
|
||||||
|
onOpenHistory = { activeTab = RootTab.History }
|
||||||
)
|
)
|
||||||
|
|
||||||
RootTab.History -> HistoryScreen(
|
RootTab.History -> HistoryScreen(
|
||||||
@@ -84,8 +92,10 @@ fun CleanScannerAppRoot(container: AppContainer) {
|
|||||||
RootTab.Settings -> SettingsScreen(
|
RootTab.Settings -> SettingsScreen(
|
||||||
historyEnabled = appState.historyEnabled,
|
historyEnabled = appState.historyEnabled,
|
||||||
warningsEnabled = appState.warningsEnabled,
|
warningsEnabled = appState.warningsEnabled,
|
||||||
|
scanFeedbackEnabled = appState.scanFeedbackEnabled,
|
||||||
onHistoryToggle = appViewModel::setHistoryEnabled,
|
onHistoryToggle = appViewModel::setHistoryEnabled,
|
||||||
onWarningsToggle = appViewModel::setWarningsEnabled
|
onWarningsToggle = appViewModel::setWarningsEnabled,
|
||||||
|
onScanFeedbackToggle = appViewModel::setScanFeedbackEnabled
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,32 +10,67 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class BatchScanRecord(
|
||||||
|
val result: ScanResult,
|
||||||
|
val timestamp: Long
|
||||||
|
)
|
||||||
|
|
||||||
data class ScannerUiState(
|
data class ScannerUiState(
|
||||||
val lastResult: ScanResult? = null,
|
val lastResult: ScanResult? = null,
|
||||||
val analysisEnabled: Boolean = true,
|
val analysisEnabled: Boolean = true,
|
||||||
val lastScanTimestamp: Long = 0L
|
val lastScanTimestamp: Long = 0L,
|
||||||
|
val batchMode: Boolean = false,
|
||||||
|
val batchResults: List<BatchScanRecord> = emptyList(),
|
||||||
|
val recentScanKeys: List<String> = emptyList(),
|
||||||
|
val duplicateFeedbackNonce: Int = 0,
|
||||||
|
val scanFeedbackNonce: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
class ScannerViewModel(
|
class ScannerViewModel(
|
||||||
private val container: AppContainer
|
private val saveScan: suspend (content: String, type: String) -> Unit,
|
||||||
|
private val nowProvider: () -> Long = { System.currentTimeMillis() }
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(ScannerUiState())
|
private val _uiState = MutableStateFlow(ScannerUiState())
|
||||||
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
fun onScan(result: ScanResult) {
|
fun onScan(result: ScanResult) {
|
||||||
val now = System.currentTimeMillis()
|
val now = nowProvider()
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
if (!current.analysisEnabled) return
|
if (!current.analysisEnabled) return
|
||||||
if (now - current.lastScanTimestamp < 800) return
|
if (now - current.lastScanTimestamp < 800) return
|
||||||
|
|
||||||
_uiState.value = current.copy(
|
val key = "${result.type}|${result.content}"
|
||||||
|
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,
|
lastResult = result,
|
||||||
analysisEnabled = false,
|
analysisEnabled = false,
|
||||||
lastScanTimestamp = now
|
lastScanTimestamp = now,
|
||||||
|
recentScanKeys = updatedRecent,
|
||||||
|
duplicateFeedbackNonce = if (isDuplicate) current.duplicateFeedbackNonce + 1 else current.duplicateFeedbackNonce,
|
||||||
|
scanFeedbackNonce = current.scanFeedbackNonce + 1
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
container.scanRepository.maybeSaveScan(result.content, result.type)
|
saveScan(result.content, result.type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +78,24 @@ class ScannerViewModel(
|
|||||||
_uiState.value = _uiState.value.copy(analysisEnabled = true, lastResult = null)
|
_uiState.value = _uiState.value.copy(analysisEnabled = true, lastResult = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setBatchMode(enabled: Boolean) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
batchMode = enabled,
|
||||||
|
analysisEnabled = true,
|
||||||
|
lastResult = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearBatchResults() {
|
||||||
|
_uiState.value = _uiState.value.copy(batchResults = emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
|
class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return ScannerViewModel(container) as T
|
return ScannerViewModel(
|
||||||
|
saveScan = { content, type -> container.scanRepository.maybeSaveScan(content, type) }
|
||||||
|
) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.clean.scanner.R
|
import com.clean.scanner.R
|
||||||
import com.clean.scanner.domain.ScanRecord
|
import com.clean.scanner.domain.ScanRecord
|
||||||
|
import com.clean.scanner.util.HistoryExportFormatter
|
||||||
import com.clean.scanner.util.Intents
|
import com.clean.scanner.util.Intents
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -91,12 +92,30 @@ fun HistoryScreen(
|
|||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val exportText = buildHistoryExportText(history)
|
val exportText = HistoryExportFormatter.formatText(history)
|
||||||
Intents.shareText(context, exportText)
|
Intents.shareContent(context, exportText, "text/plain")
|
||||||
},
|
},
|
||||||
enabled = history.isNotEmpty()
|
enabled = history.isNotEmpty()
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.share_history))
|
Text(stringResource(R.string.share_txt))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val exportCsv = HistoryExportFormatter.formatCsv(history)
|
||||||
|
Intents.shareContent(context, exportCsv, "text/csv")
|
||||||
|
},
|
||||||
|
enabled = history.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.share_csv))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val exportJson = HistoryExportFormatter.formatJson(history)
|
||||||
|
Intents.shareContent(context, exportJson, "application/json")
|
||||||
|
},
|
||||||
|
enabled = history.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.share_json))
|
||||||
}
|
}
|
||||||
TextButton(onClick = { showDeleteAll.value = true }) {
|
TextButton(onClick = { showDeleteAll.value = true }) {
|
||||||
Text(stringResource(R.string.delete_all))
|
Text(stringResource(R.string.delete_all))
|
||||||
@@ -148,12 +167,3 @@ private fun HistoryRow(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildHistoryExportText(history: List<ScanRecord>): String {
|
|
||||||
if (history.isEmpty()) return ""
|
|
||||||
val formatter = DateFormat.getDateTimeInstance()
|
|
||||||
return history.joinToString(separator = "\n\n") { item ->
|
|
||||||
val time = formatter.format(Date(item.timestamp))
|
|
||||||
"$time\n${item.type}\n${item.content}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package com.clean.scanner.ui.screens
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.ToneGenerator
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -17,7 +21,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -29,10 +32,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -40,10 +47,13 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -51,21 +61,40 @@ import androidx.core.app.ActivityCompat
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clean.scanner.R
|
import com.clean.scanner.R
|
||||||
import com.clean.scanner.domain.ScanResult
|
import com.clean.scanner.domain.ScanResult
|
||||||
|
import com.clean.scanner.ui.BatchScanRecord
|
||||||
import com.clean.scanner.ui.components.CameraPreview
|
import com.clean.scanner.ui.components.CameraPreview
|
||||||
import com.clean.scanner.util.ClipboardUtil
|
import com.clean.scanner.util.ClipboardUtil
|
||||||
import com.clean.scanner.util.Intents
|
import com.clean.scanner.util.Intents
|
||||||
|
import com.clean.scanner.util.ScanContentParsers
|
||||||
import com.clean.scanner.util.UrlRiskScorer
|
import com.clean.scanner.util.UrlRiskScorer
|
||||||
|
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||||
|
import com.google.mlkit.vision.barcode.common.Barcode
|
||||||
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ScannerScreen(
|
fun ScannerScreen(
|
||||||
analysisEnabled: Boolean,
|
analysisEnabled: Boolean,
|
||||||
lastResult: ScanResult?,
|
lastResult: ScanResult?,
|
||||||
|
batchMode: Boolean,
|
||||||
|
batchResults: List<BatchScanRecord>,
|
||||||
|
duplicateFeedbackNonce: Int,
|
||||||
|
scanFeedbackNonce: Int,
|
||||||
warningsEnabled: Boolean,
|
warningsEnabled: Boolean,
|
||||||
|
scanFeedbackEnabled: Boolean,
|
||||||
onScan: (ScanResult) -> Unit,
|
onScan: (ScanResult) -> Unit,
|
||||||
onScanAgain: () -> Unit
|
onScanAgain: () -> Unit,
|
||||||
|
onBatchModeChange: (Boolean) -> Unit,
|
||||||
|
onClearBatchResults: () -> Unit,
|
||||||
|
onOpenHistory: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
val duplicateSnackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) }
|
||||||
|
|
||||||
var cameraGranted by remember {
|
var cameraGranted by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
@@ -79,9 +108,12 @@ fun ScannerScreen(
|
|||||||
var torchAvailable by remember { mutableStateOf(false) }
|
var torchAvailable by remember { mutableStateOf(false) }
|
||||||
var showRiskWarning by remember { mutableStateOf(false) }
|
var showRiskWarning by remember { mutableStateOf(false) }
|
||||||
var pendingOpenUrl by remember { mutableStateOf<String?>(null) }
|
var pendingOpenUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showImageScanFailed by remember { mutableStateOf(false) }
|
||||||
|
var showNoCodeInImage by remember { mutableStateOf(false) }
|
||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
|
val imageScanner = remember { BarcodeScanning.getClient() }
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission()
|
contract = ActivityResultContracts.RequestPermission()
|
||||||
) { granted ->
|
) { granted ->
|
||||||
cameraGranted = granted
|
cameraGranted = granted
|
||||||
@@ -93,8 +125,66 @@ fun ScannerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val imagePicker = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri ->
|
||||||
|
if (uri == null) return@rememberLauncherForActivityResult
|
||||||
|
val image = try {
|
||||||
|
InputImage.fromFilePath(context, uri)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
showImageScanFailed = true
|
||||||
|
return@rememberLauncherForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
imageScanner.process(image)
|
||||||
|
.addOnSuccessListener { barcodes ->
|
||||||
|
val first = barcodes.firstOrNull()
|
||||||
|
val raw = first?.rawValue
|
||||||
|
if (raw.isNullOrBlank()) {
|
||||||
|
showNoCodeInImage = true
|
||||||
|
} else {
|
||||||
|
onScan(ScanResult(content = raw, type = first.valueType.toHumanType()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnFailureListener {
|
||||||
|
showImageScanFailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (!cameraGranted) launcher.launch(Manifest.permission.CAMERA)
|
if (!cameraGranted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(duplicateFeedbackNonce) {
|
||||||
|
if (duplicateFeedbackNonce > 0) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.already_scanned),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
val result = duplicateSnackbarHostState.showSnackbar(
|
||||||
|
message = context.getString(R.string.already_scanned),
|
||||||
|
actionLabel = context.getString(R.string.view_history),
|
||||||
|
withDismissAction = true
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed) {
|
||||||
|
onOpenHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(scanFeedbackNonce, scanFeedbackEnabled) {
|
||||||
|
if (scanFeedbackEnabled && scanFeedbackNonce > 0) {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP, 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
imageScanner.close()
|
||||||
|
toneGenerator.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -133,13 +223,14 @@ fun ScannerScreen(
|
|||||||
center = androidx.compose.ui.geometry.Offset(cx, cy)
|
center = androidx.compose.ui.geometry.Offset(cx, cy)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.pinch_to_zoom_hint),
|
text = stringResource(R.string.pinch_to_zoom_hint),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = 40.dp)
|
.padding(bottom = if (batchMode) 190.dp else 56.dp)
|
||||||
.background(
|
.background(
|
||||||
color = Color.Black.copy(alpha = 0.35f),
|
color = Color.Black.copy(alpha = 0.35f),
|
||||||
shape = RoundedCornerShape(18.dp)
|
shape = RoundedCornerShape(18.dp)
|
||||||
@@ -147,17 +238,62 @@ fun ScannerScreen(
|
|||||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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) {
|
if (torchAvailable) {
|
||||||
RowTopToggle(
|
OverlayToggle(
|
||||||
checked = torchEnabled,
|
checked = torchEnabled,
|
||||||
onCheckedChange = { torchEnabled = it },
|
onCheckedChange = { torchEnabled = it },
|
||||||
label = stringResource(R.string.flashlight)
|
label = stringResource(R.string.flashlight)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
OverlayToggle(
|
||||||
|
checked = batchMode,
|
||||||
|
onCheckedChange = onBatchModeChange,
|
||||||
|
label = stringResource(R.string.batch_mode)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batchMode) {
|
||||||
|
Box(modifier = Modifier.align(Alignment.BottomCenter)) {
|
||||||
|
BatchResultsPanel(
|
||||||
|
results = batchResults,
|
||||||
|
onClear = onClearBatchResults
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = duplicateSnackbarHostState,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = if (batchMode) 12.dp else 80.dp)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
PermissionContent(
|
PermissionContent(
|
||||||
showSettingsHint = showSettingsHint,
|
showSettingsHint = showSettingsHint,
|
||||||
onRequestPermission = { launcher.launch(Manifest.permission.CAMERA) },
|
onRequestPermission = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
val intent = Intent(
|
val intent = Intent(
|
||||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
@@ -168,7 +304,7 @@ fun ScannerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastResult != null) {
|
if (lastResult != null && !batchMode) {
|
||||||
ModalBottomSheet(onDismissRequest = onScanAgain) {
|
ModalBottomSheet(onDismissRequest = onScanAgain) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -211,6 +347,51 @@ fun ScannerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
when (lastResult.type) {
|
||||||
|
"Phone" -> {
|
||||||
|
Button(onClick = {
|
||||||
|
Intents.dialPhone(context, ScanContentParsers.extractPhoneNumber(lastResult.content))
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.call_number))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"SMS" -> {
|
||||||
|
Button(onClick = {
|
||||||
|
val smsData = ScanContentParsers.parseSms(lastResult.content)
|
||||||
|
Intents.sendSms(context, smsData.first, smsData.second)
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.send_sms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"Email" -> {
|
||||||
|
Button(onClick = {
|
||||||
|
Intents.sendEmail(context, ScanContentParsers.extractEmail(lastResult.content), null)
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.send_email))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"WiFi" -> {
|
||||||
|
Button(onClick = { Intents.openWifiSettings(context) }) {
|
||||||
|
Text(stringResource(R.string.open_wifi_settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"Contact" -> {
|
||||||
|
Button(onClick = { Intents.addContact(context, lastResult.content) }) {
|
||||||
|
Text(stringResource(R.string.add_contact))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"Calendar" -> {
|
||||||
|
Button(onClick = { Intents.addCalendarEvent(context, lastResult.content) }) {
|
||||||
|
Text(stringResource(R.string.add_calendar_event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,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
|
@Composable
|
||||||
private fun RowTopToggle(
|
private fun OverlayToggle(
|
||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
label: String
|
label: String
|
||||||
) {
|
) {
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.background(
|
||||||
.padding(12.dp),
|
color = Color.Black.copy(alpha = 0.35f),
|
||||||
contentAlignment = Alignment.TopStart
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
Column {
|
|
||||||
Text(text = label, color = Color.White)
|
Text(text = label, color = Color.White)
|
||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
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
|
@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(
|
fun SettingsScreen(
|
||||||
historyEnabled: Boolean,
|
historyEnabled: Boolean,
|
||||||
warningsEnabled: Boolean,
|
warningsEnabled: Boolean,
|
||||||
|
scanFeedbackEnabled: Boolean,
|
||||||
onHistoryToggle: (Boolean, Boolean) -> Unit,
|
onHistoryToggle: (Boolean, Boolean) -> Unit,
|
||||||
onWarningsToggle: (Boolean) -> Unit
|
onWarningsToggle: (Boolean) -> Unit,
|
||||||
|
onScanFeedbackToggle: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
val showDeleteConfirm = remember { mutableStateOf(false) }
|
val showDeleteConfirm = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -70,6 +72,11 @@ fun SettingsScreen(
|
|||||||
Text(text = stringResource(R.string.security_warnings))
|
Text(text = stringResource(R.string.security_warnings))
|
||||||
Switch(checked = warningsEnabled, onCheckedChange = onWarningsToggle)
|
Switch(checked = warningsEnabled, onCheckedChange = onWarningsToggle)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(text = stringResource(R.string.scan_feedback))
|
||||||
|
Switch(checked = scanFeedbackEnabled, onCheckedChange = onScanFeedbackToggle)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Text(text = stringResource(R.string.about))
|
Text(text = stringResource(R.string.about))
|
||||||
Text(text = stringResource(R.string.version))
|
Text(text = stringResource(R.string.version))
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.clean.scanner.util
|
||||||
|
|
||||||
|
import com.clean.scanner.domain.ScanRecord
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
object HistoryExportFormatter {
|
||||||
|
fun formatText(history: List<ScanRecord>): String {
|
||||||
|
if (history.isEmpty()) return ""
|
||||||
|
val formatter = DateFormat.getDateTimeInstance()
|
||||||
|
return history.joinToString(separator = "\n\n") { item ->
|
||||||
|
val time = formatter.format(Date(item.timestamp))
|
||||||
|
"$time\n${item.type}\n${item.content}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatCsv(history: List<ScanRecord>): String {
|
||||||
|
if (history.isEmpty()) return ""
|
||||||
|
val formatter = DateFormat.getDateTimeInstance()
|
||||||
|
val header = "timestamp,type,content"
|
||||||
|
val rows = history.joinToString(separator = "\n") { item ->
|
||||||
|
val ts = csvEscape(formatter.format(Date(item.timestamp)))
|
||||||
|
val type = csvEscape(item.type)
|
||||||
|
val content = csvEscape(item.content)
|
||||||
|
"$ts,$type,$content"
|
||||||
|
}
|
||||||
|
return "$header\n$rows"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatJson(history: List<ScanRecord>): String {
|
||||||
|
if (history.isEmpty()) return "[]"
|
||||||
|
return history.joinToString(prefix = "[\n", postfix = "\n]", separator = ",\n") { item ->
|
||||||
|
val escapedType = jsonEscape(item.type)
|
||||||
|
val escapedContent = jsonEscape(item.content)
|
||||||
|
" {\"id\":${item.id},\"timestamp\":${item.timestamp},\"type\":\"$escapedType\",\"content\":\"$escapedContent\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun csvEscape(value: String): String {
|
||||||
|
val escaped = value.replace("\"", "\"\"")
|
||||||
|
return "\"$escaped\""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonEscape(value: String): String {
|
||||||
|
return value
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ package com.clean.scanner.util
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.core.content.ContextCompat.startActivity
|
import androidx.core.content.ContextCompat.startActivity
|
||||||
|
|
||||||
object Intents {
|
object Intents {
|
||||||
@@ -12,11 +15,64 @@ object Intents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun shareText(context: Context, text: String) {
|
fun shareText(context: Context, text: String) {
|
||||||
|
shareContent(context, text, "text/plain")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareContent(context: Context, text: String, mimeType: String) {
|
||||||
val intent = Intent(Intent.ACTION_SEND)
|
val intent = Intent(Intent.ACTION_SEND)
|
||||||
.setType("text/plain")
|
.setType(mimeType)
|
||||||
.putExtra(Intent.EXTRA_TEXT, text)
|
.putExtra(Intent.EXTRA_TEXT, text)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
val chooser = Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
val chooser = Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
startActivity(context, chooser, null)
|
startActivity(context, chooser, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dialPhone(context: Context, number: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${number.trim()}"))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
startActivity(context, intent, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendSms(context: Context, number: String, message: String?) {
|
||||||
|
val base = number.trim()
|
||||||
|
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:$base"))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
if (!message.isNullOrBlank()) {
|
||||||
|
intent.putExtra("sms_body", message)
|
||||||
|
}
|
||||||
|
startActivity(context, intent, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendEmail(context: Context, email: String, subject: String?) {
|
||||||
|
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${email.trim()}"))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
if (!subject.isNullOrBlank()) {
|
||||||
|
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||||
|
}
|
||||||
|
startActivity(context, intent, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openWifiSettings(context: Context) {
|
||||||
|
val intent = Intent(Settings.ACTION_WIFI_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
startActivity(context, intent, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addContact(context: Context, rawContent: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_INSERT).apply {
|
||||||
|
type = ContactsContract.Contacts.CONTENT_TYPE
|
||||||
|
putExtra(ContactsContract.Intents.Insert.NOTES, rawContent)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
startActivity(context, intent, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addCalendarEvent(context: Context, rawContent: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_INSERT).apply {
|
||||||
|
data = CalendarContract.Events.CONTENT_URI
|
||||||
|
putExtra(CalendarContract.Events.TITLE, "Scanned event")
|
||||||
|
putExtra(CalendarContract.Events.DESCRIPTION, rawContent)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
startActivity(context, intent, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.clean.scanner.util
|
||||||
|
|
||||||
|
object ScanContentParsers {
|
||||||
|
fun extractPhoneNumber(raw: String): String {
|
||||||
|
return raw.substringAfter("tel:", raw)
|
||||||
|
.substringBefore('?')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSms(raw: String): Pair<String, String?> {
|
||||||
|
val cleaned = raw.trim()
|
||||||
|
return if (cleaned.startsWith("SMSTO:", ignoreCase = true)) {
|
||||||
|
val parts = cleaned.split(':', limit = 3)
|
||||||
|
val number = parts.getOrNull(1).orEmpty()
|
||||||
|
val body = parts.getOrNull(2)
|
||||||
|
number to body
|
||||||
|
} else {
|
||||||
|
cleaned to null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extractEmail(raw: String): String {
|
||||||
|
val cleaned = raw.trim()
|
||||||
|
if (cleaned.startsWith("mailto:", ignoreCase = true)) {
|
||||||
|
return cleaned.substringAfter("mailto:").substringBefore('?').trim()
|
||||||
|
}
|
||||||
|
val match = Regex("[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+")
|
||||||
|
.find(cleaned)
|
||||||
|
return match?.value ?: cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<string name="privacy">Datenschutz</string>
|
<string name="privacy">Datenschutz</string>
|
||||||
<string name="privacy_text">Keine Datenübertragung, keine Werbung, kein Tracking.</string>
|
<string name="privacy_text">Keine Datenübertragung, keine Werbung, kein Tracking.</string>
|
||||||
<string name="security_warnings">Sicherheitswarnungen</string>
|
<string name="security_warnings">Sicherheitswarnungen</string>
|
||||||
|
<string name="scan_feedback">Scan-Feedback (Ton + Vibration)</string>
|
||||||
<string name="about">Über</string>
|
<string name="about">Über</string>
|
||||||
<string name="copy">Kopieren</string>
|
<string name="copy">Kopieren</string>
|
||||||
<string name="share">Teilen</string>
|
<string name="share">Teilen</string>
|
||||||
@@ -33,4 +34,22 @@
|
|||||||
<string name="request_camera">Kamera erlauben</string>
|
<string name="request_camera">Kamera erlauben</string>
|
||||||
<string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string>
|
<string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string>
|
||||||
<string name="share_history">Historie teilen</string>
|
<string name="share_history">Historie teilen</string>
|
||||||
|
<string name="share_txt">TXT</string>
|
||||||
|
<string name="share_csv">CSV</string>
|
||||||
|
<string name="share_json">JSON</string>
|
||||||
|
<string name="scan_from_image">Aus Bild scannen</string>
|
||||||
|
<string name="batch_mode">Stapelmodus</string>
|
||||||
|
<string name="batch_captures_count">Stapel-Scans: %1$d</string>
|
||||||
|
<string name="clear_batch">Stapel leeren</string>
|
||||||
|
<string name="share_batch">Stapel teilen</string>
|
||||||
|
<string name="no_code_found_in_image">Im gewählten Bild wurde kein QR- oder Barcode gefunden.</string>
|
||||||
|
<string name="image_scan_failed">Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen.</string>
|
||||||
|
<string name="already_scanned">Bereits gescannt</string>
|
||||||
|
<string name="view_history">Historie anzeigen</string>
|
||||||
|
<string name="call_number">Nummer anrufen</string>
|
||||||
|
<string name="send_sms">SMS senden</string>
|
||||||
|
<string name="send_email">E-Mail senden</string>
|
||||||
|
<string name="open_wifi_settings">WLAN-Einstellungen öffnen</string>
|
||||||
|
<string name="add_contact">Kontakt hinzufügen</string>
|
||||||
|
<string name="add_calendar_event">Kalendereintrag hinzufügen</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<string name="privacy">Privacy</string>
|
<string name="privacy">Privacy</string>
|
||||||
<string name="privacy_text">No data transfer, no ads, no tracking.</string>
|
<string name="privacy_text">No data transfer, no ads, no tracking.</string>
|
||||||
<string name="security_warnings">Security warnings</string>
|
<string name="security_warnings">Security warnings</string>
|
||||||
|
<string name="scan_feedback">Scan feedback (beep + haptic)</string>
|
||||||
<string name="about">About</string>
|
<string name="about">About</string>
|
||||||
<string name="copy">Copy</string>
|
<string name="copy">Copy</string>
|
||||||
<string name="share">Share</string>
|
<string name="share">Share</string>
|
||||||
@@ -33,4 +34,22 @@
|
|||||||
<string name="request_camera">Allow camera</string>
|
<string name="request_camera">Allow camera</string>
|
||||||
<string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string>
|
<string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string>
|
||||||
<string name="share_history">Share history</string>
|
<string name="share_history">Share history</string>
|
||||||
|
<string name="share_txt">TXT</string>
|
||||||
|
<string name="share_csv">CSV</string>
|
||||||
|
<string name="share_json">JSON</string>
|
||||||
|
<string name="scan_from_image">Scan from image</string>
|
||||||
|
<string name="batch_mode">Batch mode</string>
|
||||||
|
<string name="batch_captures_count">Batch captures: %1$d</string>
|
||||||
|
<string name="clear_batch">Clear batch</string>
|
||||||
|
<string name="share_batch">Share batch</string>
|
||||||
|
<string name="no_code_found_in_image">No QR or barcode found in the selected image.</string>
|
||||||
|
<string name="image_scan_failed">Could not read this image. Try another one.</string>
|
||||||
|
<string name="already_scanned">Already scanned</string>
|
||||||
|
<string name="view_history">View history</string>
|
||||||
|
<string name="call_number">Call number</string>
|
||||||
|
<string name="send_sms">Send SMS</string>
|
||||||
|
<string name="send_email">Send email</string>
|
||||||
|
<string name="open_wifi_settings">Open Wi-Fi settings</string>
|
||||||
|
<string name="add_contact">Add contact</string>
|
||||||
|
<string name="add_calendar_event">Add calendar event</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.clean.scanner.testutil
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestDispatcher
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.rules.TestWatcher
|
||||||
|
import org.junit.runner.Description
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MainDispatcherRule(
|
||||||
|
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
|
||||||
|
) : TestWatcher() {
|
||||||
|
override fun starting(description: Description) {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun finished(description: Description) {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.clean.scanner.ui
|
||||||
|
|
||||||
|
import com.clean.scanner.domain.ScanResult
|
||||||
|
import com.clean.scanner.testutil.MainDispatcherRule
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ScannerViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onScan_firstSingleScan_updatesStateAndSaves() = runTest {
|
||||||
|
val saved = mutableListOf<Pair<String, String>>()
|
||||||
|
var now = 1_000L
|
||||||
|
val viewModel = ScannerViewModel(
|
||||||
|
saveScan = { content, type -> saved += content to type },
|
||||||
|
nowProvider = { now }
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.onScan(ScanResult(content = "https://example.com", type = "URL"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertEquals("https://example.com", state.lastResult?.content)
|
||||||
|
assertEquals(false, state.analysisEnabled)
|
||||||
|
assertEquals(1, state.scanFeedbackNonce)
|
||||||
|
assertEquals(0, state.duplicateFeedbackNonce)
|
||||||
|
assertEquals(listOf("URL|https://example.com"), state.recentScanKeys)
|
||||||
|
assertEquals(listOf("https://example.com" to "URL"), saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun duplicateScan_afterResume_incrementsDuplicateFeedback() = runTest {
|
||||||
|
val saved = mutableListOf<Pair<String, String>>()
|
||||||
|
var now = 1_000L
|
||||||
|
val viewModel = ScannerViewModel(
|
||||||
|
saveScan = { content, type -> saved += content to type },
|
||||||
|
nowProvider = { now }
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = ScanResult(content = "ABC", type = "Text")
|
||||||
|
viewModel.onScan(result)
|
||||||
|
viewModel.resumeScanning()
|
||||||
|
|
||||||
|
now = 2_000L
|
||||||
|
viewModel.onScan(result)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertEquals(2, state.scanFeedbackNonce)
|
||||||
|
assertEquals(1, state.duplicateFeedbackNonce)
|
||||||
|
assertEquals(2, saved.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun batchMode_addsUniqueKeepsNewestFirst_andClearWorks() = runTest {
|
||||||
|
val saved = mutableListOf<Pair<String, String>>()
|
||||||
|
var now = 1_000L
|
||||||
|
val viewModel = ScannerViewModel(
|
||||||
|
saveScan = { content, type -> saved += content to type },
|
||||||
|
nowProvider = { now }
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.setBatchMode(true)
|
||||||
|
viewModel.onScan(ScanResult(content = "A", type = "Text"))
|
||||||
|
|
||||||
|
now = 2_000L
|
||||||
|
viewModel.onScan(ScanResult(content = "B", type = "Text"))
|
||||||
|
|
||||||
|
now = 3_000L
|
||||||
|
viewModel.onScan(ScanResult(content = "A", type = "Text"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertEquals(2, state.batchResults.size)
|
||||||
|
assertEquals("B", state.batchResults[0].result.content)
|
||||||
|
assertEquals(2_000L, state.batchResults[0].timestamp)
|
||||||
|
assertEquals("A", state.batchResults[1].result.content)
|
||||||
|
assertEquals(1, state.duplicateFeedbackNonce)
|
||||||
|
assertEquals(3, state.scanFeedbackNonce)
|
||||||
|
assertTrue(state.analysisEnabled)
|
||||||
|
|
||||||
|
viewModel.clearBatchResults()
|
||||||
|
assertTrue(viewModel.uiState.value.batchResults.isEmpty())
|
||||||
|
assertEquals(3, saved.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.clean.scanner.util
|
||||||
|
|
||||||
|
import com.clean.scanner.domain.ScanRecord
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HistoryExportFormatterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun formatText_empty_returnsEmptyString() {
|
||||||
|
assertEquals("", HistoryExportFormatter.formatText(emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun formatCsv_quotesAndEscapesFields() {
|
||||||
|
val history = listOf(
|
||||||
|
ScanRecord(
|
||||||
|
id = 1,
|
||||||
|
timestamp = 0L,
|
||||||
|
type = "Te,xt",
|
||||||
|
content = "hello \"world\""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val csv = HistoryExportFormatter.formatCsv(history)
|
||||||
|
|
||||||
|
assertTrue(csv.startsWith("timestamp,type,content\n"))
|
||||||
|
assertTrue(csv.contains("\"Te,xt\""))
|
||||||
|
assertTrue(csv.contains("\"hello \"\"world\"\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun formatJson_escapesQuotesBackslashAndNewline() {
|
||||||
|
val history = listOf(
|
||||||
|
ScanRecord(
|
||||||
|
id = 7,
|
||||||
|
timestamp = 123L,
|
||||||
|
type = "Type\"A",
|
||||||
|
content = "line1\\line2\nnext"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = HistoryExportFormatter.formatJson(history)
|
||||||
|
|
||||||
|
assertTrue(json.contains("\"id\":7"))
|
||||||
|
assertTrue(json.contains("\"timestamp\":123"))
|
||||||
|
assertTrue(json.contains("\"type\":\"Type\\\"A\""))
|
||||||
|
assertTrue(json.contains("\"content\":\"line1\\\\line2\\nnext\""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.clean.scanner.util
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ScanContentParsersTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractPhoneNumber_handlesTelUri() {
|
||||||
|
val phone = ScanContentParsers.extractPhoneNumber("tel:+123456789?foo=bar")
|
||||||
|
assertEquals("+123456789", phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSms_handlesSmstoWithBody() {
|
||||||
|
val (number, body) = ScanContentParsers.parseSms("SMSTO:+49123456:hello there")
|
||||||
|
assertEquals("+49123456", number)
|
||||||
|
assertEquals("hello there", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractEmail_handlesMailtoAndPlainText() {
|
||||||
|
assertEquals("x@y.com", ScanContentParsers.extractEmail("mailto:x@y.com?subject=hi"))
|
||||||
|
assertEquals("a.b+c@d.dev", ScanContentParsers.extractEmail("contact me at a.b+c@d.dev now"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user