initial commit

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2026-03-01 14:08:01 +01:00
commit c0441bff4b
Se han modificado 60 ficheros con 2794 adiciones y 0 borrados

35
.github/workflows/build-release-apk.yml vendido Archivo normal
Ver fichero

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

15
.gitignore vendido Archivo normal
Ver fichero

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

22
LICENSE Archivo normal
Ver fichero

@@ -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.

168
README.md Archivo normal
Ver fichero

@@ -0,0 +1,168 @@
# URLFinder
<p align="center">
<img src="app/src/main/res/drawable/ic_urlfinder_logo.xml" width="120" alt="URLFinder logo"/>
</p>
<p align="center">
<strong>A free, open-source Android app that detects URLs and QR codes in real time using your device camera.</strong>
</p>
<p align="center">
<a href="https://github.com/manalejandro/URLFinder/releases"><img src="https://img.shields.io/github/v/release/manalejandro/URLFinder?style=flat-square" alt="Latest release"/></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License"/></a>
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/platform-Android-green?style=flat-square" alt="Android"/></a>
<img src="https://img.shields.io/badge/min%20SDK-24-orange?style=flat-square" alt="Min SDK 24"/>
</p>
---
## 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.
```
---
<p align="center">
Made with ❤️ using Kotlin, Jetpack Compose, CameraX and ML Kit<br/>
<a href="https://github.com/manalejandro/URLFinder">https://github.com/manalejandro/URLFinder</a>
</p>

1
app/.gitignore vendido Archivo normal
Ver fichero

@@ -0,0 +1 @@
/build

70
app/build.gradle.kts Archivo normal
Ver fichero

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

21
app/proguard-rules.pro vendido Archivo normal
Ver fichero

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

Ver fichero

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

Ver fichero

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.URLFinder">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.URLFinder">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

Ver fichero

@@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1565C0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0D47A1;stop-opacity:1" />
</linearGradient>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Background circle -->
<circle cx="48" cy="48" r="44" fill="url(#bgGrad)" filter="url(#shadow)"/>
<!-- Accent ring -->
<circle cx="48" cy="48" r="42" fill="none" stroke="#42A5F5" stroke-width="1.5" stroke-opacity="0.4"/>
<!-- Magnifying glass circle -->
<circle cx="42" cy="44" r="18" fill="none" stroke="white" stroke-width="4.5" stroke-linecap="round"/>
<!-- Magnifying glass handle -->
<line x1="56" y1="58" x2="70" y2="72" stroke="white" stroke-width="4.5" stroke-linecap="round"/>
<!-- Chain link left pill -->
<rect x="30" y="40" width="16" height="8" rx="4" fill="white"/>
<!-- Chain link right pill -->
<rect x="38" y="40" width="16" height="8" rx="4" fill="white"/>
<!-- Middle gap (mask the overlap to look like interlinked) -->
<rect x="38" y="41.5" width="8" height="5" rx="0" fill="#1565C0"/>
<!-- Top-right highlight dot -->
<circle cx="72" cy="20" r="5" fill="#64B5F6"/>
<circle cx="72" cy="20" r="3" fill="#E3F2FD"/>
</svg>

Después

Anchura:  |  Altura:  |  Tamaño: 1.4 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 57 KiB

Ver fichero

@@ -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<String?>(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")
}
}
}
}

Ver fichero

@@ -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<List<DetectedUrl>>(emptyList())
/** Live stream of all URLs detected in the current camera session */
val detectedUrls: StateFlow<List<DetectedUrl>> = _detectedUrls.asStateFlow()
private val _isScanning = MutableStateFlow(false)
/** Whether the camera analyzer is actively processing frames */
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
private val _isFlashOn = MutableStateFlow(false)
/** Whether the camera torch/flash is currently enabled */
val isFlashOn: StateFlow<Boolean> = _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<DetectedUrl>()
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<DetectedUrl>) {
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://")

Ver fichero

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

Ver fichero

@@ -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<String?>(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") }
}
)
}

Ver fichero

@@ -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<List<HistoryEntry>>(load())
/** Live list of persisted URL history entries, newest first. */
val history: StateFlow<List<HistoryEntry>> = _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<HistoryEntry>) {
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<HistoryEntry> {
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())
}
}

Ver fichero

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

Ver fichero

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

Ver fichero

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

Ver fichero

@@ -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
)
*/
)

Ver fichero

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M3.27,3L2,4.27l5,5V13h3v9l3.58,-6.14L17.73,20 19,18.73 3.27,3zM17,10h-4l4,-8H7v2.18l8.46,8.46L17,10z"/>
</vector>

Ver fichero

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z"/>
</vector>

Ver fichero

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- GitHub Octocat simplified outline -->
<path
android:fillColor="@android:color/black"
android:pathData="M12,2A10,10 0,0 0,2 12c0,4.42 2.87,8.17 6.84,9.5 0.5,0.08 0.66,-0.23 0.66,-0.5v-1.69c-2.77,0.6 -3.36,-1.34 -3.36,-1.34 -0.46,-1.16 -1.11,-1.47 -1.11,-1.47 -0.91,-0.62 0.07,-0.6 0.07,-0.6 1,0.07 1.53,1.03 1.53,1.03 0.87,1.52 2.34,1.07 2.91,0.83 0.09,-0.65 0.35,-1.09 0.63,-1.34 -2.22,-0.25 -4.55,-1.11 -4.55,-4.92 0,-1.11 0.38,-2 1.03,-2.71 -0.1,-0.25 -0.44,-1.29 0.1,-2.64 0,0 0.84,-0.27 2.75,1.02 0.79,-0.22 1.65,-0.33 2.5,-0.33s1.71,0.11 2.5,0.33c1.91,-1.29 2.75,-1.02 2.75,-1.02 0.55,1.35 0.2,2.39 0.1,2.64 0.65,0.71 1.03,1.6 1.03,2.71 0,3.82 -2.34,4.66 -4.57,4.91 0.36,0.31 0.69,0.92 0.69,1.85V21c0,0.27 0.16,0.59 0.67,0.5C19.14,20.16 22,16.42 22,12A10,10 0,0 0,12 2Z"/>
</vector>

Ver fichero

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

Ver fichero

@@ -0,0 +1,59 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="96"
android:viewportHeight="96">
<group android:scaleX="0.58"
android:scaleY="0.58"
android:translateX="20.16"
android:translateY="20.16">
<path
android:pathData="M48,48m-44,0a44,44 0,1 1,88 0a44,44 0,1 1,-88 0">
<aapt:attr name="android:fillColor">
<gradient
android:startX="4"
android:startY="4"
android:endX="92"
android:endY="92"
android:type="linear">
<item android:offset="0" android:color="#FF1565C0"/>
<item android:offset="1" android:color="#FF0D47A1"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M48,48m-42,0a42,42 0,1 1,84 0a42,42 0,1 1,-84 0"
android:strokeAlpha="0.4"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#42A5F5"/>
<path
android:pathData="M42,44m-18,0a18,18 0,1 1,36 0a18,18 0,1 1,-36 0"
android:strokeWidth="4.5"
android:fillColor="#00000000"
android:strokeColor="#ffffff"
android:strokeLineCap="round"/>
<path
android:fillColor="#FF000000"
android:pathData="M56,58L70,72"
android:strokeWidth="4.5"
android:strokeColor="#ffffff"
android:strokeLineCap="round"/>
<path
android:pathData="M34,40L42,40A4,4 0,0 1,46 44L46,44A4,4 0,0 1,42 48L34,48A4,4 0,0 1,30 44L30,44A4,4 0,0 1,34 40z"
android:fillColor="#ffffff"/>
<path
android:pathData="M42,40L50,40A4,4 0,0 1,54 44L54,44A4,4 0,0 1,50 48L42,48A4,4 0,0 1,38 44L38,44A4,4 0,0 1,42 40z"
android:fillColor="#ffffff"/>
<path
android:pathData="M38,41.5h8v5h-8z"
android:fillColor="#1565C0"/>
<path
android:pathData="M72,20m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#64B5F6"/>
<path
android:pathData="M72,20m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"
android:fillColor="#E3F2FD"/>
</group>
</vector>

Ver fichero

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"/>
</vector>

Ver fichero

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M19,19L5,19L5,5h7L12,3L5,3C3.89,3 3,3.9 3,5v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41L19,10h2L21,3h-7z"/>
</vector>

Ver fichero

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Open source / code icon -->
<path
android:fillColor="@android:color/black"
android:pathData="M9.4,16.6L4.8,12l4.6,-4.6L8,6l-6,6 6,6 1.4,-1.4zM14.6,16.6l4.6,-4.6 -4.6,-4.6L16,6l6,6 -6,6 -1.4,-1.4z"/>
</vector>

Ver fichero

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M3,11L5,11L5,13L3,13L3,11ZM11,5L13,5L13,7L11,7L11,5ZM9,3L9,9L3,9L3,3L9,3ZM7,5L5,5L5,7L7,7L7,5ZM15,3L21,3L21,9L15,9L15,3ZM17,5L17,7L19,7L19,5L17,5ZM3,15L9,15L9,21L3,21L3,15ZM5,17L5,19L7,19L7,17L5,17ZM21,11L21,13L17,13L17,11L21,11ZM13,13L13,15L11,15L11,13L13,13ZM15,11L15,13L17,13L17,11L15,11ZM19,15L21,15L21,17L19,17L19,15ZM13,17L13,21L11,21L11,17L13,17ZM15,19L15,21L17,21L17,19L15,19ZM19,17L21,17L21,21L19,21L19,19ZM17,15L19,15L19,17L17,17L17,15Z"/>
</vector>

Ver fichero

@@ -0,0 +1,58 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="240dp"
android:viewportWidth="240"
android:viewportHeight="240">
<!-- Top-left corner -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M20,60 L20,20 L60,20" />
<!-- Top-right corner -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M180,20 L220,20 L220,60" />
<!-- Bottom-left corner -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M20,180 L20,220 L60,220" />
<!-- Bottom-right corner -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M180,220 L220,220 L220,180" />
<!-- Center crosshair horizontal -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeAlpha="0.5"
android:pathData="M100,120 L140,120" />
<!-- Center crosshair vertical -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeAlpha="0.5"
android:pathData="M120,100 L120,140" />
</vector>

Ver fichero

@@ -0,0 +1,63 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="96"
android:viewportHeight="96">
<!-- Background circle — deep blue -->
<path
android:fillColor="#1565C0"
android:pathData="M48,4 C23.699,4 4,23.699 4,48 C4,72.301 23.699,92 48,92 C72.301,92 92,72.301 92,48 C92,23.699 72.301,4 48,4 Z" />
<!-- Subtle inner ring -->
<path
android:strokeColor="#42A5F5"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:pathData="M48,8 C26.461,8 9,25.461 9,47 C9,68.539 26.461,86 48,86 C69.539,86 87,68.539 87,47 C87,25.461 69.539,8 48,8 Z" />
<!-- Magnifying glass circle — white -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="4.5"
android:fillColor="#00000000"
android:pathData="M42,25 C29.85,25 20,34.85 20,47 C20,59.15 29.85,69 42,69 C54.15,69 64,59.15 64,47 C64,34.85 54.15,25 42,25 Z" />
<!-- Magnifying glass handle — white -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="4.5"
android:strokeLineCap="round"
android:pathData="M58,62 L73,77" />
<!-- Chain link background — semi-transparent white -->
<path
android:fillColor="#33FFFFFF"
android:pathData="M29,43 C29,40.239 31.239,38 34,38 L50,38 C52.761,38 55,40.239 55,43 L55,51 C55,53.761 52.761,56 50,56 L34,56 C31.239,56 29,53.761 29,51 Z" />
<!-- Chain link left — white -->
<path
android:fillColor="#FFFFFF"
android:pathData="M29,43 C29,40.791 30.791,39 33,39 L42,39 C44.209,39 46,40.791 46,43 L46,51 C46,53.209 44.209,55 42,55 L33,55 C30.791,55 29,53.209 29,51 Z" />
<!-- Chain link right — light blue -->
<path
android:fillColor="#90CAF9"
android:pathData="M38,43 C38,40.791 39.791,39 42,39 L51,39 C53.209,39 55,40.791 55,43 L55,51 C55,53.209 53.209,55 51,55 L42,55 C39.791,55 38,53.209 38,51 Z" />
<!-- Center gap to simulate interlinked chain -->
<path
android:fillColor="#1565C0"
android:pathData="M41,40 L43,40 L43,54 L41,54 Z" />
<!-- Accent dot — light blue -->
<path
android:fillColor="#64B5F6"
android:pathData="M72,14 C72,11.239 74.239,9 77,9 C79.761,9 82,11.239 82,14 C82,16.761 79.761,19 77,19 C74.239,19 72,16.761 72,14 Z" />
<!-- Accent dot inner — white -->
<path
android:fillColor="#FFFFFF"
android:pathData="M74.5,14 C74.5,12.619 75.619,11.5 77,11.5 C78.381,11.5 79.5,12.619 79.5,14 C79.5,15.381 78.381,16.5 77,16.5 C75.619,16.5 74.5,15.381 74.5,14 Z" />
</vector>

Ver fichero

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

Ver fichero

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Ver fichero

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.6 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 4.5 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.7 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.9 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.6 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 6.7 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 5.5 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 10 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 7.4 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 14 KiB

Ver fichero

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

Ver fichero

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#145CB7</color>
</resources>

Ver fichero

@@ -0,0 +1,9 @@
<resources>
<string name="app_name">URLFinder</string>
<string name="permission_camera_rationale">URLFinder needs camera access to scan URLs and QR codes.</string>
<string name="grant_permission">Grant Camera Permission</string>
<string name="detected_urls">Detected URLs</string>
<string name="scan_hint">Point the camera at a URL or QR code</string>
<string name="clear_results">Clear results</string>
<string name="open_in_browser">Open in browser</string>
</resources>

Ver fichero

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.URLFinder" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

Ver fichero

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

Ver fichero

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

Ver fichero

@@ -0,0 +1,17 @@
package com.manalejandro.urlfinder
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

5
build.gradle.kts Archivo normal
Ver fichero

@@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}

23
gradle.properties Archivo normal
Ver fichero

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

Ver fichero

@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21

46
gradle/libs.versions.toml Archivo normal
Ver fichero

@@ -0,0 +1,46 @@
[versions]
agp = "9.0.1"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
lifecycleViewmodelCompose = "2.10.0"
activityCompose = "1.12.4"
kotlin = "2.0.21"
composeBom = "2024.09.00"
cameraX = "1.4.2"
mlkitTextRecognition = "16.0.1"
mlkitBarcodeScanning = "17.3.0"
accompanistPermissions = "0.37.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
mlkit-text-recognition = { group = "com.google.mlkit", name = "text-recognition", version.ref = "mlkitTextRecognition" }
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcodeScanning" }
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendido Archivo normal

Archivo binario no mostrado.

9
gradle/wrapper/gradle-wrapper.properties vendido Archivo normal
Ver fichero

@@ -0,0 +1,9 @@
#Sun Mar 01 13:28:10 CET 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendido Archivo ejecutable
Ver fichero

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# 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 ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# 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
if ! command -v java >/dev/null 2>&1
then
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
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendido Archivo normal
Ver fichero

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@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=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@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="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 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!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

26
settings.gradle.kts Archivo normal
Ver fichero

@@ -0,0 +1,26 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "URLFinder"
include(":app")