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
@@ -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)
}
}