1
app/.gitignore
vendido
Archivo normal
@@ -0,0 +1 @@
|
||||
/build
|
||||
70
app/build.gradle.kts
Archivo normal
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
31
app/src/main/AndroidManifest.xml
Archivo normal
@@ -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>
|
||||
38
app/src/main/assets/ic_urlfinder_logo.svg
Archivo normal
@@ -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 |
BIN
app/src/main/ic_launcher-playstore.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 57 KiB |
340
app/src/main/java/com/manalejandro/urlfinder/CameraScreen.kt
Archivo normal
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
237
app/src/main/java/com/manalejandro/urlfinder/CameraViewModel.kt
Archivo normal
@@ -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://")
|
||||
32
app/src/main/java/com/manalejandro/urlfinder/MainActivity.kt
Archivo normal
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
451
app/src/main/java/com/manalejandro/urlfinder/SettingsScreen.kt
Archivo normal
@@ -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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
91
app/src/main/java/com/manalejandro/urlfinder/UrlHistoryRepository.kt
Archivo normal
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
130
app/src/main/java/com/manalejandro/urlfinder/UrlResultsPanel.kt
Archivo normal
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
app/src/main/java/com/manalejandro/urlfinder/ui/theme/Color.kt
Archivo normal
@@ -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)
|
||||
58
app/src/main/java/com/manalejandro/urlfinder/ui/theme/Theme.kt
Archivo normal
@@ -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
|
||||
)
|
||||
}
|
||||
34
app/src/main/java/com/manalejandro/urlfinder/ui/theme/Type.kt
Archivo normal
@@ -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
|
||||
)
|
||||
*/
|
||||
)
|
||||
10
app/src/main/res/drawable/ic_flash_off.xml
Archivo normal
@@ -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>
|
||||
|
||||
10
app/src/main/res/drawable/ic_flash_on.xml
Archivo normal
@@ -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>
|
||||
|
||||
11
app/src/main/res/drawable/ic_github.xml
Archivo normal
@@ -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>
|
||||
|
||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Archivo normal
@@ -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>
|
||||
59
app/src/main/res/drawable/ic_launcher_foreground.xml
Archivo normal
@@ -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>
|
||||
10
app/src/main/res/drawable/ic_link.xml
Archivo normal
@@ -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>
|
||||
|
||||
10
app/src/main/res/drawable/ic_open_in_browser.xml
Archivo normal
@@ -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>
|
||||
|
||||
11
app/src/main/res/drawable/ic_open_source.xml
Archivo normal
@@ -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>
|
||||
|
||||
10
app/src/main/res/drawable/ic_qr_code.xml
Archivo normal
@@ -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>
|
||||
|
||||
58
app/src/main/res/drawable/ic_scan_frame.xml
Archivo normal
@@ -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>
|
||||
|
||||
63
app/src/main/res/drawable/ic_urlfinder_logo.xml
Archivo normal
@@ -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>
|
||||
10
app/src/main/res/drawable/ic_warning.xml
Archivo normal
@@ -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>
|
||||
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Archivo normal
@@ -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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Archivo normal
@@ -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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.6 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 4.5 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.6 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 6.7 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 5.5 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 10 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 7.4 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 14 KiB |
10
app/src/main/res/values/colors.xml
Archivo normal
@@ -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>
|
||||
4
app/src/main/res/values/ic_launcher_background.xml
Archivo normal
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#145CB7</color>
|
||||
</resources>
|
||||
9
app/src/main/res/values/strings.xml
Archivo normal
@@ -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>
|
||||
5
app/src/main/res/values/themes.xml
Archivo normal
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.URLFinder" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Archivo normal
@@ -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>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Archivo normal
@@ -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>
|
||||
17
app/src/test/java/com/manalejandro/urlfinder/ExampleUnitTest.kt
Archivo normal
@@ -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)
|
||||
}
|
||||
}
|
||||