Compare commits

...

10 Commits

Author SHA1 Message Date
Hadrian Burkhardt cd73c35c4d base64 enocding + display 2026-05-10 00:13:43 +02:00
Hadrian Burkhardt 1b610f6c4d added contact email address 2026-05-08 18:46:19 +02:00
Hadrian Burkhardt 01922c16e7 in app reviews 2026-05-08 18:41:07 +02:00
Hadrian Burkhardt ad45c7ef27 updates to newer version 2026-05-08 18:30:42 +02:00
Hadrian Burkhardt 00e485da6e Upgrade AGP dependency from 9.0.1 to 9.2.1
Upgrade Gradle version to 9.4.1
2026-05-08 18:15:19 +02:00
Hadrian Burkhardt 4c443a0b86 better url risk scorer, icon, language, views reduced. 2026-05-08 18:09:57 +02:00
Hadrian Burkhardt a0646273bc event ticketing whitelist 2026-03-03 16:11:28 +01:00
Hadrian Burkhardt 3d7620954f removed batchmode from personal usecase
update ticketing and events view
2026-03-03 14:13:25 +01:00
Hadrian Burkhardt fb94b7214a more responsive bounding box 2026-02-26 05:42:07 +01:00
Hadrian Burkhardt 229244d878 wifi icon + qrcode stamps 2026-02-26 05:29:45 +01:00
59 changed files with 1834 additions and 409 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.
+7 -43
View File
@@ -1,4 +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] 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.
@@ -7,47 +12,6 @@
## 2. Event & Ticketing ## 2. Event & Ticketing
- [Done] Scan tickets at venues and quickly validate repeated entries. - [Done] Scan tickets at venues and quickly validate repeated entries.
- [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.
+4 -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
@@ -87,6 +87,8 @@ dependencies {
implementation("androidx.camera:camera-view:1.5.3") implementation("androidx.camera:camera-view:1.5.3")
implementation("com.google.mlkit:barcode-scanning:17.3.0") implementation("com.google.mlkit:barcode-scanning:17.3.0")
implementation("com.google.android.play:review:2.0.2")
implementation("com.google.android.play:review-ktx:2.0.2")
implementation("com.github.bitfireAT:vcard4android:main-SNAPSHOT") implementation("com.github.bitfireAT:vcard4android:main-SNAPSHOT")
implementation("androidx.room:room-runtime:2.8.4") implementation("androidx.room:room-runtime:2.8.4")
@@ -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
+10 -3
View File
@@ -1,14 +1,21 @@
<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
tools:targetApi="33"
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,10 +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 de.softwareapp_hb.privateqrscanner.util.readableBarcodePayload
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
@@ -46,10 +47,10 @@ class MlKitBarcodeAnalyzer(
) : ImageAnalysis.Analyzer, AutoCloseable { ) : ImageAnalysis.Analyzer, AutoCloseable {
private companion object { private companion object {
const val MATCH_DISTANCE_THRESHOLD = 0.18f const val MATCH_DISTANCE_THRESHOLD = 0.18f
const val BOX_SMOOTHING_ALPHA = 0.35f const val BOX_SMOOTHING_ALPHA = 0.65f
const val MAX_MISSED_FRAMES = 2 const val MAX_MISSED_FRAMES = 1
const val MIN_ANALYSIS_INTERVAL_MS = 45L const val MIN_ANALYSIS_INTERVAL_MS = 30L
const val MIN_STATE_PUBLISH_INTERVAL_MS = 66L const val MIN_STATE_PUBLISH_INTERVAL_MS = 33L
} }
private val scanner = BarcodeScanning.getClient( private val scanner = BarcodeScanning.getClient(
@@ -116,7 +117,7 @@ class MlKitBarcodeAnalyzer(
scanner.process(image) scanner.process(image)
.addOnSuccessListener { barcodes -> .addOnSuccessListener { barcodes ->
val readable = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() } val readable = barcodes.firstOrNull { it.readableBarcodePayload() != null }
val boxes = barcodes.mapNotNull { barcode -> val boxes = barcodes.mapNotNull { barcode ->
val bounds = barcode.boundingBox ?: return@mapNotNull null val bounds = barcode.boundingBox ?: return@mapNotNull null
val normalized = normalizeBoundingBox( val normalized = normalizeBoundingBox(
@@ -143,6 +144,7 @@ class MlKitBarcodeAnalyzer(
sourceHeight = sourceHeight sourceHeight = sourceHeight
) )
if (readable != null) { if (readable != null) {
val payload = readable.readableBarcodePayload() ?: return@addOnSuccessListener
val readableBox = readable.boundingBox?.let { bounds -> val readableBox = readable.boundingBox?.let { bounds ->
val normalized = normalizeBoundingBox( val normalized = normalizeBoundingBox(
rect = bounds, rect = bounds,
@@ -164,8 +166,9 @@ class MlKitBarcodeAnalyzer(
} }
onDetected( onDetected(
ScanResult( ScanResult(
content = readable.rawValue.orEmpty(), content = payload.content,
type = readable.valueType.toHumanType() type = readable.valueType.toHumanType(),
isBase64Encoded = payload.isBase64Encoded
), ),
gatedReadableBox, gatedReadableBox,
sourceWidth, sourceWidth,
@@ -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,6 +1,14 @@
package com.clean.scanner.domain package de.softwareapp_hb.privateqrscanner.domain
data class ScanResult( data class ScanResult(
val content: String, val content: String,
val type: String val type: String,
) val isBase64Encoded: Boolean = false
) {
val displayType: String
get() = if (isBase64Encoded && !type.contains("base64", ignoreCase = true)) {
"$type (Base64)"
} else {
type
}
}
@@ -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,9 +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.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
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
@@ -14,6 +16,7 @@ class SettingsRepository(private val context: Context) {
val historyEnabled = booleanPreferencesKey("history_enabled") val historyEnabled = booleanPreferencesKey("history_enabled")
val warningsEnabled = booleanPreferencesKey("warnings_enabled") val warningsEnabled = booleanPreferencesKey("warnings_enabled")
val scanFeedbackEnabled = booleanPreferencesKey("scan_feedback_enabled") val scanFeedbackEnabled = booleanPreferencesKey("scan_feedback_enabled")
val useCaseView = stringPreferencesKey("use_case_view")
} }
val historyEnabled: Flow<Boolean> = context.dataStore.data.map { prefs -> val historyEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
@@ -28,6 +31,10 @@ class SettingsRepository(private val context: Context) {
prefs[Keys.scanFeedbackEnabled] ?: true prefs[Keys.scanFeedbackEnabled] ?: true
} }
val useCaseView: Flow<UseCaseView> = context.dataStore.data.map { prefs ->
UseCaseView.fromStorageKey(prefs[Keys.useCaseView])
}
suspend fun setHistoryEnabled(enabled: Boolean) { suspend fun setHistoryEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.historyEnabled] = enabled } context.dataStore.edit { it[Keys.historyEnabled] = enabled }
} }
@@ -39,4 +46,8 @@ class SettingsRepository(private val context: Context) {
suspend fun setScanFeedbackEnabled(enabled: Boolean) { suspend fun setScanFeedbackEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.scanFeedbackEnabled] = enabled } context.dataStore.edit { it[Keys.scanFeedbackEnabled] = enabled }
} }
suspend fun setUseCaseView(useCaseView: UseCaseView) {
context.dataStore.edit { it[Keys.useCaseView] = useCaseView.storageKey }
}
} }
@@ -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
@@ -16,6 +16,7 @@ data class AppUiState(
val historyEnabled: Boolean = false, val historyEnabled: Boolean = false,
val warningsEnabled: Boolean = true, val warningsEnabled: Boolean = true,
val scanFeedbackEnabled: Boolean = true, val scanFeedbackEnabled: Boolean = true,
val useCaseView: UseCaseView = UseCaseView.default,
val history: List<ScanRecord> = emptyList(), val history: List<ScanRecord> = emptyList(),
val searchQuery: String = "" val searchQuery: String = ""
) )
@@ -24,19 +25,39 @@ class AppViewModel(
private val container: AppContainer private val container: AppContainer
) : ViewModel() { ) : ViewModel() {
private data class SettingsState(
val historyEnabled: Boolean,
val warningsEnabled: Boolean,
val scanFeedbackEnabled: Boolean,
val useCaseView: UseCaseView
)
private val query = MutableStateFlow("") private val query = MutableStateFlow("")
val uiState: StateFlow<AppUiState> = combine( private val settingsState = combine(
container.settingsRepository.historyEnabled, container.settingsRepository.historyEnabled,
container.settingsRepository.warningsEnabled, container.settingsRepository.warningsEnabled,
container.settingsRepository.scanFeedbackEnabled, container.settingsRepository.scanFeedbackEnabled,
container.scanRepository.observeHistory(), container.settingsRepository.useCaseView
query ) { historyEnabled, warningsEnabled, scanFeedbackEnabled, useCaseView ->
) { historyEnabled, warningsEnabled, scanFeedbackEnabled, history, q -> SettingsState(
AppUiState(
historyEnabled = historyEnabled, historyEnabled = historyEnabled,
warningsEnabled = warningsEnabled, warningsEnabled = warningsEnabled,
scanFeedbackEnabled = scanFeedbackEnabled, scanFeedbackEnabled = scanFeedbackEnabled,
useCaseView = useCaseView
)
}
val uiState: StateFlow<AppUiState> = combine(
settingsState,
container.scanRepository.observeHistory(),
query
) { settings, history, q ->
AppUiState(
historyEnabled = settings.historyEnabled,
warningsEnabled = settings.warningsEnabled,
scanFeedbackEnabled = settings.scanFeedbackEnabled,
useCaseView = settings.useCaseView,
history = if (q.isBlank()) history else history.filter { history = if (q.isBlank()) history else history.filter {
it.content.contains(q, ignoreCase = true) || it.type.contains(q, ignoreCase = true) it.content.contains(q, ignoreCase = true) || it.type.contains(q, ignoreCase = true)
}, },
@@ -69,6 +90,12 @@ class AppViewModel(
} }
} }
fun setUseCaseView(useCaseView: UseCaseView) {
viewModelScope.launch {
container.settingsRepository.setUseCaseView(useCaseView)
}
}
fun deleteHistoryItem(id: Long) { fun deleteHistoryItem(id: Long) {
viewModelScope.launch { viewModelScope.launch {
container.scanRepository.deleteById(id) container.scanRepository.deleteById(id)
@@ -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 }
@@ -70,11 +70,17 @@ fun CleanScannerAppRoot(container: AppContainer) {
lastResult = scannerState.lastResult, lastResult = scannerState.lastResult,
batchMode = scannerState.batchMode, batchMode = scannerState.batchMode,
batchResults = scannerState.batchResults, batchResults = scannerState.batchResults,
eventTicketWhitelistCount = scannerState.eventTicketWhitelistCount,
duplicateFeedbackNonce = scannerState.duplicateFeedbackNonce, duplicateFeedbackNonce = scannerState.duplicateFeedbackNonce,
scanFeedbackNonce = scannerState.scanFeedbackNonce, scanFeedbackNonce = scannerState.scanFeedbackNonce,
warningsEnabled = appState.warningsEnabled, warningsEnabled = appState.warningsEnabled,
scanFeedbackEnabled = appState.scanFeedbackEnabled, scanFeedbackEnabled = appState.scanFeedbackEnabled,
useCaseView = appState.useCaseView,
onScan = scannerViewModel::onScan, onScan = scannerViewModel::onScan,
onEvaluateEventTicketScan = scannerViewModel::evaluateEventTicketScan,
onAuditDuplicateTicket = scannerViewModel::auditDuplicateTicketScan,
onAuditUnregisteredTicket = scannerViewModel::auditUnregisteredTicketScan,
onReplaceEventTicketWhitelist = scannerViewModel::replaceEventTicketWhitelist,
onScanAgain = scannerViewModel::resumeScanning, onScanAgain = scannerViewModel::resumeScanning,
onBatchModeChange = scannerViewModel::setBatchMode, onBatchModeChange = scannerViewModel::setBatchMode,
onClearBatchResults = scannerViewModel::clearBatchResults, onClearBatchResults = scannerViewModel::clearBatchResults,
@@ -84,6 +90,7 @@ fun CleanScannerAppRoot(container: AppContainer) {
RootTab.History -> HistoryScreen( RootTab.History -> HistoryScreen(
query = appState.searchQuery, query = appState.searchQuery,
history = appState.history, history = appState.history,
useCaseView = appState.useCaseView,
onQueryChange = appViewModel::setQuery, onQueryChange = appViewModel::setQuery,
onDelete = appViewModel::deleteHistoryItem, onDelete = appViewModel::deleteHistoryItem,
onClearAll = appViewModel::clearHistory onClearAll = appViewModel::clearHistory
@@ -93,9 +100,11 @@ fun CleanScannerAppRoot(container: AppContainer) {
historyEnabled = appState.historyEnabled, historyEnabled = appState.historyEnabled,
warningsEnabled = appState.warningsEnabled, warningsEnabled = appState.warningsEnabled,
scanFeedbackEnabled = appState.scanFeedbackEnabled, scanFeedbackEnabled = appState.scanFeedbackEnabled,
selectedUseCaseView = appState.useCaseView,
onHistoryToggle = appViewModel::setHistoryEnabled, onHistoryToggle = appViewModel::setHistoryEnabled,
onWarningsToggle = appViewModel::setWarningsEnabled, onWarningsToggle = appViewModel::setWarningsEnabled,
onScanFeedbackToggle = appViewModel::setScanFeedbackEnabled onScanFeedbackToggle = appViewModel::setScanFeedbackEnabled,
onUseCaseViewSelected = appViewModel::setUseCaseView
) )
} }
} }
@@ -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
@@ -21,11 +21,19 @@ data class ScannerUiState(
val lastScanTimestamp: Long = 0L, val lastScanTimestamp: Long = 0L,
val batchMode: Boolean = false, val batchMode: Boolean = false,
val batchResults: List<BatchScanRecord> = emptyList(), val batchResults: List<BatchScanRecord> = emptyList(),
val eventTicketWhitelistCount: Int = 0,
val recentScanKeys: List<String> = emptyList(), val recentScanKeys: List<String> = emptyList(),
val duplicateFeedbackNonce: Int = 0, val duplicateFeedbackNonce: Int = 0,
val scanFeedbackNonce: Int = 0 val scanFeedbackNonce: Int = 0
) )
enum class EventTicketScanDecision {
Accept,
Unregistered,
DuplicateAlert,
Ignore
}
class ScannerViewModel( class ScannerViewModel(
private val saveScan: suspend (content: String, type: String) -> Unit, private val saveScan: suspend (content: String, type: String) -> Unit,
private val nowProvider: () -> Long = { System.currentTimeMillis() } private val nowProvider: () -> Long = { System.currentTimeMillis() }
@@ -33,12 +41,17 @@ class ScannerViewModel(
private companion object { private companion object {
const val GENERAL_DEBOUNCE_MS = 800L const val GENERAL_DEBOUNCE_MS = 800L
const val SAME_CODE_HOLDOFF_MS = 2500L const val SAME_CODE_HOLDOFF_MS = 2500L
const val EVENT_TICKET_HOLDOFF_MS = 30_000L
} }
private val _uiState = MutableStateFlow(ScannerUiState()) private val _uiState = MutableStateFlow(ScannerUiState())
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow() val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
private val recentScanKeySet = LinkedHashSet<String>(200) private val recentScanKeySet = LinkedHashSet<String>(200)
private val batchKeySet = LinkedHashSet<String>(100) private val batchKeySet = LinkedHashSet<String>(100)
private val eventTicketSeenKeys = HashSet<String>()
private val eventTicketWhitelistIds = HashSet<String>()
private val eventTicketDuplicateCooldowns = HashMap<String, Long>()
private val eventTicketRecentlyAccepted = HashMap<String, Long>()
private var lastAcceptedKey: String? = null private var lastAcceptedKey: String? = null
private var lastAcceptedTimestamp: Long = 0L private var lastAcceptedTimestamp: Long = 0L
@@ -47,7 +60,7 @@ class ScannerViewModel(
val current = _uiState.value val current = _uiState.value
if (!current.analysisEnabled) return if (!current.analysisEnabled) return
val key = "${result.type}|${result.content}" val key = "${result.displayType}|${result.content}"
if (now - current.lastScanTimestamp < GENERAL_DEBOUNCE_MS) return if (now - current.lastScanTimestamp < GENERAL_DEBOUNCE_MS) return
if (key == lastAcceptedKey && now - lastAcceptedTimestamp < SAME_CODE_HOLDOFF_MS) return if (key == lastAcceptedKey && now - lastAcceptedTimestamp < SAME_CODE_HOLDOFF_MS) return
@@ -94,7 +107,7 @@ class ScannerViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
saveScan(result.content, result.type) saveScan(result.content, result.displayType)
} }
} }
@@ -112,9 +125,61 @@ class ScannerViewModel(
fun clearBatchResults() { fun clearBatchResults() {
batchKeySet.clear() batchKeySet.clear()
eventTicketSeenKeys.clear()
eventTicketDuplicateCooldowns.clear()
eventTicketRecentlyAccepted.clear()
_uiState.value = _uiState.value.copy(batchResults = emptyList()) _uiState.value = _uiState.value.copy(batchResults = emptyList())
} }
fun auditDuplicateTicketScan(result: ScanResult) {
viewModelScope.launch {
saveScan(result.content, "Duplicate ticket (${result.displayType})")
}
}
fun auditUnregisteredTicketScan(result: ScanResult) {
viewModelScope.launch {
saveScan(result.content, "Unregistered ticket (${result.displayType})")
}
}
fun replaceEventTicketWhitelist(ids: Set<String>) {
eventTicketWhitelistIds.clear()
eventTicketWhitelistIds.addAll(ids.map(::normalizeWhitelistId).filter { it.isNotBlank() })
_uiState.value = _uiState.value.copy(eventTicketWhitelistCount = eventTicketWhitelistIds.size)
}
fun evaluateEventTicketScan(result: ScanResult): EventTicketScanDecision {
val key = "${result.displayType}|${result.content}"
val normalizedContent = normalizeWhitelistId(result.content)
val now = nowProvider()
if (eventTicketWhitelistIds.isNotEmpty() && normalizedContent !in eventTicketWhitelistIds) {
return EventTicketScanDecision.Unregistered
}
if (key !in eventTicketSeenKeys) {
eventTicketSeenKeys.add(key)
eventTicketRecentlyAccepted[key] = now
return EventTicketScanDecision.Accept
}
val acceptedAt = eventTicketRecentlyAccepted[key] ?: 0L
if (now - acceptedAt < EVENT_TICKET_HOLDOFF_MS) {
return EventTicketScanDecision.Ignore
}
val cooldownUntil = eventTicketDuplicateCooldowns[key] ?: 0L
if (now < cooldownUntil) {
return EventTicketScanDecision.Ignore
}
eventTicketDuplicateCooldowns[key] = now + EVENT_TICKET_HOLDOFF_MS
return EventTicketScanDecision.DuplicateAlert
}
private fun normalizeWhitelistId(value: String): String {
return value.trim().lowercase()
}
class Factory(private val container: AppContainer) : ViewModelProvider.Factory { class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
@@ -0,0 +1,62 @@
package de.softwareapp_hb.privateqrscanner.ui
import de.softwareapp_hb.privateqrscanner.R
enum class UseCaseView(
val storageKey: String,
val titleRes: Int
) {
EverydayPersonal(
storageKey = "everyday_personal",
titleRes = R.string.use_case_everyday_personal
),
EventTicketing(
storageKey = "event_ticketing",
titleRes = R.string.use_case_event_ticketing
);
companion object {
val default = EverydayPersonal
fun fromStorageKey(value: String?): UseCaseView {
return entries.firstOrNull { it.storageKey == value } ?: default
}
}
}
data class UseCaseCapabilities(
val allowScanFromImage: Boolean = true,
val allowBatchMode: Boolean = false,
val allowCopy: Boolean = true,
val allowShare: Boolean = true,
val allowOpenUrl: Boolean = true,
val allowAddContact: Boolean = false,
val allowDialPhone: Boolean = false,
val allowSendSms: Boolean = false,
val allowSendEmail: Boolean = false,
val allowOpenWifiSettings: Boolean = false,
val allowAddCalendarEvent: Boolean = false,
val allowHistoryExport: Boolean = false,
val allowBatchShare: Boolean = true
)
fun UseCaseView.capabilities(): UseCaseCapabilities {
return when (this) {
UseCaseView.EverydayPersonal -> UseCaseCapabilities(
allowScanFromImage = true,
allowBatchMode = false,
allowCopy = true,
allowShare = true,
allowOpenUrl = true
)
UseCaseView.EventTicketing -> UseCaseCapabilities(
allowScanFromImage = false,
allowBatchMode = true,
allowCopy = true,
allowShare = true,
allowOpenUrl = false,
allowBatchShare = true
)
}
}
@@ -7,6 +7,8 @@ import android.view.ScaleGestureDetector
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.core.UseCaseGroup import androidx.camera.core.UseCaseGroup
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
@@ -22,8 +24,9 @@ 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 de.softwareapp_hb.privateqrscanner.domain.ScanResult
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
@@ -43,7 +46,7 @@ fun CameraPreview(
sourceWidth: Int, sourceWidth: Int,
sourceHeight: Int sourceHeight: Int
) -> Unit = { _, _, _, _, _ -> }, ) -> Unit = { _, _, _, _, _ -> },
onScan: (String, String, DetectionBox?, Int, Int) -> Unit onScan: (ScanResult, DetectionBox?, Int, Int) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -70,7 +73,7 @@ fun CameraPreview(
} }
}, },
onDetected = { result, readableBox, sourceWidth, sourceHeight -> onDetected = { result, readableBox, sourceWidth, sourceHeight ->
latestOnScan.value(result.content, result.type, readableBox, sourceWidth, sourceHeight) latestOnScan.value(result, readableBox, sourceWidth, sourceHeight)
} }
) )
} }
@@ -81,9 +84,9 @@ fun CameraPreview(
override fun onScale(detector: ScaleGestureDetector): Boolean { override fun onScale(detector: ScaleGestureDetector): Boolean {
val camera = cameraRef.value ?: return false val camera = cameraRef.value ?: return false
val zoomState = camera.cameraInfo.zoomState.value ?: return false val zoomState = camera.cameraInfo.zoomState.value ?: return false
val nextZoom = zoomRatio.value * detector.scaleFactor val nextZoom = zoomRatio.floatValue * detector.scaleFactor
val clampedZoom = max(zoomState.minZoomRatio, min(nextZoom, zoomState.maxZoomRatio)) val clampedZoom = max(zoomState.minZoomRatio, min(nextZoom, zoomState.maxZoomRatio))
zoomRatio.value = clampedZoom zoomRatio.floatValue = clampedZoom
camera.cameraControl.setZoomRatio(clampedZoom) camera.cameraControl.setZoomRatio(clampedZoom)
return true return true
} }
@@ -104,12 +107,21 @@ fun CameraPreview(
provider.unbindAll() provider.unbindAll()
val preview = Preview.Builder().build().apply { val preview = Preview.Builder().build().apply {
setSurfaceProvider(previewView.surfaceProvider) surfaceProvider = previewView.surfaceProvider
} }
val imageAnalysis = ImageAnalysis.Builder() val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setTargetResolution(Size(1280, 720)) .setResolutionSelector(
ResolutionSelector.Builder()
.setResolutionStrategy(
ResolutionStrategy(
Size(1280, 720),
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
)
)
.build()
)
.build().apply { .build().apply {
setAnalyzer(cameraExecutor, analyzer) setAnalyzer(cameraExecutor, analyzer)
} }
@@ -134,7 +146,7 @@ fun CameraPreview(
onTorchAvailabilityChanged(camera.cameraInfo.hasFlashUnit()) onTorchAvailabilityChanged(camera.cameraInfo.hasFlashUnit())
cameraRef.value = camera cameraRef.value = camera
zoomRatio.value = camera.cameraInfo.zoomState.value?.zoomRatio ?: 1f zoomRatio.floatValue = camera.cameraInfo.zoomState.value?.zoomRatio ?: 1f
} }
LaunchedEffect(torchEnabled) { LaunchedEffect(torchEnabled) {
@@ -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,10 +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.util.HistoryExportFormatter import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import com.clean.scanner.util.Intents 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.text.DateFormat
import java.util.Date import java.util.Date
@@ -35,11 +37,13 @@ import java.util.Date
fun HistoryScreen( fun HistoryScreen(
query: String, query: String,
history: List<ScanRecord>, history: List<ScanRecord>,
useCaseView: UseCaseView,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
onDelete: (Long) -> Unit, onDelete: (Long) -> Unit,
onClearAll: () -> Unit onClearAll: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val capabilities = useCaseView.capabilities()
val showDeleteAll = remember { mutableStateOf(false) } val showDeleteAll = remember { mutableStateOf(false) }
val selectedItem = remember { mutableStateOf<ScanRecord?>(null) } val selectedItem = remember { mutableStateOf<ScanRecord?>(null) }
@@ -64,10 +68,20 @@ fun HistoryScreen(
val detail = selectedItem.value val detail = selectedItem.value
if (detail != null) { if (detail != null) {
val detailIsBase64 = detail.isBase64Encoded()
AlertDialog( AlertDialog(
onDismissRequest = { selectedItem.value = null }, onDismissRequest = { selectedItem.value = null },
title = { Text(text = detail.type) }, title = { Text(text = detail.type) },
text = { Text(text = detail.content) }, text = {
if (detailIsBase64) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = stringResource(R.string.base64_encoded_notice))
Text(text = detail.content)
}
} else {
Text(text = detail.content)
}
},
confirmButton = { confirmButton = {
TextButton(onClick = { selectedItem.value = null }) { TextButton(onClick = { selectedItem.value = null }) {
Text(text = stringResource(R.string.confirm)) Text(text = stringResource(R.string.confirm))
@@ -90,6 +104,7 @@ fun HistoryScreen(
) )
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
if (capabilities.allowHistoryExport) {
TextButton( TextButton(
onClick = { onClick = {
val exportText = HistoryExportFormatter.formatText(history) val exportText = HistoryExportFormatter.formatText(history)
@@ -117,6 +132,7 @@ fun HistoryScreen(
) { ) {
Text(stringResource(R.string.share_json)) Text(stringResource(R.string.share_json))
} }
}
TextButton(onClick = { showDeleteAll.value = true }) { TextButton(onClick = { showDeleteAll.value = true }) {
Text(stringResource(R.string.delete_all)) Text(stringResource(R.string.delete_all))
} }
@@ -161,9 +177,20 @@ private fun HistoryRow(
.clickable { onOpenDetails() } .clickable { onOpenDetails() }
.padding(vertical = 12.dp)) { .padding(vertical = 12.dp)) {
Text(text = item.type) Text(text = item.type)
Text(text = item.content, maxLines = 2) Text(
text = if (item.isBase64Encoded()) {
stringResource(R.string.base64_encoded_inline, item.content)
} else {
item.content
},
maxLines = 2
)
Text(text = DateFormat.getDateTimeInstance().format(Date(item.timestamp))) Text(text = DateFormat.getDateTimeInstance().format(Date(item.timestamp)))
} }
} }
) )
} }
private fun ScanRecord.isBase64Encoded(): Boolean {
return type.contains("base64", ignoreCase = true)
}
@@ -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,10 +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 de.softwareapp_hb.privateqrscanner.util.readableBarcodePayload
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
@@ -174,7 +175,7 @@ internal fun GalleryScanPreviewDialog(
} }
val live = barcodes.mapNotNull { barcode -> val live = barcodes.mapNotNull { barcode ->
val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null val payload = barcode.readableBarcodePayload() ?: return@mapNotNull null
val normalizedBox = barcode.boundingBox?.let { bounds -> val normalizedBox = barcode.boundingBox?.let { bounds ->
val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f) val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f)
val topN = ((bounds.top + cropTop) / imgH).coerceIn(0f, 1f) val topN = ((bounds.top + cropTop) / imgH).coerceIn(0f, 1f)
@@ -189,10 +190,14 @@ internal fun GalleryScanPreviewDialog(
DetectionBox(leftN, topN, rightN, bottomN, corners) DetectionBox(leftN, topN, rightN, bottomN, corners)
} }
GalleryScanCandidate( GalleryScanCandidate(
result = ScanResult(content = raw, type = barcode.valueType.toHumanType()), result = ScanResult(
content = payload.content,
type = barcode.valueType.toHumanType(),
isBase64Encoded = payload.isBase64Encoded
),
box = normalizedBox box = normalizedBox
) )
}.distinctBy { "${it.result.type}|${it.result.content}" } }.distinctBy { "${it.result.displayType}|${it.result.content}" }
liveCandidates = live liveCandidates = live
} }
@@ -328,12 +333,16 @@ internal fun GalleryScanPreviewDialog(
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Text( Text(
text = "${index + 1}. ${candidate.result.type}", text = "${index + 1}. ${candidate.result.displayType}",
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Text( Text(
text = candidate.result.content, text = if (candidate.result.isBase64Encoded) {
stringResource(R.string.base64_encoded_inline, candidate.result.content)
} else {
candidate.result.content
},
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
@@ -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
@@ -75,7 +75,8 @@ internal fun OverlayIconToggle(
@Composable @Composable
internal fun BatchResultsPanel( internal fun BatchResultsPanel(
results: List<BatchScanRecord>, results: List<BatchScanRecord>,
onClear: () -> Unit onClear: () -> Unit,
allowShare: Boolean
) { ) {
val context = LocalContext.current val context = LocalContext.current
val timeFormat = remember { DateFormat.getTimeInstance(DateFormat.SHORT) } val timeFormat = remember { DateFormat.getTimeInstance(DateFormat.SHORT) }
@@ -101,6 +102,11 @@ internal fun BatchResultsPanel(
color = Color.White color = Color.White
) )
results.take(3).forEach { item -> results.take(3).forEach { item ->
val contentText = if (item.result.isBase64Encoded) {
stringResource(R.string.base64_encoded_inline, item.result.content)
} else {
item.result.content
}
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -108,7 +114,7 @@ internal fun BatchResultsPanel(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "${item.result.type}: ${item.result.content}", text = "${item.result.displayType}: $contentText",
color = Color.White.copy(alpha = 0.92f), color = Color.White.copy(alpha = 0.92f),
maxLines = 1 maxLines = 1
) )
@@ -125,6 +131,7 @@ internal fun BatchResultsPanel(
tint = Color.White tint = Color.White
) )
} }
if (allowShare) {
IconButton(onClick = { Intents.shareText(context, item.result.content) }) { IconButton(onClick = { Intents.shareText(context, item.result.content) }) {
Icon( Icon(
imageVector = Icons.Default.Share, imageVector = Icons.Default.Share,
@@ -135,10 +142,12 @@ internal fun BatchResultsPanel(
} }
} }
} }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onClear, enabled = results.isNotEmpty()) { TextButton(onClick = onClear, enabled = results.isNotEmpty()) {
Text(stringResource(R.string.clear_batch)) Text(stringResource(R.string.clear_batch))
} }
if (allowShare) {
TextButton( TextButton(
onClick = { Intents.shareText(context, buildBatchExport(results)) }, onClick = { Intents.shareText(context, buildBatchExport(results)) },
enabled = results.isNotEmpty() enabled = results.isNotEmpty()
@@ -149,12 +158,14 @@ internal fun BatchResultsPanel(
} }
} }
} }
}
private fun buildBatchExport(results: List<BatchScanRecord>): String { private fun buildBatchExport(results: List<BatchScanRecord>): String {
if (results.isEmpty()) return "" if (results.isEmpty()) return ""
val formatter = DateFormat.getDateTimeInstance() val formatter = DateFormat.getDateTimeInstance()
return results.joinToString(separator = "\n\n") { item -> return results.joinToString(separator = "\n\n") { item ->
"${formatter.format(Date(item.timestamp))}\n${item.result.type}\n${item.result.content}" val encoding = if (item.result.isBase64Encoded) "\nEncoding: Base64" else ""
"${formatter.format(Date(item.timestamp))}\n${item.result.displayType}$encoding\n${item.result.content}"
} }
} }
@@ -1,5 +1,6 @@
package com.clean.scanner.ui.screens package de.softwareapp_hb.privateqrscanner.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -8,8 +9,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -18,12 +22,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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.R
import com.clean.scanner.util.ParsedContact import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import com.clean.scanner.util.ScanContentParsers import de.softwareapp_hb.privateqrscanner.util.ParsedContact
import com.clean.scanner.util.UrlRiskScorer import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers
import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@@ -35,10 +42,13 @@ private data class ResultField(
@Composable @Composable
internal fun ResultVisualCard( internal fun ResultVisualCard(
result: ScanResult, result: ScanResult,
onOpenUrl: ((String) -> Unit)? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val contact = remember(result.content) { ScanContentParsers.parseContact(result.content) } val contact = remember(result) {
if (contact != null || result.type == "Contact") { if (result.isBase64Encoded) null else ScanContentParsers.parseContact(result.content)
}
if (!result.isBase64Encoded && (contact != null || result.type == "Contact")) {
ContactVisualCard( ContactVisualCard(
contact = contact, contact = contact,
rawContent = result.content, rawContent = result.content,
@@ -57,10 +67,34 @@ internal fun ResultVisualCard(
modifier = Modifier.padding(14.dp), modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (!result.isBase64Encoded && result.type == "WiFi") {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = null,
tint = Color(0xFF1D4ED8)
)
Text( Text(
text = result.type, text = "Wi-Fi",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
}
} else {
Text(
text = result.displayType,
style = MaterialTheme.typography.titleMedium
)
}
if (result.isBase64Encoded) {
Text(
text = stringResource(R.string.base64_encoded_notice),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF4F6277)
)
}
if (fields.isEmpty()) { if (fields.isEmpty()) {
Text( Text(
text = result.content, text = result.content,
@@ -74,9 +108,19 @@ internal fun ResultVisualCard(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4F6277) color = Color(0xFF4F6277)
) )
val isClickableUrl = result.type == "URL" &&
field.label == "Link" &&
onOpenUrl != null
Text( Text(
text = field.value, text = field.value,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (isClickableUrl) Color(0xFF1D4ED8) else Color.Unspecified,
textDecoration = if (isClickableUrl) TextDecoration.Underline else null,
modifier = if (isClickableUrl) {
Modifier.clickable { onOpenUrl(field.value) }
} else {
Modifier
},
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -247,6 +291,9 @@ private fun selectContactTemplate(contact: ParsedContact?, rawContent: String):
} }
private fun buildResultFields(result: ScanResult): List<ResultField> { private fun buildResultFields(result: ScanResult): List<ResultField> {
if (result.isBase64Encoded) {
return listOf(ResultField("Encoded data", result.content))
}
return when (result.type) { return when (result.type) {
"Contact" -> { "Contact" -> {
val contact = ScanContentParsers.parseContact(result.content) val contact = ScanContentParsers.parseContact(result.content)
@@ -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
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material.icons.filled.ViewModule import androidx.compose.material.icons.filled.ViewModule
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -49,6 +50,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -70,20 +72,27 @@ 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 de.softwareapp_hb.privateqrscanner.ui.EventTicketScanDecision
import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import com.clean.scanner.ui.components.CameraPreview import com.clean.scanner.ui.components.CameraPreview
import com.clean.scanner.util.ClipboardUtil import de.softwareapp_hb.privateqrscanner.ui.capabilities
import com.clean.scanner.util.Intents import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil
import com.clean.scanner.util.ScanContentParsers import de.softwareapp_hb.privateqrscanner.util.Intents
import com.clean.scanner.util.UrlRiskScorer import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers
import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer
import de.softwareapp_hb.privateqrscanner.util.readableBarcodePayload
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
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.max import kotlin.math.max
internal data class GalleryScanCandidate( internal data class GalleryScanCandidate(
@@ -98,11 +107,17 @@ fun ScannerScreen(
lastResult: ScanResult?, lastResult: ScanResult?,
batchMode: Boolean, batchMode: Boolean,
batchResults: List<BatchScanRecord>, batchResults: List<BatchScanRecord>,
eventTicketWhitelistCount: Int,
duplicateFeedbackNonce: Int, duplicateFeedbackNonce: Int,
scanFeedbackNonce: Int, scanFeedbackNonce: Int,
warningsEnabled: Boolean, warningsEnabled: Boolean,
scanFeedbackEnabled: Boolean, scanFeedbackEnabled: Boolean,
useCaseView: UseCaseView,
onScan: (ScanResult) -> Unit, onScan: (ScanResult) -> Unit,
onEvaluateEventTicketScan: (ScanResult) -> EventTicketScanDecision,
onAuditDuplicateTicket: (ScanResult) -> Unit,
onAuditUnregisteredTicket: (ScanResult) -> Unit,
onReplaceEventTicketWhitelist: (Set<String>) -> Unit,
onScanAgain: () -> Unit, onScanAgain: () -> Unit,
onBatchModeChange: (Boolean) -> Unit, onBatchModeChange: (Boolean) -> Unit,
onClearBatchResults: () -> Unit, onClearBatchResults: () -> Unit,
@@ -110,9 +125,20 @@ fun ScannerScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val capabilities = remember(useCaseView) { useCaseView.capabilities() }
val forceBatchMode = useCaseView == UseCaseView.EventTicketing
val showBatchModeToggle = capabilities.allowBatchMode && !forceBatchMode
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) }
LaunchedEffect(forceBatchMode, showBatchModeToggle, batchMode) {
when {
forceBatchMode && !batchMode -> onBatchModeChange(true)
!forceBatchMode && !showBatchModeToggle && batchMode -> onBatchModeChange(false)
}
}
var cameraGranted by remember { var cameraGranted by remember {
mutableStateOf( mutableStateOf(
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
@@ -126,6 +152,10 @@ fun ScannerScreen(
var torchAvailable by remember { mutableStateOf(false) } var torchAvailable by remember { mutableStateOf(false) }
var showRiskWarning by remember { mutableStateOf(false) } var showRiskWarning by remember { mutableStateOf(false) }
var pendingOpenUrl by remember { mutableStateOf<String?>(null) } var pendingOpenUrl by remember { mutableStateOf<String?>(null) }
var showDuplicateTicketAlert by remember { mutableStateOf(false) }
var showUnregisteredTicketAlert by remember { mutableStateOf(false) }
var duplicateTicketAlertContent by remember { mutableStateOf<String?>(null) }
var unregisteredTicketAlertContent by remember { mutableStateOf<String?>(null) }
var showImageScanFailed by remember { mutableStateOf(false) } var showImageScanFailed by remember { mutableStateOf(false) }
var imageScanCandidates by remember { mutableStateOf<List<GalleryScanCandidate>>(emptyList()) } var imageScanCandidates by remember { mutableStateOf<List<GalleryScanCandidate>>(emptyList()) }
var imageScanPreviewUri by remember { mutableStateOf<Uri?>(null) } var imageScanPreviewUri by remember { mutableStateOf<Uri?>(null) }
@@ -134,6 +164,8 @@ fun ScannerScreen(
var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(emptyList()) } var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(emptyList()) }
var detectionSourceWidth by remember { mutableIntStateOf(0) } var detectionSourceWidth by remember { mutableIntStateOf(0) }
var detectionSourceHeight by remember { mutableIntStateOf(0) } var detectionSourceHeight by remember { mutableIntStateOf(0) }
var lastHandledScanFeedbackNonce by remember { mutableIntStateOf(scanFeedbackNonce) }
val scope = rememberCoroutineScope()
val activity = context as? Activity val activity = context as? Activity
val imageScanner = remember { val imageScanner = remember {
BarcodeScanning.getClient( BarcodeScanning.getClient(
@@ -143,6 +175,13 @@ fun ScannerScreen(
.build() .build()
) )
} }
fun ScanResult.visibleAlertContent(): String {
return if (isBase64Encoded) {
context.getString(R.string.base64_encoded_inline, content)
} else {
content
}
}
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
@@ -172,7 +211,7 @@ fun ScannerScreen(
imageScanner.process(image) imageScanner.process(image)
.addOnSuccessListener { barcodes -> .addOnSuccessListener { barcodes ->
val candidates = barcodes.mapNotNull { barcode -> val candidates = barcodes.mapNotNull { barcode ->
val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null val payload = barcode.readableBarcodePayload() ?: return@mapNotNull null
val normalizedBox = barcode.boundingBox?.let { bounds -> val normalizedBox = barcode.boundingBox?.let { bounds ->
val corners = barcode.cornerPoints?.map { p -> val corners = barcode.cornerPoints?.map { p ->
DetectionPoint( DetectionPoint(
@@ -189,16 +228,48 @@ fun ScannerScreen(
) )
} }
GalleryScanCandidate( GalleryScanCandidate(
result = ScanResult(content = raw, type = barcode.valueType.toHumanType()), result = ScanResult(
content = payload.content,
type = barcode.valueType.toHumanType(),
isBase64Encoded = payload.isBase64Encoded
),
box = normalizedBox box = normalizedBox
) )
}.distinctBy { "${it.result.type}|${it.result.content}" } }.distinctBy { "${it.result.displayType}|${it.result.content}" }
imageScanCandidates = candidates imageScanCandidates = candidates
} }
.addOnFailureListener { .addOnFailureListener {
showImageScanFailed = true showImageScanFailed = true
} }
} }
val whitelistPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
val ids = withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { reader ->
parseWhitelistIds(reader.readText())
} ?: emptySet()
}.getOrElse { emptySet() }
}
if (ids.isEmpty()) {
Toast.makeText(
context,
context.getString(R.string.whitelist_import_empty),
Toast.LENGTH_SHORT
).show()
} else {
onReplaceEventTicketWhitelist(ids)
Toast.makeText(
context,
context.getString(R.string.whitelist_import_success, ids.size),
Toast.LENGTH_SHORT
).show()
}
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!cameraGranted) permissionLauncher.launch(Manifest.permission.CAMERA) if (!cameraGranted) permissionLauncher.launch(Manifest.permission.CAMERA)
@@ -206,6 +277,8 @@ fun ScannerScreen(
LaunchedEffect(duplicateFeedbackNonce) { LaunchedEffect(duplicateFeedbackNonce) {
if (duplicateFeedbackNonce > 0) { if (duplicateFeedbackNonce > 0) {
if (useCaseView == UseCaseView.EventTicketing) return@LaunchedEffect
Toast.makeText( Toast.makeText(
context, context,
context.getString(R.string.already_scanned), context.getString(R.string.already_scanned),
@@ -223,10 +296,13 @@ fun ScannerScreen(
} }
LaunchedEffect(scanFeedbackNonce, scanFeedbackEnabled) { LaunchedEffect(scanFeedbackNonce, scanFeedbackEnabled) {
if (scanFeedbackEnabled && scanFeedbackNonce > 0) { if (scanFeedbackEnabled && scanFeedbackNonce > lastHandledScanFeedbackNonce) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP, 120) toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP, 120)
} }
if (scanFeedbackNonce > lastHandledScanFeedbackNonce) {
lastHandledScanFeedbackNonce = scanFeedbackNonce
}
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -267,7 +343,7 @@ fun ScannerScreen(
detectionSourceWidth = sourceWidth detectionSourceWidth = sourceWidth
detectionSourceHeight = sourceHeight detectionSourceHeight = sourceHeight
}, },
onScan = { content, type, readableBox, sourceWidth, sourceHeight -> onScan = { scanResult, readableBox, sourceWidth, sourceHeight ->
val box = readableBox ?: return@CameraPreview val box = readableBox ?: return@CameraPreview
if (sourceWidth <= 0 || sourceHeight <= 0 || viewW <= 0f || viewH <= 0f) { if (sourceWidth <= 0 || sourceHeight <= 0 || viewW <= 0f || viewH <= 0f) {
return@CameraPreview return@CameraPreview
@@ -286,7 +362,26 @@ fun ScannerScreen(
val insideAim = centerX in aimLeft..aimRight && centerY in aimTop..aimBottom val insideAim = centerX in aimLeft..aimRight && centerY in aimTop..aimBottom
if (!insideAim) return@CameraPreview if (!insideAim) return@CameraPreview
onScan(ScanResult(content = content, type = type)) if (forceBatchMode) {
when (onEvaluateEventTicketScan(scanResult)) {
EventTicketScanDecision.Accept -> Unit
EventTicketScanDecision.Unregistered -> {
onAuditUnregisteredTicket(scanResult)
unregisteredTicketAlertContent = scanResult.visibleAlertContent()
showUnregisteredTicketAlert = true
return@CameraPreview
}
EventTicketScanDecision.DuplicateAlert -> {
onAuditDuplicateTicket(scanResult)
duplicateTicketAlertContent = scanResult.visibleAlertContent()
showDuplicateTicketAlert = true
return@CameraPreview
}
EventTicketScanDecision.Ignore -> return@CameraPreview
}
}
onScan(scanResult)
} }
) )
@@ -373,7 +468,7 @@ fun ScannerScreen(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = if (batchMode) 190.dp else 56.dp) .padding(bottom = if (isBatchModeActive) 190.dp else 56.dp)
.background( .background(
color = Color.Black.copy(alpha = 0.35f), color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(18.dp) shape = RoundedCornerShape(18.dp)
@@ -381,6 +476,7 @@ fun ScannerScreen(
.padding(horizontal = 14.dp, vertical = 8.dp) .padding(horizontal = 14.dp, vertical = 8.dp)
) )
if (capabilities.allowScanFromImage) {
IconButton( IconButton(
onClick = { imagePicker.launch("image/*") }, onClick = { imagePicker.launch("image/*") },
modifier = Modifier modifier = Modifier
@@ -397,6 +493,54 @@ fun ScannerScreen(
tint = Color.White tint = Color.White
) )
} }
}
if (useCaseView == UseCaseView.EventTicketing) {
IconButton(
onClick = { whitelistPicker.launch("*/*") },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 12.dp, end = if (capabilities.allowScanFromImage) 64.dp else 12.dp)
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(14.dp)
)
) {
Icon(
imageVector = Icons.Default.UploadFile,
contentDescription = stringResource(R.string.import_whitelist),
tint = Color.White
)
}
}
Text(
text = stringResource(useCaseView.titleRes),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp, start = 64.dp, end = 64.dp)
.background(
color = Color.Black.copy(alpha = 0.4f),
shape = RoundedCornerShape(14.dp)
)
.padding(horizontal = 12.dp, vertical = 6.dp)
)
if (useCaseView == UseCaseView.EventTicketing) {
Text(
text = stringResource(R.string.whitelist_loaded_count, eventTicketWhitelistCount),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 56.dp, start = 64.dp, end = 64.dp)
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
Column( Column(
modifier = Modifier modifier = Modifier
@@ -414,6 +558,7 @@ fun ScannerScreen(
showLabel = false showLabel = false
) )
} }
if (showBatchModeToggle) {
OverlayIconToggle( OverlayIconToggle(
checked = batchMode, checked = batchMode,
onCheckedChange = onBatchModeChange, onCheckedChange = onBatchModeChange,
@@ -422,12 +567,14 @@ fun ScannerScreen(
uncheckedImageVector = Icons.AutoMirrored.Filled.ViewList uncheckedImageVector = Icons.AutoMirrored.Filled.ViewList
) )
} }
}
if (batchMode) { if (isBatchModeActive && (showBatchModeToggle || forceBatchMode)) {
Box(modifier = Modifier.align(Alignment.BottomCenter)) { Box(modifier = Modifier.align(Alignment.BottomCenter)) {
BatchResultsPanel( BatchResultsPanel(
results = batchResults, results = batchResults,
onClear = onClearBatchResults onClear = onClearBatchResults,
allowShare = capabilities.allowBatchShare
) )
} }
} }
@@ -436,7 +583,7 @@ fun ScannerScreen(
hostState = duplicateSnackbarHostState, hostState = duplicateSnackbarHostState,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = if (batchMode) 12.dp else 80.dp) .padding(bottom = if (isBatchModeActive) 12.dp else 80.dp)
) )
} else if (!galleryOpen) { } else if (!galleryOpen) {
PermissionContent( PermissionContent(
@@ -458,9 +605,13 @@ fun ScannerScreen(
) )
} }
if (lastResult != null && !batchMode) { if (lastResult != null && !isBatchModeActive) {
val parsedContact = remember(lastResult.content) { ScanContentParsers.parseContact(lastResult.content) } val parsedContact = remember(lastResult) {
val parsedEvent = remember(lastResult.content) { ScanContentParsers.parseCalendarEvent(lastResult.content) } if (lastResult.isBase64Encoded) null else ScanContentParsers.parseContact(lastResult.content)
}
val parsedEvent = remember(lastResult) {
if (lastResult.isBase64Encoded) null else ScanContentParsers.parseCalendarEvent(lastResult.content)
}
ModalBottomSheet(onDismissRequest = onScanAgain) { ModalBottomSheet(onDismissRequest = onScanAgain) {
Column( Column(
@@ -469,17 +620,31 @@ fun ScannerScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (parsedContact == null) { ResultVisualCard(
Text(text = "${stringResource(R.string.content_type)}: ${lastResult.type}") result = lastResult,
onOpenUrl = { url ->
val risk = UrlRiskScorer.score(url)
val risky = warningsEnabled && risk.score >= 3
if (risky) {
pendingOpenUrl = url
showRiskWarning = true
} else {
Intents.openUrl(context, url)
} }
ResultVisualCard(result = lastResult) }
)
val hasQuickActions = capabilities.allowCopy ||
capabilities.allowShare ||
(capabilities.allowAddContact && parsedContact != null)
if (hasQuickActions) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()), .horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (parsedContact != null) { if (capabilities.allowAddContact && parsedContact != null) {
IconButton(onClick = { IconButton(onClick = {
Intents.addContact(context, parsedContact, lastResult.content) Intents.addContact(context, parsedContact, lastResult.content)
}) { }) {
@@ -489,26 +654,15 @@ fun ScannerScreen(
) )
} }
} }
if (capabilities.allowCopy) {
IconButton(onClick = { ClipboardUtil.copy(context, lastResult.content) }) { IconButton(onClick = { ClipboardUtil.copy(context, lastResult.content) }) {
Icon( Icon(
imageVector = Icons.Default.ContentCopy, imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.copy) contentDescription = stringResource(R.string.copy)
) )
} }
if (lastResult.type == "URL") {
Button(onClick = {
val risk = UrlRiskScorer.score(lastResult.content)
val risky = warningsEnabled && risk.score >= 3
if (risky) {
pendingOpenUrl = lastResult.content
showRiskWarning = true
} else {
Intents.openUrl(context, lastResult.content)
}
}) {
Text(stringResource(R.string.open))
}
} }
if (capabilities.allowShare) {
IconButton(onClick = { Intents.shareText(context, lastResult.content) }) { IconButton(onClick = { Intents.shareText(context, lastResult.content) }) {
Icon( Icon(
imageVector = Icons.Default.Share, imageVector = Icons.Default.Share,
@@ -516,17 +670,23 @@ fun ScannerScreen(
) )
} }
} }
}
}
if (!lastResult.isBase64Encoded) {
when (lastResult.type) { when (lastResult.type) {
"Phone" -> { "Phone" -> {
if (capabilities.allowDialPhone) {
Button(onClick = { Button(onClick = {
Intents.dialPhone(context, ScanContentParsers.extractPhoneNumber(lastResult.content)) Intents.dialPhone(context, ScanContentParsers.extractPhoneNumber(lastResult.content))
}) { }) {
Text(stringResource(R.string.call_number)) Text(stringResource(R.string.call_number))
} }
} }
}
"SMS" -> { "SMS" -> {
if (capabilities.allowSendSms) {
Button(onClick = { Button(onClick = {
val smsData = ScanContentParsers.parseSms(lastResult.content) val smsData = ScanContentParsers.parseSms(lastResult.content)
Intents.sendSms(context, smsData.first, smsData.second) Intents.sendSms(context, smsData.first, smsData.second)
@@ -534,22 +694,28 @@ fun ScannerScreen(
Text(stringResource(R.string.send_sms)) Text(stringResource(R.string.send_sms))
} }
} }
}
"Email" -> { "Email" -> {
if (capabilities.allowSendEmail) {
Button(onClick = { Button(onClick = {
Intents.sendEmail(context, ScanContentParsers.extractEmail(lastResult.content), null) Intents.sendEmail(context, ScanContentParsers.extractEmail(lastResult.content), null)
}) { }) {
Text(stringResource(R.string.send_email)) Text(stringResource(R.string.send_email))
} }
} }
}
"WiFi" -> { "WiFi" -> {
if (capabilities.allowOpenWifiSettings) {
Button(onClick = { Intents.openWifiSettings(context) }) { Button(onClick = { Intents.openWifiSettings(context) }) {
Text(stringResource(R.string.open_wifi_settings)) Text(stringResource(R.string.open_wifi_settings))
} }
} }
}
"Calendar" -> { "Calendar" -> {
if (capabilities.allowAddCalendarEvent) {
Button(onClick = { Button(onClick = {
Intents.addCalendarEvent(context, parsedEvent, lastResult.content) Intents.addCalendarEvent(context, parsedEvent, lastResult.content)
}) { }) {
@@ -560,6 +726,8 @@ fun ScannerScreen(
} }
} }
} }
}
}
if (showRiskWarning && pendingOpenUrl != null) { if (showRiskWarning && pendingOpenUrl != null) {
AlertDialog( AlertDialog(
@@ -579,6 +747,51 @@ fun ScannerScreen(
) )
} }
if (showDuplicateTicketAlert && useCaseView == UseCaseView.EventTicketing) {
AlertDialog(
onDismissRequest = { showDuplicateTicketAlert = false },
title = { Text(text = stringResource(R.string.duplicate_ticket_alert_title), color = Color(0xFFB00020)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = stringResource(R.string.duplicate_ticket_alert_message))
if (!duplicateTicketAlertContent.isNullOrBlank()) {
Text(
text = stringResource(R.string.duplicate_ticket_alert_code, duplicateTicketAlertContent!!),
color = Color(0xFFB00020)
)
}
}
},
confirmButton = {
TextButton(onClick = { showDuplicateTicketAlert = false }) {
Text(stringResource(R.string.confirm))
}
}
)
}
if (showUnregisteredTicketAlert && useCaseView == UseCaseView.EventTicketing) {
AlertDialog(
onDismissRequest = { showUnregisteredTicketAlert = false },
title = { Text(text = stringResource(R.string.unregistered_ticket_alert_title), color = Color(0xFFB00020)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = stringResource(R.string.unregistered_ticket_alert_message))
if (!unregisteredTicketAlertContent.isNullOrBlank()) {
Text(
text = stringResource(R.string.unregistered_ticket_alert_code, unregisteredTicketAlertContent!!),
color = Color(0xFFB00020)
)
}
}
},
confirmButton = {
TextButton(onClick = { showUnregisteredTicketAlert = false }) {
Text(stringResource(R.string.confirm))
}
}
)
}
if (imageScanPreviewUri != null) { if (imageScanPreviewUri != null) {
GalleryScanPreviewDialog( GalleryScanPreviewDialog(
imageUri = imageScanPreviewUri, imageUri = imageScanPreviewUri,
@@ -608,3 +821,13 @@ fun ScannerScreen(
} }
} }
} }
private fun parseWhitelistIds(raw: String): Set<String> {
if (raw.isBlank()) return emptySet()
return raw
.split('\n', '\r', ',', ';', '\t')
.asSequence()
.map { it.trim().lowercase() }
.filter { it.isNotBlank() }
.toSet()
}
@@ -1,14 +1,13 @@
package com.clean.scanner.ui.screens package de.softwareapp_hb.privateqrscanner.ui.screens
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -19,22 +18,24 @@ 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.util.Intents import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import de.softwareapp_hb.privateqrscanner.util.InAppReviewRequester
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
historyEnabled: Boolean, historyEnabled: Boolean,
warningsEnabled: Boolean, warningsEnabled: Boolean,
scanFeedbackEnabled: Boolean, scanFeedbackEnabled: Boolean,
selectedUseCaseView: UseCaseView,
onHistoryToggle: (Boolean, Boolean) -> Unit, onHistoryToggle: (Boolean, Boolean) -> Unit,
onWarningsToggle: (Boolean) -> Unit, onWarningsToggle: (Boolean) -> Unit,
onScanFeedbackToggle: (Boolean) -> Unit onScanFeedbackToggle: (Boolean) -> Unit,
onUseCaseViewSelected: (UseCaseView) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val showDeleteConfirm = remember { mutableStateOf(false) } val showDeleteConfirm = remember { mutableStateOf(false) }
val showFeatureRequestForm = remember { mutableStateOf(false) } val showUseCasePicker = remember { mutableStateOf(false) }
val requesterNeed = remember { mutableStateOf("") }
if (showDeleteConfirm.value) { if (showDeleteConfirm.value) {
AlertDialog( AlertDialog(
@@ -56,45 +57,28 @@ fun SettingsScreen(
) )
} }
if (showFeatureRequestForm.value) { if (showUseCasePicker.value) {
AlertDialog( AlertDialog(
onDismissRequest = { showFeatureRequestForm.value = false }, onDismissRequest = { showUseCasePicker.value = false },
title = { Text(stringResource(R.string.feature_request_title)) }, title = { Text(stringResource(R.string.select_use_case_view)) },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedTextField( UseCaseView.entries.forEach { candidate ->
value = requesterNeed.value,
onValueChange = { requesterNeed.value = it },
label = { Text(stringResource(R.string.feature_request_details)) }
)
}
},
confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
val body = buildString { onUseCaseViewSelected(candidate)
appendLine("Request:") showUseCasePicker.value = false
append(requesterNeed.value.trim()) },
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(candidate.titleRes))
}
}
} }
Intents.sendEmail(
context = context,
email = context.getString(R.string.support_email),
subject = context.getString(R.string.feature_request_subject),
body = body
)
showFeatureRequestForm.value = false
requesterNeed.value = ""
Toast.makeText(
context,
context.getString(R.string.feature_request_sent),
Toast.LENGTH_SHORT
).show()
},
enabled = requesterNeed.value.isNotBlank()
) { Text(stringResource(R.string.send_request)) }
}, },
confirmButton = {},
dismissButton = { dismissButton = {
TextButton(onClick = { showFeatureRequestForm.value = false }) { TextButton(onClick = { showUseCasePicker.value = false }) {
Text(stringResource(R.string.cancel)) Text(stringResource(R.string.cancel))
} }
} }
@@ -129,14 +113,22 @@ fun SettingsScreen(
Text(text = stringResource(R.string.scan_feedback)) Text(text = stringResource(R.string.scan_feedback))
Switch(checked = scanFeedbackEnabled, onCheckedChange = onScanFeedbackToggle) Switch(checked = scanFeedbackEnabled, onCheckedChange = onScanFeedbackToggle)
Spacer(modifier = Modifier.height(16.dp))
Text(text = stringResource(R.string.active_use_case_view))
Text(text = stringResource(selectedUseCaseView.titleRes))
TextButton(onClick = { showUseCasePicker.value = true }) {
Text(stringResource(R.string.select_use_case_view))
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text(text = stringResource(R.string.about)) Text(text = stringResource(R.string.about))
Text(text = stringResource(R.string.version)) Text(text = stringResource(R.string.version))
Text(text = stringResource(R.string.licenses)) Text(text = stringResource(R.string.licenses))
Text(text = stringResource(R.string.contact)) Text(text = stringResource(R.string.contact))
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
TextButton(onClick = { showFeatureRequestForm.value = true }) { TextButton(onClick = { InAppReviewRequester.requestReview(context) }) {
Text(text = stringResource(R.string.feature_request)) Text(text = stringResource(R.string.review_app))
} }
} }
} }
@@ -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
@@ -0,0 +1,86 @@
package de.softwareapp_hb.privateqrscanner.util
import com.google.mlkit.vision.barcode.common.Barcode
import java.util.Base64
internal data class ReadableBarcodePayload(
val content: String,
val isBase64Encoded: Boolean
)
fun Barcode.readablePayload(): String? {
return readableBarcodePayload()?.content
}
internal fun Barcode.readableBarcodePayload(): ReadableBarcodePayload? {
return readablePayload(rawValue, displayValue, rawBytes)
}
internal fun readablePayload(
rawValue: String?,
displayValue: String?,
rawBytes: ByteArray?
): ReadableBarcodePayload? {
val bytes = rawBytes?.takeIf { it.isNotEmpty() }
rawValue?.trim()?.takeIf { it.isNotBlank() }?.let { value ->
return if (value.isLikelyHumanReadable()) {
ReadableBarcodePayload(content = value, isBase64Encoded = false)
} else {
value.asBase64Payload(bytes)
}
}
displayValue?.trim()?.takeIf { it.isNotBlank() }?.let { value ->
return if (value.isLikelyHumanReadable()) {
ReadableBarcodePayload(content = value, isBase64Encoded = false)
} else {
value.asBase64Payload(bytes)
}
}
bytes ?: return null
val utf8 = bytes.toString(Charsets.UTF_8).trim()
if (utf8.isLikelyHumanReadable()) {
return ReadableBarcodePayload(content = utf8, isBase64Encoded = false)
}
return bytes.asBase64Payload()
}
private fun String.isLikelyHumanReadable(): Boolean {
if (isBlank()) return false
var index = 0
while (index < length) {
val codePoint = codePointAt(index)
val charCount = Character.charCount(codePoint)
if (
Character.isISOControl(codePoint) && codePoint != '\n'.code &&
codePoint != '\r'.code && codePoint != '\t'.code
) {
return false
}
if (
codePoint == '\uFFFD'.code ||
(this[index].isSurrogate() && charCount == 1) ||
codePoint.isNonCharacter()
) {
return false
}
index += charCount
}
return true
}
private fun String.asBase64Payload(rawBytes: ByteArray?): ReadableBarcodePayload {
return rawBytes?.asBase64Payload() ?: toByteArray(Charsets.UTF_8).asBase64Payload()
}
private fun ByteArray.asBase64Payload(): ReadableBarcodePayload {
return ReadableBarcodePayload(
content = Base64.getEncoder().encodeToString(this),
isBase64Encoded = true
)
}
private fun Int.isNonCharacter(): Boolean {
return this in 0xFDD0..0xFDEF || (this and 0xFFFE) == 0xFFFE
}
@@ -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
@@ -0,0 +1,26 @@
package de.softwareapp_hb.privateqrscanner.util
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import com.google.android.play.core.review.ReviewManagerFactory
object InAppReviewRequester {
fun requestReview(context: Context) {
val activity = context.findActivity() ?: return
val manager = ReviewManagerFactory.create(activity)
manager.requestReviewFlow().addOnCompleteListener { request ->
if (request.isSuccessful) {
manager.launchReviewFlow(activity, request.result)
}
}
}
private tailrec fun Context.findActivity(): Activity? {
return when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
}
}
@@ -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
reasons += "URL uses HTTP"
} }
if (host.contains("xn--", ignoreCase = true)) {
score += 2 if (trimmed.any { it.isISOControl() || it.isWhitespace() }) {
reasons += "Host contains punycode" 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) { 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")
if (isPrivateOrReservedIpv4(ipv4)) {
add(2, "Host is a private or reserved IP address")
} }
val percentEncodedCount = Regex("%[0-9a-fA-F]{2}").findAll(raw).count() } else if (isIpv6Literal(hostDetails.rawHost)) {
if (percentEncodedCount > 10) { add(2, "Host is an IP address")
score += 1 if (isPrivateOrReservedIpv6(hostDetails.rawHost)) {
reasons += "Many percent-encodings" add(2, "Host is a private or reserved IP address")
} }
if (!uri.userInfo.isNullOrBlank()) { } else if (host.isNotBlank()) {
score += 2 scoreDomainShape(host, labels, ::add)
reasons += "Contains userinfo" 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" />
+19 -11
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>
@@ -28,9 +28,11 @@
<string name="delete_history_on_disable">Vorhandene Historie beim Deaktivieren löschen?</string> <string name="delete_history_on_disable">Vorhandene Historie beim Deaktivieren löschen?</string>
<string name="version">Version 1.0.0</string> <string name="version">Version 1.0.0</string>
<string name="licenses">Open-Source-Lizenzen</string> <string name="licenses">Open-Source-Lizenzen</string>
<string name="contact">Kontakt: support@example.com</string> <string name="contact">Kontakt: softwareapp.hb@gmail.com</string>
<string name="content_type">Typ</string> <string name="content_type">Typ</string>
<string name="content_value">Inhalt</string> <string name="content_value">Inhalt</string>
<string name="base64_encoded_notice">Als Base64 angezeigt, weil die gescannten Daten nicht als Text dargestellt werden können.</string>
<string name="base64_encoded_inline">Base64-codiert: %1$s</string>
<string name="request_camera">Kamera erlauben</string> <string name="request_camera">Kamera erlauben</string>
<string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string> <string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string>
<string name="aim_center_hint">Code im mittleren Rahmen ausrichten.</string> <string name="aim_center_hint">Code im mittleren Rahmen ausrichten.</string>
@@ -41,6 +43,10 @@
<string name="share_csv">CSV</string> <string name="share_csv">CSV</string>
<string name="share_json">JSON</string> <string name="share_json">JSON</string>
<string name="scan_from_image">Aus Bild scannen</string> <string name="scan_from_image">Aus Bild scannen</string>
<string name="import_whitelist">Whitelist importieren</string>
<string name="whitelist_loaded_count">Geladene registrierte IDs: %1$d</string>
<string name="whitelist_import_success">%1$d IDs importiert.</string>
<string name="whitelist_import_empty">IDs konnten aus dieser Datei nicht importiert werden.</string>
<string name="batch_mode">Stapelmodus</string> <string name="batch_mode">Stapelmodus</string>
<string name="batch_captures_count">Stapel-Scans: %1$d</string> <string name="batch_captures_count">Stapel-Scans: %1$d</string>
<string name="clear_batch">Stapel leeren</string> <string name="clear_batch">Stapel leeren</string>
@@ -50,6 +56,12 @@
<string name="image_scan_pick_subtitle">Wähle ein Ergebnis aus:</string> <string name="image_scan_pick_subtitle">Wähle ein Ergebnis aus:</string>
<string name="image_scan_failed">Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen.</string> <string name="image_scan_failed">Dieses Bild konnte nicht gelesen werden. Bitte anderes Bild versuchen.</string>
<string name="already_scanned">Bereits gescannt</string> <string name="already_scanned">Bereits gescannt</string>
<string name="duplicate_ticket_alert_title">Doppeltes Ticket erkannt</string>
<string name="duplicate_ticket_alert_message">Dieser Ticket-/Code wurde bereits gescannt. Bitte Eintritt sofort prüfen, um Betrug zu verhindern.</string>
<string name="duplicate_ticket_alert_code">Code: %1$s</string>
<string name="unregistered_ticket_alert_title">Nicht registriertes Ticket erkannt</string>
<string name="unregistered_ticket_alert_message">Dieser Ticket-/Code ist nicht in der importierten Whitelist. Bitte Registrierung prüfen.</string>
<string name="unregistered_ticket_alert_code">Code: %1$s</string>
<string name="view_history">Historie anzeigen</string> <string name="view_history">Historie anzeigen</string>
<string name="call_number">Nummer anrufen</string> <string name="call_number">Nummer anrufen</string>
<string name="send_sms">SMS senden</string> <string name="send_sms">SMS senden</string>
@@ -57,13 +69,9 @@
<string name="open_wifi_settings">WLAN-Einstellungen öffnen</string> <string name="open_wifi_settings">WLAN-Einstellungen öffnen</string>
<string name="add_contact">Kontakt hinzufügen</string> <string name="add_contact">Kontakt hinzufügen</string>
<string name="add_calendar_event">Kalendereintrag hinzufügen</string> <string name="add_calendar_event">Kalendereintrag hinzufügen</string>
<string name="support_email">support@example.com</string> <string name="review_app">Bei Google Play bewerten</string>
<string name="feature_request">Feature-Request-Formular</string> <string name="active_use_case_view">Aktive Use-Case-Ansicht</string>
<string name="feature_request_title">Feature-Request</string> <string name="select_use_case_view">Use-Case-Ansicht wählen</string>
<string name="feature_request_name">Dein Name</string> <string name="use_case_everyday_personal">Alltägliche private Nutzung</string>
<string name="feature_request_email">Deine E-Mail</string> <string name="use_case_event_ticketing">Events &amp; Ticketing</string>
<string name="feature_request_details">Was brauchst du?</string>
<string name="feature_request_subject">Feature-Request von App-Nutzer</string>
<string name="send_request">Anfrage senden</string>
<string name="feature_request_sent">E-Mail-App wird geöffnet...</string>
</resources> </resources>
+19 -11
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>
@@ -28,9 +28,11 @@
<string name="delete_history_on_disable">Delete existing history when disabling?</string> <string name="delete_history_on_disable">Delete existing history when disabling?</string>
<string name="version">Version 1.0.0</string> <string name="version">Version 1.0.0</string>
<string name="licenses">Open-source licenses</string> <string name="licenses">Open-source licenses</string>
<string name="contact">Contact: support@example.com</string> <string name="contact">Contact: softwareapp.hb@gmail.com</string>
<string name="content_type">Type</string> <string name="content_type">Type</string>
<string name="content_value">Content</string> <string name="content_value">Content</string>
<string name="base64_encoded_notice">Displayed as Base64 because the scanned data cannot be shown as text.</string>
<string name="base64_encoded_inline">Base64 encoded: %1$s</string>
<string name="request_camera">Allow camera</string> <string name="request_camera">Allow camera</string>
<string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string> <string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string>
<string name="aim_center_hint">Aim the code inside the center frame.</string> <string name="aim_center_hint">Aim the code inside the center frame.</string>
@@ -41,6 +43,10 @@
<string name="share_csv">CSV</string> <string name="share_csv">CSV</string>
<string name="share_json">JSON</string> <string name="share_json">JSON</string>
<string name="scan_from_image">Scan from image</string> <string name="scan_from_image">Scan from image</string>
<string name="import_whitelist">Import whitelist</string>
<string name="whitelist_loaded_count">Registered IDs loaded: %1$d</string>
<string name="whitelist_import_success">Imported %1$d IDs.</string>
<string name="whitelist_import_empty">Could not import IDs from this file.</string>
<string name="batch_mode">Batch mode</string> <string name="batch_mode">Batch mode</string>
<string name="batch_captures_count">Batch captures: %1$d</string> <string name="batch_captures_count">Batch captures: %1$d</string>
<string name="clear_batch">Clear batch</string> <string name="clear_batch">Clear batch</string>
@@ -50,6 +56,12 @@
<string name="image_scan_pick_subtitle">Choose a result to use:</string> <string name="image_scan_pick_subtitle">Choose a result to use:</string>
<string name="image_scan_failed">Could not read this image. Try another one.</string> <string name="image_scan_failed">Could not read this image. Try another one.</string>
<string name="already_scanned">Already scanned</string> <string name="already_scanned">Already scanned</string>
<string name="duplicate_ticket_alert_title">Duplicate ticket detected</string>
<string name="duplicate_ticket_alert_message">This ticket/code was scanned before. Verify entry immediately to prevent fraud.</string>
<string name="duplicate_ticket_alert_code">Code: %1$s</string>
<string name="unregistered_ticket_alert_title">Unregistered ticket detected</string>
<string name="unregistered_ticket_alert_message">This ticket/code is not in the imported whitelist. Verify attendee registration.</string>
<string name="unregistered_ticket_alert_code">Code: %1$s</string>
<string name="view_history">View history</string> <string name="view_history">View history</string>
<string name="call_number">Call number</string> <string name="call_number">Call number</string>
<string name="send_sms">Send SMS</string> <string name="send_sms">Send SMS</string>
@@ -57,13 +69,9 @@
<string name="open_wifi_settings">Open Wi-Fi settings</string> <string name="open_wifi_settings">Open Wi-Fi settings</string>
<string name="add_contact">Add contact</string> <string name="add_contact">Add contact</string>
<string name="add_calendar_event">Add calendar event</string> <string name="add_calendar_event">Add calendar event</string>
<string name="support_email">support@example.com</string> <string name="review_app">Review on Google Play</string>
<string name="feature_request">Feature request form</string> <string name="active_use_case_view">Active use-case view</string>
<string name="feature_request_title">Feature request</string> <string name="select_use_case_view">Select use-case view</string>
<string name="feature_request_name">Your name</string> <string name="use_case_everyday_personal">Everyday personal use</string>
<string name="feature_request_email">Your email</string> <string name="use_case_event_ticketing">Event &amp; ticketing</string>
<string name="feature_request_details">What do you need?</string>
<string name="feature_request_subject">Feature request from app user</string>
<string name="send_request">Send request</string>
<string name="feature_request_sent">Opening email app...</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
@@ -115,4 +115,21 @@ class ScannerViewModelTest {
assertEquals(1, saved.size) assertEquals(1, saved.size)
assertEquals(1, state.batchResults.size) assertEquals(1, state.batchResults.size)
} }
@Test
fun onScan_base64EncodedScan_savesTypeWithBase64Marker() = runTest {
val saved = mutableListOf<Pair<String, String>>()
val viewModel = ScannerViewModel(
saveScan = { content, type -> saved += content to type },
nowProvider = { 1_000L }
)
viewModel.onScan(ScanResult(content = "AAEC", type = "Text", isBase64Encoded = true))
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals("Text (Base64)", state.lastResult?.displayType)
assertEquals(listOf("Text (Base64)|AAEC"), state.recentScanKeys)
assertEquals(listOf("AAEC" to "Text (Base64)"), saved)
}
} }
@@ -0,0 +1,71 @@
package de.softwareapp_hb.privateqrscanner.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BarcodePayloadTest {
@Test
fun readablePayload_prefersDisplayableRawValue() {
val payload = readablePayload(
rawValue = " hello world ",
displayValue = null,
rawBytes = null
)
assertEquals("hello world", payload?.content)
assertFalse(payload?.isBase64Encoded ?: true)
}
@Test
fun readablePayload_keepsDisplayableUtf8Bytes() {
val text = "Ticket \uD83D\uDE00"
val payload = readablePayload(
rawValue = null,
displayValue = null,
rawBytes = text.toByteArray(Charsets.UTF_8)
)
assertEquals(text, payload?.content)
assertFalse(payload?.isBase64Encoded ?: true)
}
@Test
fun readablePayload_base64EncodesBinaryBytes() {
val payload = readablePayload(
rawValue = null,
displayValue = null,
rawBytes = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0xFF.toByte())
)
assertEquals("AAECA/8=", payload?.content)
assertTrue(payload?.isBase64Encoded ?: false)
}
@Test
fun readablePayload_base64EncodesRawBytesWhenRawValueContainsReplacementCharacter() {
val payload = readablePayload(
rawValue = "broken \uFFFD",
displayValue = null,
rawBytes = byteArrayOf(0x00, 0x01, 0x02)
)
assertEquals("AAEC", payload?.content)
assertTrue(payload?.isBase64Encoded ?: false)
}
@Test
fun readablePayload_base64EncodesRawValueWhenNoBytesAreAvailable() {
val payload = readablePayload(
rawValue = "A\u0000B",
displayValue = null,
rawBytes = null
)
assertEquals("QQBC", payload?.content)
assertTrue(payload?.isBase64Encoded ?: false)
}
}
@@ -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) }
)
}
} }
+2 -2
View File
@@ -1,5 +1,5 @@
plugins { plugins {
id("com.android.application") version "9.0.1" apply false id("com.android.application") version "9.2.1" apply false
id("com.google.devtools.ksp") version "2.3.5" apply false id("com.google.devtools.ksp") version "2.3.5" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
} }
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+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