splitting large files into submodules

This commit is contained in:
Hadrian Burkhardt
2026-02-26 05:25:15 +01:00
parent 3d2d451815
commit 5d83ff4a6d
8 changed files with 1000 additions and 777 deletions
+15 -3
View File
@@ -8,10 +8,15 @@ Offline-first, ad-free QR/barcode scanner built with Kotlin, Jetpack Compose, Ca
## Architektur ## Architektur
- `ui/`: Compose screens/components + ViewModels (MVVM) - `ui/`: Compose screens/components + ViewModels (MVVM)
- `ui/screens/ScannerScreen.kt`: Scanner-Orchestrierung (Camera, Overlay, Actions, Bottom Sheet)
- `ui/screens/ScannerResultCards.kt`: strukturierte Ergebnis-Visualisierung (inkl. Kontaktkarten)
- `ui/screens/ScannerGalleryPreviewDialog.kt`: Bild-Scan-Vorschau mit Zoom/Pan + Live-Re-Detection
- `ui/screens/ScannerOverlayComponents.kt`: Overlay-Toggles, Batch-Panel, Permission-Content
- `ui/screens/BarcodeTypeMapper.kt`: ML-Kit `valueType` -> lesbarer Typ
- `data/`: ML Kit analyzer, Room entities/DAO, repository - `data/`: ML Kit analyzer, Room entities/DAO, repository
- `domain/`: app models (`ScanResult`, `ScanRecord`, `UrlRiskResult`) - `domain/`: app models (`ScanResult`, `ScanRecord`, `UrlRiskResult`)
- `settings/`: DataStore preferences (history + warnings toggles) - `settings/`: DataStore preferences (history + warnings toggles)
- `util/`: URL risk scoring, clipboard, intents - `util/`: URL risk scoring, clipboard, intents, content parser (`vCard`/`MECARD`/`WIFI`/`VEVENT`)
## Datenschutz ## Datenschutz
- Keine Werbung - Keine Werbung
@@ -23,7 +28,9 @@ Offline-first, ad-free QR/barcode scanner built with Kotlin, Jetpack Compose, Ca
- Home: Scan-Button, lokaler Historie-Toggle (Default: OFF), Datenschutz-Dialog - Home: Scan-Button, lokaler Historie-Toggle (Default: OFF), Datenschutz-Dialog
- Scanner: CameraX Live-Preview, Fadenkreuz-Overlay, Taschenlampe, Debounce gegen Doppelscans, Live-Hinweise zu erkannten/lesbaren Codes - Scanner: CameraX Live-Preview, Fadenkreuz-Overlay, Taschenlampe, Debounce gegen Doppelscans, Live-Hinweise zu erkannten/lesbaren Codes
- Bild-Scan: Multi-Code-Erkennung aus einem Bild mit Ergebnis-Auswahl - Bild-Scan: Multi-Code-Erkennung aus einem Bild mit Ergebnis-Auswahl
- Ergebnis-Bottom-Sheet: Typ/Inhalt + Copy/Share/Open/Scan again - Ergebnis-Bottom-Sheet: strukturierte Anzeige + Copy/Share/Open/Scan again + kontextspezifische Aktionen
- Kontakt-Workflows: vCard/MECARD parsen, visuelle Kontaktkarte, "Zu Kontakten hinzufügen"
- Office/Admin-Workflows: Wi-Fi QR parsen + Einstellungen öffnen, Kalender-QR parsen + Event anlegen
- URL-Sicherheitswarnung bei lokalem `riskScore >= 3` (kein Blocken, nur Hinweis) - URL-Sicherheitswarnung bei lokalem `riskScore >= 3` (kein Blocken, nur Hinweis)
- Historie: Suche, Swipe-to-delete, Alles-löschen, Detailansicht mit Volltext - Historie: Suche, Swipe-to-delete, Alles-löschen, Detailansicht mit Volltext
- Einstellungen: Historie an/aus (mit optionalem Löschen), Warnungen an/aus, About-Infos - Einstellungen: Historie an/aus (mit optionalem Löschen), Warnungen an/aus, About-Infos
@@ -52,4 +59,9 @@ CLI:
./gradlew testDebugUnitTest ./gradlew testDebugUnitTest
``` ```
- URL-Risk-Scorer tests: `app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt` - Wichtige Test-Suites:
- `app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt`
- `app/src/test/java/com/clean/scanner/util/HistoryExportFormatterTest.kt`
- `app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt`
- `app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt`
- `app/src/androidTest/java/com/clean/scanner/util/IntentsTest.kt`
@@ -0,0 +1,18 @@
package com.clean.scanner.ui.screens
import com.google.mlkit.vision.barcode.common.Barcode
internal fun Int.toHumanType(): String = when (this) {
Barcode.TYPE_CONTACT_INFO -> "Contact"
Barcode.TYPE_URL -> "URL"
Barcode.TYPE_WIFI -> "WiFi"
Barcode.TYPE_PHONE -> "Phone"
Barcode.TYPE_SMS -> "SMS"
Barcode.TYPE_EMAIL -> "Email"
Barcode.TYPE_CALENDAR_EVENT -> "Calendar"
Barcode.TYPE_ISBN -> "ISBN"
Barcode.TYPE_PRODUCT -> "Product"
Barcode.TYPE_TEXT -> "Text"
Barcode.TYPE_GEO -> "Geo"
else -> "Unknown"
}
@@ -0,0 +1,355 @@
package com.clean.scanner.ui.screens
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.graphics.Paint
import android.net.Uri
import android.os.Build
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
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.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
import com.clean.scanner.data.scanner.DetectionBox
import com.clean.scanner.data.scanner.DetectionPoint
import com.clean.scanner.domain.ScanResult
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.max
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
private fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitmap? {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
ImageDecoder.decodeBitmap(source)
} else {
context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream)
}
}
} catch (_: Exception) {
null
}
}
private suspend fun detectBarcodes(
scanner: BarcodeScanner,
image: InputImage
): List<Barcode> = suspendCancellableCoroutine { cont ->
scanner.process(image)
.addOnSuccessListener { barcodes ->
if (cont.isActive) cont.resume(barcodes)
}
.addOnFailureListener { error ->
if (cont.isActive) cont.resumeWithException(error)
}
}
@Composable
internal fun GalleryScanPreviewDialog(
imageUri: Uri?,
candidates: List<GalleryScanCandidate>,
onPick: (GalleryScanCandidate) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val bitmap = remember(imageUri) { imageUri?.let { loadBitmapFromUri(context, it) } }
var liveCandidates by remember(imageUri, candidates) { mutableStateOf(candidates) }
var zoom by remember(imageUri) { mutableFloatStateOf(1f) }
var pan by remember(imageUri) { mutableStateOf(Offset.Zero) }
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
var scanTick by remember { mutableIntStateOf(0) }
val markerPaint = remember {
Paint().apply {
color = android.graphics.Color.WHITE
textAlign = Paint.Align.CENTER
textSize = 34f
isAntiAlias = true
isFakeBoldText = true
}
}
val scanner = remember {
BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.enableAllPotentialBarcodes()
.build()
)
}
DisposableEffect(Unit) {
onDispose { scanner.close() }
}
LaunchedEffect(bitmap, viewportSize, zoom, pan, scanTick) {
val bmp = bitmap ?: return@LaunchedEffect
val vw = viewportSize.width.toFloat()
val vh = viewportSize.height.toFloat()
if (vw <= 0f || vh <= 0f) return@LaunchedEffect
delay(120)
val imgW = bmp.width.toFloat()
val imgH = bmp.height.toFloat()
if (imgW <= 1f || imgH <= 1f) return@LaunchedEffect
val baseScale = max(vw / imgW, vh / imgH)
val effectiveScale = (baseScale * zoom).coerceAtLeast(0.01f)
val cx = vw * 0.5f
val cy = vh * 0.5f
fun screenToImageX(screenX: Float): Float {
return ((screenX - cx - pan.x) / effectiveScale) + (imgW * 0.5f)
}
fun screenToImageY(screenY: Float): Float {
return ((screenY - cy - pan.y) / effectiveScale) + (imgH * 0.5f)
}
val left = screenToImageX(0f).coerceIn(0f, imgW - 1f)
val right = screenToImageX(vw).coerceIn(0f, imgW - 1f)
val top = screenToImageY(0f).coerceIn(0f, imgH - 1f)
val bottom = screenToImageY(vh).coerceIn(0f, imgH - 1f)
val cropLeft = minOf(left, right).toInt()
val cropTop = minOf(top, bottom).toInt()
val cropW = (kotlin.math.abs(right - left)).toInt().coerceAtLeast(8)
val cropH = (kotlin.math.abs(bottom - top)).toInt().coerceAtLeast(8)
val boundedW = cropW.coerceAtMost(bmp.width - cropLeft)
val boundedH = cropH.coerceAtMost(bmp.height - cropTop)
if (boundedW <= 4 || boundedH <= 4) return@LaunchedEffect
val cropped = Bitmap.createBitmap(bmp, cropLeft, cropTop, boundedW, boundedH)
val barcodes = try {
detectBarcodes(scanner, InputImage.fromBitmap(cropped, 0))
} catch (_: Exception) {
emptyList()
}
val live = barcodes.mapNotNull { barcode ->
val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val normalizedBox = barcode.boundingBox?.let { bounds ->
val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f)
val topN = ((bounds.top + cropTop) / imgH).coerceIn(0f, 1f)
val rightN = ((bounds.right + cropLeft) / imgW).coerceIn(0f, 1f)
val bottomN = ((bounds.bottom + cropTop) / imgH).coerceIn(0f, 1f)
val corners = barcode.cornerPoints?.map { p ->
DetectionPoint(
x = ((p.x + cropLeft) / imgW).coerceIn(0f, 1f),
y = ((p.y + cropTop) / imgH).coerceIn(0f, 1f)
)
} ?: emptyList()
DetectionBox(leftN, topN, rightN, bottomN, corners)
}
GalleryScanCandidate(
result = ScanResult(content = raw, type = barcode.valueType.toHumanType()),
box = normalizedBox
)
}.distinctBy { "${it.result.type}|${it.result.content}" }
liveCandidates = live
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.image_scan_pick_title, liveCandidates.size)) },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (bitmap != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
.onSizeChanged {
viewportSize = it
scanTick++
}
.pointerInput(bitmap) {
detectTransformGestures { _, panChange, zoomChange, _ ->
zoom = (zoom * zoomChange).coerceIn(1f, 6f)
pan += panChange
scanTick++
}
}
.background(Color.Black.copy(alpha = 0.32f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(R.string.scan_from_image),
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
.graphicsLayer {
scaleX = zoom
scaleY = zoom
translationX = pan.x
translationY = pan.y
},
contentScale = ContentScale.Crop
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
) {
val imageW = bitmap.width.toFloat()
val imageH = bitmap.height.toFloat()
if (imageW <= 0f || imageH <= 0f) return@Canvas
val baseScale = max(size.width / imageW, size.height / imageH)
val effectiveScale = baseScale * zoom
val cx = size.width * 0.5f
val cy = size.height * 0.5f
fun imageToScreen(ix: Float, iy: Float): Offset {
val sx = cx + ((ix - imageW * 0.5f) * effectiveScale) + pan.x
val sy = cy + ((iy - imageH * 0.5f) * effectiveScale) + pan.y
return Offset(sx, sy)
}
liveCandidates.forEachIndexed { index, candidate ->
val box = candidate.box ?: return@forEachIndexed
val color = Color(0xFF4AE3A3).copy(alpha = 0.96f)
val points = box.corners.map { p ->
imageToScreen(p.x * imageW, p.y * imageH)
}
if (points.size >= 4) {
val path = Path().apply {
moveTo(points.first().x, points.first().y)
points.drop(1).forEach { pt -> lineTo(pt.x, pt.y) }
close()
}
drawPath(path = path, color = color, style = Stroke(width = 4f))
} else {
val lt = imageToScreen(box.left * imageW, box.top * imageH)
val rb = imageToScreen(box.right * imageW, box.bottom * imageH)
val left = minOf(lt.x, rb.x)
val top = minOf(lt.y, rb.y)
val right = maxOf(lt.x, rb.x)
val bottom = maxOf(lt.y, rb.y)
if (right > left && bottom > top) {
drawRoundRect(
color = color,
topLeft = Offset(left, top),
size = Size(right - left, bottom - top),
cornerRadius = CornerRadius(10f, 10f),
style = Stroke(width = 4f)
)
}
}
val center = if (points.isNotEmpty()) {
val sx = points.sumOf { it.x.toDouble() }.toFloat() / points.size
val sy = points.sumOf { it.y.toDouble() }.toFloat() / points.size
Offset(sx, sy)
} else {
imageToScreen(
(box.left + box.right) * 0.5f * imageW,
(box.top + box.bottom) * 0.5f * imageH
)
}
drawCircle(
color = Color.Black.copy(alpha = 0.65f),
radius = 16f,
center = center
)
drawContext.canvas.nativeCanvas.drawText(
"${index + 1}",
center.x,
center.y + 11f,
markerPaint
)
}
}
}
}
if (liveCandidates.isEmpty()) {
Text(text = stringResource(R.string.no_code_found_in_image))
} else {
Text(text = stringResource(R.string.image_scan_pick_subtitle))
liveCandidates.forEachIndexed { index, candidate ->
TextButton(
onClick = { onPick(candidate) },
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "${index + 1}. ${candidate.result.type}",
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = candidate.result.content,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@@ -0,0 +1,184 @@
package com.clean.scanner.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
import com.clean.scanner.ui.BatchScanRecord
import com.clean.scanner.util.ClipboardUtil
import com.clean.scanner.util.Intents
import java.text.DateFormat
import java.util.Date
@Composable
internal fun OverlayIconToggle(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
label: String,
checkedImageVector: androidx.compose.ui.graphics.vector.ImageVector,
uncheckedImageVector: androidx.compose.ui.graphics.vector.ImageVector,
showLabel: Boolean = true
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
IconToggleButton(
checked = checked,
onCheckedChange = onCheckedChange
) {
Icon(
imageVector = if (checked) checkedImageVector else uncheckedImageVector,
contentDescription = label,
tint = if (checked) Color(0xFF4AE3A3) else Color.White
)
}
if (showLabel) {
Text(
text = label,
color = Color.White,
textAlign = TextAlign.Center
)
}
}
}
@Composable
internal 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
internal fun PermissionContent(
showSettingsHint: Boolean,
onRequestPermission: () -> Unit,
onOpenSettings: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center
) {
Text(text = stringResource(R.string.camera_permission_title))
Text(text = stringResource(R.string.camera_permission_rationale))
Button(onClick = onRequestPermission) {
Text(text = stringResource(R.string.request_camera))
}
if (showSettingsHint) {
TextButton(onClick = onOpenSettings) {
Text(stringResource(R.string.open_settings))
}
}
}
}
@@ -0,0 +1,319 @@
package com.clean.scanner.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.clean.scanner.domain.ScanResult
import com.clean.scanner.util.ParsedContact
import com.clean.scanner.util.ScanContentParsers
import com.clean.scanner.util.UrlRiskScorer
import java.text.DateFormat
import java.util.Date
private data class ResultField(
val label: String,
val value: String
)
@Composable
internal fun ResultVisualCard(
result: ScanResult,
modifier: Modifier = Modifier
) {
val contact = remember(result.content) { ScanContentParsers.parseContact(result.content) }
if (contact != null || result.type == "Contact") {
ContactVisualCard(
contact = contact,
rawContent = result.content,
modifier = modifier
)
return
}
val fields = remember(result) { buildResultFields(result) }
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF2F7FF)),
shape = RoundedCornerShape(14.dp)
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = result.type,
style = MaterialTheme.typography.titleMedium
)
if (fields.isEmpty()) {
Text(
text = result.content,
style = MaterialTheme.typography.bodyMedium
)
} else {
fields.forEach { field ->
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = field.label,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4F6277)
)
Text(
text = field.value,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
private enum class ContactCardTemplate {
Minimal,
Corporate,
Playful
}
@Composable
private fun ContactVisualCard(
contact: ParsedContact?,
rawContent: String,
modifier: Modifier = Modifier
) {
val template = remember(contact, rawContent) { selectContactTemplate(contact, rawContent) }
val background = when (template) {
ContactCardTemplate.Corporate -> Brush.linearGradient(
listOf(Color(0xFF081C3B), Color(0xFF0F2E58), Color(0xFF134B73))
)
ContactCardTemplate.Playful -> Brush.linearGradient(
listOf(Color(0xFFFFF9EC), Color(0xFFF8F1FF), Color(0xFFEFF6FF))
)
ContactCardTemplate.Minimal -> Brush.linearGradient(
listOf(Color(0xFFF9FAFC), Color(0xFFF1F5F9))
)
}
val accentColor = when (template) {
ContactCardTemplate.Corporate -> Color(0xFF7AF7CF)
ContactCardTemplate.Playful -> Color(0xFF304FFE)
ContactCardTemplate.Minimal -> Color(0xFF1E293B)
}
val textPrimary = when (template) {
ContactCardTemplate.Corporate -> Color.White
ContactCardTemplate.Playful -> Color(0xFF16181D)
ContactCardTemplate.Minimal -> Color(0xFF0F172A)
}
val textMuted = when (template) {
ContactCardTemplate.Corporate -> Color(0xFFC7D6E8)
ContactCardTemplate.Playful -> Color(0xFF55657B)
ContactCardTemplate.Minimal -> Color(0xFF475569)
}
val name = contact?.fullName ?: "Contact"
val subtitle = listOfNotNull(contact?.title, contact?.organization).distinct().joinToString("")
val initials = remember(name) { initialsFromName(name) }
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.background(background)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.background(
color = accentColor.copy(
alpha = when (template) {
ContactCardTemplate.Corporate -> 0.18f
ContactCardTemplate.Playful -> 0.15f
ContactCardTemplate.Minimal -> 0.12f
}
),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp, vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = initials,
color = accentColor,
style = MaterialTheme.typography.titleMedium
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = name,
color = textPrimary,
style = MaterialTheme.typography.headlineSmall
)
if (subtitle.isNotBlank()) {
Text(
text = subtitle,
color = textMuted,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
ContactLine("Phone", contact?.phones?.firstOrNull(), textPrimary, textMuted)
ContactLine("Email", contact?.emails?.firstOrNull(), textPrimary, textMuted)
ContactLine("Address", contact?.address, textPrimary, textMuted)
ContactLine("Note", contact?.note, textPrimary, textMuted)
if (contact == null) {
ContactLine("Raw", rawContent, textPrimary, textMuted)
}
}
}
}
private fun initialsFromName(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
if (parts.isEmpty()) return "?"
return parts.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar() }
.joinToString("")
.ifBlank { "?" }
}
@Composable
private fun ContactLine(
label: String,
value: String?,
textPrimary: Color,
textMuted: Color
) {
if (value.isNullOrBlank()) return
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
color = textMuted,
style = MaterialTheme.typography.labelSmall
)
Text(
text = value,
color = textPrimary,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
private fun selectContactTemplate(contact: ParsedContact?, rawContent: String): ContactCardTemplate {
val isCardPayload = rawContent.contains("BEGIN:VCARD", ignoreCase = true) ||
rawContent.startsWith("MECARD:", ignoreCase = true)
val looksCorporate = !contact?.organization.isNullOrBlank() ||
!contact?.title.isNullOrBlank()
val denseStructuredData = listOf(
contact?.phones?.firstOrNull(),
contact?.emails?.firstOrNull(),
contact?.address,
contact?.note
).count { !it.isNullOrBlank() } >= 3
return when {
looksCorporate || isCardPayload -> ContactCardTemplate.Corporate
denseStructuredData -> ContactCardTemplate.Minimal
else -> ContactCardTemplate.Playful
}
}
private fun buildResultFields(result: ScanResult): List<ResultField> {
return when (result.type) {
"Contact" -> {
val contact = ScanContentParsers.parseContact(result.content)
listOfNotNull(
contact?.fullName?.let { ResultField("Name", it) },
contact?.organization?.let { ResultField("Company", it) },
contact?.title?.let { ResultField("Title", it) },
contact?.phones?.firstOrNull()?.let { ResultField("Phone", it) },
contact?.emails?.firstOrNull()?.let { ResultField("Email", it) },
contact?.address?.let { ResultField("Address", it) }
)
}
"Calendar" -> {
val event = ScanContentParsers.parseCalendarEvent(result.content)
val dateTime = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
listOfNotNull(
event?.title?.let { ResultField("Title", it) },
event?.location?.let { ResultField("Location", it) },
event?.startMillis?.let { ResultField("Start", dateTime.format(Date(it))) },
event?.endMillis?.let { ResultField("End", dateTime.format(Date(it))) },
event?.description?.let { ResultField("Details", it) }
)
}
"WiFi" -> {
val map = parseWifiFields(result.content)
listOfNotNull(
map["S"]?.takeIf { it.isNotBlank() }?.let { ResultField("SSID", it) },
map["T"]?.takeIf { it.isNotBlank() }?.let { ResultField("Security", it) },
map["P"]?.takeIf { it.isNotBlank() }?.let { ResultField("Password", it) }
)
}
"URL" -> {
val score = UrlRiskScorer.score(result.content)
listOf(
ResultField("Link", result.content),
ResultField("Risk score", score.score.toString())
)
}
"SMS" -> {
val (number, body) = ScanContentParsers.parseSms(result.content)
listOfNotNull(
number.takeIf { it.isNotBlank() }?.let { ResultField("To", it) },
body?.takeIf { it.isNotBlank() }?.let { ResultField("Message", it) }
)
}
"Email" -> {
val email = ScanContentParsers.extractEmail(result.content)
listOf(ResultField("Email", email))
}
"Phone" -> {
val phone = ScanContentParsers.extractPhoneNumber(result.content)
listOf(ResultField("Phone", phone))
}
else -> emptyList()
}
}
private fun parseWifiFields(raw: String): Map<String, String> {
val cleaned = raw.trim()
if (!cleaned.startsWith("WIFI:", ignoreCase = true)) return emptyMap()
val payload = cleaned.substringAfter("WIFI:", "").trim().trimEnd(';')
val values = mutableMapOf<String, String>()
payload.split(';').forEach { token ->
val key = token.substringBefore(':', "").trim()
if (key.isBlank()) return@forEach
val value = token.substringAfter(':', "").trim()
values[key] = value
}
return values
}
@@ -3,22 +3,15 @@ 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.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.graphics.Paint
import android.media.AudioManager import android.media.AudioManager
import android.media.ToneGenerator import android.media.ToneGenerator
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.Settings import android.provider.Settings
import android.widget.Toast 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.horizontalScroll 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
@@ -29,7 +22,6 @@ 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.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ViewList import androidx.compose.material.icons.automirrored.filled.ViewList
@@ -41,13 +33,9 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.ViewModule import androidx.compose.material.icons.filled.ViewModule
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api 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.IconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -58,7 +46,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect 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.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -68,16 +55,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -85,7 +66,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource 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.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
@@ -98,23 +78,15 @@ 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.ParsedContact
import com.clean.scanner.util.ScanContentParsers 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.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
import java.text.DateFormat
import java.util.Date
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.max import kotlin.math.max
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
private data class GalleryScanCandidate( internal data class GalleryScanCandidate(
val result: ScanResult, val result: ScanResult,
val box: DetectionBox? val box: DetectionBox?
) )
@@ -636,748 +608,3 @@ fun ScannerScreen(
} }
} }
} }
private data class ResultField(
val label: String,
val value: String
)
@Composable
private fun ResultVisualCard(
result: ScanResult,
modifier: Modifier = Modifier
) {
val contact = remember(result.content) { ScanContentParsers.parseContact(result.content) }
if (contact != null || result.type == "Contact") {
ContactVisualCard(
contact = contact,
rawContent = result.content,
modifier = modifier
)
return
}
val fields = remember(result) { buildResultFields(result) }
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF2F7FF)),
shape = RoundedCornerShape(14.dp)
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = result.type,
style = MaterialTheme.typography.titleMedium
)
if (fields.isEmpty()) {
Text(
text = result.content,
style = MaterialTheme.typography.bodyMedium
)
} else {
fields.forEach { field ->
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = field.label,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4F6277)
)
Text(
text = field.value,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
private enum class ContactCardTemplate {
Minimal,
Corporate,
Playful
}
@Composable
private fun ContactVisualCard(
contact: ParsedContact?,
rawContent: String,
modifier: Modifier = Modifier
) {
val template = remember(contact, rawContent) { selectContactTemplate(contact, rawContent) }
val background = when (template) {
ContactCardTemplate.Corporate -> Brush.linearGradient(
listOf(Color(0xFF081C3B), Color(0xFF0F2E58), Color(0xFF134B73))
)
ContactCardTemplate.Playful -> Brush.linearGradient(
listOf(Color(0xFFFFF9EC), Color(0xFFF8F1FF), Color(0xFFEFF6FF))
)
ContactCardTemplate.Minimal -> Brush.linearGradient(
listOf(Color(0xFFF9FAFC), Color(0xFFF1F5F9))
)
}
val accentColor = when (template) {
ContactCardTemplate.Corporate -> Color(0xFF7AF7CF)
ContactCardTemplate.Playful -> Color(0xFF304FFE)
ContactCardTemplate.Minimal -> Color(0xFF1E293B)
}
val textPrimary = when (template) {
ContactCardTemplate.Corporate -> Color.White
ContactCardTemplate.Playful -> Color(0xFF16181D)
ContactCardTemplate.Minimal -> Color(0xFF0F172A)
}
val textMuted = when (template) {
ContactCardTemplate.Corporate -> Color(0xFFC7D6E8)
ContactCardTemplate.Playful -> Color(0xFF55657B)
ContactCardTemplate.Minimal -> Color(0xFF475569)
}
val name = contact?.fullName ?: "Contact"
val subtitle = listOfNotNull(contact?.title, contact?.organization).distinct().joinToString("")
val initials = remember(name) { initialsFromName(name) }
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.background(background)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.background(
color = accentColor.copy(
alpha = when (template) {
ContactCardTemplate.Corporate -> 0.18f
ContactCardTemplate.Playful -> 0.15f
ContactCardTemplate.Minimal -> 0.12f
}
),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp, vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = initials,
color = accentColor,
style = MaterialTheme.typography.titleMedium
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = name,
color = textPrimary,
style = MaterialTheme.typography.headlineSmall
)
if (subtitle.isNotBlank()) {
Text(
text = subtitle,
color = textMuted,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
ContactLine("Phone", contact?.phones?.firstOrNull(), textPrimary, textMuted)
ContactLine("Email", contact?.emails?.firstOrNull(), textPrimary, textMuted)
ContactLine("Address", contact?.address, textPrimary, textMuted)
ContactLine("Note", contact?.note, textPrimary, textMuted)
if (contact == null) {
ContactLine("Raw", rawContent, textPrimary, textMuted)
}
}
}
}
private fun initialsFromName(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
if (parts.isEmpty()) return "?"
return parts.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar() }
.joinToString("")
.ifBlank { "?" }
}
@Composable
private fun ContactLine(
label: String,
value: String?,
textPrimary: Color,
textMuted: Color
) {
if (value.isNullOrBlank()) return
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
color = textMuted,
style = MaterialTheme.typography.labelSmall
)
Text(
text = value,
color = textPrimary,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
private fun selectContactTemplate(contact: ParsedContact?, rawContent: String): ContactCardTemplate {
val isCardPayload = rawContent.contains("BEGIN:VCARD", ignoreCase = true) ||
rawContent.startsWith("MECARD:", ignoreCase = true)
val looksCorporate = !contact?.organization.isNullOrBlank() ||
!contact?.title.isNullOrBlank()
val denseStructuredData = listOf(
contact?.phones?.firstOrNull(),
contact?.emails?.firstOrNull(),
contact?.address,
contact?.note
).count { !it.isNullOrBlank() } >= 3
return when {
looksCorporate || isCardPayload -> ContactCardTemplate.Corporate
denseStructuredData -> ContactCardTemplate.Minimal
else -> ContactCardTemplate.Playful
}
}
private fun buildResultFields(result: ScanResult): List<ResultField> {
return when (result.type) {
"Contact" -> {
val contact = ScanContentParsers.parseContact(result.content)
listOfNotNull(
contact?.fullName?.let { ResultField("Name", it) },
contact?.organization?.let { ResultField("Company", it) },
contact?.title?.let { ResultField("Title", it) },
contact?.phones?.firstOrNull()?.let { ResultField("Phone", it) },
contact?.emails?.firstOrNull()?.let { ResultField("Email", it) },
contact?.address?.let { ResultField("Address", it) }
)
}
"Calendar" -> {
val event = ScanContentParsers.parseCalendarEvent(result.content)
val dateTime = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
listOfNotNull(
event?.title?.let { ResultField("Title", it) },
event?.location?.let { ResultField("Location", it) },
event?.startMillis?.let { ResultField("Start", dateTime.format(Date(it))) },
event?.endMillis?.let { ResultField("End", dateTime.format(Date(it))) },
event?.description?.let { ResultField("Details", it) }
)
}
"WiFi" -> {
val map = parseWifiFields(result.content)
listOfNotNull(
map["S"]?.takeIf { it.isNotBlank() }?.let { ResultField("SSID", it) },
map["T"]?.takeIf { it.isNotBlank() }?.let { ResultField("Security", it) },
map["P"]?.takeIf { it.isNotBlank() }?.let { ResultField("Password", it) }
)
}
"URL" -> {
val score = UrlRiskScorer.score(result.content)
listOf(
ResultField("Link", result.content),
ResultField("Risk score", score.score.toString())
)
}
"SMS" -> {
val (number, body) = ScanContentParsers.parseSms(result.content)
listOfNotNull(
number.takeIf { it.isNotBlank() }?.let { ResultField("To", it) },
body?.takeIf { it.isNotBlank() }?.let { ResultField("Message", it) }
)
}
"Email" -> {
val email = ScanContentParsers.extractEmail(result.content)
listOf(ResultField("Email", email))
}
"Phone" -> {
val phone = ScanContentParsers.extractPhoneNumber(result.content)
listOf(ResultField("Phone", phone))
}
else -> emptyList()
}
}
private fun parseWifiFields(raw: String): Map<String, String> {
val cleaned = raw.trim()
if (!cleaned.startsWith("WIFI:", ignoreCase = true)) return emptyMap()
val payload = cleaned.substringAfter("WIFI:", "").trim().trimEnd(';')
val values = mutableMapOf<String, String>()
payload.split(';').forEach { token ->
val key = token.substringBefore(':', "").trim()
if (key.isBlank()) return@forEach
val value = token.substringAfter(':', "").trim()
values[key] = value
}
return values
}
@Composable
private fun OverlayIconToggle(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
label: String,
checkedImageVector: androidx.compose.ui.graphics.vector.ImageVector,
uncheckedImageVector: androidx.compose.ui.graphics.vector.ImageVector,
showLabel: Boolean = true
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
IconToggleButton(
checked = checked,
onCheckedChange = onCheckedChange
) {
Icon(
imageVector = if (checked) checkedImageVector else uncheckedImageVector,
contentDescription = label,
tint = if (checked) Color(0xFF4AE3A3) else Color.White
)
}
if (showLabel) {
Text(
text = label,
color = Color.White,
textAlign = TextAlign.Center
)
}
}
}
@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}"
}
}
private fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitmap? {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
ImageDecoder.decodeBitmap(source)
} else {
context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream)
}
}
} catch (_: Exception) {
null
}
}
private suspend fun detectBarcodes(
scanner: BarcodeScanner,
image: InputImage
): List<Barcode> = suspendCancellableCoroutine { cont ->
scanner.process(image)
.addOnSuccessListener { barcodes ->
if (cont.isActive) cont.resume(barcodes)
}
.addOnFailureListener { error ->
if (cont.isActive) cont.resumeWithException(error)
}
}
@Composable
private fun GalleryScanPreviewDialog(
imageUri: Uri?,
candidates: List<GalleryScanCandidate>,
onPick: (GalleryScanCandidate) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val bitmap = remember(imageUri) { imageUri?.let { loadBitmapFromUri(context, it) } }
var liveCandidates by remember(imageUri, candidates) { mutableStateOf(candidates) }
var zoom by remember(imageUri) { mutableFloatStateOf(1f) }
var pan by remember(imageUri) { mutableStateOf(Offset.Zero) }
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
var scanTick by remember { mutableIntStateOf(0) }
val markerPaint = remember {
Paint().apply {
color = android.graphics.Color.WHITE
textAlign = Paint.Align.CENTER
textSize = 34f
isAntiAlias = true
isFakeBoldText = true
}
}
val scanner = remember {
BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.enableAllPotentialBarcodes()
.build()
)
}
DisposableEffect(Unit) {
onDispose { scanner.close() }
}
LaunchedEffect(bitmap, viewportSize, zoom, pan, scanTick) {
val bmp = bitmap ?: return@LaunchedEffect
val vw = viewportSize.width.toFloat()
val vh = viewportSize.height.toFloat()
if (vw <= 0f || vh <= 0f) return@LaunchedEffect
delay(120)
val imgW = bmp.width.toFloat()
val imgH = bmp.height.toFloat()
if (imgW <= 1f || imgH <= 1f) return@LaunchedEffect
val baseScale = max(vw / imgW, vh / imgH)
val effectiveScale = (baseScale * zoom).coerceAtLeast(0.01f)
val cx = vw * 0.5f
val cy = vh * 0.5f
fun screenToImageX(screenX: Float): Float {
return ((screenX - cx - pan.x) / effectiveScale) + (imgW * 0.5f)
}
fun screenToImageY(screenY: Float): Float {
return ((screenY - cy - pan.y) / effectiveScale) + (imgH * 0.5f)
}
val left = screenToImageX(0f).coerceIn(0f, imgW - 1f)
val right = screenToImageX(vw).coerceIn(0f, imgW - 1f)
val top = screenToImageY(0f).coerceIn(0f, imgH - 1f)
val bottom = screenToImageY(vh).coerceIn(0f, imgH - 1f)
val cropLeft = minOf(left, right).toInt()
val cropTop = minOf(top, bottom).toInt()
val cropW = (kotlin.math.abs(right - left)).toInt().coerceAtLeast(8)
val cropH = (kotlin.math.abs(bottom - top)).toInt().coerceAtLeast(8)
val boundedW = cropW.coerceAtMost(bmp.width - cropLeft)
val boundedH = cropH.coerceAtMost(bmp.height - cropTop)
if (boundedW <= 4 || boundedH <= 4) return@LaunchedEffect
val cropped = Bitmap.createBitmap(bmp, cropLeft, cropTop, boundedW, boundedH)
val barcodes = try {
detectBarcodes(scanner, InputImage.fromBitmap(cropped, 0))
} catch (_: Exception) {
emptyList()
}
val live = barcodes.mapNotNull { barcode ->
val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val normalizedBox = barcode.boundingBox?.let { bounds ->
val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f)
val topN = ((bounds.top + cropTop) / imgH).coerceIn(0f, 1f)
val rightN = ((bounds.right + cropLeft) / imgW).coerceIn(0f, 1f)
val bottomN = ((bounds.bottom + cropTop) / imgH).coerceIn(0f, 1f)
val corners = barcode.cornerPoints?.map { p ->
DetectionPoint(
x = ((p.x + cropLeft) / imgW).coerceIn(0f, 1f),
y = ((p.y + cropTop) / imgH).coerceIn(0f, 1f)
)
} ?: emptyList()
DetectionBox(leftN, topN, rightN, bottomN, corners)
}
GalleryScanCandidate(
result = ScanResult(content = raw, type = barcode.valueType.toHumanType()),
box = normalizedBox
)
}.distinctBy { "${it.result.type}|${it.result.content}" }
liveCandidates = live
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.image_scan_pick_title, liveCandidates.size)) },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (bitmap != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
.onSizeChanged {
viewportSize = it
scanTick++
}
.pointerInput(bitmap) {
detectTransformGestures { _, panChange, zoomChange, _ ->
zoom = (zoom * zoomChange).coerceIn(1f, 6f)
pan += panChange
scanTick++
}
}
.background(Color.Black.copy(alpha = 0.32f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(R.string.scan_from_image),
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
.graphicsLayer {
scaleX = zoom
scaleY = zoom
translationX = pan.x
translationY = pan.y
},
contentScale = ContentScale.Crop
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
) {
val imageW = bitmap.width.toFloat()
val imageH = bitmap.height.toFloat()
if (imageW <= 0f || imageH <= 0f) return@Canvas
val baseScale = max(size.width / imageW, size.height / imageH)
val effectiveScale = baseScale * zoom
val cx = size.width * 0.5f
val cy = size.height * 0.5f
fun imageToScreen(ix: Float, iy: Float): Offset {
val sx = cx + ((ix - imageW * 0.5f) * effectiveScale) + pan.x
val sy = cy + ((iy - imageH * 0.5f) * effectiveScale) + pan.y
return Offset(sx, sy)
}
liveCandidates.forEachIndexed { index, candidate ->
val box = candidate.box ?: return@forEachIndexed
val color = Color(0xFF4AE3A3).copy(alpha = 0.96f)
val points = box.corners.map { p ->
imageToScreen(p.x * imageW, p.y * imageH)
}
if (points.size >= 4) {
val path = Path().apply {
moveTo(points.first().x, points.first().y)
points.drop(1).forEach { pt -> lineTo(pt.x, pt.y) }
close()
}
drawPath(path = path, color = color, style = Stroke(width = 4f))
} else {
val lt = imageToScreen(box.left * imageW, box.top * imageH)
val rb = imageToScreen(box.right * imageW, box.bottom * imageH)
val left = minOf(lt.x, rb.x)
val top = minOf(lt.y, rb.y)
val right = maxOf(lt.x, rb.x)
val bottom = maxOf(lt.y, rb.y)
if (right > left && bottom > top) {
drawRoundRect(
color = color,
topLeft = Offset(left, top),
size = Size(right - left, bottom - top),
cornerRadius = CornerRadius(10f, 10f),
style = Stroke(width = 4f)
)
}
}
val center = if (points.isNotEmpty()) {
val sx = points.sumOf { it.x.toDouble() }.toFloat() / points.size
val sy = points.sumOf { it.y.toDouble() }.toFloat() / points.size
Offset(sx, sy)
} else {
imageToScreen(
(box.left + box.right) * 0.5f * imageW,
(box.top + box.bottom) * 0.5f * imageH
)
}
drawCircle(
color = Color.Black.copy(alpha = 0.65f),
radius = 16f,
center = center
)
drawContext.canvas.nativeCanvas.drawText(
"${index + 1}",
center.x,
center.y + 11f,
markerPaint
)
}
}
}
}
if (liveCandidates.isEmpty()) {
Text(text = stringResource(R.string.no_code_found_in_image))
} else {
Text(text = stringResource(R.string.image_scan_pick_subtitle))
liveCandidates.forEachIndexed { index, candidate ->
TextButton(
onClick = { onPick(candidate) },
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "${index + 1}. ${candidate.result.type}",
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = candidate.result.content,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
private fun PermissionContent(
showSettingsHint: Boolean,
onRequestPermission: () -> Unit,
onOpenSettings: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center
) {
Text(text = stringResource(R.string.camera_permission_title))
Text(text = stringResource(R.string.camera_permission_rationale))
Button(onClick = onRequestPermission) {
Text(text = stringResource(R.string.request_camera))
}
if (showSettingsHint) {
TextButton(onClick = onOpenSettings) {
Text(stringResource(R.string.open_settings))
}
}
}
}
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"
}
@@ -27,6 +27,13 @@ data class ParsedCalendarEvent(
val allDay: Boolean = false val allDay: Boolean = false
) )
data class ParsedWifiNetwork(
val ssid: String? = null,
val security: String? = null,
val password: String? = null,
val hidden: Boolean? = null
)
object ScanContentParsers { object ScanContentParsers {
fun extractPhoneNumber(raw: String): String { fun extractPhoneNumber(raw: String): String {
return raw.substringAfter("tel:", raw) return raw.substringAfter("tel:", raw)
@@ -134,6 +141,41 @@ object ScanContentParsers {
) )
} }
fun parseWifi(raw: String): ParsedWifiNetwork? {
val cleaned = raw.trim()
if (!cleaned.startsWith("WIFI:", ignoreCase = true)) return null
val payload = cleaned.substringAfter("WIFI:", "").trim().trimEnd(';')
if (payload.isBlank()) return null
var ssid: String? = null
var security: String? = null
var password: String? = null
var hidden: Boolean? = null
splitByUnescaped(payload, ';').forEach { token ->
if (token.isBlank()) return@forEach
val idx = indexOfUnescaped(token, ':')
if (idx <= 0) return@forEach
val key = token.substring(0, idx).trim().uppercase(Locale.US)
val value = unescapeWifiValue(token.substring(idx + 1).trim())
when (key) {
"S" -> ssid = value.ifBlank { null }
"T" -> security = value.ifBlank { null }
"P" -> password = value.ifBlank { null }
"H", "HIDDEN" -> hidden = value.equals("true", ignoreCase = true) || value == "1"
}
}
if (ssid == null && security == null && password == null && hidden == null) return null
return ParsedWifiNetwork(
ssid = ssid,
security = security,
password = password,
hidden = hidden
)
}
private fun parseVCard(raw: String): ParsedContact? { private fun parseVCard(raw: String): ParsedContact? {
val fromLibrary = parseVCardWithLibrary(raw) val fromLibrary = parseVCardWithLibrary(raw)
val fromFallback = parseVCardFallback(raw) val fromFallback = parseVCardFallback(raw)
@@ -439,6 +481,48 @@ object ScanContentParsers {
return TimeZone.getTimeZone(tzId) return TimeZone.getTimeZone(tzId)
} }
private fun splitByUnescaped(input: String, separator: Char): List<String> {
val out = mutableListOf<String>()
val current = StringBuilder()
var escaped = false
input.forEach { ch ->
when {
escaped -> {
current.append(ch)
escaped = false
}
ch == '\\' -> escaped = true
ch == separator -> {
out += current.toString()
current.setLength(0)
}
else -> current.append(ch)
}
}
out += current.toString()
return out
}
private fun indexOfUnescaped(input: String, needle: Char): Int {
var escaped = false
input.forEachIndexed { index, ch ->
when {
escaped -> escaped = false
ch == '\\' -> escaped = true
ch == needle -> return index
}
}
return -1
}
private fun unescapeWifiValue(value: String): String {
return value
.replace("\\\\", "\\")
.replace("\\;", ";")
.replace("\\:", ":")
.replace("\\,", ",")
}
private fun mergeParsedContacts( private fun mergeParsedContacts(
primary: ParsedContact?, primary: ParsedContact?,
secondary: ParsedContact? secondary: ParsedContact?
@@ -163,4 +163,28 @@ class ScanContentParsersTest {
assertTrue(parsed?.phones?.contains("+43 7252 72720-77") == true) assertTrue(parsed?.phones?.contains("+43 7252 72720-77") == true)
assertEquals("203 New York Ave, New York, NY 11377, USA", parsed?.address) assertEquals("203 New York Ave, New York, NY 11377, USA", parsed?.address)
} }
@Test
fun parseWifi_handlesStandardPayload() {
val raw = "WIFI:T:WPA;S:OfficeNet;P:superSecret;H:false;;"
val parsed = ScanContentParsers.parseWifi(raw)
assertNotNull(parsed)
assertEquals("OfficeNet", parsed?.ssid)
assertEquals("WPA", parsed?.security)
assertEquals("superSecret", parsed?.password)
assertEquals(false, parsed?.hidden)
}
@Test
fun parseWifi_handlesEscapedCharactersAndHiddenFlag() {
val raw = "WIFI:T:WPA2;S:Cafe\\;Guest\\,2nd\\:Floor;P:p\\\\ass\\;word;H:true;;"
val parsed = ScanContentParsers.parseWifi(raw)
assertNotNull(parsed)
assertEquals("Cafe;Guest,2nd:Floor", parsed?.ssid)
assertEquals("WPA2", parsed?.security)
assertEquals("p\\ass;word", parsed?.password)
assertEquals(true, parsed?.hidden)
}
} }