From 4c443a0b8607f4422a4641094324b6b4c1aea793 Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Fri, 8 May 2026 18:09:57 +0200 Subject: [PATCH] better url risk scorer, icon, language, views reduced. --- README.md | 2 +- USE_CASES.md | 46 +- app/build.gradle.kts | 4 +- .../com/clean/scanner/util/IntentsTest.kt | 2 +- app/src/main/AndroidManifest.xml | 12 +- .../java/com/clean/scanner/AppContainer.kt | 8 +- .../java/com/clean/scanner/CleanScannerApp.kt | 2 +- .../java/com/clean/scanner/MainActivity.kt | 6 +- .../data/local/CleanScannerDatabase.kt | 2 +- .../com/clean/scanner/data/local/ScanDao.kt | 2 +- .../clean/scanner/data/local/ScanEntity.kt | 2 +- .../scanner/data/repository/ScanRepository.kt | 10 +- .../data/scanner/MlKitBarcodeAnalyzer.kt | 6 +- .../com/clean/scanner/domain/ScanRecord.kt | 2 +- .../com/clean/scanner/domain/ScanResult.kt | 2 +- .../com/clean/scanner/domain/UrlRiskResult.kt | 2 +- .../scanner/settings/SettingsRepository.kt | 4 +- .../java/com/clean/scanner/ui/AppViewModel.kt | 6 +- .../clean/scanner/ui/CleanScannerAppRoot.kt | 12 +- .../com/clean/scanner/ui/ScannerViewModel.kt | 6 +- .../java/com/clean/scanner/ui/UseCaseView.kt | 116 +--- .../scanner/ui/components/CameraPreview.kt | 6 +- .../scanner/ui/screens/BarcodeTypeMapper.kt | 2 +- .../clean/scanner/ui/screens/HistoryScreen.kt | 14 +- .../clean/scanner/ui/screens/HomeScreen.kt | 4 +- .../ui/screens/ScannerGalleryPreviewDialog.kt | 12 +- .../ui/screens/ScannerOverlayComponents.kt | 10 +- .../scanner/ui/screens/ScannerResultCards.kt | 10 +- .../clean/scanner/ui/screens/ScannerScreen.kt | 40 +- .../scanner/ui/screens/SettingsScreen.kt | 8 +- .../java/com/clean/scanner/ui/theme/Theme.kt | 2 +- .../com/clean/scanner/util/BarcodePayload.kt | 2 +- .../com/clean/scanner/util/ClipboardUtil.kt | 2 +- .../scanner/util/HistoryExportFormatter.kt | 4 +- .../java/com/clean/scanner/util/Intents.kt | 2 +- .../clean/scanner/util/ScanContentParsers.kt | 2 +- .../com/clean/scanner/util/UrlRiskScorer.kt | 607 +++++++++++++++++- .../res/drawable/ic_launcher_background.xml | 17 + .../res/drawable/ic_launcher_foreground.xml | 22 + .../main/res/drawable/ic_launcher_legacy.xml | 32 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../main/res/mipmap-anydpi/ic_launcher.xml | 3 + .../res/mipmap-anydpi/ic_launcher_round.xml | 3 + app/src/main/res/values-de/strings.xml | 10 +- app/src/main/res/values/strings.xml | 10 +- app/src/main/res/xml/locales_config.xml | 5 + .../scanner/testutil/MainDispatcherRule.kt | 2 +- .../clean/scanner/ui/ScannerViewModelTest.kt | 6 +- .../util/HistoryExportFormatterTest.kt | 4 +- .../scanner/util/ScanContentParsersTest.kt | 2 +- .../clean/scanner/util/UrlRiskScorerTest.kt | 80 ++- settings.gradle.kts | 2 +- store-assets/private-qr-scanner-icon-512.png | Bin 0 -> 12439 bytes store-assets/private-qr-scanner-icon.svg | 11 + 55 files changed, 879 insertions(+), 321 deletions(-) create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_legacy.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 app/src/main/res/xml/locales_config.xml create mode 100644 store-assets/private-qr-scanner-icon-512.png create mode 100644 store-assets/private-qr-scanner-icon.svg 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 0000000000000000000000000000000000000000..d44e21eb3eb9244a3478e9373769f869685ee0b2 GIT binary patch literal 12439 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 Ym!ltW&5}vq2f~I_6g1=uWGw>zAF-Xu;{X5v literal 0 HcmV?d00001 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 @@ + + + + + + + + + + +