From d9378fa78e41fb6b1b0ebe640dc3f9526a50643a Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Mon, 9 Feb 2026 02:19:10 +0000 Subject: [PATCH] init gradle --- .gitignore | 18 ++ README.md | 35 +++ app/build.gradle.kts | 100 ++++++++ app/proguard-rules.pro | 1 + app/src/main/AndroidManifest.xml | 24 ++ .../java/com/clean/scanner/AppContainer.kt | 23 ++ .../java/com/clean/scanner/CleanScannerApp.kt | 13 + .../java/com/clean/scanner/MainActivity.kt | 22 ++ .../data/local/CleanScannerDatabase.kt | 9 + .../com/clean/scanner/data/local/ScanDao.kt | 21 ++ .../clean/scanner/data/local/ScanEntity.kt | 12 + .../scanner/data/repository/ScanRepository.kt | 41 ++++ .../data/scanner/MlKitBarcodeAnalyzer.kt | 47 ++++ .../com/clean/scanner/domain/ScanRecord.kt | 8 + .../com/clean/scanner/domain/ScanResult.kt | 6 + .../com/clean/scanner/domain/UrlRiskResult.kt | 6 + .../scanner/settings/SettingsRepository.kt | 33 +++ .../java/com/clean/scanner/ui/AppViewModel.kt | 81 +++++++ .../clean/scanner/ui/CleanScannerAppRoot.kt | 103 ++++++++ .../com/clean/scanner/ui/ScannerViewModel.kt | 52 ++++ .../scanner/ui/components/CameraPreview.kt | 83 +++++++ .../clean/scanner/ui/screens/HistoryScreen.kt | 110 +++++++++ .../clean/scanner/ui/screens/HomeScreen.kt | 59 +++++ .../clean/scanner/ui/screens/ScannerScreen.kt | 227 ++++++++++++++++++ .../scanner/ui/screens/SettingsScreen.kt | 79 ++++++ .../com/clean/scanner/util/ClipboardUtil.kt | 12 + .../java/com/clean/scanner/util/Intents.kt | 22 ++ .../com/clean/scanner/util/UrlRiskScorer.kt | 45 ++++ app/src/main/res/values-de/strings.xml | 34 +++ app/src/main/res/values/strings.xml | 34 +++ app/src/main/res/values/themes.xml | 7 + .../clean/scanner/util/UrlRiskScorerTest.kt | 76 ++++++ build.gradle.kts | 5 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 56921 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 176 ++++++++++++++ gradlew.bat | 84 +++++++ settings.gradle.kts | 18 ++ 39 files changed, 1735 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/clean/scanner/AppContainer.kt create mode 100644 app/src/main/java/com/clean/scanner/CleanScannerApp.kt create mode 100644 app/src/main/java/com/clean/scanner/MainActivity.kt create mode 100644 app/src/main/java/com/clean/scanner/data/local/CleanScannerDatabase.kt create mode 100644 app/src/main/java/com/clean/scanner/data/local/ScanDao.kt create mode 100644 app/src/main/java/com/clean/scanner/data/local/ScanEntity.kt create mode 100644 app/src/main/java/com/clean/scanner/data/repository/ScanRepository.kt create mode 100644 app/src/main/java/com/clean/scanner/data/scanner/MlKitBarcodeAnalyzer.kt create mode 100644 app/src/main/java/com/clean/scanner/domain/ScanRecord.kt create mode 100644 app/src/main/java/com/clean/scanner/domain/ScanResult.kt create mode 100644 app/src/main/java/com/clean/scanner/domain/UrlRiskResult.kt create mode 100644 app/src/main/java/com/clean/scanner/settings/SettingsRepository.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/AppViewModel.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/CleanScannerAppRoot.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/components/CameraPreview.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/screens/HistoryScreen.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/screens/HomeScreen.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt create mode 100644 app/src/main/java/com/clean/scanner/ui/screens/SettingsScreen.kt create mode 100644 app/src/main/java/com/clean/scanner/util/ClipboardUtil.kt create mode 100644 app/src/main/java/com/clean/scanner/util/Intents.kt create mode 100644 app/src/main/java/com/clean/scanner/util/UrlRiskScorer.kt create mode 100644 app/src/main/res/values-de/strings.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts 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 0000000000000000000000000000000000000000..b498d2444600d918cbd5949508e2ade6fbef0301 GIT binary patch literal 56921 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 literal 0 HcmV?d00001 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")