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")