init gradle
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user