From 5d83ff4a6d2ddc22823e6a2b44b912d77378cac5 Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Thu, 26 Feb 2026 05:25:15 +0100 Subject: [PATCH] splitting large files into submodules --- README.md | 18 +- .../scanner/ui/screens/BarcodeTypeMapper.kt | 18 + .../ui/screens/ScannerGalleryPreviewDialog.kt | 355 ++++++++ .../ui/screens/ScannerOverlayComponents.kt | 184 +++++ .../scanner/ui/screens/ScannerResultCards.kt | 319 +++++++ .../clean/scanner/ui/screens/ScannerScreen.kt | 775 +----------------- .../clean/scanner/util/ScanContentParsers.kt | 84 ++ .../scanner/util/ScanContentParsersTest.kt | 24 + 8 files changed, 1000 insertions(+), 777 deletions(-) create mode 100644 app/src/main/java/com/clean/scanner/ui/screens/BarcodeTypeMapper.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/screens/ScannerOverlayComponents.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/screens/ScannerResultCards.kt diff --git a/README.md b/README.md index e5d043e..c8f0c63 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/app/src/main/java/com/clean/scanner/ui/screens/BarcodeTypeMapper.kt b/app/src/main/java/com/clean/scanner/ui/screens/BarcodeTypeMapper.kt new file mode 100644 index 0000000..2e3029c --- /dev/null +++ b/app/src/main/java/com/clean/scanner/ui/screens/BarcodeTypeMapper.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" +} diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt new file mode 100644 index 0000000..15d45b7 --- /dev/null +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt @@ -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 = 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, + 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)) + } + } + ) +} diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerOverlayComponents.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerOverlayComponents.kt new file mode 100644 index 0000000..2fac0ee --- /dev/null +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerOverlayComponents.kt @@ -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, + 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): 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)) + } + } + } +} diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerResultCards.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerResultCards.kt new file mode 100644 index 0000000..4744b50 --- /dev/null +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerResultCards.kt @@ -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 { + 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 { + val cleaned = raw.trim() + if (!cleaned.startsWith("WIFI:", ignoreCase = true)) return emptyMap() + val payload = cleaned.substringAfter("WIFI:", "").trim().trimEnd(';') + val values = mutableMapOf() + payload.split(';').forEach { token -> + val key = token.substringBefore(':', "").trim() + if (key.isBlank()) return@forEach + val value = token.substringAfter(':', "").trim() + values[key] = value + } + return values +} diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt index 338e966..055f88c 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt @@ -3,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 { - 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 { - val cleaned = raw.trim() - if (!cleaned.startsWith("WIFI:", ignoreCase = true)) return emptyMap() - val payload = cleaned.substringAfter("WIFI:", "").trim().trimEnd(';') - val values = mutableMapOf() - 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, - 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): 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 = 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, - 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" -} diff --git a/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt b/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt index 435edaa..428fbc3 100644 --- a/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt +++ b/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt @@ -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 { + val out = mutableListOf() + 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? diff --git a/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt b/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt index da00ac8..4eb38f1 100644 --- a/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt +++ b/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt @@ -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) + } }