init gradle

This commit is contained in:
Hadrian Burkhardt
2026-02-09 02:19:10 +00:00
parent 84ebd57bb6
commit d9378fa78e
39 changed files with 1735 additions and 0 deletions
+100
View File
@@ -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")
}
+1
View File
@@ -0,0 +1 @@
# Keep minimal for MVP
+24
View File
@@ -0,0 +1,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".CleanScannerApp"
android:allowBackup="true"
android:icon="@android:drawable/ic_menu_camera"
android:label="@string/app_name"
android:roundIcon="@android:drawable/ic_menu_camera"
android:supportsRtl="true"
android:theme="@style/Theme.CleanScanner">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
}
}
}
@@ -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
}
@@ -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<List<ScanEntity>>
@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()
}
@@ -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
)
@@ -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<List<ScanRecord>> = 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()
}
@@ -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"
}
}
@@ -0,0 +1,8 @@
package com.clean.scanner.domain
data class ScanRecord(
val id: Long,
val content: String,
val type: String,
val timestamp: Long
)
@@ -0,0 +1,6 @@
package com.clean.scanner.domain
data class ScanResult(
val content: String,
val type: String
)
@@ -0,0 +1,6 @@
package com.clean.scanner.domain
data class UrlRiskResult(
val score: Int,
val reasons: List<String>
)
@@ -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<Boolean> = context.dataStore.data.map { prefs ->
prefs[Keys.historyEnabled] ?: false
}
val warningsEnabled: Flow<Boolean> = 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 }
}
}
@@ -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<ScanRecord> = emptyList(),
val searchQuery: String = ""
)
class AppViewModel(
private val container: AppContainer
) : ViewModel() {
private val query = MutableStateFlow("")
val uiState: StateFlow<AppUiState> = 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 <T : ViewModel> create(modelClass: Class<T>): T {
return AppViewModel(container) as T
}
}
}
@@ -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
)
}
}
}
}
@@ -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<ScannerUiState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return ScannerViewModel(container) as T
}
}
}
@@ -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<androidx.camera.core.Camera?>(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 }
)
}
@@ -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<ScanRecord>,
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)))
}
}
)
}
@@ -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
)
}
}
@@ -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<String?>(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))
}
}
}
}
@@ -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))
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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<String>()
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)
}
}
+34
View File
@@ -0,0 +1,34 @@
<resources>
<string name="app_name">Clean Scanner</string>
<string name="scan">Scannen</string>
<string name="scan_again">Nochmal scannen</string>
<string name="history">Historie</string>
<string name="settings">Einstellungen</string>
<string name="save_history">Historie speichern (lokal)</string>
<string name="privacy">Datenschutz</string>
<string name="privacy_text">Keine Datenübertragung, keine Werbung, kein Tracking.</string>
<string name="security_warnings">Sicherheitswarnungen</string>
<string name="about">Über</string>
<string name="copy">Kopieren</string>
<string name="share">Teilen</string>
<string name="open">Öffnen</string>
<string name="cancel">Abbrechen</string>
<string name="open_anyway">Trotzdem öffnen</string>
<string name="risk_warning">Diese URL wirkt ungewöhnlich. Prüfe sie, bevor du öffnest.</string>
<string name="delete_all">Alles löschen</string>
<string name="confirm_delete_all">Alle Historie-Einträge löschen?</string>
<string name="confirm">Bestätigen</string>
<string name="search">Suchen</string>
<string name="flashlight">Taschenlampe</string>
<string name="camera_permission_title">Kamerazugriff erforderlich</string>
<string name="camera_permission_rationale">Kameraberechtigung wird zum Scannen von QR- und Barcodes benötigt.</string>
<string name="open_settings">Einstellungen öffnen</string>
<string name="no_camera">Keine Kameraberechtigung</string>
<string name="delete_history_on_disable">Vorhandene Historie beim Deaktivieren löschen?</string>
<string name="version">Version 1.0.0</string>
<string name="licenses">Open-Source-Lizenzen</string>
<string name="contact">Kontakt: support@example.com</string>
<string name="content_type">Typ</string>
<string name="content_value">Inhalt</string>
<string name="request_camera">Kamera erlauben</string>
</resources>
+34
View File
@@ -0,0 +1,34 @@
<resources>
<string name="app_name">Clean Scanner</string>
<string name="scan">Scan</string>
<string name="scan_again">Scan again</string>
<string name="history">History</string>
<string name="settings">Settings</string>
<string name="save_history">Save history (local)</string>
<string name="privacy">Privacy</string>
<string name="privacy_text">No data transfer, no ads, no tracking.</string>
<string name="security_warnings">Security warnings</string>
<string name="about">About</string>
<string name="copy">Copy</string>
<string name="share">Share</string>
<string name="open">Open</string>
<string name="cancel">Cancel</string>
<string name="open_anyway">Open anyway</string>
<string name="risk_warning">This URL looks unusual. Check it before opening.</string>
<string name="delete_all">Delete all</string>
<string name="confirm_delete_all">Delete all history entries?</string>
<string name="confirm">Confirm</string>
<string name="search">Search</string>
<string name="flashlight">Flashlight</string>
<string name="camera_permission_title">Camera access required</string>
<string name="camera_permission_rationale">Camera permission is needed to scan QR and barcodes.</string>
<string name="open_settings">Open settings</string>
<string name="no_camera">No camera permission</string>
<string name="delete_history_on_disable">Delete existing history when disabling?</string>
<string name="version">Version 1.0.0</string>
<string name="licenses">Open-source licenses</string>
<string name="contact">Contact: support@example.com</string>
<string name="content_type">Type</string>
<string name="content_value">Content</string>
<string name="request_camera">Allow camera</string>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.CleanScanner" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>
</resources>
@@ -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())
}
}