diff --git a/README.md b/README.md index c8f0c63..2005ea3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Clean Scanner (MVP) +# Private QR Scanner (MVP) Offline-first, ad-free QR/barcode scanner built with Kotlin, Jetpack Compose, CameraX, and on-device ML Kit. diff --git a/USE_CASES.md b/USE_CASES.md index 368839a..5989a86 100644 --- a/USE_CASES.md +++ b/USE_CASES.md @@ -1,9 +1,9 @@ -# Clean Scanner Use Cases +# Private QR Scanner Use Cases ## Use-Case Views - [Done] Each use case has an individual view profile that shows only relevant functions. - [Done] Default profile is **Everyday Personal Use**. -- [Done] Other profiles can be selected in **Settings**. +- [Done] Only **Everyday Personal Use** and **Event & Ticketing** can be selected in **Settings**. ## 1. Everyday Personal Use - [Done] Scan restaurant menus, product QR labels, and website links quickly. @@ -15,45 +15,3 @@ - [Done] Enable **Stapelmodus (Batch Mode)** by default in this view for fast check-in flow. - [Done] Use batch mode to process multiple attendees without leaving the camera. - [Done] Share batch captures to organizers for quick reconciliation. - -## 3. Inventory & Operations -- [Done] Scan product barcodes in stock rooms. -- [Done] Use batch mode for continuous scanning of many items. -- [Done] Export and share history (TXT/CSV/JSON) for downstream reporting. - -## 4. Field Work & Service Teams -- [Done] Scan device labels/serials on-site. -- [Done] Save local history for audit trails when enabled. -- [Done] Share captured codes with support teams in real time. - -## 5. Office & Admin Workflows -- [Done] Scan contact QR/vCard and add to contacts. -- [Done] Scan Wi-Fi setup QR and jump to Wi-Fi settings. -- [Done] Scan calendar/event data and create calendar entries. - -## 6. Communication Shortcuts -- [Done] Scan phone/SMS/email QR data. -- [Done] One-tap actions: call, send SMS, send email. -- [Done] Reduce manual entry errors for phone numbers and addresses. - -## 7. Security-Conscious Browsing -- [Done] Scan URL QR codes and get local warning prompts for suspicious patterns. -- [Done] Decide whether to open risky links with explicit confirmation. -- [Done] Keep scanning offline-first without backend calls. - -## 8. Offline / Low-Connectivity Scenarios -- [Done] Use the scanner with no internet dependency for core scanning. -- [Done] Keep data local-first and share outputs when connectivity returns. -- [Done] Useful for travel, warehouses, and remote job sites. - -## 9. Accessibility & Speed -- [Done] Launch directly into camera for 0-click scan flow. -- [Done] Pinch-to-zoom for small or distant QR/barcodes. -- [Done] Friendly scanner guide with immediate feedback on successful scans. - -## 10. Team Handover & Data Transfer -- Export scan history in multiple formats: - - [Done] TXT for human-readable logs - - [Done] CSV for spreadsheets/BI tools - - [Done] JSON for system integrations -- [Done] Share exports to teammates via native Android share sheet. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5fa9737..145a01a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,11 +5,11 @@ plugins { } android { - namespace = "com.clean.scanner" + namespace = "de.softwareapp_hb.privateqrscanner" compileSdk = 36 defaultConfig { - applicationId = "com.clean.scanner" + applicationId = "de.softwareapp_hb.privateqrscanner" minSdk = 24 targetSdk = 36 versionCode = 1 diff --git a/app/src/androidTest/java/com/clean/scanner/util/IntentsTest.kt b/app/src/androidTest/java/com/clean/scanner/util/IntentsTest.kt index 50bbe35..2b076cf 100644 --- a/app/src/androidTest/java/com/clean/scanner/util/IntentsTest.kt +++ b/app/src/androidTest/java/com/clean/scanner/util/IntentsTest.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.util +package de.softwareapp_hb.privateqrscanner.util import android.provider.ContactsContract import android.provider.ContactsContract.CommonDataKinds.Organization diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7657033..ecaba30 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,14 +1,20 @@ - + + + + + UseCaseCapabilities( - allowScanFromImage = true, - allowBatchMode = true, - allowCopy = true, - allowShare = true, - allowOpenUrl = false, - allowHistoryExport = true, - allowBatchShare = true - ) - - UseCaseView.FieldWorkServiceTeams -> UseCaseCapabilities( - allowScanFromImage = true, - allowBatchMode = true, - allowCopy = true, - allowShare = true, - allowOpenUrl = true, - allowHistoryExport = true, - allowBatchShare = true - ) - - UseCaseView.OfficeAdmin -> UseCaseCapabilities( - allowScanFromImage = true, - allowBatchMode = false, - allowCopy = true, - allowShare = true, - allowOpenUrl = true, - allowAddContact = true, - allowDialPhone = true, - allowSendSms = true, - allowSendEmail = true, - allowOpenWifiSettings = true, - allowAddCalendarEvent = true - ) - - UseCaseView.CommunicationShortcuts -> UseCaseCapabilities( - allowScanFromImage = true, - allowBatchMode = false, - allowCopy = true, - allowShare = true, - allowOpenUrl = false, - allowDialPhone = true, - allowSendSms = true, - allowSendEmail = true - ) - - UseCaseView.SecurityBrowsing -> UseCaseCapabilities( - allowScanFromImage = true, - allowBatchMode = false, - allowCopy = true, - allowShare = true, - allowOpenUrl = true - ) - - UseCaseView.OfflineLowConnectivity -> UseCaseCapabilities( - allowScanFromImage = true, - allowBatchMode = true, - allowCopy = true, - allowShare = true, - allowOpenUrl = false, - allowBatchShare = true - ) - - UseCaseView.AccessibilitySpeed -> UseCaseCapabilities( - allowScanFromImage = true, - allowBatchMode = false, - allowCopy = true, - allowShare = true, - allowOpenUrl = true - ) - - UseCaseView.TeamHandoverTransfer -> UseCaseCapabilities( - allowScanFromImage = true, - allowBatchMode = true, - allowCopy = true, - allowShare = true, - allowOpenUrl = false, - allowHistoryExport = true, - allowBatchShare = true - ) } } diff --git a/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt b/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt index 852fee9..3426bd0 100644 --- a/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt +++ b/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.ui.components +package de.softwareapp_hb.privateqrscanner.ui.components import android.annotation.SuppressLint import android.util.Size @@ -22,8 +22,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat -import com.clean.scanner.data.scanner.DetectionBox -import com.clean.scanner.data.scanner.MlKitBarcodeAnalyzer +import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox +import de.softwareapp_hb.privateqrscanner.data.scanner.MlKitBarcodeAnalyzer import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import kotlin.math.max 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 index 2e3029c..3bd266b 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/BarcodeTypeMapper.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/BarcodeTypeMapper.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.ui.screens +package de.softwareapp_hb.privateqrscanner.ui.screens import com.google.mlkit.vision.barcode.common.Barcode diff --git a/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt index 1560114..7a2472b 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.ui.screens +package de.softwareapp_hb.privateqrscanner.ui.screens import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -24,12 +24,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.clean.scanner.R -import com.clean.scanner.domain.ScanRecord -import com.clean.scanner.ui.UseCaseView -import com.clean.scanner.ui.capabilities -import com.clean.scanner.util.HistoryExportFormatter -import com.clean.scanner.util.Intents +import de.softwareapp_hb.privateqrscanner.R +import de.softwareapp_hb.privateqrscanner.domain.ScanRecord +import de.softwareapp_hb.privateqrscanner.ui.UseCaseView +import de.softwareapp_hb.privateqrscanner.ui.capabilities +import de.softwareapp_hb.privateqrscanner.util.HistoryExportFormatter +import de.softwareapp_hb.privateqrscanner.util.Intents import java.text.DateFormat import java.util.Date diff --git a/app/src/main/java/com/clean/scanner/ui/screens/HomeScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/HomeScreen.kt index cd81249..372d884 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/HomeScreen.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.ui.screens +package de.softwareapp_hb.privateqrscanner.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -20,7 +20,7 @@ import androidx.compose.ui.Modifier 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 de.softwareapp_hb.privateqrscanner.R @Composable fun HomeScreen( 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 index 640ae45..bd06191 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerGalleryPreviewDialog.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.ui.screens +package de.softwareapp_hb.privateqrscanner.ui.screens import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -50,11 +50,11 @@ 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.clean.scanner.util.readablePayload +import de.softwareapp_hb.privateqrscanner.R +import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox +import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint +import de.softwareapp_hb.privateqrscanner.domain.ScanResult +import de.softwareapp_hb.privateqrscanner.util.readablePayload import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScannerOptions 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 index 3e1e8f0..82dc2db 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerOverlayComponents.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerOverlayComponents.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.ui.screens +package de.softwareapp_hb.privateqrscanner.ui.screens import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -27,10 +27,10 @@ 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 de.softwareapp_hb.privateqrscanner.R +import de.softwareapp_hb.privateqrscanner.ui.BatchScanRecord +import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil +import de.softwareapp_hb.privateqrscanner.util.Intents import java.text.DateFormat import java.util.Date 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 index 58a2047..1b54142 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerResultCards.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerResultCards.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.ui.screens +package de.softwareapp_hb.privateqrscanner.ui.screens import androidx.compose.foundation.clickable import androidx.compose.foundation.background @@ -25,10 +25,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextDecoration 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 de.softwareapp_hb.privateqrscanner.domain.ScanResult +import de.softwareapp_hb.privateqrscanner.util.ParsedContact +import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers +import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer import java.text.DateFormat import java.util.Date 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 2a04ad0..6094599 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 @@ -1,4 +1,4 @@ -package com.clean.scanner.ui.screens +package de.softwareapp_hb.privateqrscanner.ui.screens import android.Manifest import android.app.Activity @@ -72,20 +72,20 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -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.clean.scanner.ui.BatchScanRecord -import com.clean.scanner.ui.EventTicketScanDecision -import com.clean.scanner.ui.UseCaseView -import com.clean.scanner.ui.components.CameraPreview -import com.clean.scanner.ui.capabilities -import com.clean.scanner.util.ClipboardUtil -import com.clean.scanner.util.Intents -import com.clean.scanner.util.ScanContentParsers -import com.clean.scanner.util.UrlRiskScorer -import com.clean.scanner.util.readablePayload +import de.softwareapp_hb.privateqrscanner.R +import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox +import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint +import de.softwareapp_hb.privateqrscanner.domain.ScanResult +import de.softwareapp_hb.privateqrscanner.ui.BatchScanRecord +import de.softwareapp_hb.privateqrscanner.ui.EventTicketScanDecision +import de.softwareapp_hb.privateqrscanner.ui.UseCaseView +import de.softwareapp_hb.privateqrscanner.ui.components.CameraPreview +import de.softwareapp_hb.privateqrscanner.ui.capabilities +import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil +import de.softwareapp_hb.privateqrscanner.util.Intents +import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers +import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer +import de.softwareapp_hb.privateqrscanner.util.readablePayload import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.common.Barcode @@ -127,15 +127,7 @@ fun ScannerScreen( val haptic = LocalHapticFeedback.current val capabilities = remember(useCaseView) { useCaseView.capabilities() } val forceBatchMode = useCaseView == UseCaseView.EventTicketing - val showBatchModeToggle = remember(useCaseView) { - when (useCaseView) { - UseCaseView.InventoryOperations, - UseCaseView.FieldWorkServiceTeams, - UseCaseView.OfflineLowConnectivity, - UseCaseView.TeamHandoverTransfer -> true - else -> false - } - } + val showBatchModeToggle = capabilities.allowBatchMode && !forceBatchMode val isBatchModeActive = forceBatchMode || batchMode val duplicateSnackbarHostState = remember { SnackbarHostState() } val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) } diff --git a/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt index d8fabed..0a2857b 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.ui.screens +package de.softwareapp_hb.privateqrscanner.ui.screens import android.widget.Toast import androidx.compose.foundation.layout.Arrangement @@ -20,9 +20,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.clean.scanner.R -import com.clean.scanner.ui.UseCaseView -import com.clean.scanner.util.Intents +import de.softwareapp_hb.privateqrscanner.R +import de.softwareapp_hb.privateqrscanner.ui.UseCaseView +import de.softwareapp_hb.privateqrscanner.util.Intents @Composable fun SettingsScreen( diff --git a/app/src/main/java/com/clean/scanner/ui/theme/Theme.kt b/app/src/main/java/com/clean/scanner/ui/theme/Theme.kt index bf5fcec..c1a85aa 100644 --- a/app/src/main/java/com/clean/scanner/ui/theme/Theme.kt +++ b/app/src/main/java/com/clean/scanner/ui/theme/Theme.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.ui.theme +package de.softwareapp_hb.privateqrscanner.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme diff --git a/app/src/main/java/com/clean/scanner/util/BarcodePayload.kt b/app/src/main/java/com/clean/scanner/util/BarcodePayload.kt index 678b9b3..8571cfc 100644 --- a/app/src/main/java/com/clean/scanner/util/BarcodePayload.kt +++ b/app/src/main/java/com/clean/scanner/util/BarcodePayload.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.util +package de.softwareapp_hb.privateqrscanner.util import com.google.mlkit.vision.barcode.common.Barcode diff --git a/app/src/main/java/com/clean/scanner/util/ClipboardUtil.kt b/app/src/main/java/com/clean/scanner/util/ClipboardUtil.kt index d79dd3e..7ab5f3f 100644 --- a/app/src/main/java/com/clean/scanner/util/ClipboardUtil.kt +++ b/app/src/main/java/com/clean/scanner/util/ClipboardUtil.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.util +package de.softwareapp_hb.privateqrscanner.util import android.content.ClipData import android.content.ClipboardManager diff --git a/app/src/main/java/com/clean/scanner/util/HistoryExportFormatter.kt b/app/src/main/java/com/clean/scanner/util/HistoryExportFormatter.kt index c8def5b..dc4f16f 100644 --- a/app/src/main/java/com/clean/scanner/util/HistoryExportFormatter.kt +++ b/app/src/main/java/com/clean/scanner/util/HistoryExportFormatter.kt @@ -1,6 +1,6 @@ -package com.clean.scanner.util +package de.softwareapp_hb.privateqrscanner.util -import com.clean.scanner.domain.ScanRecord +import de.softwareapp_hb.privateqrscanner.domain.ScanRecord import java.text.DateFormat import java.util.Date diff --git a/app/src/main/java/com/clean/scanner/util/Intents.kt b/app/src/main/java/com/clean/scanner/util/Intents.kt index 7b26c14..3f03796 100644 --- a/app/src/main/java/com/clean/scanner/util/Intents.kt +++ b/app/src/main/java/com/clean/scanner/util/Intents.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.util +package de.softwareapp_hb.privateqrscanner.util import android.content.ContentValues import android.content.Context 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 428fbc3..22bf0fa 100644 --- a/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt +++ b/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.util +package de.softwareapp_hb.privateqrscanner.util import at.bitfire.vcard4android.Contact import java.io.StringReader diff --git a/app/src/main/java/com/clean/scanner/util/UrlRiskScorer.kt b/app/src/main/java/com/clean/scanner/util/UrlRiskScorer.kt index 692c810..29a75bf 100644 --- a/app/src/main/java/com/clean/scanner/util/UrlRiskScorer.kt +++ b/app/src/main/java/com/clean/scanner/util/UrlRiskScorer.kt @@ -1,45 +1,598 @@ -package com.clean.scanner.util +package de.softwareapp_hb.privateqrscanner.util -import com.clean.scanner.domain.UrlRiskResult +import de.softwareapp_hb.privateqrscanner.domain.UrlRiskResult +import java.net.IDN import java.net.URI +import java.net.URLDecoder +import java.util.Locale object UrlRiskScorer { fun score(raw: String): UrlRiskResult { - val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return UrlRiskResult(0, emptyList()) - val host = uri.host.orEmpty() + val trimmed = raw.trim() + val uri = runCatching { URI(trimmed) }.getOrNull() ?: return UrlRiskResult(0, emptyList()) + val scheme = uri.scheme?.lowercase(Locale.US) ?: return UrlRiskResult(0, emptyList()) + val hostDetails = extractHost(uri) + val host = hostDetails.asciiHost + val labels = host.split('.').filter { it.isNotBlank() } val reasons = mutableListOf() var score = 0 - if (host.matches(Regex("^\\d{1,3}(\\.\\d{1,3}){3}$"))) { - score += 2 - reasons += "Host is an IP address" + fun add(points: Int, reason: String) { + if (reason !in reasons) { + score += points + reasons += reason + } } - if (uri.scheme.equals("http", ignoreCase = true)) { - score += 2 - reasons += "URL uses HTTP" + + if (trimmed.any { it.isISOControl() || it.isWhitespace() }) { + add(3, "Contains whitespace or control characters") } - if (host.contains("xn--", ignoreCase = true)) { - score += 2 - reasons += "Host contains punycode" + + when (scheme) { + "http" -> add(2, "URL uses HTTP") + "https" -> Unit + in HIGH_RISK_SCHEMES -> add(5, "Uses a potentially unsafe URL scheme") + else -> add(2, "Uses a non-web URL scheme") } + + if (scheme in WEB_SCHEMES && hostDetails.rawHost.isBlank()) { + add(3, "Web URL has no host") + } + + if (!uri.userInfo.isNullOrBlank() || uri.rawAuthority.orEmpty().contains("@")) { + add(2, "Contains userinfo") + } + + if (PERCENT_ENCODING.containsMatchIn(hostDetails.rawHost)) { + add(2, "Host contains percent-encoding") + } + + if (hostDetails.hasPunycode) { + add(2, "Host contains punycode") + } + + if (hostDetails.hasUnicode) { + add(2, "Host contains non-ASCII characters") + } + if (host.length > 40) { - score += 1 - reasons += "Host is unusually long" + add(1, "Host is unusually long") } - if ((uri.rawQuery?.length ?: 0) > 120) { - score += 1 - reasons += "Query is unusually long" - } - val percentEncodedCount = Regex("%[0-9a-fA-F]{2}").findAll(raw).count() - if (percentEncodedCount > 10) { - score += 1 - reasons += "Many percent-encodings" - } - if (!uri.userInfo.isNullOrBlank()) { - score += 2 - reasons += "Contains userinfo" + + val ipv4 = parseIpv4(host) + if (ipv4 != null) { + add(2, "Host is an IP address") + if (isPrivateOrReservedIpv4(ipv4)) { + add(2, "Host is a private or reserved IP address") + } + } else if (isIpv6Literal(hostDetails.rawHost)) { + add(2, "Host is an IP address") + if (isPrivateOrReservedIpv6(hostDetails.rawHost)) { + add(2, "Host is a private or reserved IP address") + } + } else if (host.isNotBlank()) { + scoreDomainShape(host, labels, ::add) + scoreKnownRiskyDomains(host, labels, ::add) + scoreBrandImpersonation(host, labels, ::add) } + scoreUrlStructure(trimmed, uri, host, ::add) + return UrlRiskResult(score = score, reasons = reasons) } + + private fun scoreDomainShape( + host: String, + labels: List, + add: (Int, String) -> Unit + ) { + val registeredDomain = registrableDomain(labels) + val registeredLabel = registeredDomain?.substringBefore('.').orEmpty() + + if (host == "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) { + add(2, "Host points to a local name") + } + + if (labels.size == 1 && host != "localhost") { + add(1, "Host has no public suffix") + } + + if (labels.size > 4) { + add(1, "Host has many subdomains") + } + + if (labels.any { it.length > 30 }) { + add(1, "Host contains an unusually long label") + } + + if (labels.any { it.contains('_') }) { + add(1, "Host contains invalid hostname characters") + } + + if (registeredLabel.count { it == '-' } >= 3) { + add(1, "Registered domain contains many hyphens") + } + + if (registeredLabel.length >= 10 && registeredLabel.count(Char::isDigit) * 100 / registeredLabel.length >= 35) { + add(1, "Registered domain contains many digits") + } + + if (labels.any { REPEATED_CHARACTER.containsMatchIn(it) }) { + add(1, "Host contains repeated characters") + } + } + + private fun scoreKnownRiskyDomains( + host: String, + labels: List, + add: (Int, String) -> Unit + ) { + val tld = labels.lastOrNull().orEmpty() + val registeredDomain = registrableDomain(labels) + + if (registeredDomain in URL_SHORTENERS) { + add(2, "Host is a URL shortener") + } + + if (tld in WATCHLIST_TLDS) { + add(1, "Host uses a commonly abused top-level domain") + } + } + + private fun scoreBrandImpersonation( + host: String, + labels: List, + add: (Int, String) -> Unit + ) { + val registeredDomain = registrableDomain(labels) + val labelsToInspect = labels.filterNot { it in PUBLIC_SUFFIX_PARTS } + + for (brand in BRAND_PROFILES) { + if (brand.officialDomains.any { host == it || host.endsWith(".$it") }) continue + + val registeredLabel = registeredDomain?.substringBefore('.').orEmpty() + if (registeredLabel == brand.name) continue + + val exactBrandLabel = labelsToInspect.any { it == brand.name } + val embeddedBrand = labelsToInspect.any { label -> + label != brand.name && label.contains(brand.name) + } + if (exactBrandLabel || embeddedBrand) { + add(3, "Host embeds a known brand outside its official domain") + return + } + + val candidateLabels = (labelsToInspect + registeredLabel).filter { it.length >= 4 }.distinct() + if (candidateLabels.any { isLookalikeBrandLabel(it, brand.name) }) { + add(3, "Host resembles a known brand") + return + } + } + } + + private fun scoreUrlStructure( + raw: String, + uri: URI, + host: String, + add: (Int, String) -> Unit + ) { + val rawPath = uri.rawPath.orEmpty() + val rawQuery = uri.rawQuery.orEmpty() + val lowerPathAndQuery = "$rawPath?$rawQuery".lowercase(Locale.US) + val lowerRaw = raw.lowercase(Locale.US) + + when { + raw.length > 500 -> add(2, "URL is extremely long") + raw.length > 250 -> add(1, "URL is unusually long") + } + + if (uri.port != -1 && !isStandardPort(uri.scheme.orEmpty(), uri.port)) { + add(1, "URL uses a non-standard port") + } + + if (rawPath.length > 120) { + add(1, "Path is unusually long") + } + + if ((uri.rawQuery?.length ?: 0) > 120) { + add(1, "Query is unusually long") + } + + if (rawPath.split('/').count { it.isNotBlank() } > 8) { + add(1, "Path has many segments") + } + + val percentEncodedCount = PERCENT_ENCODING.findAll(raw).count() + if (percentEncodedCount > 10) { + add(1, "Many percent-encodings") + } + + if (ENCODED_CONTROL_CHARACTER.containsMatchIn(raw)) { + add(3, "URL contains encoded control characters") + } + + if (raw.contains('\\')) { + add(2, "URL contains backslashes") + } + + if (rawPath.contains("//")) { + add(1, "Path contains nested URL separators") + } + + if (rawPath.contains('@') || rawQuery.contains('@')) { + add(1, "Path or query contains an at-sign") + } + + if (DANGEROUS_FILE_EXTENSIONS.any { lowerPathAndQuery.contains(it) }) { + add(2, "URL points to a potentially executable file") + } else if (ARCHIVE_FILE_EXTENSIONS.any { lowerPathAndQuery.contains(it) }) { + add(1, "URL points to an archive file") + } + + if (CREDENTIAL_KEYWORDS.any { lowerPathAndQuery.contains(it) }) { + add(1, "URL contains account or credential keywords") + } + + if (hasExternalRedirect(rawQuery, host)) { + add(2, "Query redirects to another domain") + } + + if (hasLongOpaqueToken(rawPath) || hasLongOpaqueToken(rawQuery)) { + add(1, "URL contains a long opaque token") + } + + if (lowerRaw.contains("%2f%2f") || lowerRaw.contains("%5c")) { + add(1, "URL contains encoded path separators") + } + } + + private fun extractHost(uri: URI): HostDetails { + val rawAuthority = uri.rawAuthority.orEmpty() + val rawHostFromAuthority = if (rawAuthority.isBlank()) { + "" + } else { + val withoutUserInfo = rawAuthority.substringAfterLast('@') + when { + withoutUserInfo.startsWith("[") -> withoutUserInfo.substringAfter('[').substringBefore(']') + withoutUserInfo.count { it == ':' } == 1 && + withoutUserInfo.substringAfterLast(':').all(Char::isDigit) -> + withoutUserInfo.substringBeforeLast(':') + else -> withoutUserInfo + } + } + val rawHost = (uri.host ?: rawHostFromAuthority) + .trim() + .trim('.') + .replace('\u3002', '.') + .replace('\uFF0E', '.') + .replace('\uFF61', '.') + val asciiHost = toAsciiHost(rawHost) + return HostDetails( + rawHost = rawHost, + asciiHost = asciiHost, + hasUnicode = rawHost.any { it.code > 127 }, + hasPunycode = asciiHost.split('.').any { it.startsWith("xn--") } || + rawHost.contains("xn--", ignoreCase = true) + ) + } + + private fun toAsciiHost(rawHost: String): String { + if (rawHost.isBlank()) return "" + return rawHost + .removeSurrounding("[", "]") + .split('.') + .filter { it.isNotBlank() } + .joinToString(".") { label -> + runCatching { IDN.toASCII(label, IDN.ALLOW_UNASSIGNED).lowercase(Locale.US) } + .getOrElse { label.lowercase(Locale.US) } + } + } + + private fun parseIpv4(host: String): IntArray? { + if (!IPV4_PATTERN.matches(host)) return null + val octets = host.split('.').mapNotNull { it.toIntOrNull() } + if (octets.size != 4 || octets.any { it !in 0..255 }) return null + return octets.toIntArray() + } + + private fun isPrivateOrReservedIpv4(octets: IntArray): Boolean { + val first = octets[0] + val second = octets[1] + val third = octets[2] + return first == 0 || + first == 10 || + first == 127 || + first >= 224 || + (first == 100 && second in 64..127) || + (first == 169 && second == 254) || + (first == 172 && second in 16..31) || + (first == 192 && second == 168) || + (first == 192 && second == 0 && third == 0) || + (first == 192 && second == 0 && third == 2) || + (first == 198 && second in 18..19) || + (first == 198 && second == 51 && third == 100) || + (first == 203 && second == 0 && third == 113) + } + + private fun isIpv6Literal(rawHost: String): Boolean { + val host = rawHost.removeSurrounding("[", "]") + return host.contains(':') && host.all { + it == ':' || it == '.' || it.isDigit() || it.lowercaseChar() in 'a'..'f' + } + } + + private fun isPrivateOrReservedIpv6(rawHost: String): Boolean { + val host = rawHost.removeSurrounding("[", "]").lowercase(Locale.US) + return host == "::" || + host == "::1" || + host.startsWith("fc") || + host.startsWith("fd") || + host.startsWith("fe80") || + host.startsWith("2001:db8") + } + + private fun registrableDomain(labels: List): String? { + if (labels.isEmpty()) return null + if (labels.size == 1) return labels.first() + val lastTwo = labels.takeLast(2).joinToString(".") + return if (lastTwo in COMMON_SECOND_LEVEL_SUFFIXES && labels.size >= 3) { + labels.takeLast(3).joinToString(".") + } else { + lastTwo + } + } + + private fun isLookalikeBrandLabel(label: String, brand: String): Boolean { + if (label == brand) return false + val normalized = normalizeLookalikes(label) + if (normalized == brand) return true + if (kotlin.math.abs(normalized.length - brand.length) > 1) return false + return levenshteinDistance(normalized, brand) <= 1 + } + + private fun normalizeLookalikes(value: String): String { + return value + .replace('0', 'o') + .replace('1', 'l') + .replace('3', 'e') + .replace('5', 's') + .replace('7', 't') + .replace("rn", "m") + } + + private fun levenshteinDistance(left: String, right: String): Int { + if (left == right) return 0 + if (left.isEmpty()) return right.length + if (right.isEmpty()) return left.length + + var previous = IntArray(right.length + 1) { it } + var current = IntArray(right.length + 1) + for (i in left.indices) { + current[0] = i + 1 + for (j in right.indices) { + val substitutionCost = if (left[i] == right[j]) 0 else 1 + current[j + 1] = minOf( + current[j] + 1, + previous[j + 1] + 1, + previous[j] + substitutionCost + ) + } + val swap = previous + previous = current + current = swap + } + return previous[right.length] + } + + private fun isStandardPort(scheme: String, port: Int): Boolean { + return (scheme.equals("http", ignoreCase = true) && port == 80) || + (scheme.equals("https", ignoreCase = true) && port == 443) + } + + private fun hasExternalRedirect(rawQuery: String, sourceHost: String): Boolean { + if (rawQuery.isBlank()) return false + return queryParams(rawQuery).any { (key, value) -> + key in REDIRECT_PARAMETER_NAMES && value.startsWith("http", ignoreCase = true) && + runCatching { + val destinationHost = extractHost(URI(value)).asciiHost + val sourceDomain = registrableDomain(sourceHost.split('.').filter { it.isNotBlank() }) + val destinationDomain = registrableDomain(destinationHost.split('.').filter { it.isNotBlank() }) + destinationDomain != null && sourceDomain != null && destinationDomain != sourceDomain + }.getOrDefault(true) + } + } + + private fun queryParams(rawQuery: String): List> { + return rawQuery + .split('&', ';') + .mapNotNull { token -> + val key = token.substringBefore('=', "").takeIf { it.isNotBlank() } ?: return@mapNotNull null + val value = token.substringAfter('=', "") + decodeUrlComponent(key).lowercase(Locale.US) to decodeUrlComponent(value) + } + } + + private fun decodeUrlComponent(value: String): String { + return runCatching { URLDecoder.decode(value, Charsets.UTF_8.name()) }.getOrDefault(value) + } + + private fun hasLongOpaqueToken(value: String): Boolean { + return value + .split('/', '&', '=', '?', '-', '_') + .any { token -> + token.length >= 48 && + token.toSet().size >= 8 && + token.count { it.isLetterOrDigit() || it == '+' || it == '/' } * 100 / token.length >= 85 + } + } + + private data class HostDetails( + val rawHost: String, + val asciiHost: String, + val hasUnicode: Boolean, + val hasPunycode: Boolean + ) + + private data class BrandProfile( + val name: String, + val officialDomains: Set + ) + + private val WEB_SCHEMES = setOf("http", "https") + private val HIGH_RISK_SCHEMES = setOf("javascript", "data", "file", "content", "intent") + private val IPV4_PATTERN = Regex("""^\d{1,3}(\.\d{1,3}){3}$""") + private val PERCENT_ENCODING = Regex("""%[0-9a-fA-F]{2}""") + private val ENCODED_CONTROL_CHARACTER = Regex("""(?i)%(00|0a|0d|09)""") + private val REPEATED_CHARACTER = Regex("""(.)\1{3,}""") + + private val COMMON_SECOND_LEVEL_SUFFIXES = setOf( + "ac.uk", + "co.in", + "co.jp", + "co.nz", + "co.uk", + "com.au", + "com.br", + "com.cn", + "com.hk", + "com.mx", + "com.sg", + "com.tr", + "ne.jp", + "net.au", + "org.uk" + ) + + private val PUBLIC_SUFFIX_PARTS = setOf( + "ac", + "co", + "com", + "edu", + "gov", + "mil", + "ne", + "net", + "org" + ) + + private val URL_SHORTENERS = setOf( + "bit.ly", + "buff.ly", + "cutt.ly", + "goo.gl", + "is.gd", + "lnkd.in", + "ow.ly", + "rebrand.ly", + "s.id", + "t.co", + "tiny.cc", + "tinyurl.com", + "trib.al", + "v.gd" + ) + + private val WATCHLIST_TLDS = setOf( + "cam", + "click", + "country", + "download", + "gq", + "kim", + "loan", + "men", + "ml", + "mov", + "quest", + "rest", + "surf", + "tk", + "top", + "work", + "xyz", + "zip" + ) + + private val CREDENTIAL_KEYWORDS = setOf( + "2fa", + "account", + "auth", + "billing", + "confirm", + "login", + "mfa", + "password", + "payment", + "recovery", + "reset", + "secure", + "security", + "signin", + "sign-in", + "unlock", + "update", + "verify", + "wallet" + ) + + private val REDIRECT_PARAMETER_NAMES = setOf( + "continue", + "dest", + "destination", + "next", + "redirect", + "redirect_uri", + "return", + "return_url", + "target", + "u", + "url" + ) + + private val DANGEROUS_FILE_EXTENSIONS = setOf( + ".apk", + ".bat", + ".cmd", + ".dmg", + ".exe", + ".jar", + ".js", + ".msi", + ".ps1", + ".scr", + ".vbs" + ) + + private val ARCHIVE_FILE_EXTENSIONS = setOf( + ".7z", + ".gz", + ".rar", + ".tar", + ".zip" + ) + + private val BRAND_PROFILES = listOf( + BrandProfile("adobe", setOf("adobe.com")), + BrandProfile("amazon", setOf("amazon.com")), + BrandProfile("apple", setOf("apple.com")), + BrandProfile("binance", setOf("binance.com")), + BrandProfile("chase", setOf("chase.com")), + BrandProfile("citibank", setOf("citibank.com")), + BrandProfile("coinbase", setOf("coinbase.com")), + BrandProfile("discord", setOf("discord.com", "discord.gg")), + BrandProfile("docusign", setOf("docusign.com")), + BrandProfile("dropbox", setOf("dropbox.com")), + BrandProfile("ebay", setOf("ebay.com")), + BrandProfile("facebook", setOf("facebook.com", "fb.com")), + BrandProfile("github", setOf("github.com")), + BrandProfile("google", setOf("google.com")), + BrandProfile("instagram", setOf("instagram.com")), + BrandProfile("microsoft", setOf("microsoft.com", "microsoftonline.com", "office.com", "live.com")), + BrandProfile("netflix", setOf("netflix.com")), + BrandProfile("paypal", setOf("paypal.com")), + BrandProfile("steam", setOf("steampowered.com", "steamcommunity.com")), + BrandProfile("telegram", setOf("telegram.org", "t.me")), + BrandProfile("whatsapp", setOf("whatsapp.com", "wa.me")), + BrandProfile("wellsfargo", setOf("wellsfargo.com")) + ) } diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..6c6d0e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..f465780 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_legacy.xml b/app/src/main/res/drawable/ic_launcher_legacy.xml new file mode 100644 index 0000000..19aefd3 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_legacy.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..c5239fb --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,3 @@ + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..c5239fb --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,3 @@ + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2702685..c22d698 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,5 +1,5 @@ - Clean Scanner + Private QR Scanner Scannen Nochmal scannen Historie @@ -80,12 +80,4 @@ Use-Case-Ansicht wählen Alltägliche private Nutzung Events & Ticketing - Inventur & Betrieb - Außendienst & Service-Teams - Büro- & Admin-Workflows - Kommunikations-Shortcuts - Sicherheitsbewusstes Browsen - Offline / geringe Konnektivität - Barrierefreiheit & Geschwindigkeit - Team-Übergabe & Datentransfer diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b0f4cad..aab1e15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - Clean Scanner + Private QR Scanner Scan Scan again History @@ -80,12 +80,4 @@ Select use-case view Everyday personal use Event & ticketing - Inventory & operations - Field work & service teams - Office & admin workflows - Communication shortcuts - Security-conscious browsing - Offline / low-connectivity - Accessibility & speed - Team handover & data transfer diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..ee4a0a8 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/com/clean/scanner/testutil/MainDispatcherRule.kt b/app/src/test/java/com/clean/scanner/testutil/MainDispatcherRule.kt index 8bb1c25..42cbd6c 100644 --- a/app/src/test/java/com/clean/scanner/testutil/MainDispatcherRule.kt +++ b/app/src/test/java/com/clean/scanner/testutil/MainDispatcherRule.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.testutil +package de.softwareapp_hb.privateqrscanner.testutil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt b/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt index 677d12e..e1d1e98 100644 --- a/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt +++ b/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt @@ -1,7 +1,7 @@ -package com.clean.scanner.ui +package de.softwareapp_hb.privateqrscanner.ui -import com.clean.scanner.domain.ScanResult -import com.clean.scanner.testutil.MainDispatcherRule +import de.softwareapp_hb.privateqrscanner.domain.ScanResult +import de.softwareapp_hb.privateqrscanner.testutil.MainDispatcherRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest diff --git a/app/src/test/java/com/clean/scanner/util/HistoryExportFormatterTest.kt b/app/src/test/java/com/clean/scanner/util/HistoryExportFormatterTest.kt index 2e88794..74e1b4a 100644 --- a/app/src/test/java/com/clean/scanner/util/HistoryExportFormatterTest.kt +++ b/app/src/test/java/com/clean/scanner/util/HistoryExportFormatterTest.kt @@ -1,6 +1,6 @@ -package com.clean.scanner.util +package de.softwareapp_hb.privateqrscanner.util -import com.clean.scanner.domain.ScanRecord +import de.softwareapp_hb.privateqrscanner.domain.ScanRecord import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test 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 4eb38f1..7f12892 100644 --- a/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt +++ b/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt @@ -1,4 +1,4 @@ -package com.clean.scanner.util +package de.softwareapp_hb.privateqrscanner.util import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt b/app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt index c22536a..df4cc71 100644 --- a/app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt +++ b/app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt @@ -1,5 +1,6 @@ -package com.clean.scanner.util +package de.softwareapp_hb.privateqrscanner.util +import de.softwareapp_hb.privateqrscanner.domain.UrlRiskResult import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -20,14 +21,22 @@ class UrlRiskScorerTest { @Test fun `ip host adds two points`() { - val result = UrlRiskScorer.score("https://192.168.1.1/path") + val result = UrlRiskScorer.score("https://93.184.216.34/path") assertEquals(2, result.score) } + @Test + fun `private ip host adds extra risk`() { + val result = UrlRiskScorer.score("https://192.168.1.1/path") + assertAtLeast(4, result.score) + assertReasonContains(result, "private or reserved IP") + } + @Test fun `punycode host adds two points`() { val result = UrlRiskScorer.score("https://xn--pple-43d.com") - assertEquals(2, result.score) + assertAtLeast(2, result.score) + assertReasonContains(result, "punycode") } @Test @@ -56,6 +65,60 @@ class UrlRiskScorerTest { assertEquals(2, result.score) } + @Test + fun `url shortener adds risk`() { + val result = UrlRiskScorer.score("https://bit.ly/privateqr") + assertAtLeast(2, result.score) + assertReasonContains(result, "shortener") + } + + @Test + fun `non web scheme is high risk`() { + val result = UrlRiskScorer.score("javascript:alert(1)") + assertAtLeast(5, result.score) + assertReasonContains(result, "unsafe URL scheme") + } + + @Test + fun `official brand login stays below warning threshold`() { + val result = UrlRiskScorer.score("https://accounts.google.com/login") + assertTrue(result.score < 3) + } + + @Test + fun `regional brand domain does not trigger impersonation heuristic`() { + val result = UrlRiskScorer.score("https://google.de/login") + assertTrue(result.score < 3) + } + + @Test + fun `brand embedded in unofficial host is risky`() { + val result = UrlRiskScorer.score("https://paypal-security.example.com/login") + assertAtLeast(3, result.score) + assertReasonContains(result, "known brand") + } + + @Test + fun `lookalike brand host is risky`() { + val result = UrlRiskScorer.score("https://paypa1.com") + assertAtLeast(3, result.score) + assertReasonContains(result, "resembles a known brand") + } + + @Test + fun `external redirect parameter is risky`() { + val result = UrlRiskScorer.score("https://example.com/login?redirect=https%3A%2F%2Fevil.test") + assertAtLeast(3, result.score) + assertReasonContains(result, "redirects to another domain") + } + + @Test + fun `downloadable executable path is risky`() { + val result = UrlRiskScorer.score("https://example.com/security-update.apk") + assertAtLeast(3, result.score) + assertReasonContains(result, "executable file") + } + @Test fun `combined risk can exceed threshold`() { val result = UrlRiskScorer.score("http://user:pass@192.168.0.1") @@ -73,4 +136,15 @@ class UrlRiskScorerTest { val result = UrlRiskScorer.score("http://xn--pple-43d.com") assertTrue(result.reasons.isNotEmpty()) } + + private fun assertAtLeast(expectedMinimum: Int, actual: Int) { + assertTrue("Expected score >= $expectedMinimum but was $actual", actual >= expectedMinimum) + } + + private fun assertReasonContains(result: UrlRiskResult, text: String) { + assertTrue( + "Expected reasons to contain '$text' but were ${result.reasons}", + result.reasons.any { it.contains(text, ignoreCase = true) } + ) + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index d236bcb..980f08e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,5 +18,5 @@ dependencyResolutionManagement { } } -rootProject.name = "CleanScanner" +rootProject.name = "PrivateQRScanner" include(":app") diff --git a/store-assets/private-qr-scanner-icon-512.png b/store-assets/private-qr-scanner-icon-512.png new file mode 100644 index 0000000..d44e21e Binary files /dev/null and b/store-assets/private-qr-scanner-icon-512.png differ diff --git a/store-assets/private-qr-scanner-icon.svg b/store-assets/private-qr-scanner-icon.svg new file mode 100644 index 0000000..ee63a57 --- /dev/null +++ b/store-assets/private-qr-scanner-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + +