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
+18
View File
@@ -0,0 +1,18 @@
# Gradle
.gradle/
build/
**/build/
# Android Studio / IntelliJ
.idea/
*.iml
# Local machine config
local.properties
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
+35
View File
@@ -0,0 +1,35 @@
# Clean Scanner (MVP)
Offline-first, ad-free QR/barcode scanner built with Kotlin + Compose + CameraX + ML Kit.
## Architektur
- `ui/`: Compose-Screens + ViewModels (MVVM presentation layer)
- `data/`: Scanner-Analyzer, Room entities/DAO, Repository
- `domain/`: app models (`ScanResult`, `ScanRecord`, `UrlRiskResult`)
- `settings/`: DataStore preferences (history toggle, warnings toggle)
- `util/`: URL risk scorer, clipboard, intents
## Datenschutz
- Keine Werbung
- Keine Tracker/Analytics/Crashlytics
- Kein Backend/keine Webrequests
- Keine `INTERNET`-Permission im Manifest
## MVP Features
- Startscreen mit Scan-Button und lokalem Historie-Toggle (Default: OFF)
- Scanner mit CameraX live preview, Taschenlampe, debounce/no double scan
- Ergebnis-Bottom-Sheet mit Copy/Share/Open/Scan again
- Lokale URL-Risikoheuristik mit Warn-Dialog ab Score `>= 3`
- Historie-Liste inkl. Suche, Swipe-delete, Alles-löschen
- Einstellungen: Historie an/aus, Warnungen an/aus, About
## Run
1. In Android Studio: Open this folder as project.
2. Let Gradle sync dependencies.
3. Run app on emulator/device (API 24+).
## Tests
- URL Risk Scorer Tests: `app/src/test/java/com/clean/scanner/util/UrlRiskScorerTest.kt` (11 cases)
## Hinweis
In dieser Umgebung war kein `gradle`/`gradlew` verfügbar, daher konnte ich Builds/Tests hier nicht lokal ausführen.
+100
View File
@@ -0,0 +1,100 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.clean.scanner"
compileSdk = 35
defaultConfig {
applicationId = "com.clean.scanner"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
buildConfigField("boolean", "FEATURE_PAYWALL_ENABLED", "false")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.navigation:navigation-compose:2.8.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("androidx.camera:camera-core:1.3.4")
implementation("androidx.camera:camera-camera2:1.3.4")
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.datastore:datastore-preferences:1.1.1")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
+1
View File
@@ -0,0 +1 @@
# Keep minimal for MVP
+24
View File
@@ -0,0 +1,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".CleanScannerApp"
android:allowBackup="true"
android:icon="@android:drawable/ic_menu_camera"
android:label="@string/app_name"
android:roundIcon="@android:drawable/ic_menu_camera"
android:supportsRtl="true"
android:theme="@style/Theme.CleanScanner">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,23 @@
package com.clean.scanner
import android.content.Context
import androidx.room.Room
import com.clean.scanner.data.local.CleanScannerDatabase
import com.clean.scanner.data.repository.ScanRepository
import com.clean.scanner.settings.SettingsRepository
class AppContainer(context: Context) {
private val appContext = context.applicationContext
private val db: CleanScannerDatabase by lazy {
Room.databaseBuilder(appContext, CleanScannerDatabase::class.java, "clean_scanner.db").build()
}
val settingsRepository: SettingsRepository by lazy {
SettingsRepository(appContext)
}
val scanRepository: ScanRepository by lazy {
ScanRepository(db.scanDao(), settingsRepository)
}
}
@@ -0,0 +1,13 @@
package com.clean.scanner
import android.app.Application
class CleanScannerApp : Application() {
lateinit var appContainer: AppContainer
private set
override fun onCreate() {
super.onCreate()
appContainer = AppContainer(this)
}
}
@@ -0,0 +1,22 @@
package com.clean.scanner
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import com.clean.scanner.ui.CleanScannerAppRoot
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val container = (application as CleanScannerApp).appContainer
setContent {
MaterialTheme {
Surface(color = MaterialTheme.colorScheme.background) {
CleanScannerAppRoot(container)
}
}
}
}
}
@@ -0,0 +1,9 @@
package com.clean.scanner.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [ScanEntity::class], version = 1, exportSchema = false)
abstract class CleanScannerDatabase : RoomDatabase() {
abstract fun scanDao(): ScanDao
}
@@ -0,0 +1,21 @@
package com.clean.scanner.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ScanDao {
@Query("SELECT * FROM scan_history ORDER BY timestamp DESC")
fun observeAll(): Flow<List<ScanEntity>>
@Insert
suspend fun insert(entity: ScanEntity)
@Query("DELETE FROM scan_history WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM scan_history")
suspend fun clearAll()
}
@@ -0,0 +1,12 @@
package com.clean.scanner.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "scan_history")
data class ScanEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val content: String,
val type: String,
val timestamp: Long
)
@@ -0,0 +1,41 @@
package com.clean.scanner.data.repository
import com.clean.scanner.data.local.ScanDao
import com.clean.scanner.data.local.ScanEntity
import com.clean.scanner.domain.ScanRecord
import com.clean.scanner.settings.SettingsRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class ScanRepository(
private val dao: ScanDao,
private val settingsRepository: SettingsRepository
) {
fun observeHistory(): Flow<List<ScanRecord>> = dao.observeAll().map { list ->
list.map { entity ->
ScanRecord(
id = entity.id,
content = entity.content,
type = entity.type,
timestamp = entity.timestamp
)
}
}
suspend fun maybeSaveScan(content: String, type: String) {
val historyEnabled = settingsRepository.historyEnabled.first()
if (!historyEnabled) return
dao.insert(
ScanEntity(
content = content,
type = type,
timestamp = System.currentTimeMillis()
)
)
}
suspend fun deleteById(id: Long) = dao.deleteById(id)
suspend fun clearAll() = dao.clearAll()
}
@@ -0,0 +1,47 @@
package com.clean.scanner.data.scanner
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.clean.scanner.domain.ScanResult
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
class MlKitBarcodeAnalyzer(
private val onDetected: (ScanResult) -> Unit
) : ImageAnalysis.Analyzer {
private val scanner = BarcodeScanning.getClient()
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image ?: run {
imageProxy.close()
return
}
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(image)
.addOnSuccessListener { barcodes ->
val first = barcodes.firstOrNull() ?: return@addOnSuccessListener
val raw = first.rawValue ?: return@addOnSuccessListener
val type = first.valueType.toHumanType()
onDetected(ScanResult(content = raw, type = type))
}
.addOnCompleteListener { imageProxy.close() }
}
private fun Int.toHumanType(): String = when (this) {
1 -> "Contact"
2 -> "Email"
3 -> "ISBN"
4 -> "Phone"
5 -> "Product"
6 -> "SMS"
7 -> "Text"
8 -> "URL"
9 -> "WiFi"
10 -> "Geo"
11 -> "Calendar"
12 -> "Driver license"
else -> "Unknown"
}
}
@@ -0,0 +1,8 @@
package com.clean.scanner.domain
data class ScanRecord(
val id: Long,
val content: String,
val type: String,
val timestamp: Long
)
@@ -0,0 +1,6 @@
package com.clean.scanner.domain
data class ScanResult(
val content: String,
val type: String
)
@@ -0,0 +1,6 @@
package com.clean.scanner.domain
data class UrlRiskResult(
val score: Int,
val reasons: List<String>
)
@@ -0,0 +1,33 @@
package com.clean.scanner.settings
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore(name = "clean_scanner_settings")
class SettingsRepository(private val context: Context) {
private object Keys {
val historyEnabled = booleanPreferencesKey("history_enabled")
val warningsEnabled = booleanPreferencesKey("warnings_enabled")
}
val historyEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[Keys.historyEnabled] ?: false
}
val warningsEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[Keys.warningsEnabled] ?: true
}
suspend fun setHistoryEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.historyEnabled] = enabled }
}
suspend fun setWarningsEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.warningsEnabled] = enabled }
}
}
@@ -0,0 +1,81 @@
package com.clean.scanner.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.clean.scanner.AppContainer
import com.clean.scanner.domain.ScanRecord
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class AppUiState(
val historyEnabled: Boolean = false,
val warningsEnabled: Boolean = true,
val history: List<ScanRecord> = emptyList(),
val searchQuery: String = ""
)
class AppViewModel(
private val container: AppContainer
) : ViewModel() {
private val query = MutableStateFlow("")
val uiState: StateFlow<AppUiState> = combine(
container.settingsRepository.historyEnabled,
container.settingsRepository.warningsEnabled,
container.scanRepository.observeHistory(),
query
) { historyEnabled, warningsEnabled, history, q ->
AppUiState(
historyEnabled = historyEnabled,
warningsEnabled = warningsEnabled,
history = if (q.isBlank()) history else history.filter {
it.content.contains(q, ignoreCase = true) || it.type.contains(q, ignoreCase = true)
},
searchQuery = q
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppUiState())
fun setQuery(value: String) {
query.value = value
}
fun setHistoryEnabled(enabled: Boolean, clearHistoryIfDisabled: Boolean) {
viewModelScope.launch {
container.settingsRepository.setHistoryEnabled(enabled)
if (!enabled && clearHistoryIfDisabled) {
container.scanRepository.clearAll()
}
}
}
fun setWarningsEnabled(enabled: Boolean) {
viewModelScope.launch {
container.settingsRepository.setWarningsEnabled(enabled)
}
}
fun deleteHistoryItem(id: Long) {
viewModelScope.launch {
container.scanRepository.deleteById(id)
}
}
fun clearHistory() {
viewModelScope.launch {
container.scanRepository.clearAll()
}
}
class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return AppViewModel(container) as T
}
}
}
@@ -0,0 +1,103 @@
package com.clean.scanner.ui
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.clean.scanner.AppContainer
import com.clean.scanner.R
import com.clean.scanner.ui.screens.HistoryScreen
import com.clean.scanner.ui.screens.HomeScreen
import com.clean.scanner.ui.screens.ScannerScreen
import com.clean.scanner.ui.screens.SettingsScreen
private enum class RootTab { Home, History, Settings }
@Composable
fun CleanScannerAppRoot(container: AppContainer) {
val appViewModel: AppViewModel = viewModel(factory = AppViewModel.Factory(container))
val scannerViewModel: ScannerViewModel = viewModel(factory = ScannerViewModel.Factory(container))
val appState by appViewModel.uiState.collectAsStateWithLifecycle()
val scannerState by scannerViewModel.uiState.collectAsStateWithLifecycle()
var activeTab by remember { mutableStateOf(RootTab.Home) }
var showScanner by remember { mutableStateOf(false) }
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = activeTab == RootTab.Home,
onClick = {
activeTab = RootTab.Home
showScanner = false
},
label = { Text(stringResource(R.string.scan)) },
icon = {}
)
NavigationBarItem(
selected = activeTab == RootTab.History,
onClick = {
activeTab = RootTab.History
showScanner = false
},
label = { Text(stringResource(R.string.history)) },
icon = {}
)
NavigationBarItem(
selected = activeTab == RootTab.Settings,
onClick = {
activeTab = RootTab.Settings
showScanner = false
},
label = { Text(stringResource(R.string.settings)) },
icon = {}
)
}
}
) { padding ->
androidx.compose.foundation.layout.Box(modifier = androidx.compose.ui.Modifier.padding(padding)) {
when {
showScanner -> ScannerScreen(
analysisEnabled = scannerState.analysisEnabled,
lastResult = scannerState.lastResult,
warningsEnabled = appState.warningsEnabled,
onScan = scannerViewModel::onScan,
onScanAgain = scannerViewModel::resumeScanning
)
activeTab == RootTab.Home -> HomeScreen(
historyEnabled = appState.historyEnabled,
onHistoryToggle = { appViewModel.setHistoryEnabled(it, false) },
onScanClick = {
showScanner = true
scannerViewModel.resumeScanning()
}
)
activeTab == RootTab.History -> HistoryScreen(
query = appState.searchQuery,
history = appState.history,
onQueryChange = appViewModel::setQuery,
onDelete = appViewModel::deleteHistoryItem,
onClearAll = appViewModel::clearHistory
)
activeTab == RootTab.Settings -> SettingsScreen(
historyEnabled = appState.historyEnabled,
warningsEnabled = appState.warningsEnabled,
onHistoryToggle = appViewModel::setHistoryEnabled,
onWarningsToggle = appViewModel::setWarningsEnabled
)
}
}
}
}
@@ -0,0 +1,52 @@
package com.clean.scanner.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.clean.scanner.AppContainer
import com.clean.scanner.domain.ScanResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class ScannerUiState(
val lastResult: ScanResult? = null,
val analysisEnabled: Boolean = true,
val lastScanTimestamp: Long = 0L
)
class ScannerViewModel(
private val container: AppContainer
) : ViewModel() {
private val _uiState = MutableStateFlow(ScannerUiState())
val uiState: StateFlow<ScannerUiState> = _uiState.asStateFlow()
fun onScan(result: ScanResult) {
val now = System.currentTimeMillis()
val current = _uiState.value
if (!current.analysisEnabled) return
if (now - current.lastScanTimestamp < 800) return
_uiState.value = current.copy(
lastResult = result,
analysisEnabled = false,
lastScanTimestamp = now
)
viewModelScope.launch {
container.scanRepository.maybeSaveScan(result.content, result.type)
}
}
fun resumeScanning() {
_uiState.value = _uiState.value.copy(analysisEnabled = true, lastResult = null)
}
class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ScannerViewModel(container) as T
}
}
}
@@ -0,0 +1,83 @@
package com.clean.scanner.ui.components
import android.annotation.SuppressLint
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.clean.scanner.data.scanner.MlKitBarcodeAnalyzer
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun CameraPreview(
modifier: Modifier = Modifier,
analysisEnabled: Boolean,
torchEnabled: Boolean,
onTorchAvailabilityChanged: (Boolean) -> Unit,
onScan: (String, String) -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
val previewView = remember { PreviewView(context) }
val cameraRef = remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
DisposableEffect(Unit) {
onDispose {
cameraExecutor.shutdown()
}
}
LaunchedEffect(analysisEnabled) {
val provider = ProcessCameraProvider.getInstance(context).get()
provider.unbindAll()
val preview = Preview.Builder().build().apply {
setSurfaceProvider(previewView.surfaceProvider)
}
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build().apply {
if (analysisEnabled) {
setAnalyzer(cameraExecutor, MlKitBarcodeAnalyzer { result ->
onScan(result.content, result.type)
})
} else {
clearAnalyzer()
}
}
val camera = provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalysis
)
onTorchAvailabilityChanged(camera.cameraInfo.hasFlashUnit())
cameraRef.value = camera
}
LaunchedEffect(torchEnabled) {
cameraRef.value?.cameraControl?.enableTorch(torchEnabled)
}
AndroidView(
modifier = modifier,
factory = { previewView }
)
}
@@ -0,0 +1,110 @@
package com.clean.scanner.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
import com.clean.scanner.domain.ScanRecord
import java.text.DateFormat
import java.util.Date
@Composable
fun HistoryScreen(
query: String,
history: List<ScanRecord>,
onQueryChange: (String) -> Unit,
onDelete: (Long) -> Unit,
onClearAll: () -> Unit
) {
val showDeleteAll = remember { mutableStateOf(false) }
if (showDeleteAll.value) {
AlertDialog(
onDismissRequest = { showDeleteAll.value = false },
title = { Text(stringResource(R.string.delete_all)) },
text = { Text(stringResource(R.string.confirm_delete_all)) },
confirmButton = {
TextButton(onClick = {
onClearAll()
showDeleteAll.value = false
}) { Text(stringResource(R.string.confirm)) }
},
dismissButton = {
TextButton(onClick = { showDeleteAll.value = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.search)) }
)
TextButton(onClick = { showDeleteAll.value = true }) {
Text(stringResource(R.string.delete_all))
}
LazyColumn {
items(history, key = { it.id }) { item ->
HistoryRow(item = item, onDelete = onDelete)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HistoryRow(item: ScanRecord, onDelete: (Long) -> Unit) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissBoxValue.EndToStart || it == SwipeToDismissBoxValue.StartToEnd) {
onDelete(item.id)
true
} else {
false
}
}
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {},
content = {
Column(modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)) {
Text(text = item.type)
Text(text = item.content, maxLines = 2)
Text(text = DateFormat.getDateTimeInstance().format(Date(item.timestamp)))
}
}
)
}
@@ -0,0 +1,59 @@
package com.clean.scanner.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
@Composable
fun HomeScreen(
historyEnabled: Boolean,
onHistoryToggle: (Boolean) -> Unit,
onScanClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center
) {
Button(
onClick = onScanClick,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(R.string.scan))
}
Spacer(modifier = Modifier.height(24.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.save_history), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
Switch(checked = historyEnabled, onCheckedChange = onHistoryToggle)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.privacy),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(R.string.privacy_text),
textAlign = TextAlign.Start
)
}
}
@@ -0,0 +1,227 @@
package com.clean.scanner.ui.screens
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.clean.scanner.R
import com.clean.scanner.domain.ScanResult
import com.clean.scanner.ui.components.CameraPreview
import com.clean.scanner.util.ClipboardUtil
import com.clean.scanner.util.Intents
import com.clean.scanner.util.UrlRiskScorer
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScannerScreen(
analysisEnabled: Boolean,
lastResult: ScanResult?,
warningsEnabled: Boolean,
onScan: (ScanResult) -> Unit,
onScanAgain: () -> Unit
) {
val context = LocalContext.current
var cameraGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == android.content.pm.PackageManager.PERMISSION_GRANTED
)
}
var showSettingsHint by remember { mutableStateOf(false) }
var torchEnabled by remember { mutableStateOf(false) }
var torchAvailable by remember { mutableStateOf(false) }
var showRiskWarning by remember { mutableStateOf(false) }
var pendingOpenUrl by remember { mutableStateOf<String?>(null) }
val activity = context as? Activity
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
cameraGranted = granted
if (!granted && activity != null) {
showSettingsHint = !ActivityCompat.shouldShowRequestPermissionRationale(
activity,
Manifest.permission.CAMERA
)
}
}
LaunchedEffect(Unit) {
if (!cameraGranted) launcher.launch(Manifest.permission.CAMERA)
}
Box(modifier = Modifier.fillMaxSize()) {
if (cameraGranted) {
CameraPreview(
modifier = Modifier.fillMaxSize(),
analysisEnabled = analysisEnabled,
torchEnabled = torchEnabled,
onTorchAvailabilityChanged = { torchAvailable = it },
onScan = { content, type ->
onScan(ScanResult(content = content, type = type))
}
)
Box(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.7f)
.height(220.dp)
.background(Color.Transparent)
)
if (torchAvailable) {
RowTopToggle(
checked = torchEnabled,
onCheckedChange = { torchEnabled = it },
label = stringResource(R.string.flashlight)
)
}
} else {
PermissionContent(
showSettingsHint = showSettingsHint,
onRequestPermission = { launcher.launch(Manifest.permission.CAMERA) },
onOpenSettings = {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
context.startActivity(intent)
}
)
}
if (lastResult != null) {
ModalBottomSheet(onDismissRequest = {}) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(text = "${stringResource(R.string.content_type)}: ${lastResult.type}")
Text(text = "${stringResource(R.string.content_value)}: ${lastResult.content}")
Button(onClick = { ClipboardUtil.copy(context, lastResult.content) }) {
Text(stringResource(R.string.copy))
}
if (lastResult.type == "URL") {
Button(onClick = {
val risk = UrlRiskScorer.score(lastResult.content)
val risky = warningsEnabled && risk.score >= 3
if (risky) {
pendingOpenUrl = lastResult.content
showRiskWarning = true
} else {
Intents.openUrl(context, lastResult.content)
}
}) {
Text(stringResource(R.string.open))
}
}
Button(onClick = { Intents.shareText(context, lastResult.content) }) {
Text(stringResource(R.string.share))
}
Button(onClick = onScanAgain) {
Text(stringResource(R.string.scan_again))
}
}
}
}
if (showRiskWarning && pendingOpenUrl != null) {
AlertDialog(
onDismissRequest = { showRiskWarning = false },
text = { Text(stringResource(R.string.risk_warning)) },
confirmButton = {
TextButton(onClick = {
Intents.openUrl(context, pendingOpenUrl!!)
showRiskWarning = false
}) { Text(stringResource(R.string.open_anyway)) }
},
dismissButton = {
TextButton(onClick = { showRiskWarning = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
}
}
@Composable
private fun RowTopToggle(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
label: String
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
contentAlignment = Alignment.TopStart
) {
Column {
Text(text = label, color = Color.White)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
}
@Composable
private fun PermissionContent(
showSettingsHint: Boolean,
onRequestPermission: () -> Unit,
onOpenSettings: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center
) {
Text(text = stringResource(R.string.camera_permission_title))
Text(text = stringResource(R.string.camera_permission_rationale))
Button(onClick = onRequestPermission) {
Text(text = stringResource(R.string.request_camera))
}
if (showSettingsHint) {
TextButton(onClick = onOpenSettings) {
Text(stringResource(R.string.open_settings))
}
}
}
}
@@ -0,0 +1,79 @@
package com.clean.scanner.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
@Composable
fun SettingsScreen(
historyEnabled: Boolean,
warningsEnabled: Boolean,
onHistoryToggle: (Boolean, Boolean) -> Unit,
onWarningsToggle: (Boolean) -> Unit
) {
val showDeleteConfirm = remember { mutableStateOf(false) }
if (showDeleteConfirm.value) {
AlertDialog(
onDismissRequest = { showDeleteConfirm.value = false },
title = { Text(stringResource(R.string.settings)) },
text = { Text(stringResource(R.string.delete_history_on_disable)) },
confirmButton = {
TextButton(onClick = {
onHistoryToggle(false, true)
showDeleteConfirm.value = false
}) { Text(stringResource(R.string.confirm)) }
},
dismissButton = {
TextButton(onClick = {
onHistoryToggle(false, false)
showDeleteConfirm.value = false
}) { Text(stringResource(R.string.cancel)) }
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
Text(text = stringResource(R.string.save_history))
Switch(
checked = historyEnabled,
onCheckedChange = { enabled ->
if (!enabled && historyEnabled) {
showDeleteConfirm.value = true
} else {
onHistoryToggle(enabled, false)
}
}
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = stringResource(R.string.security_warnings))
Switch(checked = warningsEnabled, onCheckedChange = onWarningsToggle)
Spacer(modifier = Modifier.height(24.dp))
Text(text = stringResource(R.string.about))
Text(text = stringResource(R.string.version))
Text(text = stringResource(R.string.licenses))
Text(text = stringResource(R.string.contact))
}
}
@@ -0,0 +1,12 @@
package com.clean.scanner.util
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
object ClipboardUtil {
fun copy(context: Context, text: String) {
val manager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(ClipData.newPlainText("scan_content", text))
}
}
@@ -0,0 +1,22 @@
package com.clean.scanner.util
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.ContextCompat.startActivity
object Intents {
fun openUrl(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(context, intent, null)
}
fun shareText(context: Context, text: String) {
val intent = Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, text)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val chooser = Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(context, chooser, null)
}
}
@@ -0,0 +1,45 @@
package com.clean.scanner.util
import com.clean.scanner.domain.UrlRiskResult
import java.net.URI
object UrlRiskScorer {
fun score(raw: String): UrlRiskResult {
val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return UrlRiskResult(0, emptyList())
val host = uri.host.orEmpty()
val reasons = mutableListOf<String>()
var score = 0
if (host.matches(Regex("^\\d{1,3}(\\.\\d{1,3}){3}$"))) {
score += 2
reasons += "Host is an IP address"
}
if (uri.scheme.equals("http", ignoreCase = true)) {
score += 2
reasons += "URL uses HTTP"
}
if (host.contains("xn--", ignoreCase = true)) {
score += 2
reasons += "Host contains punycode"
}
if (host.length > 40) {
score += 1
reasons += "Host is unusually long"
}
if ((uri.rawQuery?.length ?: 0) > 120) {
score += 1
reasons += "Query is unusually long"
}
val percentEncodedCount = Regex("%[0-9a-fA-F]{2}").findAll(raw).count()
if (percentEncodedCount > 10) {
score += 1
reasons += "Many percent-encodings"
}
if (!uri.userInfo.isNullOrBlank()) {
score += 2
reasons += "Contains userinfo"
}
return UrlRiskResult(score = score, reasons = reasons)
}
}
+34
View File
@@ -0,0 +1,34 @@
<resources>
<string name="app_name">Clean Scanner</string>
<string name="scan">Scannen</string>
<string name="scan_again">Nochmal scannen</string>
<string name="history">Historie</string>
<string name="settings">Einstellungen</string>
<string name="save_history">Historie speichern (lokal)</string>
<string name="privacy">Datenschutz</string>
<string name="privacy_text">Keine Datenübertragung, keine Werbung, kein Tracking.</string>
<string name="security_warnings">Sicherheitswarnungen</string>
<string name="about">Über</string>
<string name="copy">Kopieren</string>
<string name="share">Teilen</string>
<string name="open">Öffnen</string>
<string name="cancel">Abbrechen</string>
<string name="open_anyway">Trotzdem öffnen</string>
<string name="risk_warning">Diese URL wirkt ungewöhnlich. Prüfe sie, bevor du öffnest.</string>
<string name="delete_all">Alles löschen</string>
<string name="confirm_delete_all">Alle Historie-Einträge löschen?</string>
<string name="confirm">Bestätigen</string>
<string name="search">Suchen</string>
<string name="flashlight">Taschenlampe</string>
<string name="camera_permission_title">Kamerazugriff erforderlich</string>
<string name="camera_permission_rationale">Kameraberechtigung wird zum Scannen von QR- und Barcodes benötigt.</string>
<string name="open_settings">Einstellungen öffnen</string>
<string name="no_camera">Keine Kameraberechtigung</string>
<string name="delete_history_on_disable">Vorhandene Historie beim Deaktivieren löschen?</string>
<string name="version">Version 1.0.0</string>
<string name="licenses">Open-Source-Lizenzen</string>
<string name="contact">Kontakt: support@example.com</string>
<string name="content_type">Typ</string>
<string name="content_value">Inhalt</string>
<string name="request_camera">Kamera erlauben</string>
</resources>
+34
View File
@@ -0,0 +1,34 @@
<resources>
<string name="app_name">Clean Scanner</string>
<string name="scan">Scan</string>
<string name="scan_again">Scan again</string>
<string name="history">History</string>
<string name="settings">Settings</string>
<string name="save_history">Save history (local)</string>
<string name="privacy">Privacy</string>
<string name="privacy_text">No data transfer, no ads, no tracking.</string>
<string name="security_warnings">Security warnings</string>
<string name="about">About</string>
<string name="copy">Copy</string>
<string name="share">Share</string>
<string name="open">Open</string>
<string name="cancel">Cancel</string>
<string name="open_anyway">Open anyway</string>
<string name="risk_warning">This URL looks unusual. Check it before opening.</string>
<string name="delete_all">Delete all</string>
<string name="confirm_delete_all">Delete all history entries?</string>
<string name="confirm">Confirm</string>
<string name="search">Search</string>
<string name="flashlight">Flashlight</string>
<string name="camera_permission_title">Camera access required</string>
<string name="camera_permission_rationale">Camera permission is needed to scan QR and barcodes.</string>
<string name="open_settings">Open settings</string>
<string name="no_camera">No camera permission</string>
<string name="delete_history_on_disable">Delete existing history when disabling?</string>
<string name="version">Version 1.0.0</string>
<string name="licenses">Open-source licenses</string>
<string name="contact">Contact: support@example.com</string>
<string name="content_type">Type</string>
<string name="content_value">Content</string>
<string name="request_camera">Allow camera</string>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.CleanScanner" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>
</resources>
@@ -0,0 +1,76 @@
package com.clean.scanner.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class UrlRiskScorerTest {
@Test
fun `https domain has low risk`() {
val result = UrlRiskScorer.score("https://example.com")
assertEquals(0, result.score)
}
@Test
fun `http adds two points`() {
val result = UrlRiskScorer.score("http://example.com")
assertEquals(2, result.score)
}
@Test
fun `ip host adds two points`() {
val result = UrlRiskScorer.score("https://192.168.1.1/path")
assertEquals(2, result.score)
}
@Test
fun `punycode host adds two points`() {
val result = UrlRiskScorer.score("https://xn--pple-43d.com")
assertEquals(2, result.score)
}
@Test
fun `long host adds one point`() {
val result = UrlRiskScorer.score("https://averyveryveryveryveryveryveryverylonghostname.com")
assertTrue(result.score >= 1)
}
@Test
fun `long query adds one point`() {
val query = "a".repeat(121)
val result = UrlRiskScorer.score("https://example.com/?q=$query")
assertEquals(1, result.score)
}
@Test
fun `many percent encodings adds one point`() {
val encoded = (1..11).joinToString("") { "%20" }
val result = UrlRiskScorer.score("https://example.com/$encoded")
assertEquals(1, result.score)
}
@Test
fun `userinfo adds two points`() {
val result = UrlRiskScorer.score("https://user:pass@example.com")
assertEquals(2, result.score)
}
@Test
fun `combined risk can exceed threshold`() {
val result = UrlRiskScorer.score("http://user:pass@192.168.0.1")
assertTrue(result.score >= 6)
}
@Test
fun `invalid url returns zero`() {
val result = UrlRiskScorer.score("not a url")
assertEquals(0, result.score)
}
@Test
fun `reasons list is populated for risky urls`() {
val result = UrlRiskScorer.score("http://xn--pple-43d.com")
assertTrue(result.reasons.isNotEmpty())
}
}
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
fi
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"
Vendored
+84
View File
@@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "CleanScanner"
include(":app")