better url risk scorer, icon, language, views reduced.

This commit is contained in:
Hadrian Burkhardt
2026-05-08 18:09:57 +02:00
parent a0646273bc
commit 4c443a0b86
55 changed files with 879 additions and 321 deletions
+1 -1
View File
@@ -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. Offline-first, ad-free QR/barcode scanner built with Kotlin, Jetpack Compose, CameraX, and on-device ML Kit.
+2 -44
View File
@@ -1,9 +1,9 @@
# Clean Scanner Use Cases # Private QR Scanner Use Cases
## Use-Case Views ## Use-Case Views
- [Done] Each use case has an individual view profile that shows only relevant functions. - [Done] Each use case has an individual view profile that shows only relevant functions.
- [Done] Default profile is **Everyday Personal Use**. - [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 ## 1. Everyday Personal Use
- [Done] Scan restaurant menus, product QR labels, and website links quickly. - [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] 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] Use batch mode to process multiple attendees without leaving the camera.
- [Done] Share batch captures to organizers for quick reconciliation. - [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.
+2 -2
View File
@@ -5,11 +5,11 @@ plugins {
} }
android { android {
namespace = "com.clean.scanner" namespace = "de.softwareapp_hb.privateqrscanner"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.clean.scanner" applicationId = "de.softwareapp_hb.privateqrscanner"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1
@@ -1,4 +1,4 @@
package com.clean.scanner.util package de.softwareapp_hb.privateqrscanner.util
import android.provider.ContactsContract import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.Organization import android.provider.ContactsContract.CommonDataKinds.Organization
+9 -3
View File
@@ -1,14 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera.any" android:required="false" /> <uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
<uses-permission android:name="android.permission.READ_CONTACTS" tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" tools:node="remove" />
<application <application
android:name=".CleanScannerApp" android:name=".CleanScannerApp"
android:allowBackup="true" android:allowBackup="true"
android:icon="@android:drawable/ic_menu_camera" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@android:drawable/ic_menu_camera" android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.CleanScanner"> android:theme="@style/Theme.CleanScanner">
<activity <activity
@@ -1,10 +1,10 @@
package com.clean.scanner package de.softwareapp_hb.privateqrscanner
import android.content.Context import android.content.Context
import androidx.room.Room import androidx.room.Room
import com.clean.scanner.data.local.CleanScannerDatabase import de.softwareapp_hb.privateqrscanner.data.local.CleanScannerDatabase
import com.clean.scanner.data.repository.ScanRepository import de.softwareapp_hb.privateqrscanner.data.repository.ScanRepository
import com.clean.scanner.settings.SettingsRepository import de.softwareapp_hb.privateqrscanner.settings.SettingsRepository
class AppContainer(context: Context) { class AppContainer(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
@@ -1,4 +1,4 @@
package com.clean.scanner package de.softwareapp_hb.privateqrscanner
import android.app.Application import android.app.Application
@@ -1,12 +1,12 @@
package com.clean.scanner package de.softwareapp_hb.privateqrscanner
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import com.clean.scanner.ui.CleanScannerAppRoot import de.softwareapp_hb.privateqrscanner.ui.CleanScannerAppRoot
import com.clean.scanner.ui.theme.CleanScannerTheme import de.softwareapp_hb.privateqrscanner.ui.theme.CleanScannerTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -1,4 +1,4 @@
package com.clean.scanner.data.local package de.softwareapp_hb.privateqrscanner.data.local
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
@@ -1,4 +1,4 @@
package com.clean.scanner.data.local package de.softwareapp_hb.privateqrscanner.data.local
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
@@ -1,4 +1,4 @@
package com.clean.scanner.data.local package de.softwareapp_hb.privateqrscanner.data.local
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@@ -1,9 +1,9 @@
package com.clean.scanner.data.repository package de.softwareapp_hb.privateqrscanner.data.repository
import com.clean.scanner.data.local.ScanDao import de.softwareapp_hb.privateqrscanner.data.local.ScanDao
import com.clean.scanner.data.local.ScanEntity import de.softwareapp_hb.privateqrscanner.data.local.ScanEntity
import com.clean.scanner.domain.ScanRecord import de.softwareapp_hb.privateqrscanner.domain.ScanRecord
import com.clean.scanner.settings.SettingsRepository import de.softwareapp_hb.privateqrscanner.settings.SettingsRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -1,11 +1,11 @@
package com.clean.scanner.data.scanner package de.softwareapp_hb.privateqrscanner.data.scanner
import android.graphics.Rect import android.graphics.Rect
import android.os.SystemClock import android.os.SystemClock
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import com.clean.scanner.domain.ScanResult import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import com.clean.scanner.util.readablePayload import de.softwareapp_hb.privateqrscanner.util.readablePayload
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
@@ -1,4 +1,4 @@
package com.clean.scanner.domain package de.softwareapp_hb.privateqrscanner.domain
data class ScanRecord( data class ScanRecord(
val id: Long, val id: Long,
@@ -1,4 +1,4 @@
package com.clean.scanner.domain package de.softwareapp_hb.privateqrscanner.domain
data class ScanResult( data class ScanResult(
val content: String, val content: String,
@@ -1,4 +1,4 @@
package com.clean.scanner.domain package de.softwareapp_hb.privateqrscanner.domain
data class UrlRiskResult( data class UrlRiskResult(
val score: Int, val score: Int,
@@ -1,11 +1,11 @@
package com.clean.scanner.settings package de.softwareapp_hb.privateqrscanner.settings
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.clean.scanner.ui.UseCaseView import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -1,10 +1,10 @@
package com.clean.scanner.ui package de.softwareapp_hb.privateqrscanner.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.clean.scanner.AppContainer import de.softwareapp_hb.privateqrscanner.AppContainer
import com.clean.scanner.domain.ScanRecord import de.softwareapp_hb.privateqrscanner.domain.ScanRecord
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -1,4 +1,4 @@
package com.clean.scanner.ui package de.softwareapp_hb.privateqrscanner.ui
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -15,11 +15,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.clean.scanner.AppContainer import de.softwareapp_hb.privateqrscanner.AppContainer
import com.clean.scanner.R import de.softwareapp_hb.privateqrscanner.R
import com.clean.scanner.ui.screens.HistoryScreen import de.softwareapp_hb.privateqrscanner.ui.screens.HistoryScreen
import com.clean.scanner.ui.screens.ScannerScreen import de.softwareapp_hb.privateqrscanner.ui.screens.ScannerScreen
import com.clean.scanner.ui.screens.SettingsScreen import de.softwareapp_hb.privateqrscanner.ui.screens.SettingsScreen
private enum class RootTab { Scanner, History, Settings } private enum class RootTab { Scanner, History, Settings }
@@ -1,10 +1,10 @@
package com.clean.scanner.ui package de.softwareapp_hb.privateqrscanner.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.clean.scanner.AppContainer import de.softwareapp_hb.privateqrscanner.AppContainer
import com.clean.scanner.domain.ScanResult import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -1,6 +1,6 @@
package com.clean.scanner.ui package de.softwareapp_hb.privateqrscanner.ui
import com.clean.scanner.R import de.softwareapp_hb.privateqrscanner.R
enum class UseCaseView( enum class UseCaseView(
val storageKey: String, val storageKey: String,
@@ -13,38 +13,6 @@ enum class UseCaseView(
EventTicketing( EventTicketing(
storageKey = "event_ticketing", storageKey = "event_ticketing",
titleRes = R.string.use_case_event_ticketing titleRes = R.string.use_case_event_ticketing
),
InventoryOperations(
storageKey = "inventory_operations",
titleRes = R.string.use_case_inventory_operations
),
FieldWorkServiceTeams(
storageKey = "field_work_service_teams",
titleRes = R.string.use_case_field_work
),
OfficeAdmin(
storageKey = "office_admin",
titleRes = R.string.use_case_office_admin
),
CommunicationShortcuts(
storageKey = "communication_shortcuts",
titleRes = R.string.use_case_communication_shortcuts
),
SecurityBrowsing(
storageKey = "security_browsing",
titleRes = R.string.use_case_security_browsing
),
OfflineLowConnectivity(
storageKey = "offline_low_connectivity",
titleRes = R.string.use_case_offline_low_connectivity
),
AccessibilitySpeed(
storageKey = "accessibility_speed",
titleRes = R.string.use_case_accessibility_speed
),
TeamHandoverTransfer(
storageKey = "team_handover_transfer",
titleRes = R.string.use_case_team_handover_transfer
); );
companion object { companion object {
@@ -90,85 +58,5 @@ fun UseCaseView.capabilities(): UseCaseCapabilities {
allowOpenUrl = false, allowOpenUrl = false,
allowBatchShare = true allowBatchShare = true
) )
UseCaseView.InventoryOperations -> 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
)
} }
} }
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.components package de.softwareapp_hb.privateqrscanner.ui.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Size import android.util.Size
@@ -22,8 +22,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clean.scanner.data.scanner.DetectionBox import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox
import com.clean.scanner.data.scanner.MlKitBarcodeAnalyzer import de.softwareapp_hb.privateqrscanner.data.scanner.MlKitBarcodeAnalyzer
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.max import kotlin.math.max
@@ -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 import com.google.mlkit.vision.barcode.common.Barcode
@@ -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.clickable
import androidx.compose.foundation.layout.Arrangement 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clean.scanner.R import de.softwareapp_hb.privateqrscanner.R
import com.clean.scanner.domain.ScanRecord import de.softwareapp_hb.privateqrscanner.domain.ScanRecord
import com.clean.scanner.ui.UseCaseView import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import com.clean.scanner.ui.capabilities import de.softwareapp_hb.privateqrscanner.ui.capabilities
import com.clean.scanner.util.HistoryExportFormatter import de.softwareapp_hb.privateqrscanner.util.HistoryExportFormatter
import com.clean.scanner.util.Intents import de.softwareapp_hb.privateqrscanner.util.Intents
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@@ -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.Arrangement
import androidx.compose.foundation.layout.Column 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.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clean.scanner.R import de.softwareapp_hb.privateqrscanner.R
@Composable @Composable
fun HomeScreen( fun HomeScreen(
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens package de.softwareapp_hb.privateqrscanner.ui.screens
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory 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.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clean.scanner.R import de.softwareapp_hb.privateqrscanner.R
import com.clean.scanner.data.scanner.DetectionBox import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox
import com.clean.scanner.data.scanner.DetectionPoint import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint
import com.clean.scanner.domain.ScanResult import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import com.clean.scanner.util.readablePayload import de.softwareapp_hb.privateqrscanner.util.readablePayload
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScannerOptions
@@ -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.background
import androidx.compose.foundation.layout.Arrangement 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.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clean.scanner.R import de.softwareapp_hb.privateqrscanner.R
import com.clean.scanner.ui.BatchScanRecord import de.softwareapp_hb.privateqrscanner.ui.BatchScanRecord
import com.clean.scanner.util.ClipboardUtil import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil
import com.clean.scanner.util.Intents import de.softwareapp_hb.privateqrscanner.util.Intents
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@@ -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.clickable
import androidx.compose.foundation.background 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.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clean.scanner.domain.ScanResult import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import com.clean.scanner.util.ParsedContact import de.softwareapp_hb.privateqrscanner.util.ParsedContact
import com.clean.scanner.util.ScanContentParsers import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers
import com.clean.scanner.util.UrlRiskScorer import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens package de.softwareapp_hb.privateqrscanner.ui.screens
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
@@ -72,20 +72,20 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clean.scanner.R import de.softwareapp_hb.privateqrscanner.R
import com.clean.scanner.data.scanner.DetectionBox import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox
import com.clean.scanner.data.scanner.DetectionPoint import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint
import com.clean.scanner.domain.ScanResult import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import com.clean.scanner.ui.BatchScanRecord import de.softwareapp_hb.privateqrscanner.ui.BatchScanRecord
import com.clean.scanner.ui.EventTicketScanDecision import de.softwareapp_hb.privateqrscanner.ui.EventTicketScanDecision
import com.clean.scanner.ui.UseCaseView import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import com.clean.scanner.ui.components.CameraPreview import de.softwareapp_hb.privateqrscanner.ui.components.CameraPreview
import com.clean.scanner.ui.capabilities import de.softwareapp_hb.privateqrscanner.ui.capabilities
import com.clean.scanner.util.ClipboardUtil import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil
import com.clean.scanner.util.Intents import de.softwareapp_hb.privateqrscanner.util.Intents
import com.clean.scanner.util.ScanContentParsers import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers
import com.clean.scanner.util.UrlRiskScorer import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer
import com.clean.scanner.util.readablePayload import de.softwareapp_hb.privateqrscanner.util.readablePayload
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
@@ -127,15 +127,7 @@ fun ScannerScreen(
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val capabilities = remember(useCaseView) { useCaseView.capabilities() } val capabilities = remember(useCaseView) { useCaseView.capabilities() }
val forceBatchMode = useCaseView == UseCaseView.EventTicketing val forceBatchMode = useCaseView == UseCaseView.EventTicketing
val showBatchModeToggle = remember(useCaseView) { val showBatchModeToggle = capabilities.allowBatchMode && !forceBatchMode
when (useCaseView) {
UseCaseView.InventoryOperations,
UseCaseView.FieldWorkServiceTeams,
UseCaseView.OfflineLowConnectivity,
UseCaseView.TeamHandoverTransfer -> true
else -> false
}
}
val isBatchModeActive = forceBatchMode || batchMode val isBatchModeActive = forceBatchMode || batchMode
val duplicateSnackbarHostState = remember { SnackbarHostState() } val duplicateSnackbarHostState = remember { SnackbarHostState() }
val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) } val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) }
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens package de.softwareapp_hb.privateqrscanner.ui.screens
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.clean.scanner.R import de.softwareapp_hb.privateqrscanner.R
import com.clean.scanner.ui.UseCaseView import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import com.clean.scanner.util.Intents import de.softwareapp_hb.privateqrscanner.util.Intents
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.theme package de.softwareapp_hb.privateqrscanner.ui.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -1,4 +1,4 @@
package com.clean.scanner.util package de.softwareapp_hb.privateqrscanner.util
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
@@ -1,4 +1,4 @@
package com.clean.scanner.util package de.softwareapp_hb.privateqrscanner.util
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
@@ -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.text.DateFormat
import java.util.Date import java.util.Date
@@ -1,4 +1,4 @@
package com.clean.scanner.util package de.softwareapp_hb.privateqrscanner.util
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
@@ -1,4 +1,4 @@
package com.clean.scanner.util package de.softwareapp_hb.privateqrscanner.util
import at.bitfire.vcard4android.Contact import at.bitfire.vcard4android.Contact
import java.io.StringReader import java.io.StringReader
@@ -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.URI
import java.net.URLDecoder
import java.util.Locale
object UrlRiskScorer { object UrlRiskScorer {
fun score(raw: String): UrlRiskResult { fun score(raw: String): UrlRiskResult {
val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return UrlRiskResult(0, emptyList()) val trimmed = raw.trim()
val host = uri.host.orEmpty() 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<String>() val reasons = mutableListOf<String>()
var score = 0 var score = 0
if (host.matches(Regex("^\\d{1,3}(\\.\\d{1,3}){3}$"))) { fun add(points: Int, reason: String) {
score += 2 if (reason !in reasons) {
reasons += "Host is an IP address" score += points
reasons += reason
}
} }
if (uri.scheme.equals("http", ignoreCase = true)) {
score += 2 if (trimmed.any { it.isISOControl() || it.isWhitespace() }) {
reasons += "URL uses HTTP" add(3, "Contains whitespace or control characters")
} }
if (host.contains("xn--", ignoreCase = true)) {
score += 2 when (scheme) {
reasons += "Host contains punycode" "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) { if (host.length > 40) {
score += 1 add(1, "Host is unusually long")
reasons += "Host is unusually long"
} }
if ((uri.rawQuery?.length ?: 0) > 120) {
score += 1 val ipv4 = parseIpv4(host)
reasons += "Query is unusually long" if (ipv4 != null) {
} add(2, "Host is an IP address")
val percentEncodedCount = Regex("%[0-9a-fA-F]{2}").findAll(raw).count() if (isPrivateOrReservedIpv4(ipv4)) {
if (percentEncodedCount > 10) { add(2, "Host is a private or reserved IP address")
score += 1 }
reasons += "Many percent-encodings" } else if (isIpv6Literal(hostDetails.rawHost)) {
} add(2, "Host is an IP address")
if (!uri.userInfo.isNullOrBlank()) { if (isPrivateOrReservedIpv6(hostDetails.rawHost)) {
score += 2 add(2, "Host is a private or reserved IP address")
reasons += "Contains userinfo" }
} 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) return UrlRiskResult(score = score, reasons = reasons)
} }
private fun scoreDomainShape(
host: String,
labels: List<String>,
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<String>,
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<String>,
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>): 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<Pair<String, String>> {
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<String>
)
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"))
)
} }
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0B1220"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#123B3F"
android:pathData="M0,66C19,54 31,51 49,56C69,62 82,58 108,38V108H0z" />
<path
android:fillColor="#165A61"
android:fillAlpha="0.74"
android:pathData="M0,0H108V29C86,43 70,47 52,42C33,36 17,40 0,52z" />
</vector>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#F8FAFC"
android:pathData="M54,19L80,29V50C80,67 69,82 54,89C39,82 28,67 28,50V29z" />
<path
android:fillColor="#DFF7F2"
android:pathData="M54,24L74,31.8V50C74,63.5 65.8,75.4 54,81.8C42.2,75.4 34,63.5 34,50V31.8z" />
<path
android:fillColor="#0B1220"
android:pathData="M41,39h8v8h-8zM53,39h8v8h-8zM65,39h8v8h-8zM41,51h8v8h-8zM65,51h8v8h-8zM41,63h8v8h-8zM53,63h8v8h-8zM65,63h8v8h-8z" />
<path
android:fillColor="#2DD4BF"
android:pathData="M53,51h8v8h-8z" />
<path
android:fillColor="#2DD4BF"
android:pathData="M31,34h5v-5h13v5h-8v5h-10zM72,29v5h5v13h-5v-8h-5v-10zM31,74v-5h5v-8h-5v13h13v-5h-8v5zM77,74h-13v-5h8v-8h5z" />
</vector>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0B1220"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#123B3F"
android:pathData="M0,66C19,54 31,51 49,56C69,62 82,58 108,38V108H0z" />
<path
android:fillColor="#165A61"
android:fillAlpha="0.74"
android:pathData="M0,0H108V29C86,43 70,47 52,42C33,36 17,40 0,52z" />
<path
android:fillColor="#F8FAFC"
android:pathData="M54,19L80,29V50C80,67 69,82 54,89C39,82 28,67 28,50V29z" />
<path
android:fillColor="#DFF7F2"
android:pathData="M54,24L74,31.8V50C74,63.5 65.8,75.4 54,81.8C42.2,75.4 34,63.5 34,50V31.8z" />
<path
android:fillColor="#0B1220"
android:pathData="M41,39h8v8h-8zM53,39h8v8h-8zM65,39h8v8h-8zM41,51h8v8h-8zM65,51h8v8h-8zM41,63h8v8h-8zM53,63h8v8h-8zM65,63h8v8h-8z" />
<path
android:fillColor="#2DD4BF"
android:pathData="M53,51h8v8h-8z" />
<path
android:fillColor="#2DD4BF"
android:pathData="M31,34h5v-5h13v5h-8v5h-10zM72,29v5h5v13h-5v-8h-5v-10zM31,74v-5h5v-8h-5v13h13v-5h-8v5zM77,74h-13v-5h8v-8h5z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@drawable/ic_launcher_legacy" />
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@drawable/ic_launcher_legacy" />
+1 -9
View File
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">Clean Scanner</string> <string name="app_name">Private QR Scanner</string>
<string name="scan">Scannen</string> <string name="scan">Scannen</string>
<string name="scan_again">Nochmal scannen</string> <string name="scan_again">Nochmal scannen</string>
<string name="history">Historie</string> <string name="history">Historie</string>
@@ -80,12 +80,4 @@
<string name="select_use_case_view">Use-Case-Ansicht wählen</string> <string name="select_use_case_view">Use-Case-Ansicht wählen</string>
<string name="use_case_everyday_personal">Alltägliche private Nutzung</string> <string name="use_case_everyday_personal">Alltägliche private Nutzung</string>
<string name="use_case_event_ticketing">Events &amp; Ticketing</string> <string name="use_case_event_ticketing">Events &amp; Ticketing</string>
<string name="use_case_inventory_operations">Inventur &amp; Betrieb</string>
<string name="use_case_field_work">Außendienst &amp; Service-Teams</string>
<string name="use_case_office_admin">Büro- &amp; Admin-Workflows</string>
<string name="use_case_communication_shortcuts">Kommunikations-Shortcuts</string>
<string name="use_case_security_browsing">Sicherheitsbewusstes Browsen</string>
<string name="use_case_offline_low_connectivity">Offline / geringe Konnektivität</string>
<string name="use_case_accessibility_speed">Barrierefreiheit &amp; Geschwindigkeit</string>
<string name="use_case_team_handover_transfer">Team-Übergabe &amp; Datentransfer</string>
</resources> </resources>
+1 -9
View File
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">Clean Scanner</string> <string name="app_name">Private QR Scanner</string>
<string name="scan">Scan</string> <string name="scan">Scan</string>
<string name="scan_again">Scan again</string> <string name="scan_again">Scan again</string>
<string name="history">History</string> <string name="history">History</string>
@@ -80,12 +80,4 @@
<string name="select_use_case_view">Select use-case view</string> <string name="select_use_case_view">Select use-case view</string>
<string name="use_case_everyday_personal">Everyday personal use</string> <string name="use_case_everyday_personal">Everyday personal use</string>
<string name="use_case_event_ticketing">Event &amp; ticketing</string> <string name="use_case_event_ticketing">Event &amp; ticketing</string>
<string name="use_case_inventory_operations">Inventory &amp; operations</string>
<string name="use_case_field_work">Field work &amp; service teams</string>
<string name="use_case_office_admin">Office &amp; admin workflows</string>
<string name="use_case_communication_shortcuts">Communication shortcuts</string>
<string name="use_case_security_browsing">Security-conscious browsing</string>
<string name="use_case_offline_low_connectivity">Offline / low-connectivity</string>
<string name="use_case_accessibility_speed">Accessibility &amp; speed</string>
<string name="use_case_team_handover_transfer">Team handover &amp; data transfer</string>
</resources> </resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en-US" />
<locale android:name="de" />
</locale-config>
@@ -1,4 +1,4 @@
package com.clean.scanner.testutil package de.softwareapp_hb.privateqrscanner.testutil
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -1,7 +1,7 @@
package com.clean.scanner.ui package de.softwareapp_hb.privateqrscanner.ui
import com.clean.scanner.domain.ScanResult import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import com.clean.scanner.testutil.MainDispatcherRule import de.softwareapp_hb.privateqrscanner.testutil.MainDispatcherRule
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -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.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -1,4 +1,4 @@
package com.clean.scanner.util package de.softwareapp_hb.privateqrscanner.util
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -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.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -20,14 +21,22 @@ class UrlRiskScorerTest {
@Test @Test
fun `ip host adds two points`() { 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) 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 @Test
fun `punycode host adds two points`() { fun `punycode host adds two points`() {
val result = UrlRiskScorer.score("https://xn--pple-43d.com") val result = UrlRiskScorer.score("https://xn--pple-43d.com")
assertEquals(2, result.score) assertAtLeast(2, result.score)
assertReasonContains(result, "punycode")
} }
@Test @Test
@@ -56,6 +65,60 @@ class UrlRiskScorerTest {
assertEquals(2, result.score) 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 @Test
fun `combined risk can exceed threshold`() { fun `combined risk can exceed threshold`() {
val result = UrlRiskScorer.score("http://user:pass@192.168.0.1") 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") val result = UrlRiskScorer.score("http://xn--pple-43d.com")
assertTrue(result.reasons.isNotEmpty()) 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) }
)
}
} }
+1 -1
View File
@@ -18,5 +18,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "CleanScanner" rootProject.name = "PrivateQRScanner"
include(":app") include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Private QR Scanner app icon">
<rect width="512" height="512" fill="#0B1220"/>
<path d="M0 313C90 257 148 242 232 268C327 297 389 274 512 180V512H0Z" fill="#123B3F"/>
<path d="M0 0H512V138C407 205 331 224 246 199C157 172 80 190 0 247Z" fill="#165A61" opacity="0.74"/>
<path d="M256 88L379 136V236C379 316 326 387 256 421C186 387 133 316 133 236V136Z" fill="#F8FAFC"/>
<path d="M256 112L351 149V236C351 300 312 356 256 387C200 356 161 300 161 236V149Z" fill="#DFF7F2"/>
<path d="M194 184H232V222H194Z M251 184H289V222H251Z M308 184H346V222H308Z M194 241H232V279H194Z M308 241H346V279H308Z M194 298H232V336H194Z M251 298H289V336H251Z M308 298H346V336H308Z" fill="#0B1220"/>
<path d="M251 241H289V279H251Z" fill="#2DD4BF"/>
<path d="M146 162H170V138H232V162H194V186H146Z M342 138V162H366V224H342V186H318V138Z M146 350V326H170V288H146V350H208V326H170V350Z M366 350H304V326H342V288H366Z" fill="#2DD4BF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB