nicer crosshair, icons instead of text buttons, share history

This commit is contained in:
Hadrian Burkhardt
2026-02-11 03:23:42 +01:00
parent d9378fa78e
commit c0e9b52897
14 changed files with 275 additions and 83 deletions
+34 -19
View File
@@ -1,35 +1,50 @@
# Clean Scanner (MVP)
Offline-first, ad-free QR/barcode scanner built with Kotlin + Compose + CameraX + ML Kit.
Offline-first, ad-free QR/barcode scanner built with Kotlin, Jetpack Compose, CameraX, and on-device ML Kit.
## Architektur
- `ui/`: Compose-Screens + ViewModels (MVVM presentation layer)
- `data/`: Scanner-Analyzer, Room entities/DAO, Repository
- `ui/`: Compose screens/components + ViewModels (MVVM)
- `data/`: ML Kit analyzer, Room entities/DAO, repository
- `domain/`: app models (`ScanResult`, `ScanRecord`, `UrlRiskResult`)
- `settings/`: DataStore preferences (history toggle, warnings toggle)
- `util/`: URL risk scorer, clipboard, intents
- `settings/`: DataStore preferences (history + warnings toggles)
- `util/`: URL risk scoring, clipboard, intents
## Datenschutz
- Keine Werbung
- Keine Tracker/Analytics/Crashlytics
- Kein Backend/keine Webrequests
- Kein Backend, keine Servercalls
- 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
- Home: Scan-Button, lokaler Historie-Toggle (Default: OFF), Datenschutz-Dialog
- Scanner: CameraX Live-Preview, Fadenkreuz-Overlay, Taschenlampe, Debounce gegen Doppelscans
- Ergebnis-Bottom-Sheet: Typ/Inhalt + Copy/Share/Open/Scan again
- URL-Sicherheitswarnung bei lokalem `riskScore >= 3` (kein Blocken, nur Hinweis)
- Historie: Suche, Swipe-to-delete, Alles-löschen, Detailansicht mit Volltext
- Einstellungen: Historie an/aus (mit optionalem Löschen), Warnungen an/aus, About-Infos
## Run
1. In Android Studio: Open this folder as project.
2. Let Gradle sync dependencies.
3. Run app on emulator/device (API 24+).
## Voraussetzungen
- Android Studio (aktuell stabil)
- JDK 17+
- Android SDK für `compileSdk = 35`
## Build & Run
1. Projekt in Android Studio öffnen.
2. Gradle Sync ausführen.
3. App auf Emulator/Device (API 24+) starten.
CLI:
```bash
./gradlew :app:assembleDebug
./gradlew :app:installDebug
```
## Tests
- URL Risk Scorer Tests: `app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt` (11 cases)
- Unit tests:
## Hinweis
In dieser Umgebung war kein `gradle`/`gradlew` verfügbar, daher konnte ich Builds/Tests hier nicht lokal ausführen.
```bash
./gradlew testDebugUnitTest
```
- URL-Risk-Scorer tests: `app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt`
+1
View File
@@ -74,6 +74,7 @@ dependencies {
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.navigation:navigation-compose:2.8.2")
@@ -6,13 +6,14 @@ import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import com.clean.scanner.ui.CleanScannerAppRoot
import com.clean.scanner.ui.theme.CleanScannerTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val container = (application as CleanScannerApp).appContainer
setContent {
MaterialTheme {
CleanScannerTheme {
Surface(color = MaterialTheme.colorScheme.background) {
CleanScannerAppRoot(container)
}
@@ -1,5 +1,7 @@
package com.clean.scanner.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
@@ -9,17 +11,17 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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 }
private enum class RootTab { Scanner, History, Settings }
@Composable
fun CleanScannerAppRoot(container: AppContainer) {
@@ -28,17 +30,16 @@ fun CleanScannerAppRoot(container: AppContainer) {
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) }
var activeTab by remember { mutableStateOf(RootTab.Scanner) }
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = activeTab == RootTab.Home,
selected = activeTab == RootTab.Scanner,
onClick = {
activeTab = RootTab.Home
showScanner = false
activeTab = RootTab.Scanner
scannerViewModel.resumeScanning()
},
label = { Text(stringResource(R.string.scan)) },
icon = {}
@@ -47,7 +48,6 @@ fun CleanScannerAppRoot(container: AppContainer) {
selected = activeTab == RootTab.History,
onClick = {
activeTab = RootTab.History
showScanner = false
},
label = { Text(stringResource(R.string.history)) },
icon = {}
@@ -56,7 +56,6 @@ fun CleanScannerAppRoot(container: AppContainer) {
selected = activeTab == RootTab.Settings,
onClick = {
activeTab = RootTab.Settings
showScanner = false
},
label = { Text(stringResource(R.string.settings)) },
icon = {}
@@ -64,9 +63,9 @@ fun CleanScannerAppRoot(container: AppContainer) {
}
}
) { padding ->
androidx.compose.foundation.layout.Box(modifier = androidx.compose.ui.Modifier.padding(padding)) {
when {
showScanner -> ScannerScreen(
Box(modifier = Modifier.padding(padding)) {
when (activeTab) {
RootTab.Scanner -> ScannerScreen(
analysisEnabled = scannerState.analysisEnabled,
lastResult = scannerState.lastResult,
warningsEnabled = appState.warningsEnabled,
@@ -74,16 +73,7 @@ fun CleanScannerAppRoot(container: AppContainer) {
onScanAgain = scannerViewModel::resumeScanning
)
activeTab == RootTab.Home -> HomeScreen(
historyEnabled = appState.historyEnabled,
onHistoryToggle = { appViewModel.setHistoryEnabled(it, false) },
onScanClick = {
showScanner = true
scannerViewModel.resumeScanning()
}
)
activeTab == RootTab.History -> HistoryScreen(
RootTab.History -> HistoryScreen(
query = appState.searchQuery,
history = appState.history,
onQueryChange = appViewModel::setQuery,
@@ -91,7 +81,7 @@ fun CleanScannerAppRoot(container: AppContainer) {
onClearAll = appViewModel::clearHistory
)
activeTab == RootTab.Settings -> SettingsScreen(
RootTab.Settings -> SettingsScreen(
historyEnabled = appState.historyEnabled,
warningsEnabled = appState.warningsEnabled,
onHistoryToggle = appViewModel::setHistoryEnabled,
@@ -1,6 +1,8 @@
package com.clean.scanner.ui.components
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
@@ -13,12 +15,14 @@ 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.lifecycle.compose.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
import kotlin.math.max
import kotlin.math.min
@SuppressLint("UnsafeOptInUsageError")
@Composable
@@ -34,6 +38,21 @@ fun CameraPreview(
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
val previewView = remember { PreviewView(context) }
val cameraRef = remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
val zoomRatio = remember { mutableStateOf(1f) }
val scaleGestureDetector = remember {
ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val camera = cameraRef.value ?: return false
val zoomState = camera.cameraInfo.zoomState.value ?: return false
val nextZoom = zoomRatio.value * detector.scaleFactor
val clampedZoom = max(zoomState.minZoomRatio, min(nextZoom, zoomState.maxZoomRatio))
zoomRatio.value = clampedZoom
camera.cameraControl.setZoomRatio(clampedZoom)
return true
}
})
}
DisposableEffect(Unit) {
onDispose {
@@ -70,6 +89,7 @@ fun CameraPreview(
onTorchAvailabilityChanged(camera.cameraInfo.hasFlashUnit())
cameraRef.value = camera
zoomRatio.value = camera.cameraInfo.zoomState.value?.zoomRatio ?: 1f
}
LaunchedEffect(torchEnabled) {
@@ -78,6 +98,16 @@ fun CameraPreview(
AndroidView(
modifier = modifier,
factory = { previewView }
factory = {
previewView.apply {
setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
performClick()
}
scaleGestureDetector.onTouchEvent(event)
true
}
}
}
)
}
@@ -1,7 +1,9 @@
package com.clean.scanner.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -19,10 +21,12 @@ import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
import com.clean.scanner.domain.ScanRecord
import com.clean.scanner.util.Intents
import java.text.DateFormat
import java.util.Date
@@ -34,7 +38,9 @@ fun HistoryScreen(
onDelete: (Long) -> Unit,
onClearAll: () -> Unit
) {
val context = LocalContext.current
val showDeleteAll = remember { mutableStateOf(false) }
val selectedItem = remember { mutableStateOf<ScanRecord?>(null) }
if (showDeleteAll.value) {
AlertDialog(
@@ -55,6 +61,20 @@ fun HistoryScreen(
)
}
val detail = selectedItem.value
if (detail != null) {
AlertDialog(
onDismissRequest = { selectedItem.value = null },
title = { Text(text = detail.type) },
text = { Text(text = detail.content) },
confirmButton = {
TextButton(onClick = { selectedItem.value = null }) {
Text(text = stringResource(R.string.confirm))
}
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -68,13 +88,28 @@ fun HistoryScreen(
label = { Text(stringResource(R.string.search)) }
)
Row(modifier = Modifier.fillMaxWidth()) {
TextButton(
onClick = {
val exportText = buildHistoryExportText(history)
Intents.shareText(context, exportText)
},
enabled = history.isNotEmpty()
) {
Text(stringResource(R.string.share_history))
}
TextButton(onClick = { showDeleteAll.value = true }) {
Text(stringResource(R.string.delete_all))
}
}
LazyColumn {
items(history, key = { it.id }) { item ->
HistoryRow(item = item, onDelete = onDelete)
HistoryRow(
item = item,
onDelete = onDelete,
onOpenDetails = { selectedItem.value = item }
)
}
}
}
@@ -82,7 +117,11 @@ fun HistoryScreen(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HistoryRow(item: ScanRecord, onDelete: (Long) -> Unit) {
private fun HistoryRow(
item: ScanRecord,
onDelete: (Long) -> Unit,
onOpenDetails: () -> Unit
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissBoxValue.EndToStart || it == SwipeToDismissBoxValue.StartToEnd) {
@@ -100,6 +139,7 @@ private fun HistoryRow(item: ScanRecord, onDelete: (Long) -> Unit) {
content = {
Column(modifier = Modifier
.fillMaxWidth()
.clickable { onOpenDetails() }
.padding(vertical = 12.dp)) {
Text(text = item.type)
Text(text = item.content, maxLines = 2)
@@ -108,3 +148,12 @@ private fun HistoryRow(item: ScanRecord, onDelete: (Long) -> Unit) {
}
)
}
private fun buildHistoryExportText(history: List<ScanRecord>): String {
if (history.isEmpty()) return ""
val formatter = DateFormat.getDateTimeInstance()
return history.joinToString(separator = "\n\n") { item ->
val time = formatter.format(Date(item.timestamp))
"$time\n${item.type}\n${item.content}"
}
}
@@ -7,11 +7,15 @@ 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.MaterialTheme
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.text.style.TextAlign
@@ -24,6 +28,21 @@ fun HomeScreen(
onHistoryToggle: (Boolean) -> Unit,
onScanClick: () -> Unit
) {
val showPrivacyDialog = remember { mutableStateOf(false) }
if (showPrivacyDialog.value) {
AlertDialog(
onDismissRequest = { showPrivacyDialog.value = false },
title = { Text(text = stringResource(R.string.privacy)) },
text = { Text(text = stringResource(R.string.privacy_text)) },
confirmButton = {
TextButton(onClick = { showPrivacyDialog.value = false }) {
Text(text = stringResource(R.string.confirm))
}
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -47,13 +66,12 @@ fun HomeScreen(
Spacer(modifier = Modifier.height(24.dp))
TextButton(onClick = { showPrivacyDialog.value = true }) {
Text(
text = stringResource(R.string.privacy),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(R.string.privacy_text),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Start
)
}
}
}
@@ -7,17 +7,27 @@ import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
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.Row
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.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -31,8 +41,11 @@ 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.geometry.CornerRadius
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
@@ -96,12 +109,42 @@ fun ScannerScreen(
}
)
Box(
Canvas(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.7f)
.height(220.dp)
.background(Color.Transparent)
) {
val guideColor = Color(0xFF7CE6C6)
val cx = size.width / 2f
val cy = size.height / 2f
drawRoundRect(
color = guideColor.copy(alpha = 0.10f),
cornerRadius = CornerRadius(26f, 26f)
)
drawRoundRect(
color = guideColor.copy(alpha = 0.90f),
cornerRadius = CornerRadius(26f, 26f),
style = Stroke(width = 4f)
)
drawCircle(
color = guideColor.copy(alpha = 0.90f),
radius = 8f,
center = androidx.compose.ui.geometry.Offset(cx, cy)
)
}
Text(
text = stringResource(R.string.pinch_to_zoom_hint),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 40.dp)
.background(
color = Color.Black.copy(alpha = 0.35f),
shape = RoundedCornerShape(18.dp)
)
.padding(horizontal = 14.dp, vertical = 8.dp)
)
if (torchAvailable) {
@@ -126,7 +169,7 @@ fun ScannerScreen(
}
if (lastResult != null) {
ModalBottomSheet(onDismissRequest = {}) {
ModalBottomSheet(onDismissRequest = onScanAgain) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -135,8 +178,17 @@ fun ScannerScreen(
) {
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))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = { ClipboardUtil.copy(context, lastResult.content) }) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.copy)
)
}
if (lastResult.type == "URL") {
Button(onClick = {
@@ -152,11 +204,12 @@ fun ScannerScreen(
Text(stringResource(R.string.open))
}
}
Button(onClick = { Intents.shareText(context, lastResult.content) }) {
Text(stringResource(R.string.share))
IconButton(onClick = { Intents.shareText(context, lastResult.content) }) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = stringResource(R.string.share)
)
}
Button(onClick = onScanAgain) {
Text(stringResource(R.string.scan_again))
}
}
}
@@ -0,0 +1,31 @@
package com.clean.scanner.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
@Composable
fun CleanScannerTheme(content: @Composable () -> Unit) {
val darkTheme = isSystemInDarkTheme()
val context = LocalContext.current
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} else {
if (darkTheme) DarkColors else LightColors
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
+2
View File
@@ -31,4 +31,6 @@
<string name="content_type">Typ</string>
<string name="content_value">Inhalt</string>
<string name="request_camera">Kamera erlauben</string>
<string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string>
<string name="share_history">Historie teilen</string>
</resources>
+2
View File
@@ -31,4 +31,6 @@
<string name="content_type">Type</string>
<string name="content_value">Content</string>
<string name="request_camera">Allow camera</string>
<string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string>
<string name="share_history">Share history</string>
</resources>
+1 -1
View File
@@ -1,5 +1,5 @@
plugins {
id("com.android.application") version "8.7.0" apply false
id("com.android.application") version "8.13.2" 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
}
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable → Regular
View File