commit c0441bff4ba174fa0fcdd332532c10e68eec6a2c Author: ale Date: Sun Mar 1 14:08:01 2026 +0100 initial commit Signed-off-by: ale diff --git a/.github/workflows/build-release-apk.yml b/.github/workflows/build-release-apk.yml new file mode 100644 index 0000000..86b067a --- /dev/null +++ b/.github/workflows/build-release-apk.yml @@ -0,0 +1,35 @@ +name: Build & Publish APK Release + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Make Gradle executable + run: chmod +x ./gradlew + + - name: Build with Gradle + run: ./gradlew build + + - name: Build Debug APK + run: ./gradlew assembleDebug + + - name: Upload APK to Release + uses: softprops/action-gh-release@v1 + with: + files: app/build/outputs/apk/debug/*.apk + env: + GITHUB_TOKEN: ${{ secrets.TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b42481d --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Manuel Alejandro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..046b0ff --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# URLFinder + +

+ URLFinder logo +

+ +

+ A free, open-source Android app that detects URLs and QR codes in real time using your device camera. +

+ +

+ Latest release + MIT License + Android + Min SDK 24 +

+ +--- + +## Features + +- πŸ“· **Real-time camera scanning** β€” live camera preview powered by CameraX +- πŸ”€ **Text URL detection** β€” recognizes URLs in any visible text using ML Kit Text Recognition +- πŸ“² **QR code scanning** β€” decodes QR codes that contain URLs with ML Kit Barcode Scanning +- πŸ”— **Clickable URLs** β€” tap any detected URL to open it in your browser (with a safety warning) +- ⚑ **Flash / torch toggle** β€” turn on the camera torch for scanning in low-light conditions +- πŸ—‚οΈ **Local history** β€” all detected URLs are saved locally and can be browsed and cleared from Settings +- ⚠️ **Safety warning dialog** β€” a confirmation dialog reminds you that URLs may be dangerous before opening +- πŸ†“ **Free & open source** β€” MIT licensed, no ads, no tracking, no analytics + +--- + +## Screenshots + +> _Coming soon_ + +--- + +## Requirements + +| | | +|---|---| +| **Android version** | 7.0 (API 24) or higher | +| **Permissions** | `CAMERA`, `INTERNET` | +| **Hardware** | Rear camera with autofocus recommended | + +--- + +## Installation + +### Download APK + +Download the latest release APK from the [Releases page](https://github.com/manalejandro/URLFinder/releases) and install it on your device. + +> You may need to enable **Install from unknown sources** in your device settings. + +### Build from source + +1. **Clone the repository** + ```bash + git clone https://github.com/manalejandro/URLFinder.git + cd URLFinder + ``` + +2. **Open in Android Studio** + - File β†’ Open β†’ select the `URLFinder` folder + - Let Gradle sync finish + +3. **Run or build** + ```bash + ./gradlew assembleDebug + # APK β†’ app/build/outputs/apk/debug/app-debug.apk + ``` + +--- + +## Architecture + +``` +MainActivity +└── CameraScreen (Jetpack Compose) + β”œβ”€β”€ CameraViewModel + β”‚ β”œβ”€β”€ CameraX (Preview + ImageAnalysis) + β”‚ β”œβ”€β”€ ML Kit Text Recognition β†’ extracts URLs from plain text + β”‚ └── ML Kit Barcode Scanner β†’ decodes QR codes + β”œβ”€β”€ UrlResultsPanel (LazyColumn of detected URLs) + └── SettingsScreen + β”œβ”€β”€ History tab (UrlHistoryRepository β€” SharedPreferences) + └── About tab (open-source info + GitHub link) +``` + +**Key design decisions:** +- Frame analysis is **throttled to 800 ms** to reduce CPU/battery usage. +- `ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST` drops frames that arrive while the previous one is still being processed. +- Both text and barcode analyzers run **concurrently** on the same frame, results are merged. +- URLs are **deduplicated** per session; the history avoids duplicate entries too. +- The history is stored as JSON in `SharedPreferences` (no external DB dependency). + +--- + +## Tech stack + +| Library | Version | Purpose | +|---|---|---| +| Kotlin | 2.0.21 | Programming language | +| Jetpack Compose (BOM) | 2024.09.00 | UI toolkit | +| Material 3 | β€” | Design system | +| CameraX | 1.4.2 | Camera access and analysis | +| ML Kit Text Recognition | 16.0.1 | URL detection in text | +| ML Kit Barcode Scanning | 17.3.0 | QR code decoding | +| Accompanist Permissions | 0.37.0 | Runtime camera permission | +| Lifecycle ViewModel Compose | 2.10.0 | State management | + +--- + +## Permissions + +| Permission | Reason | +|---|---| +| `CAMERA` | Required to capture and analyze camera frames for URLs and QR codes | +| `INTERNET` | Required to open detected URLs in the browser | + +--- + +## Contributing + +Contributions are welcome! Feel free to: + +- Open an [issue](https://github.com/manalejandro/URLFinder/issues) to report a bug or suggest a feature +- Fork the repo and submit a pull request + +Please follow the existing code style (Kotlin + Compose conventions) and add documentation to public APIs. + +--- + +## License + +``` +MIT License + +Copyright (c) 2026 Manuel Alejandro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +--- + +

+ Made with ❀️ using Kotlin, Jetpack Compose, CameraX and ML Kit
+ https://github.com/manalejandro/URLFinder +

+ diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..097c463 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.manalejandro.urlfinder" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.manalejandro.urlfinder" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + // CameraX + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + // ML Kit + implementation(libs.mlkit.text.recognition) + implementation(libs.mlkit.barcode.scanning) + // Permissions + implementation(libs.accompanist.permissions) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/manalejandro/urlfinder/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/manalejandro/urlfinder/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..fb37de2 --- /dev/null +++ b/app/src/androidTest/java/com/manalejandro/urlfinder/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.manalejandro.urlfinder + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.manalejandro.urlfinder", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1b93123 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/ic_urlfinder_logo.svg b/app/src/main/assets/ic_urlfinder_logo.svg new file mode 100644 index 0000000..27e3583 --- /dev/null +++ b/app/src/main/assets/ic_urlfinder_logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..da8af94 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/manalejandro/urlfinder/CameraScreen.kt b/app/src/main/java/com/manalejandro/urlfinder/CameraScreen.kt new file mode 100644 index 0000000..af74003 --- /dev/null +++ b/app/src/main/java/com/manalejandro/urlfinder/CameraScreen.kt @@ -0,0 +1,340 @@ +package com.manalejandro.urlfinder + +import androidx.activity.compose.BackHandler +import android.Manifest +import androidx.camera.view.PreviewView +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.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.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +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.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale + +/** + * Root composable for the URLFinder app. + * Handles camera permission flow and top-level navigation between [CameraContent] + * and [SettingsScreen]. + * + * @param repository History repository shared across screens. + * @param viewModel Camera ViewModel, injected by Compose. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraScreen( + repository: UrlHistoryRepository, + viewModel: CameraViewModel = viewModel() +) { + // Wire repository into the ViewModel once + viewModel.historyRepository = repository + + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + var showSettings by remember { mutableStateOf(false) } + + // Intercept the system back button/gesture when Settings is open + // so it navigates back to the camera instead of closing the app. + BackHandler(enabled = showSettings) { + showSettings = false + } + + if (showSettings) { + SettingsScreen( + repository = repository, + onBack = { showSettings = false } + ) + return + } + + when { + cameraPermissionState.status.isGranted -> { + CameraContent( + viewModel = viewModel, + onOpenSettings = { showSettings = true } + ) + } + cameraPermissionState.status.shouldShowRationale -> { + PermissionRationaleContent( + message = "URLFinder needs access to your camera to scan URLs and QR codes.", + onRequestPermission = { cameraPermissionState.launchPermissionRequest() } + ) + } + else -> { + PermissionRationaleContent( + message = "Camera permission is required to scan URLs and QR codes. Please grant it.", + onRequestPermission = { cameraPermissionState.launchPermissionRequest() } + ) + } + } +} + +/** + * Full-screen camera UI with live [PreviewView], flash toggle, settings button + * and the URL results overlay panel. + */ +@Composable +private fun CameraContent( + viewModel: CameraViewModel, + onOpenSettings: () -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val detectedUrls by viewModel.detectedUrls.collectAsState() + val isFlashOn by viewModel.isFlashOn.collectAsState() + var urlToOpen by remember { mutableStateOf(null) } + val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current + + val previewView = remember { PreviewView(context) } + + // Start (or restart) the camera whenever the lifecycle resumes. + // This handles the return from Settings without crashing. + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.startCamera(context, lifecycleOwner, previewView) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + // Safety warning modal + urlToOpen?.let { url -> + SafetyWarningDialog( + url = url, + onConfirm = { + runCatching { uriHandler.openUri(url) } + urlToOpen = null + }, + onDismiss = { urlToOpen = null } + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + // --- Camera preview layer --- + AndroidView( + factory = { previewView }, + modifier = Modifier.fillMaxSize() + ) + + // --- Scanning overlay --- + ScanningOverlay() + + // --- Top action bar (flash + settings) β€” respects status bar height --- + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End + ) { + // Flash toggle button + IconButton( + onClick = { viewModel.toggleFlash() }, + modifier = Modifier + .background( + color = if (isFlashOn) Color(0xFFFFD600) else Color.Black.copy(alpha = 0.55f), + shape = MaterialTheme.shapes.small + ) + ) { + Icon( + painter = painterResource( + id = if (isFlashOn) R.drawable.ic_flash_on else R.drawable.ic_flash_off + ), + contentDescription = if (isFlashOn) "Flash on" else "Flash off", + tint = if (isFlashOn) Color.Black else Color.White + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Settings button + IconButton( + onClick = onOpenSettings, + modifier = Modifier + .background( + color = Color.Black.copy(alpha = 0.55f), + shape = MaterialTheme.shapes.small + ) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = Color.White + ) + } + } + + // --- URL results panel anchored to the bottom --- + AnimatedVisibility( + visible = detectedUrls.isNotEmpty(), + modifier = Modifier.align(Alignment.BottomCenter), + enter = slideInVertically { it } + fadeIn(), + exit = fadeOut() + ) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.93f), + tonalElevation = 8.dp, + shadowElevation = 8.dp, + shape = MaterialTheme.shapes.large + ) { + Column(modifier = Modifier.padding(top = 8.dp)) { + // Header + Text( + text = "Detected URLs (${detectedUrls.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + + // URL list β€” items open the safety warning dialog + UrlResultsPanel( + urls = detectedUrls, + onUrlClick = { urlToOpen = it.url } + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Clear button β€” full width at the bottom of the panel + Button( + onClick = { viewModel.clearResults() }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_link), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Clear results") + } + } + } + } + } +} + +/** + * Semi-transparent scanning overlay with corner guides and a hint label. + */ +@Composable +private fun ScanningOverlay() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(id = R.drawable.ic_scan_frame), + contentDescription = "Scan area", + tint = Color.White.copy(alpha = 0.8f), + modifier = Modifier.size(240.dp) + ) + Text( + text = "Point the camera at a URL or QR code", + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 8.dp) + .background( + Color.Black.copy(alpha = 0.45f), + shape = MaterialTheme.shapes.small + ) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } +} + +/** + * Displayed when camera permission has not been granted yet. + * + * @param message Human-readable explanation of why the permission is needed. + * @param onRequestPermission Lambda called when the user taps the grant button. + */ +@Composable +private fun PermissionRationaleContent(message: String, onRequestPermission: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + // Logo shown in full color (no tint) + Icon( + painter = painterResource(id = R.drawable.ic_urlfinder_logo), + contentDescription = "URLFinder logo", + tint = Color.Unspecified, + modifier = Modifier.size(96.dp) + ) + Text( + text = "URLFinder", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 24.dp) + ) + Button(onClick = onRequestPermission) { + Text("Grant Camera Permission") + } + } + } +} diff --git a/app/src/main/java/com/manalejandro/urlfinder/CameraViewModel.kt b/app/src/main/java/com/manalejandro/urlfinder/CameraViewModel.kt new file mode 100644 index 0000000..d726ec1 --- /dev/null +++ b/app/src/main/java/com/manalejandro/urlfinder/CameraViewModel.kt @@ -0,0 +1,237 @@ +package com.manalejandro.urlfinder + +import android.content.Context +import android.util.Log +import android.util.Patterns +import androidx.annotation.OptIn +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** Source type that originated the detected URL */ +enum class UrlSource { TEXT, QR_CODE } + +/** A URL found by the scanner with its source type */ +data class DetectedUrl( + val url: String, + val source: UrlSource +) + +/** + * ViewModel responsible for managing the camera lifecycle and URL detection pipeline. + * Uses CameraX for camera access, ML Kit Text Recognition for detecting URLs in + * plain text, and ML Kit Barcode Scanning for QR codes. + */ +class CameraViewModel : ViewModel() { + + companion object { + private const val TAG = "CameraViewModel" + /** Minimum interval between frame analyses in milliseconds (throttle) */ + private const val ANALYSIS_INTERVAL_MS = 800L + } + + private val _detectedUrls = MutableStateFlow>(emptyList()) + /** Live stream of all URLs detected in the current camera session */ + val detectedUrls: StateFlow> = _detectedUrls.asStateFlow() + + private val _isScanning = MutableStateFlow(false) + /** Whether the camera analyzer is actively processing frames */ + val isScanning: StateFlow = _isScanning.asStateFlow() + + private val _isFlashOn = MutableStateFlow(false) + /** Whether the camera torch/flash is currently enabled */ + val isFlashOn: StateFlow = _isFlashOn.asStateFlow() + + /** Exposed repository β€” injected from outside (e.g. from Application context) */ + var historyRepository: UrlHistoryRepository? = null + + private val analysisExecutor = Executors.newSingleThreadExecutor() + private val textRecognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + private val barcodeScanner = BarcodeScanning.getClient() + private val lastAnalysisTimestamp = AtomicLong(0L) + private val isProcessing = AtomicBoolean(false) + private var camera: Camera? = null + + /** URL regex backed by Android's built-in Patterns.WEB_URL */ + private val urlRegex = Patterns.WEB_URL.toRegex() + + /** + * Binds CameraX use cases (Preview + ImageAnalysis) to the given [lifecycleOwner]. + * + * @param context Application or Activity context used to get the camera provider. + * @param lifecycleOwner The lifecycle owner that controls camera lifetime. + * @param previewView The [PreviewView] that will render the camera feed. + */ + fun startCamera( + context: Context, + lifecycleOwner: LifecycleOwner, + previewView: PreviewView + ) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { analysis -> + analysis.setAnalyzer(analysisExecutor) { imageProxy -> + analyzeFrame(imageProxy) + } + } + + try { + cameraProvider.unbindAll() + camera = cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalysis + ) + // Restore flash state if it was on before rebind + camera?.cameraControl?.enableTorch(_isFlashOn.value) + _isScanning.value = true + } catch (e: Exception) { + Log.e(TAG, "Camera binding failed", e) + } + }, ContextCompat.getMainExecutor(context)) + } + + /** + * Toggles the camera torch on or off. + */ + fun toggleFlash() { + val newState = !_isFlashOn.value + _isFlashOn.value = newState + camera?.cameraControl?.enableTorch(newState) + } + + /** + * Analyzes a single camera [frame][ImageProxy] for URLs (text) and QR codes. + * Throttles analysis to [ANALYSIS_INTERVAL_MS] ms to reduce CPU usage. + */ + @OptIn(ExperimentalGetImage::class) + private fun analyzeFrame(imageProxy: ImageProxy) { + val now = System.currentTimeMillis() + val elapsed = now - lastAnalysisTimestamp.get() + + // Throttle: skip frames that arrive too quickly + if (elapsed < ANALYSIS_INTERVAL_MS || !isProcessing.compareAndSet(false, true)) { + imageProxy.close() + return + } + lastAnalysisTimestamp.set(now) + + val mediaImage = imageProxy.image + if (mediaImage == null) { + imageProxy.close() + isProcessing.set(false) + return + } + + val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + val foundUrls = mutableListOf() + var pendingTasks = 2 + + fun checkDone() { + pendingTasks-- + if (pendingTasks == 0) { + mergeResults(foundUrls) + imageProxy.close() + isProcessing.set(false) + } + } + + // --- Text recognition (URLs in plain text) --- + textRecognizer.process(inputImage) + .addOnSuccessListener { visionText -> + val urls = urlRegex.findAll(visionText.text) + .map { it.value.normalizeUrl() } + .filter { it.isNotBlank() } + .map { DetectedUrl(it, UrlSource.TEXT) } + .toList() + synchronized(foundUrls) { foundUrls.addAll(urls) } + } + .addOnFailureListener { e -> Log.e(TAG, "Text recognition failed", e) } + .addOnCompleteListener { checkDone() } + + // --- Barcode / QR code scanning --- + barcodeScanner.process(inputImage) + .addOnSuccessListener { barcodes -> + val urls = barcodes + .filter { it.valueType == Barcode.TYPE_URL || it.format == Barcode.FORMAT_QR_CODE } + .mapNotNull { it.url?.url ?: it.rawValue } + .filter { it.looksLikeUrl() } + .map { DetectedUrl(it.normalizeUrl(), UrlSource.QR_CODE) } + synchronized(foundUrls) { foundUrls.addAll(urls) } + } + .addOnFailureListener { e -> Log.e(TAG, "Barcode scanning failed", e) } + .addOnCompleteListener { checkDone() } + } + + /** + * Merges newly detected URLs with the existing accumulated list, + * avoiding exact-URL duplicates. Also saves to history. + */ + private fun mergeResults(newUrls: List) { + viewModelScope.launch { + if (newUrls.isEmpty()) return@launch + val existing = _detectedUrls.value.map { it.url }.toSet() + val unique = newUrls.filter { it.url !in existing } + if (unique.isNotEmpty()) { + _detectedUrls.value = _detectedUrls.value + unique + unique.forEach { historyRepository?.add(it) } + } + } + } + + /** Clears the accumulated URL list so the user can start a fresh scan */ + fun clearResults() { + _detectedUrls.value = emptyList() + } + + override fun onCleared() { + super.onCleared() + analysisExecutor.shutdown() + textRecognizer.close() + barcodeScanner.close() + } +} + +// --- Extension helpers --- + +/** Ensures a URL string starts with a proper HTTP/HTTPS scheme */ +private fun String.normalizeUrl(): String = + if (startsWith("http://") || startsWith("https://")) this + else "https://$this" + +/** Returns true if the string looks like a URL */ +private fun String.looksLikeUrl(): Boolean = + Patterns.WEB_URL.matcher(this).matches() || + startsWith("http://") || + startsWith("https://") diff --git a/app/src/main/java/com/manalejandro/urlfinder/MainActivity.kt b/app/src/main/java/com/manalejandro/urlfinder/MainActivity.kt new file mode 100644 index 0000000..410b82a --- /dev/null +++ b/app/src/main/java/com/manalejandro/urlfinder/MainActivity.kt @@ -0,0 +1,32 @@ +package com.manalejandro.urlfinder + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.manalejandro.urlfinder.ui.theme.URLFinderTheme + +/** + * Single-activity entry point for URLFinder. + * Creates the [UrlHistoryRepository] and hosts [CameraScreen]. + */ +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + URLFinderTheme { + Surface(modifier = Modifier.fillMaxSize()) { + val context = LocalContext.current + val repository = remember { UrlHistoryRepository(context) } + CameraScreen(repository = repository) + } + } + } + } +} diff --git a/app/src/main/java/com/manalejandro/urlfinder/SettingsScreen.kt b/app/src/main/java/com/manalejandro/urlfinder/SettingsScreen.kt new file mode 100644 index 0000000..f6e165a --- /dev/null +++ b/app/src/main/java/com/manalejandro/urlfinder/SettingsScreen.kt @@ -0,0 +1,451 @@ +package com.manalejandro.urlfinder + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.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.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Settings screen with two tabs: URL History and About. + * + * @param repository History repository to read/clear entries. + * @param onBack Callback invoked when the user taps the back button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + repository: UrlHistoryRepository, + onBack: () -> Unit +) { + var selectedTab by remember { mutableIntStateOf(0) } + val tabs = listOf("History", "About") + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title) } + ) + } + } + + when (selectedTab) { + 0 -> HistoryTab(repository = repository) + 1 -> AboutTab() + } + } + } +} + +// --------------------------------------------------------------------------- +// History Tab +// --------------------------------------------------------------------------- + +@Composable +private fun HistoryTab(repository: UrlHistoryRepository) { + val history by repository.history.collectAsState() + var urlToOpen by remember { mutableStateOf(null) } + var showClearConfirm by remember { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current + + // Dangerous-URL warning dialog + urlToOpen?.let { url -> + SafetyWarningDialog( + url = url, + onConfirm = { + runCatching { uriHandler.openUri(url) } + urlToOpen = null + }, + onDismiss = { urlToOpen = null } + ) + } + + // Clear all confirmation dialog + if (showClearConfirm) { + AlertDialog( + onDismissRequest = { showClearConfirm = false }, + title = { Text("Clear history") }, + text = { Text("Are you sure you want to delete all saved URLs? This cannot be undone.") }, + confirmButton = { + Button( + onClick = { + repository.clear() + showClearConfirm = false + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { Text("Delete all") } + }, + dismissButton = { + TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") } + } + ) + } + + if (history.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(id = R.drawable.ic_link), + contentDescription = null, + tint = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.size(64.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "No history yet", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.outline + ) + Text( + text = "Scanned URLs will appear here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + } + } else { + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + items(history, key = { it.id }) { entry -> + HistoryItem( + entry = entry, + onClick = { urlToOpen = entry.url } + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + } + } + + // Clear all button β€” full width at the bottom + Button( + onClick = { showClearConfirm = true }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Clear all history") + } + } + } +} + +@Composable +private fun HistoryItem(entry: HistoryEntry, onClick: () -> Unit) { + val dateFormat = remember { SimpleDateFormat("MMM d, yyyy Β· HH:mm", Locale.getDefault()) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + Icon( + painter = painterResource( + id = if (entry.source == UrlSource.QR_CODE) R.drawable.ic_qr_code else R.drawable.ic_link + ), + contentDescription = null, + tint = if (entry.source == UrlSource.QR_CODE) + MaterialTheme.colorScheme.secondary + else + MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { + Text( + text = entry.url, + style = MaterialTheme.typography.bodyMedium.copy( + textDecoration = TextDecoration.Underline + ), + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = dateFormat.format(Date(entry.timestamp)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + Icon( + painter = painterResource(id = R.drawable.ic_open_in_browser), + contentDescription = "Open in browser", + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(16.dp) + ) + } +} + +// --------------------------------------------------------------------------- +// About Tab +// --------------------------------------------------------------------------- + +@Composable +private fun AboutTab() { + val uriHandler = LocalUriHandler.current + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + // Multicolor logo (not tinted) + Icon( + painter = painterResource(id = R.drawable.ic_urlfinder_logo), + contentDescription = "URLFinder logo", + tint = androidx.compose.ui.graphics.Color.Unspecified, + modifier = Modifier.size(96.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "URLFinder", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Version 1.0", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + + item { + HorizontalDivider() + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "URLFinder is a free and open-source Android app that uses your " + + "device camera to detect URLs in text and QR codes in real time, " + + "letting you open them directly in your browser.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth() + ) + } + + item { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_open_source), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "Free & Open Source Software", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Text( + text = "URLFinder is released under the MIT License. " + + "You are free to use, modify and distribute it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + item { + OutlinedButton( + onClick = { + runCatching { + uriHandler.openUri("https://github.com/manalejandro/URLFinder") + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = painterResource(id = R.drawable.ic_github), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("View on GitHub") + } + } + + item { + HorizontalDivider() + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Built with ❀️ using Kotlin, Jetpack Compose, CameraX & ML Kit", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + } +} + +// --------------------------------------------------------------------------- +// Safety Warning Dialog +// --------------------------------------------------------------------------- + +/** + * Modal dialog warning the user that the URL may be dangerous before opening it. + * + * @param url The URL that is about to be opened. + * @param onConfirm Called when the user accepts the risk and wants to proceed. + * @param onDismiss Called when the user cancels. + */ +@Composable +fun SafetyWarningDialog( + url: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_warning), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp) + ) + }, + title = { Text("Open URL?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "⚠️ This URL may be dangerous. You are responsible for any " + + "content you access through it.", + style = MaterialTheme.typography.bodyMedium + ) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(8.dp) + ) + } + } + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { Text("Open anyway") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + diff --git a/app/src/main/java/com/manalejandro/urlfinder/UrlHistoryRepository.kt b/app/src/main/java/com/manalejandro/urlfinder/UrlHistoryRepository.kt new file mode 100644 index 0000000..51c049c --- /dev/null +++ b/app/src/main/java/com/manalejandro/urlfinder/UrlHistoryRepository.kt @@ -0,0 +1,91 @@ +package com.manalejandro.urlfinder + +import android.content.Context +import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID + +/** + * A persisted URL history entry with a stable unique [id], timestamp and source type. + */ +data class HistoryEntry( + val id: String = UUID.randomUUID().toString(), + val url: String, + val source: UrlSource, + val timestamp: Long = System.currentTimeMillis() +) + +/** + * Repository that persists detected URLs locally using SharedPreferences (JSON). + * Provides a [StateFlow] of [HistoryEntry] items and operations to add/clear them. + */ +class UrlHistoryRepository(context: Context) { + + companion object { + private const val PREFS_NAME = "url_history_prefs" + private const val KEY_HISTORY = "history" + private const val MAX_ENTRIES = 200 + } + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val _history = MutableStateFlow>(load()) + /** Live list of persisted URL history entries, newest first. */ + val history: StateFlow> = _history.asStateFlow() + + /** + * Adds a [DetectedUrl] to history if it is not already present. + * Keeps at most [MAX_ENTRIES] entries. + */ + fun add(detectedUrl: DetectedUrl) { + val current = _history.value + if (current.any { it.url == detectedUrl.url }) return + val entry = HistoryEntry(url = detectedUrl.url, source = detectedUrl.source) + val updated = (listOf(entry) + current).take(MAX_ENTRIES) + _history.value = updated + save(updated) + } + + /** Removes all history entries from memory and disk. */ + fun clear() { + _history.value = emptyList() + prefs.edit { remove(KEY_HISTORY) } + } + + // --- Serialization --- + + private fun save(entries: List) { + val array = JSONArray() + entries.forEach { e -> + val obj = JSONObject().apply { + put("id", e.id) + put("url", e.url) + put("source", e.source.name) + put("ts", e.timestamp) + } + array.put(obj) + } + prefs.edit { putString(KEY_HISTORY, array.toString()) } + } + + private fun load(): List { + val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList() + return runCatching { + val array = JSONArray(json) + (0 until array.length()).map { i -> + val obj = array.getJSONObject(i) + HistoryEntry( + id = obj.optString("id", UUID.randomUUID().toString()), + url = obj.getString("url"), + source = UrlSource.valueOf(obj.getString("source")), + timestamp = obj.getLong("ts") + ) + } + }.getOrDefault(emptyList()) + } +} + diff --git a/app/src/main/java/com/manalejandro/urlfinder/UrlResultsPanel.kt b/app/src/main/java/com/manalejandro/urlfinder/UrlResultsPanel.kt new file mode 100644 index 0000000..51df65a --- /dev/null +++ b/app/src/main/java/com/manalejandro/urlfinder/UrlResultsPanel.kt @@ -0,0 +1,130 @@ +package com.manalejandro.urlfinder + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Displays a scrollable list of [DetectedUrl] items. + * Each item triggers [onUrlClick] when tapped instead of opening the browser + * directly, allowing the caller to show a safety warning first. + * + * @param urls The list of detected URLs to display. + * @param onUrlClick Called with the tapped [DetectedUrl]. + */ +@Composable +fun UrlResultsPanel( + urls: List, + onUrlClick: (DetectedUrl) -> Unit = {} +) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + ) { + itemsIndexed(urls) { index, detectedUrl -> + UrlItem(detectedUrl = detectedUrl, onClick = { onUrlClick(detectedUrl) }) + if (index < urls.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + } + } + } +} + +/** + * A single URL card item. + * Calls [onClick] when tapped so the parent can handle navigation/warnings. + * + * @param detectedUrl The URL data to display. + * @param onClick Callback invoked on tap. + */ +@Composable +private fun UrlItem(detectedUrl: DetectedUrl, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp) + ) { + // Icon varies by source type + Icon( + painter = painterResource( + id = if (detectedUrl.source == UrlSource.QR_CODE) + R.drawable.ic_qr_code + else + R.drawable.ic_link + ), + contentDescription = if (detectedUrl.source == UrlSource.QR_CODE) + "QR Code URL" + else + "Text URL", + tint = if (detectedUrl.source == UrlSource.QR_CODE) + MaterialTheme.colorScheme.secondary + else + MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (detectedUrl.source == UrlSource.QR_CODE) "QR Code" else "Text", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + fontWeight = FontWeight.Medium + ) + Text( + text = detectedUrl.url, + style = MaterialTheme.typography.bodyMedium.copy( + textDecoration = TextDecoration.Underline + ), + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Icon( + painter = painterResource(id = R.drawable.ic_open_in_browser), + contentDescription = "Open in browser", + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(18.dp) + ) + } + } +} diff --git a/app/src/main/java/com/manalejandro/urlfinder/ui/theme/Color.kt b/app/src/main/java/com/manalejandro/urlfinder/ui/theme/Color.kt new file mode 100644 index 0000000..cf1f376 --- /dev/null +++ b/app/src/main/java/com/manalejandro/urlfinder/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.manalejandro.urlfinder.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/manalejandro/urlfinder/ui/theme/Theme.kt b/app/src/main/java/com/manalejandro/urlfinder/ui/theme/Theme.kt new file mode 100644 index 0000000..3759d7f --- /dev/null +++ b/app/src/main/java/com/manalejandro/urlfinder/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.manalejandro.urlfinder.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun URLFinderTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/manalejandro/urlfinder/ui/theme/Type.kt b/app/src/main/java/com/manalejandro/urlfinder/ui/theme/Type.kt new file mode 100644 index 0000000..ed5ed48 --- /dev/null +++ b/app/src/main/java/com/manalejandro/urlfinder/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.manalejandro.urlfinder.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_flash_off.xml b/app/src/main/res/drawable/ic_flash_off.xml new file mode 100644 index 0000000..2a01f8d --- /dev/null +++ b/app/src/main/res/drawable/ic_flash_off.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_flash_on.xml b/app/src/main/res/drawable/ic_flash_on.xml new file mode 100644 index 0000000..9d3348d --- /dev/null +++ b/app/src/main/res/drawable/ic_flash_on.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml new file mode 100644 index 0000000..2bfe162 --- /dev/null +++ b/app/src/main/res/drawable/ic_github.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..8c561c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 0000000..ec08a83 --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_open_in_browser.xml b/app/src/main/res/drawable/ic_open_in_browser.xml new file mode 100644 index 0000000..b4cc0b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_browser.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_open_source.xml b/app/src/main/res/drawable/ic_open_source.xml new file mode 100644 index 0000000..b7e671f --- /dev/null +++ b/app/src/main/res/drawable/ic_open_source.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_qr_code.xml b/app/src/main/res/drawable/ic_qr_code.xml new file mode 100644 index 0000000..c2c0d58 --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_scan_frame.xml b/app/src/main/res/drawable/ic_scan_frame.xml new file mode 100644 index 0000000..cd85f9a --- /dev/null +++ b/app/src/main/res/drawable/ic_scan_frame.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_urlfinder_logo.xml b/app/src/main/res/drawable/ic_urlfinder_logo.xml new file mode 100644 index 0000000..239e011 --- /dev/null +++ b/app/src/main/res/drawable/ic_urlfinder_logo.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..3fef0d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..40ccaa2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9a7afd3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..42b867f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..ec011ea Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..16719c6 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..87be5fe Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..1b6b6c8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..2a8f05a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..7dcdec8 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9ae568f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..31b2600 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #145CB7 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..acecebb --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + URLFinder + URLFinder needs camera access to scan URLs and QR codes. + Grant Camera Permission + Detected URLs + Point the camera at a URL or QR code + Clear results + Open in browser + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2c4d763 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +