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

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