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
- `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
- `domain/`: app models (`ScanResult`, `ScanRecord`, `UrlRiskResult`)
- `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
- 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
- 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
- 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)
- Historie: Suche, Swipe-to-delete, Alles-löschen, Detailansicht mit Volltext
- Einstellungen: Historie an/aus (mit optionalem Löschen), Warnungen an/aus, About-Infos
@@ -52,4 +59,9 @@ CLI:
./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.app.Activity
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.ToneGenerator
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -58,7 +46,6 @@ 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
@@ -68,16 +55,10 @@ 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.Brush
import androidx.compose.ui.graphics.Color
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.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.platform.LocalContext
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.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 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.util.ClipboardUtil
import com.clean.scanner.util.Intents
import com.clean.scanner.util.ParsedContact
import com.clean.scanner.util.ScanContentParsers
import com.clean.scanner.util.UrlRiskScorer
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode
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 kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
private data class GalleryScanCandidate(
internal data class GalleryScanCandidate(
val result: ScanResult,
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
)
data class ParsedWifiNetwork(
val ssid: String? = null,
val security: String? = null,
val password: String? = null,
val hidden: Boolean? = null
)
object ScanContentParsers {
fun extractPhoneNumber(raw: String): String {
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? {
val fromLibrary = parseVCardWithLibrary(raw)
val fromFallback = parseVCardFallback(raw)
@@ -439,6 +481,48 @@ object ScanContentParsers {
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(
primary: ParsedContact?,
secondary: ParsedContact?
@@ -163,4 +163,28 @@ class ScanContentParsersTest {
assertTrue(parsed?.phones?.contains("+43 7252 72720-77") == true)
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)
}
}