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.
+2 -44
View File
@@ -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.
+2 -2
View File
@@ -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
@@ -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
+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-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
android:name=".CleanScannerApp"
android:allowBackup="true"
android:icon="@android:drawable/ic_menu_camera"
android:icon="@mipmap/ic_launcher"
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:theme="@style/Theme.CleanScanner">
<activity
@@ -1,10 +1,10 @@
package com.clean.scanner
package de.softwareapp_hb.privateqrscanner
import android.content.Context
import androidx.room.Room
import com.clean.scanner.data.local.CleanScannerDatabase
import com.clean.scanner.data.repository.ScanRepository
import com.clean.scanner.settings.SettingsRepository
import de.softwareapp_hb.privateqrscanner.data.local.CleanScannerDatabase
import de.softwareapp_hb.privateqrscanner.data.repository.ScanRepository
import de.softwareapp_hb.privateqrscanner.settings.SettingsRepository
class AppContainer(context: Context) {
private val appContext = context.applicationContext
@@ -1,4 +1,4 @@
package com.clean.scanner
package de.softwareapp_hb.privateqrscanner
import android.app.Application
@@ -1,12 +1,12 @@
package com.clean.scanner
package de.softwareapp_hb.privateqrscanner
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import com.clean.scanner.ui.CleanScannerAppRoot
import com.clean.scanner.ui.theme.CleanScannerTheme
import de.softwareapp_hb.privateqrscanner.ui.CleanScannerAppRoot
import de.softwareapp_hb.privateqrscanner.ui.theme.CleanScannerTheme
class MainActivity : ComponentActivity() {
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.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.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.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 com.clean.scanner.data.local.ScanEntity
import com.clean.scanner.domain.ScanRecord
import com.clean.scanner.settings.SettingsRepository
import de.softwareapp_hb.privateqrscanner.data.local.ScanDao
import de.softwareapp_hb.privateqrscanner.data.local.ScanEntity
import de.softwareapp_hb.privateqrscanner.domain.ScanRecord
import de.softwareapp_hb.privateqrscanner.settings.SettingsRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
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.os.SystemClock
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.clean.scanner.domain.ScanResult
import com.clean.scanner.util.readablePayload
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.BarcodeScannerOptions
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(
val id: Long,
@@ -1,4 +1,4 @@
package com.clean.scanner.domain
package de.softwareapp_hb.privateqrscanner.domain
data class ScanResult(
val content: String,
@@ -1,4 +1,4 @@
package com.clean.scanner.domain
package de.softwareapp_hb.privateqrscanner.domain
data class UrlRiskResult(
val score: Int,
@@ -1,11 +1,11 @@
package com.clean.scanner.settings
package de.softwareapp_hb.privateqrscanner.settings
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.edit
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.map
@@ -1,10 +1,10 @@
package com.clean.scanner.ui
package de.softwareapp_hb.privateqrscanner.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.clean.scanner.AppContainer
import com.clean.scanner.domain.ScanRecord
import de.softwareapp_hb.privateqrscanner.AppContainer
import de.softwareapp_hb.privateqrscanner.domain.ScanRecord
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
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.padding
@@ -15,11 +15,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.clean.scanner.AppContainer
import com.clean.scanner.R
import com.clean.scanner.ui.screens.HistoryScreen
import com.clean.scanner.ui.screens.ScannerScreen
import com.clean.scanner.ui.screens.SettingsScreen
import de.softwareapp_hb.privateqrscanner.AppContainer
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.ui.screens.HistoryScreen
import de.softwareapp_hb.privateqrscanner.ui.screens.ScannerScreen
import de.softwareapp_hb.privateqrscanner.ui.screens.SettingsScreen
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.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.clean.scanner.AppContainer
import com.clean.scanner.domain.ScanResult
import de.softwareapp_hb.privateqrscanner.AppContainer
import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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(
val storageKey: String,
@@ -13,38 +13,6 @@ enum class UseCaseView(
EventTicketing(
storageKey = "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 {
@@ -90,85 +58,5 @@ fun UseCaseView.capabilities(): UseCaseCapabilities {
allowOpenUrl = false,
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.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
@@ -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
@@ -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
@@ -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(
@@ -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
@@ -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
@@ -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
@@ -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) }
@@ -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(
@@ -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
@@ -1,4 +1,4 @@
package com.clean.scanner.util
package de.softwareapp_hb.privateqrscanner.util
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.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.util.Date
@@ -1,4 +1,4 @@
package com.clean.scanner.util
package de.softwareapp_hb.privateqrscanner.util
import android.content.ContentValues
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 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.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<String>()
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 (host.contains("xn--", ignoreCase = true)) {
score += 2
reasons += "Host contains punycode"
if (trimmed.any { it.isISOControl() || it.isWhitespace() }) {
add(3, "Contains whitespace or control characters")
}
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 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")
}
val percentEncodedCount = Regex("%[0-9a-fA-F]{2}").findAll(raw).count()
if (percentEncodedCount > 10) {
score += 1
reasons += "Many percent-encodings"
} 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")
}
if (!uri.userInfo.isNullOrBlank()) {
score += 2
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)
}
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>
<string name="app_name">Clean Scanner</string>
<string name="app_name">Private QR Scanner</string>
<string name="scan">Scannen</string>
<string name="scan_again">Nochmal scannen</string>
<string name="history">Historie</string>
@@ -80,12 +80,4 @@
<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_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>
+1 -9
View File
@@ -1,5 +1,5 @@
<resources>
<string name="app_name">Clean Scanner</string>
<string name="app_name">Private QR Scanner</string>
<string name="scan">Scan</string>
<string name="scan_again">Scan again</string>
<string name="history">History</string>
@@ -80,12 +80,4 @@
<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_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>
+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.ExperimentalCoroutinesApi
@@ -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
@@ -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
@@ -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
@@ -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) }
)
}
}
+1 -1
View File
@@ -18,5 +18,5 @@ dependencyResolutionManagement {
}
}
rootProject.name = "CleanScanner"
rootProject.name = "PrivateQRScanner"
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