diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9871477
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+# Gradle
+.gradle/
+build/
+**/build/
+
+# Android Studio / IntelliJ
+.idea/
+*.iml
+
+# Local machine config
+local.properties
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c991d84
--- /dev/null
+++ b/README.md
@@ -0,0 +1,35 @@
+# Clean Scanner (MVP)
+
+Offline-first, ad-free QR/barcode scanner built with Kotlin + Compose + CameraX + ML Kit.
+
+## Architektur
+- `ui/`: Compose-Screens + ViewModels (MVVM presentation layer)
+- `data/`: Scanner-Analyzer, Room entities/DAO, Repository
+- `domain/`: app models (`ScanResult`, `ScanRecord`, `UrlRiskResult`)
+- `settings/`: DataStore preferences (history toggle, warnings toggle)
+- `util/`: URL risk scorer, clipboard, intents
+
+## Datenschutz
+- Keine Werbung
+- Keine Tracker/Analytics/Crashlytics
+- Kein Backend/keine Webrequests
+- Keine `INTERNET`-Permission im Manifest
+
+## MVP Features
+- Startscreen mit Scan-Button und lokalem Historie-Toggle (Default: OFF)
+- Scanner mit CameraX live preview, Taschenlampe, debounce/no double scan
+- Ergebnis-Bottom-Sheet mit Copy/Share/Open/Scan again
+- Lokale URL-Risikoheuristik mit Warn-Dialog ab Score `>= 3`
+- Historie-Liste inkl. Suche, Swipe-delete, Alles-löschen
+- Einstellungen: Historie an/aus, Warnungen an/aus, About
+
+## Run
+1. In Android Studio: Open this folder as project.
+2. Let Gradle sync dependencies.
+3. Run app on emulator/device (API 24+).
+
+## Tests
+- URL Risk Scorer Tests: `app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt` (11 cases)
+
+## Hinweis
+In dieser Umgebung war kein `gradle`/`gradlew` verfügbar, daher konnte ich Builds/Tests hier nicht lokal ausführen.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..e80df87
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,100 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.devtools.ksp")
+}
+
+android {
+ namespace = "com.clean.scanner"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.clean.scanner"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0.0"
+ buildConfigField("boolean", "FEATURE_PAYWALL_ENABLED", "false")
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.14"
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
+ implementation("androidx.activity:activity-compose:1.9.2")
+
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.navigation:navigation-compose:2.8.2")
+
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+
+ implementation("androidx.camera:camera-core:1.3.4")
+ implementation("androidx.camera:camera-camera2:1.3.4")
+ implementation("androidx.camera:camera-lifecycle:1.3.4")
+ implementation("androidx.camera:camera-view:1.3.4")
+
+ implementation("com.google.mlkit:barcode-scanning:17.3.0")
+
+ implementation("androidx.room:room-runtime:2.6.1")
+ implementation("androidx.room:room-ktx:2.6.1")
+ ksp("androidx.room:room-compiler:2.6.1")
+
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..20d5951
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Keep minimal for MVP
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..7657033
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/clean/scanner/AppContainer.kt b/app/src/main/java/com/clean/scanner/AppContainer.kt
new file mode 100644
index 0000000..d21f8ac
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/AppContainer.kt
@@ -0,0 +1,23 @@
+package com.clean.scanner
+
+import android.content.Context
+import androidx.room.Room
+import com.clean.scanner.data.local.CleanScannerDatabase
+import com.clean.scanner.data.repository.ScanRepository
+import com.clean.scanner.settings.SettingsRepository
+
+class AppContainer(context: Context) {
+ private val appContext = context.applicationContext
+
+ private val db: CleanScannerDatabase by lazy {
+ Room.databaseBuilder(appContext, CleanScannerDatabase::class.java, "clean_scanner.db").build()
+ }
+
+ val settingsRepository: SettingsRepository by lazy {
+ SettingsRepository(appContext)
+ }
+
+ val scanRepository: ScanRepository by lazy {
+ ScanRepository(db.scanDao(), settingsRepository)
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/CleanScannerApp.kt b/app/src/main/java/com/clean/scanner/CleanScannerApp.kt
new file mode 100644
index 0000000..2d0169a
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/CleanScannerApp.kt
@@ -0,0 +1,13 @@
+package com.clean.scanner
+
+import android.app.Application
+
+class CleanScannerApp : Application() {
+ lateinit var appContainer: AppContainer
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ appContainer = AppContainer(this)
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/MainActivity.kt b/app/src/main/java/com/clean/scanner/MainActivity.kt
new file mode 100644
index 0000000..d88f4da
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/MainActivity.kt
@@ -0,0 +1,22 @@
+package com.clean.scanner
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import com.clean.scanner.ui.CleanScannerAppRoot
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val container = (application as CleanScannerApp).appContainer
+ setContent {
+ MaterialTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ CleanScannerAppRoot(container)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/data/local/CleanScannerDatabase.kt b/app/src/main/java/com/clean/scanner/data/local/CleanScannerDatabase.kt
new file mode 100644
index 0000000..8cc247c
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/data/local/CleanScannerDatabase.kt
@@ -0,0 +1,9 @@
+package com.clean.scanner.data.local
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(entities = [ScanEntity::class], version = 1, exportSchema = false)
+abstract class CleanScannerDatabase : RoomDatabase() {
+ abstract fun scanDao(): ScanDao
+}
diff --git a/app/src/main/java/com/clean/scanner/data/local/ScanDao.kt b/app/src/main/java/com/clean/scanner/data/local/ScanDao.kt
new file mode 100644
index 0000000..9ad4f45
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/data/local/ScanDao.kt
@@ -0,0 +1,21 @@
+package com.clean.scanner.data.local
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface ScanDao {
+ @Query("SELECT * FROM scan_history ORDER BY timestamp DESC")
+ fun observeAll(): Flow>
+
+ @Insert
+ suspend fun insert(entity: ScanEntity)
+
+ @Query("DELETE FROM scan_history WHERE id = :id")
+ suspend fun deleteById(id: Long)
+
+ @Query("DELETE FROM scan_history")
+ suspend fun clearAll()
+}
diff --git a/app/src/main/java/com/clean/scanner/data/local/ScanEntity.kt b/app/src/main/java/com/clean/scanner/data/local/ScanEntity.kt
new file mode 100644
index 0000000..9268122
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/data/local/ScanEntity.kt
@@ -0,0 +1,12 @@
+package com.clean.scanner.data.local
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "scan_history")
+data class ScanEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val content: String,
+ val type: String,
+ val timestamp: Long
+)
diff --git a/app/src/main/java/com/clean/scanner/data/repository/ScanRepository.kt b/app/src/main/java/com/clean/scanner/data/repository/ScanRepository.kt
new file mode 100644
index 0000000..cb9bc71
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/data/repository/ScanRepository.kt
@@ -0,0 +1,41 @@
+package com.clean.scanner.data.repository
+
+import com.clean.scanner.data.local.ScanDao
+import com.clean.scanner.data.local.ScanEntity
+import com.clean.scanner.domain.ScanRecord
+import com.clean.scanner.settings.SettingsRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+class ScanRepository(
+ private val dao: ScanDao,
+ private val settingsRepository: SettingsRepository
+) {
+ fun observeHistory(): Flow> = dao.observeAll().map { list ->
+ list.map { entity ->
+ ScanRecord(
+ id = entity.id,
+ content = entity.content,
+ type = entity.type,
+ timestamp = entity.timestamp
+ )
+ }
+ }
+
+ suspend fun maybeSaveScan(content: String, type: String) {
+ val historyEnabled = settingsRepository.historyEnabled.first()
+ if (!historyEnabled) return
+ dao.insert(
+ ScanEntity(
+ content = content,
+ type = type,
+ timestamp = System.currentTimeMillis()
+ )
+ )
+ }
+
+ suspend fun deleteById(id: Long) = dao.deleteById(id)
+
+ suspend fun clearAll() = dao.clearAll()
+}
diff --git a/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt b/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt
new file mode 100644
index 0000000..05c4898
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt
@@ -0,0 +1,47 @@
+package com.clean.scanner.data.scanner
+
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import com.clean.scanner.domain.ScanResult
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.common.InputImage
+
+class MlKitBarcodeAnalyzer(
+ private val onDetected: (ScanResult) -> Unit
+) : ImageAnalysis.Analyzer {
+
+ private val scanner = BarcodeScanning.getClient()
+
+ override fun analyze(imageProxy: ImageProxy) {
+ val mediaImage = imageProxy.image ?: run {
+ imageProxy.close()
+ return
+ }
+ val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
+
+ scanner.process(image)
+ .addOnSuccessListener { barcodes ->
+ val first = barcodes.firstOrNull() ?: return@addOnSuccessListener
+ val raw = first.rawValue ?: return@addOnSuccessListener
+ val type = first.valueType.toHumanType()
+ onDetected(ScanResult(content = raw, type = type))
+ }
+ .addOnCompleteListener { imageProxy.close() }
+ }
+
+ private fun Int.toHumanType(): String = when (this) {
+ 1 -> "Contact"
+ 2 -> "Email"
+ 3 -> "ISBN"
+ 4 -> "Phone"
+ 5 -> "Product"
+ 6 -> "SMS"
+ 7 -> "Text"
+ 8 -> "URL"
+ 9 -> "WiFi"
+ 10 -> "Geo"
+ 11 -> "Calendar"
+ 12 -> "Driver license"
+ else -> "Unknown"
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/domain/ScanRecord.kt b/app/src/main/java/com/clean/scanner/domain/ScanRecord.kt
new file mode 100644
index 0000000..3c7b8a2
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/domain/ScanRecord.kt
@@ -0,0 +1,8 @@
+package com.clean.scanner.domain
+
+data class ScanRecord(
+ val id: Long,
+ val content: String,
+ val type: String,
+ val timestamp: Long
+)
diff --git a/app/src/main/java/com/clean/scanner/domain/ScanResult.kt b/app/src/main/java/com/clean/scanner/domain/ScanResult.kt
new file mode 100644
index 0000000..39b4808
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/domain/ScanResult.kt
@@ -0,0 +1,6 @@
+package com.clean.scanner.domain
+
+data class ScanResult(
+ val content: String,
+ val type: String
+)
diff --git a/app/src/main/java/com/clean/scanner/domain/UrlRiskResult.kt b/app/src/main/java/com/clean/scanner/domain/UrlRiskResult.kt
new file mode 100644
index 0000000..e229972
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/domain/UrlRiskResult.kt
@@ -0,0 +1,6 @@
+package com.clean.scanner.domain
+
+data class UrlRiskResult(
+ val score: Int,
+ val reasons: List
+)
diff --git a/app/src/main/java/com/clean/scanner/settings/SettingsRepository.kt b/app/src/main/java/com/clean/scanner/settings/SettingsRepository.kt
new file mode 100644
index 0000000..fa90b52
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/settings/SettingsRepository.kt
@@ -0,0 +1,33 @@
+package com.clean.scanner.settings
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.dataStore by preferencesDataStore(name = "clean_scanner_settings")
+
+class SettingsRepository(private val context: Context) {
+ private object Keys {
+ val historyEnabled = booleanPreferencesKey("history_enabled")
+ val warningsEnabled = booleanPreferencesKey("warnings_enabled")
+ }
+
+ val historyEnabled: Flow = context.dataStore.data.map { prefs ->
+ prefs[Keys.historyEnabled] ?: false
+ }
+
+ val warningsEnabled: Flow = context.dataStore.data.map { prefs ->
+ prefs[Keys.warningsEnabled] ?: true
+ }
+
+ suspend fun setHistoryEnabled(enabled: Boolean) {
+ context.dataStore.edit { it[Keys.historyEnabled] = enabled }
+ }
+
+ suspend fun setWarningsEnabled(enabled: Boolean) {
+ context.dataStore.edit { it[Keys.warningsEnabled] = enabled }
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/ui/AppViewModel.kt b/app/src/main/java/com/clean/scanner/ui/AppViewModel.kt
new file mode 100644
index 0000000..7470cb1
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/ui/AppViewModel.kt
@@ -0,0 +1,81 @@
+package com.clean.scanner.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.clean.scanner.AppContainer
+import com.clean.scanner.domain.ScanRecord
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+data class AppUiState(
+ val historyEnabled: Boolean = false,
+ val warningsEnabled: Boolean = true,
+ val history: List = emptyList(),
+ val searchQuery: String = ""
+)
+
+class AppViewModel(
+ private val container: AppContainer
+) : ViewModel() {
+
+ private val query = MutableStateFlow("")
+
+ val uiState: StateFlow = combine(
+ container.settingsRepository.historyEnabled,
+ container.settingsRepository.warningsEnabled,
+ container.scanRepository.observeHistory(),
+ query
+ ) { historyEnabled, warningsEnabled, history, q ->
+ AppUiState(
+ historyEnabled = historyEnabled,
+ warningsEnabled = warningsEnabled,
+ history = if (q.isBlank()) history else history.filter {
+ it.content.contains(q, ignoreCase = true) || it.type.contains(q, ignoreCase = true)
+ },
+ searchQuery = q
+ )
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppUiState())
+
+ fun setQuery(value: String) {
+ query.value = value
+ }
+
+ fun setHistoryEnabled(enabled: Boolean, clearHistoryIfDisabled: Boolean) {
+ viewModelScope.launch {
+ container.settingsRepository.setHistoryEnabled(enabled)
+ if (!enabled && clearHistoryIfDisabled) {
+ container.scanRepository.clearAll()
+ }
+ }
+ }
+
+ fun setWarningsEnabled(enabled: Boolean) {
+ viewModelScope.launch {
+ container.settingsRepository.setWarningsEnabled(enabled)
+ }
+ }
+
+ fun deleteHistoryItem(id: Long) {
+ viewModelScope.launch {
+ container.scanRepository.deleteById(id)
+ }
+ }
+
+ fun clearHistory() {
+ viewModelScope.launch {
+ container.scanRepository.clearAll()
+ }
+ }
+
+ class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return AppViewModel(container) as T
+ }
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt b/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt
new file mode 100644
index 0000000..607698e
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt
@@ -0,0 +1,103 @@
+package com.clean.scanner.ui
+
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.clean.scanner.AppContainer
+import com.clean.scanner.R
+import com.clean.scanner.ui.screens.HistoryScreen
+import com.clean.scanner.ui.screens.HomeScreen
+import com.clean.scanner.ui.screens.ScannerScreen
+import com.clean.scanner.ui.screens.SettingsScreen
+
+private enum class RootTab { Home, History, Settings }
+
+@Composable
+fun CleanScannerAppRoot(container: AppContainer) {
+ val appViewModel: AppViewModel = viewModel(factory = AppViewModel.Factory(container))
+ val scannerViewModel: ScannerViewModel = viewModel(factory = ScannerViewModel.Factory(container))
+ val appState by appViewModel.uiState.collectAsStateWithLifecycle()
+ val scannerState by scannerViewModel.uiState.collectAsStateWithLifecycle()
+
+ var activeTab by remember { mutableStateOf(RootTab.Home) }
+ var showScanner by remember { mutableStateOf(false) }
+
+ Scaffold(
+ bottomBar = {
+ NavigationBar {
+ NavigationBarItem(
+ selected = activeTab == RootTab.Home,
+ onClick = {
+ activeTab = RootTab.Home
+ showScanner = false
+ },
+ label = { Text(stringResource(R.string.scan)) },
+ icon = {}
+ )
+ NavigationBarItem(
+ selected = activeTab == RootTab.History,
+ onClick = {
+ activeTab = RootTab.History
+ showScanner = false
+ },
+ label = { Text(stringResource(R.string.history)) },
+ icon = {}
+ )
+ NavigationBarItem(
+ selected = activeTab == RootTab.Settings,
+ onClick = {
+ activeTab = RootTab.Settings
+ showScanner = false
+ },
+ label = { Text(stringResource(R.string.settings)) },
+ icon = {}
+ )
+ }
+ }
+ ) { padding ->
+ androidx.compose.foundation.layout.Box(modifier = androidx.compose.ui.Modifier.padding(padding)) {
+ when {
+ showScanner -> ScannerScreen(
+ analysisEnabled = scannerState.analysisEnabled,
+ lastResult = scannerState.lastResult,
+ warningsEnabled = appState.warningsEnabled,
+ onScan = scannerViewModel::onScan,
+ onScanAgain = scannerViewModel::resumeScanning
+ )
+
+ activeTab == RootTab.Home -> HomeScreen(
+ historyEnabled = appState.historyEnabled,
+ onHistoryToggle = { appViewModel.setHistoryEnabled(it, false) },
+ onScanClick = {
+ showScanner = true
+ scannerViewModel.resumeScanning()
+ }
+ )
+
+ activeTab == RootTab.History -> HistoryScreen(
+ query = appState.searchQuery,
+ history = appState.history,
+ onQueryChange = appViewModel::setQuery,
+ onDelete = appViewModel::deleteHistoryItem,
+ onClearAll = appViewModel::clearHistory
+ )
+
+ activeTab == RootTab.Settings -> SettingsScreen(
+ historyEnabled = appState.historyEnabled,
+ warningsEnabled = appState.warningsEnabled,
+ onHistoryToggle = appViewModel::setHistoryEnabled,
+ onWarningsToggle = appViewModel::setWarningsEnabled
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt b/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt
new file mode 100644
index 0000000..a974b50
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt
@@ -0,0 +1,52 @@
+package com.clean.scanner.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.clean.scanner.AppContainer
+import com.clean.scanner.domain.ScanResult
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class ScannerUiState(
+ val lastResult: ScanResult? = null,
+ val analysisEnabled: Boolean = true,
+ val lastScanTimestamp: Long = 0L
+)
+
+class ScannerViewModel(
+ private val container: AppContainer
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(ScannerUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun onScan(result: ScanResult) {
+ val now = System.currentTimeMillis()
+ val current = _uiState.value
+ if (!current.analysisEnabled) return
+ if (now - current.lastScanTimestamp < 800) return
+
+ _uiState.value = current.copy(
+ lastResult = result,
+ analysisEnabled = false,
+ lastScanTimestamp = now
+ )
+
+ viewModelScope.launch {
+ container.scanRepository.maybeSaveScan(result.content, result.type)
+ }
+ }
+
+ fun resumeScanning() {
+ _uiState.value = _uiState.value.copy(analysisEnabled = true, lastResult = null)
+ }
+
+ class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return ScannerViewModel(container) as T
+ }
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt b/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt
new file mode 100644
index 0000000..116ffd5
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt
@@ -0,0 +1,83 @@
+package com.clean.scanner.ui.components
+
+import android.annotation.SuppressLint
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import com.clean.scanner.data.scanner.MlKitBarcodeAnalyzer
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+@SuppressLint("UnsafeOptInUsageError")
+@Composable
+fun CameraPreview(
+ modifier: Modifier = Modifier,
+ analysisEnabled: Boolean,
+ torchEnabled: Boolean,
+ onTorchAvailabilityChanged: (Boolean) -> Unit,
+ onScan: (String, String) -> Unit
+) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
+ val previewView = remember { PreviewView(context) }
+ val cameraRef = remember { mutableStateOf(null) }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ cameraExecutor.shutdown()
+ }
+ }
+
+ LaunchedEffect(analysisEnabled) {
+ val provider = ProcessCameraProvider.getInstance(context).get()
+ provider.unbindAll()
+
+ val preview = Preview.Builder().build().apply {
+ setSurfaceProvider(previewView.surfaceProvider)
+ }
+
+ val imageAnalysis = ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build().apply {
+ if (analysisEnabled) {
+ setAnalyzer(cameraExecutor, MlKitBarcodeAnalyzer { result ->
+ onScan(result.content, result.type)
+ })
+ } else {
+ clearAnalyzer()
+ }
+ }
+
+ val camera = provider.bindToLifecycle(
+ lifecycleOwner,
+ CameraSelector.DEFAULT_BACK_CAMERA,
+ preview,
+ imageAnalysis
+ )
+
+ onTorchAvailabilityChanged(camera.cameraInfo.hasFlashUnit())
+ cameraRef.value = camera
+ }
+
+ LaunchedEffect(torchEnabled) {
+ cameraRef.value?.cameraControl?.enableTorch(torchEnabled)
+ }
+
+ AndroidView(
+ modifier = modifier,
+ factory = { previewView }
+ )
+}
diff --git a/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt
new file mode 100644
index 0000000..3134eb1
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt
@@ -0,0 +1,110 @@
+package com.clean.scanner.ui.screens
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.SwipeToDismissBox
+import androidx.compose.material3.SwipeToDismissBoxValue
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberSwipeToDismissBoxState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.clean.scanner.R
+import com.clean.scanner.domain.ScanRecord
+import java.text.DateFormat
+import java.util.Date
+
+@Composable
+fun HistoryScreen(
+ query: String,
+ history: List,
+ onQueryChange: (String) -> Unit,
+ onDelete: (Long) -> Unit,
+ onClearAll: () -> Unit
+) {
+ val showDeleteAll = remember { mutableStateOf(false) }
+
+ if (showDeleteAll.value) {
+ AlertDialog(
+ onDismissRequest = { showDeleteAll.value = false },
+ title = { Text(stringResource(R.string.delete_all)) },
+ text = { Text(stringResource(R.string.confirm_delete_all)) },
+ confirmButton = {
+ TextButton(onClick = {
+ onClearAll()
+ showDeleteAll.value = false
+ }) { Text(stringResource(R.string.confirm)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteAll.value = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.Top
+ ) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.search)) }
+ )
+
+ TextButton(onClick = { showDeleteAll.value = true }) {
+ Text(stringResource(R.string.delete_all))
+ }
+
+ LazyColumn {
+ items(history, key = { it.id }) { item ->
+ HistoryRow(item = item, onDelete = onDelete)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun HistoryRow(item: ScanRecord, onDelete: (Long) -> Unit) {
+ val dismissState = rememberSwipeToDismissBoxState(
+ confirmValueChange = {
+ if (it == SwipeToDismissBoxValue.EndToStart || it == SwipeToDismissBoxValue.StartToEnd) {
+ onDelete(item.id)
+ true
+ } else {
+ false
+ }
+ }
+ )
+
+ SwipeToDismissBox(
+ state = dismissState,
+ backgroundContent = {},
+ content = {
+ Column(modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp)) {
+ Text(text = item.type)
+ Text(text = item.content, maxLines = 2)
+ Text(text = DateFormat.getDateTimeInstance().format(Date(item.timestamp)))
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/clean/scanner/ui/screens/HomeScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/HomeScreen.kt
new file mode 100644
index 0000000..0488def
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/ui/screens/HomeScreen.kt
@@ -0,0 +1,59 @@
+package com.clean.scanner.ui.screens
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.clean.scanner.R
+
+@Composable
+fun HomeScreen(
+ historyEnabled: Boolean,
+ onHistoryToggle: (Boolean) -> Unit,
+ onScanClick: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Button(
+ onClick = onScanClick,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(R.string.scan))
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(text = stringResource(R.string.save_history), style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(8.dp))
+ Switch(checked = historyEnabled, onCheckedChange = onHistoryToggle)
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(R.string.privacy),
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = stringResource(R.string.privacy_text),
+ textAlign = TextAlign.Start
+ )
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt
new file mode 100644
index 0000000..61c84c3
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt
@@ -0,0 +1,227 @@
+package com.clean.scanner.ui.screens
+
+import android.Manifest
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import com.clean.scanner.R
+import com.clean.scanner.domain.ScanResult
+import com.clean.scanner.ui.components.CameraPreview
+import com.clean.scanner.util.ClipboardUtil
+import com.clean.scanner.util.Intents
+import com.clean.scanner.util.UrlRiskScorer
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ScannerScreen(
+ analysisEnabled: Boolean,
+ lastResult: ScanResult?,
+ warningsEnabled: Boolean,
+ onScan: (ScanResult) -> Unit,
+ onScanAgain: () -> Unit
+) {
+ val context = LocalContext.current
+ var cameraGranted by remember {
+ mutableStateOf(
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.CAMERA
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ )
+ }
+ var showSettingsHint by remember { mutableStateOf(false) }
+ var torchEnabled by remember { mutableStateOf(false) }
+ var torchAvailable by remember { mutableStateOf(false) }
+ var showRiskWarning by remember { mutableStateOf(false) }
+ var pendingOpenUrl by remember { mutableStateOf(null) }
+ val activity = context as? Activity
+
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ cameraGranted = granted
+ if (!granted && activity != null) {
+ showSettingsHint = !ActivityCompat.shouldShowRequestPermissionRationale(
+ activity,
+ Manifest.permission.CAMERA
+ )
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ if (!cameraGranted) launcher.launch(Manifest.permission.CAMERA)
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (cameraGranted) {
+ CameraPreview(
+ modifier = Modifier.fillMaxSize(),
+ analysisEnabled = analysisEnabled,
+ torchEnabled = torchEnabled,
+ onTorchAvailabilityChanged = { torchAvailable = it },
+ onScan = { content, type ->
+ onScan(ScanResult(content = content, type = type))
+ }
+ )
+
+ Box(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .fillMaxWidth(0.7f)
+ .height(220.dp)
+ .background(Color.Transparent)
+ )
+
+ if (torchAvailable) {
+ RowTopToggle(
+ checked = torchEnabled,
+ onCheckedChange = { torchEnabled = it },
+ label = stringResource(R.string.flashlight)
+ )
+ }
+ } else {
+ PermissionContent(
+ showSettingsHint = showSettingsHint,
+ onRequestPermission = { launcher.launch(Manifest.permission.CAMERA) },
+ onOpenSettings = {
+ val intent = Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", context.packageName, null)
+ )
+ context.startActivity(intent)
+ }
+ )
+ }
+
+ if (lastResult != null) {
+ ModalBottomSheet(onDismissRequest = {}) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(text = "${stringResource(R.string.content_type)}: ${lastResult.type}")
+ Text(text = "${stringResource(R.string.content_value)}: ${lastResult.content}")
+ Button(onClick = { ClipboardUtil.copy(context, lastResult.content) }) {
+ Text(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))
+ }
+ }
+ Button(onClick = { Intents.shareText(context, lastResult.content) }) {
+ Text(stringResource(R.string.share))
+ }
+ Button(onClick = onScanAgain) {
+ Text(stringResource(R.string.scan_again))
+ }
+ }
+ }
+ }
+
+ if (showRiskWarning && pendingOpenUrl != null) {
+ AlertDialog(
+ onDismissRequest = { showRiskWarning = false },
+ text = { Text(stringResource(R.string.risk_warning)) },
+ confirmButton = {
+ TextButton(onClick = {
+ Intents.openUrl(context, pendingOpenUrl!!)
+ showRiskWarning = false
+ }) { Text(stringResource(R.string.open_anyway)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { showRiskWarning = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun RowTopToggle(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ label: String
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ contentAlignment = Alignment.TopStart
+ ) {
+ Column {
+ Text(text = label, color = Color.White)
+ Switch(checked = checked, onCheckedChange = onCheckedChange)
+ }
+ }
+}
+
+@Composable
+private fun PermissionContent(
+ showSettingsHint: Boolean,
+ onRequestPermission: () -> Unit,
+ onOpenSettings: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(text = stringResource(R.string.camera_permission_title))
+ Text(text = stringResource(R.string.camera_permission_rationale))
+ Button(onClick = onRequestPermission) {
+ Text(text = stringResource(R.string.request_camera))
+ }
+ if (showSettingsHint) {
+ TextButton(onClick = onOpenSettings) {
+ Text(stringResource(R.string.open_settings))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt
new file mode 100644
index 0000000..eabcab1
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt
@@ -0,0 +1,79 @@
+package com.clean.scanner.ui.screens
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.clean.scanner.R
+
+@Composable
+fun SettingsScreen(
+ historyEnabled: Boolean,
+ warningsEnabled: Boolean,
+ onHistoryToggle: (Boolean, Boolean) -> Unit,
+ onWarningsToggle: (Boolean) -> Unit
+) {
+ val showDeleteConfirm = remember { mutableStateOf(false) }
+
+ if (showDeleteConfirm.value) {
+ AlertDialog(
+ onDismissRequest = { showDeleteConfirm.value = false },
+ title = { Text(stringResource(R.string.settings)) },
+ text = { Text(stringResource(R.string.delete_history_on_disable)) },
+ confirmButton = {
+ TextButton(onClick = {
+ onHistoryToggle(false, true)
+ showDeleteConfirm.value = false
+ }) { Text(stringResource(R.string.confirm)) }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ onHistoryToggle(false, false)
+ showDeleteConfirm.value = false
+ }) { Text(stringResource(R.string.cancel)) }
+ }
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Top
+ ) {
+ Text(text = stringResource(R.string.save_history))
+ Switch(
+ checked = historyEnabled,
+ onCheckedChange = { enabled ->
+ if (!enabled && historyEnabled) {
+ showDeleteConfirm.value = true
+ } else {
+ onHistoryToggle(enabled, false)
+ }
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(text = stringResource(R.string.security_warnings))
+ Switch(checked = warningsEnabled, onCheckedChange = onWarningsToggle)
+
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(text = stringResource(R.string.about))
+ Text(text = stringResource(R.string.version))
+ Text(text = stringResource(R.string.licenses))
+ Text(text = stringResource(R.string.contact))
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/util/ClipboardUtil.kt b/app/src/main/java/com/clean/scanner/util/ClipboardUtil.kt
new file mode 100644
index 0000000..d79dd3e
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/util/ClipboardUtil.kt
@@ -0,0 +1,12 @@
+package com.clean.scanner.util
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+
+object ClipboardUtil {
+ fun copy(context: Context, text: String) {
+ val manager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ manager.setPrimaryClip(ClipData.newPlainText("scan_content", text))
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/util/Intents.kt b/app/src/main/java/com/clean/scanner/util/Intents.kt
new file mode 100644
index 0000000..4abbde1
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/util/Intents.kt
@@ -0,0 +1,22 @@
+package com.clean.scanner.util
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.core.content.ContextCompat.startActivity
+
+object Intents {
+ fun openUrl(context: Context, url: String) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(context, intent, null)
+ }
+
+ fun shareText(context: Context, text: String) {
+ val intent = Intent(Intent.ACTION_SEND)
+ .setType("text/plain")
+ .putExtra(Intent.EXTRA_TEXT, text)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ val chooser = Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(context, chooser, null)
+ }
+}
diff --git a/app/src/main/java/com/clean/scanner/util/UrlRiskScorer.kt b/app/src/main/java/com/clean/scanner/util/UrlRiskScorer.kt
new file mode 100644
index 0000000..692c810
--- /dev/null
+++ b/app/src/main/java/com/clean/scanner/util/UrlRiskScorer.kt
@@ -0,0 +1,45 @@
+package com.clean.scanner.util
+
+import com.clean.scanner.domain.UrlRiskResult
+import java.net.URI
+
+object UrlRiskScorer {
+ fun score(raw: String): UrlRiskResult {
+ val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return UrlRiskResult(0, emptyList())
+ val host = uri.host.orEmpty()
+ val reasons = mutableListOf()
+ var score = 0
+
+ if (host.matches(Regex("^\\d{1,3}(\\.\\d{1,3}){3}$"))) {
+ score += 2
+ reasons += "Host is an IP address"
+ }
+ if (uri.scheme.equals("http", ignoreCase = true)) {
+ score += 2
+ reasons += "URL uses HTTP"
+ }
+ if (host.contains("xn--", ignoreCase = true)) {
+ score += 2
+ reasons += "Host contains punycode"
+ }
+ if (host.length > 40) {
+ score += 1
+ reasons += "Host is unusually long"
+ }
+ if ((uri.rawQuery?.length ?: 0) > 120) {
+ score += 1
+ reasons += "Query is unusually long"
+ }
+ val percentEncodedCount = Regex("%[0-9a-fA-F]{2}").findAll(raw).count()
+ if (percentEncodedCount > 10) {
+ score += 1
+ reasons += "Many percent-encodings"
+ }
+ if (!uri.userInfo.isNullOrBlank()) {
+ score += 2
+ reasons += "Contains userinfo"
+ }
+
+ return UrlRiskResult(score = score, reasons = reasons)
+ }
+}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000..78fb9db
--- /dev/null
+++ b/app/src/main/res/values-de/strings.xml
@@ -0,0 +1,34 @@
+
+ Clean Scanner
+ Scannen
+ Nochmal scannen
+ Historie
+ Einstellungen
+ Historie speichern (lokal)
+ Datenschutz
+ Keine Datenübertragung, keine Werbung, kein Tracking.
+ Sicherheitswarnungen
+ Über
+ Kopieren
+ Teilen
+ Öffnen
+ Abbrechen
+ Trotzdem öffnen
+ Diese URL wirkt ungewöhnlich. Prüfe sie, bevor du öffnest.
+ Alles löschen
+ Alle Historie-Einträge löschen?
+ Bestätigen
+ Suchen
+ Taschenlampe
+ Kamerazugriff erforderlich
+ Kameraberechtigung wird zum Scannen von QR- und Barcodes benötigt.
+ Einstellungen öffnen
+ Keine Kameraberechtigung
+ Vorhandene Historie beim Deaktivieren löschen?
+ Version 1.0.0
+ Open-Source-Lizenzen
+ Kontakt: support@example.com
+ Typ
+ Inhalt
+ Kamera erlauben
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..734b747
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,34 @@
+
+ Clean Scanner
+ Scan
+ Scan again
+ History
+ Settings
+ Save history (local)
+ Privacy
+ No data transfer, no ads, no tracking.
+ Security warnings
+ About
+ Copy
+ Share
+ Open
+ Cancel
+ Open anyway
+ This URL looks unusual. Check it before opening.
+ Delete all
+ Delete all history entries?
+ Confirm
+ Search
+ Flashlight
+ Camera access required
+ Camera permission is needed to scan QR and barcodes.
+ Open settings
+ No camera permission
+ Delete existing history when disabling?
+ Version 1.0.0
+ Open-source licenses
+ Contact: support@example.com
+ Type
+ Content
+ Allow camera
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..75404fe
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt b/app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt
new file mode 100644
index 0000000..c22536a
--- /dev/null
+++ b/app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt
@@ -0,0 +1,76 @@
+package com.clean.scanner.util
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class UrlRiskScorerTest {
+
+ @Test
+ fun `https domain has low risk`() {
+ val result = UrlRiskScorer.score("https://example.com")
+ assertEquals(0, result.score)
+ }
+
+ @Test
+ fun `http adds two points`() {
+ val result = UrlRiskScorer.score("http://example.com")
+ assertEquals(2, result.score)
+ }
+
+ @Test
+ fun `ip host adds two points`() {
+ val result = UrlRiskScorer.score("https://192.168.1.1/path")
+ assertEquals(2, result.score)
+ }
+
+ @Test
+ fun `punycode host adds two points`() {
+ val result = UrlRiskScorer.score("https://xn--pple-43d.com")
+ assertEquals(2, result.score)
+ }
+
+ @Test
+ fun `long host adds one point`() {
+ val result = UrlRiskScorer.score("https://averyveryveryveryveryveryveryverylonghostname.com")
+ assertTrue(result.score >= 1)
+ }
+
+ @Test
+ fun `long query adds one point`() {
+ val query = "a".repeat(121)
+ val result = UrlRiskScorer.score("https://example.com/?q=$query")
+ assertEquals(1, result.score)
+ }
+
+ @Test
+ fun `many percent encodings adds one point`() {
+ val encoded = (1..11).joinToString("") { "%20" }
+ val result = UrlRiskScorer.score("https://example.com/$encoded")
+ assertEquals(1, result.score)
+ }
+
+ @Test
+ fun `userinfo adds two points`() {
+ val result = UrlRiskScorer.score("https://user:pass@example.com")
+ assertEquals(2, result.score)
+ }
+
+ @Test
+ fun `combined risk can exceed threshold`() {
+ val result = UrlRiskScorer.score("http://user:pass@192.168.0.1")
+ assertTrue(result.score >= 6)
+ }
+
+ @Test
+ fun `invalid url returns zero`() {
+ val result = UrlRiskScorer.score("not a url")
+ assertEquals(0, result.score)
+ }
+
+ @Test
+ fun `reasons list is populated for risky urls`() {
+ val result = UrlRiskScorer.score("http://xn--pple-43d.com")
+ assertTrue(result.reasons.isNotEmpty())
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..07acc53
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.7.0" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
+ id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..2318707
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..b498d24
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..2fa91c5
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..17a9170
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,176 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
+ DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
+fi
+
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..e95643d
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..b315feb
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "CleanScanner"
+include(":app")