Compare commits
10 Commits
5d83ff4a6d
...
cd73c35c4d
| Author | SHA1 | Date | |
|---|---|---|---|
| cd73c35c4d | |||
| 1b610f6c4d | |||
| 01922c16e7 | |||
| ad45c7ef27 | |||
| 00e485da6e | |||
| 4c443a0b86 | |||
| a0646273bc | |||
| 3d7620954f | |||
| fb94b7214a | |||
| 229244d878 |
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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 |
Reference in New Issue
Block a user