diff --git a/CLAUDE.md b/CLAUDE.md
index 35456fc..034e18b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,9 +25,11 @@ app/src/main/java/mx/joykoo/scanner/
NoSoftKeyboardWebView.kt — IME suppression via null InputConnection
ScannerIntegration.kt — Scanner lifecycle interface
BroadcastScannerIntegration.kt — Intent-output scans → JS injection (React-safe value setter)
- KioskController.kt — Lock Task kiosk toggle (persisted); device-owner full lockdown + HOME alias, else screen pinning
+ KioskController.kt — Lock Task kiosk toggle (session-only until KIOSK_PERSISTED); device-owner full lockdown + HOME alias, else screen pinning
KioskAdminReceiver.kt — DeviceAdminReceiver for `dpm set-device-owner`
StatusStrip.kt — App-drawn status strip (Wi-Fi + battery only); system bars stay hidden
+ Permissions.kt — Runtime permission prompts (camera, storage ≤ Android 9); asked at launch and on demand
+ FileTransfers.kt — Downloads → Downloads folder; file uploads with camera option; photos kept in Pictures/Joykoo
```
## Build
@@ -79,10 +81,17 @@ All configurable values are in `AppConfig.kt`:
## Kiosk & Errors
-- Kiosk mode (`KioskController`): when on, admin dialog hides "Salir" and Back never exits. Device owner required for real lockdown; the app can't be uninstalled while device owner, so set it only with the release-signed APK.
+- Kiosk mode (`KioskController`): **off by default and not persisted** while it is being tested (`AppConfig.KIOSK_PERSISTED = false`) — each launch starts unlocked and clears any leftover lockdown; scanning `KIOSK_BARCODE` toggles it for the session. When on, admin dialog hides "Salir" and Back never exits. Device owner required for real lockdown; the app can't be uninstalled while device owner, so set it only with the release-signed APK.
- Error overlay: per-cause messages (no Wi-Fi with auto-retry on reconnect, unreachable, 5xx, SSL, repeated renderer crash). 4xx responses are left to the site.
- Renderer crash (`onRenderProcessGone`): the WebView is replaced and the last URL (tracked via `doUpdateVisitedHistory`) reloaded.
+## Files & Permissions
+
+- Permissions are requested at first launch (camera; storage on Android ≤ 9) and again when a feature needs one.
+- Downloads: https on allowed hosts via `DownloadManager` (session cookie forwarded); `data:` decoded natively; `blob:` read in-page and returned via `evaluateJavascript` polling (no JS interface). A document-start hook keeps blobs readable if the site revokes the URL immediately. Saved to Downloads, with a toast on completion.
+- Uploads: `` image fields offer the camera; `capture` opens it directly. Photos are saved to Pictures/Joykoo.
+- In-page camera (`getUserMedia`) is granted to allowed hosts only, video only.
+
## Security
- HTTPS only, no cleartext, mixed content never allowed, no SSL error bypass
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c2cbaf9..1b5bf3a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,14 @@
+
+
+
+
+
+
` uploads, offering the camera for image fields. Photos taken
+ * through the camera are kept in Pictures/Joykoo as well as uploaded.
+ *
+ * Downloads: https URLs on allowed hosts go through DownloadManager (with the session
+ * cookie); `data:` URLs are decoded natively; `blob:` URLs are read in the page and
+ * handed back through evaluateJavascript polling, so no JavaScript interface is exposed.
+ *
+ * Must be constructed in onCreate (it registers an activity-result launcher).
+ */
+class FileTransfers(
+ private val activity: AppCompatActivity,
+ private val permissions: Permissions,
+ private val webView: () -> WebView,
+) {
+ private val handler = Handler(Looper.getMainLooper())
+ private val downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ private val activeDownloads = mutableSetOf()
+ private var blobCounter = 0
+
+ private var uploadCallback: ValueCallback>? = null
+ private var pendingPhotoUri: Uri? = null
+
+ private val chooserLauncher = activity.registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result -> onChooserResult(result.resultCode, result.data) }
+
+ private val downloadCompleteReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
+ if (activeDownloads.remove(id)) reportDownloadResult(id)
+ }
+ }
+
+ init {
+ // Sent by the system download provider, so the receiver must be exported.
+ ContextCompat.registerReceiver(
+ activity, downloadCompleteReceiver,
+ IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
+ ContextCompat.RECEIVER_EXPORTED
+ )
+ }
+
+ fun onDestroy() {
+ handler.removeCallbacksAndMessages(null)
+ activity.unregisterReceiver(downloadCompleteReceiver)
+ uploadCallback?.onReceiveValue(null)
+ uploadCallback = null
+ }
+
+ // ── Downloads ───────────────────────────────────────────────────────
+
+ /** Wire to WebView.setDownloadListener. */
+ fun onDownloadStart(url: String, userAgent: String, contentDisposition: String?, mimeType: String?) {
+ val fileName = fileNameFor(url, contentDisposition, mimeType)
+ val save: () -> Unit = when {
+ url.startsWith("blob:") -> { { readBlob(url, fileName, mimeType) } }
+ url.startsWith("data:") -> { { saveDataUrl(url, fileName) } }
+ isAllowedHttps(url) -> { { enqueueDownload(url, userAgent, fileName, mimeType) } }
+ else -> {
+ Log.w(TAG, "Blocked download from $url")
+ return
+ }
+ }
+ permissions.withPermissions(Permissions.STORAGE) {
+ if (permissions.allGranted(Permissions.STORAGE)) save()
+ else toast(R.string.download_permission_denied)
+ }
+ }
+
+ private fun enqueueDownload(url: String, userAgent: String, fileName: String, mimeType: String?) {
+ try {
+ val request = DownloadManager.Request(Uri.parse(url)).apply {
+ if (!mimeType.isNullOrBlank()) setMimeType(mimeType)
+ CookieManager.getInstance().getCookie(url)?.let { addRequestHeader("Cookie", it) }
+ addRequestHeader("User-Agent", userAgent)
+ setTitle(fileName)
+ setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
+ }
+ activeDownloads += downloadManager.enqueue(request)
+ toast(activity.getString(R.string.download_started, fileName))
+ } catch (e: Exception) {
+ Log.e(TAG, "Download failed to start: $url", e)
+ toast(R.string.download_failed)
+ }
+ }
+
+ private fun reportDownloadResult(id: Long) {
+ downloadManager.query(DownloadManager.Query().setFilterById(id))?.use { c ->
+ if (!c.moveToFirst()) return
+ val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
+ val title = c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE))
+ if (status == DownloadManager.STATUS_SUCCESSFUL) {
+ toast(activity.getString(R.string.download_done, title))
+ } else {
+ Log.e(TAG, "Download $id ended with status $status")
+ toast(R.string.download_failed)
+ }
+ }
+ }
+
+ private fun saveDataUrl(url: String, fileName: String) {
+ val comma = url.indexOf(',')
+ if (comma < 0) {
+ toast(R.string.download_failed)
+ return
+ }
+ val header = url.substring(5, comma)
+ val payload = url.substring(comma + 1)
+ val bytes = try {
+ if (header.endsWith(";base64")) Base64.decode(payload, Base64.DEFAULT)
+ else Uri.decode(payload).toByteArray()
+ } catch (e: IllegalArgumentException) {
+ Log.e(TAG, "Bad data: URL", e)
+ toast(R.string.download_failed)
+ return
+ }
+ val mime = header.substringBefore(';').ifBlank { "application/octet-stream" }
+ saveBytes(fileName, mime, bytes)
+ }
+
+ /**
+ * Reads a blob: URL inside the page as a data: URL and polls for the result.
+ * The blob comes from the createObjectURL hook in [BLOB_HOOK_JS] when available,
+ * since sites often revoke the URL right after triggering the download.
+ */
+ private fun readBlob(url: String, fileName: String, mimeType: String?) {
+ val key = "b${++blobCounter}"
+ webView().evaluateJavascript("""
+ (function(url, key) {
+ var store = window.__jkBlobResults = window.__jkBlobResults || {};
+ var blob = window.__jkBlobs && window.__jkBlobs[url];
+ (blob ? Promise.resolve(blob) : fetch(url).then(function(r) { return r.blob(); }))
+ .then(function(b) {
+ return new Promise(function(resolve, reject) {
+ var fr = new FileReader();
+ fr.onload = function() { resolve(fr.result); };
+ fr.onerror = function() { reject(fr.error); };
+ fr.readAsDataURL(b);
+ });
+ })
+ .then(function(data) { store[key] = { ok: true, data: data }; })
+ .catch(function(e) { store[key] = { ok: false, error: String(e) }; });
+ })(${JSONObject.quote(url)}, ${JSONObject.quote(key)});
+ """.trimIndent(), null)
+ pollBlob(key, fileName, mimeType, attemptsLeft = BLOB_POLL_ATTEMPTS)
+ }
+
+ private fun pollBlob(key: String, fileName: String, mimeType: String?, attemptsLeft: Int) {
+ handler.postDelayed({
+ webView().evaluateJavascript("""
+ (function(key) {
+ var store = window.__jkBlobResults, r = store && store[key];
+ if (r) delete store[key];
+ return r || null;
+ })(${JSONObject.quote(key)});
+ """.trimIndent()) { json ->
+ if (json == null || json == "null") {
+ if (attemptsLeft > 1) pollBlob(key, fileName, mimeType, attemptsLeft - 1)
+ else {
+ Log.e(TAG, "Timed out reading blob download")
+ toast(R.string.download_failed)
+ }
+ return@evaluateJavascript
+ }
+ val result = JSONObject(json)
+ if (result.optBoolean("ok")) {
+ saveDataUrl(result.getString("data"), fileName)
+ } else {
+ Log.e(TAG, "Blob read failed: ${result.optString("error")}")
+ toast(R.string.download_failed)
+ }
+ }
+ }, BLOB_POLL_INTERVAL_MS)
+ }
+
+ /** Writes [bytes] to the public Downloads folder. */
+ private fun saveBytes(fileName: String, mimeType: String, bytes: ByteArray) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val resolver = activity.contentResolver
+ val values = ContentValues().apply {
+ put(MediaStore.Downloads.DISPLAY_NAME, fileName)
+ put(MediaStore.Downloads.MIME_TYPE, mimeType)
+ put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
+ put(MediaStore.Downloads.IS_PENDING, 1)
+ }
+ val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
+ ?: throw IllegalStateException("MediaStore insert returned null")
+ resolver.openOutputStream(uri)!!.use { it.write(bytes) }
+ values.clear()
+ values.put(MediaStore.Downloads.IS_PENDING, 0)
+ resolver.update(uri, values, null, null)
+ } else {
+ @Suppress("DEPRECATION")
+ val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ dir.mkdirs()
+ val file = uniqueFile(dir, fileName)
+ file.writeBytes(bytes)
+ MediaScannerConnection.scanFile(activity, arrayOf(file.path), arrayOf(mimeType), null)
+ }
+ toast(activity.getString(R.string.download_done, fileName))
+ } catch (e: Exception) {
+ Log.e(TAG, "Saving $fileName failed", e)
+ toast(R.string.download_failed)
+ }
+ }
+
+ private fun uniqueFile(dir: File, name: String): File {
+ var file = File(dir, name)
+ var n = 1
+ while (file.exists()) {
+ file = File(dir, "${name.substringBeforeLast('.')} ($n)" +
+ (name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }))
+ n++
+ }
+ return file
+ }
+
+ private fun fileNameFor(url: String, contentDisposition: String?, mimeType: String?): String {
+ // guessFileName only understands http(s); blob/data names come from the disposition.
+ val name = URLUtil.guessFileName(
+ if (url.startsWith("http")) url else "", contentDisposition, mimeType
+ ).replace(Regex("[\\\\/:*?\"<>|]"), "_")
+ if (name.contains('.')) return name
+ val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
+ return if (ext != null) "$name.$ext" else name
+ }
+
+ private fun isAllowedHttps(url: String): Boolean {
+ val uri = Uri.parse(url)
+ return uri.scheme == "https" && uri.host?.let(AppConfig::isAllowedHost) == true
+ }
+
+ // ── Uploads () ─────────────────────────────────────
+
+ /** Wire to WebChromeClient.onShowFileChooser. */
+ fun onShowFileChooser(callback: ValueCallback>, params: FileChooserParams): Boolean {
+ uploadCallback?.onReceiveValue(null)
+ uploadCallback = callback
+ val offerCamera = acceptsImages(params.acceptTypes)
+ val needed = if (offerCamera) Permissions.CAMERA + Permissions.STORAGE else emptyArray()
+ permissions.withPermissions(needed) { launchChooser(params, offerCamera) }
+ return true
+ }
+
+ private fun launchChooser(params: FileChooserParams, offerCamera: Boolean) {
+ // Launching the camera without the CAMERA grant throws, so it is only offered when granted.
+ val camera = if (offerCamera && permissions.allGranted(Permissions.CAMERA + Permissions.STORAGE))
+ cameraIntent() else null
+ val picker = params.createIntent()
+ val intent = when {
+ camera != null && params.isCaptureEnabled -> camera
+ camera != null -> Intent.createChooser(picker, null)
+ .putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(camera))
+ else -> picker
+ }
+ try {
+ chooserLauncher.launch(intent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e(TAG, "No app to pick files", e)
+ onChooserResult(Activity.RESULT_CANCELED, null)
+ }
+ }
+
+ private fun cameraIntent(): Intent? {
+ val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val values = ContentValues().apply {
+ put(MediaStore.Images.Media.DISPLAY_NAME, "JOYKOO_$stamp.jpg")
+ put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Joykoo")
+ }
+ }
+ val uri = try {
+ activity.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
+ } catch (e: Exception) {
+ Log.e(TAG, "Could not create photo entry", e)
+ null
+ } ?: return null
+ pendingPhotoUri = uri
+ return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ .putExtra(MediaStore.EXTRA_OUTPUT, uri)
+ .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ private fun onChooserResult(resultCode: Int, data: Intent?) {
+ val photo = pendingPhotoUri
+ pendingPhotoUri = null
+ val picked = FileChooserParams.parseResult(resultCode, data)
+ val result = when {
+ resultCode != Activity.RESULT_OK -> null
+ picked != null -> picked
+ photo != null -> arrayOf(photo) // camera writes to EXTRA_OUTPUT and returns no data
+ else -> null
+ }
+ // Drop the empty gallery entry unless the camera actually filled it.
+ if (photo != null && result?.contains(photo) != true) {
+ try { activity.contentResolver.delete(photo, null, null) } catch (_: Exception) {}
+ }
+ uploadCallback?.onReceiveValue(result)
+ uploadCallback = null
+ }
+
+ private fun acceptsImages(acceptTypes: Array?): Boolean {
+ val types = acceptTypes.orEmpty().flatMap { it.split(',') }.map { it.trim().lowercase() }
+ .filter { it.isNotEmpty() }
+ return types.isEmpty() || types.any {
+ it.startsWith("image/") || it == "*/*" || it in IMAGE_EXTENSIONS
+ }
+ }
+
+ private fun toast(res: Int) = toast(activity.getString(res))
+ private fun toast(text: String) = Toast.makeText(activity, text, Toast.LENGTH_LONG).show()
+
+ companion object {
+ private const val TAG = "JoykooScanner"
+ private const val BLOB_POLL_INTERVAL_MS = 200L
+ private const val BLOB_POLL_ATTEMPTS = 150 // 30 s
+ private val IMAGE_EXTENSIONS = setOf(".jpg", ".jpeg", ".png", ".webp", ".heic", ".gif")
+
+ /** Injected at document start: remembers blobs behind object URLs and delays
+ * revocation, so a download whose URL the site revokes immediately can still be read. */
+ val BLOB_HOOK_JS = """
+ (function() {
+ if (window.__jkBlobs) return;
+ var blobs = window.__jkBlobs = {};
+ var create = URL.createObjectURL, revoke = URL.revokeObjectURL;
+ URL.createObjectURL = function(obj) {
+ var url = create.apply(URL, arguments);
+ if (obj instanceof Blob) blobs[url] = obj;
+ return url;
+ };
+ URL.revokeObjectURL = function(url) {
+ setTimeout(function() { delete blobs[url]; revoke.call(URL, url); }, 60000);
+ };
+ })();
+ """.trimIndent()
+ }
+}
diff --git a/app/src/main/java/mx/joykoo/scanner/MainActivity.kt b/app/src/main/java/mx/joykoo/scanner/MainActivity.kt
index 39eed23..837afb8 100644
--- a/app/src/main/java/mx/joykoo/scanner/MainActivity.kt
+++ b/app/src/main/java/mx/joykoo/scanner/MainActivity.kt
@@ -22,6 +22,7 @@ import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
+import android.webkit.PermissionRequest
import android.webkit.RenderProcessGoneDetail
import android.webkit.SslErrorHandler
import android.webkit.ValueCallback
@@ -37,6 +38,8 @@ import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
+import androidx.webkit.WebViewCompat
+import androidx.webkit.WebViewFeature
import org.json.JSONObject
class MainActivity : AppCompatActivity() {
@@ -46,8 +49,9 @@ class MainActivity : AppCompatActivity() {
private lateinit var scanner: ScannerIntegration
private lateinit var kiosk: KioskController
private lateinit var statusStrip: StatusStrip
+ private lateinit var permissions: Permissions
+ private lateinit var fileTransfers: FileTransfers
private val handler = Handler(Looper.getMainLooper())
- private var fileUploadCallback: ValueCallback>? = null
// Error overlay state: what is shown and which URL Retry should load
private var currentError: ErrorKind? = null
@@ -68,6 +72,8 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
+ permissions = Permissions(this)
+ fileTransfers = FileTransfers(this, permissions) { webView }
keepScreenAwake()
enterImmersiveMode()
setupWebView()
@@ -84,6 +90,7 @@ class MainActivity : AppCompatActivity() {
if (savedInstanceState == null) {
webView.loadUrl(AppConfig.HOME_URL)
+ permissions.requestStartupPermissions()
}
ensureWebViewFocus()
@@ -171,6 +178,15 @@ class MainActivity : AppCompatActivity() {
webView.webViewClient = ScannerWebViewClient()
webView.webChromeClient = ScannerWebChromeClient()
+ webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
+ fileTransfers.onDownloadStart(url, userAgent, contentDisposition, mimeType)
+ }
+ // Lets blob: downloads be read even if the site revokes the URL right away.
+ // Older WebViews without this feature get the same hook in onPageFinished.
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
+ val origins = AppConfig.ALLOWED_HOSTS.flatMap { listOf("https://$it", "https://*.$it") }.toSet()
+ WebViewCompat.addDocumentStartJavaScript(webView, FileTransfers.BLOB_HOOK_JS, origins)
+ }
}
// ── Error Overlay ───────────────────────────────────────────────────
@@ -360,6 +376,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
scanner.onDestroy()
+ fileTransfers.onDestroy()
handler.removeCallbacksAndMessages(null)
webView.destroy()
super.onDestroy()
@@ -548,7 +565,7 @@ class MainActivity : AppCompatActivity() {
): Boolean {
val host = request.url.host ?: return true
// Allow navigation within the company's domains
- if (AppConfig.ALLOWED_HOSTS.any { host == it || host.endsWith(".$it") }) {
+ if (AppConfig.isAllowedHost(host)) {
return false // let WebView handle it
}
// Block navigation to external sites
@@ -570,6 +587,7 @@ class MainActivity : AppCompatActivity() {
super.onPageFinished(view, url)
CookieManager.getInstance().flush()
injectKeyboardSuppression(view)
+ view.evaluateJavascript(FileTransfers.BLOB_HOOK_JS, null)
hideKeyboard()
ensureWebViewFocus()
}
@@ -637,39 +655,27 @@ class MainActivity : AppCompatActivity() {
webView: WebView,
filePathCallback: ValueCallback>,
fileChooserParams: FileChooserParams
- ): Boolean {
- fileUploadCallback?.onReceiveValue(null)
- fileUploadCallback = filePathCallback
+ ): Boolean = fileTransfers.onShowFileChooser(filePathCallback, fileChooserParams)
- val intent = fileChooserParams.createIntent()
- try {
- startActivityForResult(intent, FILE_CHOOSER_REQUEST)
- } catch (e: Exception) {
- fileUploadCallback?.onReceiveValue(null)
- fileUploadCallback = null
- return false
+ /** In-page camera (getUserMedia), e.g. for camera barcode/photo widgets on the site. */
+ override fun onPermissionRequest(request: PermissionRequest) {
+ val host = request.origin.host
+ val wantsCamera = PermissionRequest.RESOURCE_VIDEO_CAPTURE in request.resources
+ if (host == null || !AppConfig.isAllowedHost(host) || !wantsCamera) {
+ request.deny()
+ return
}
- return true
- }
- }
-
- @Deprecated("Deprecated in Java")
- override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) {
- super.onActivityResult(requestCode, resultCode, data)
- if (requestCode == FILE_CHOOSER_REQUEST) {
- fileUploadCallback?.onReceiveValue(
- if (resultCode == RESULT_OK && data != null) {
- WebChromeClient.FileChooserParams.parseResult(resultCode, data)
+ permissions.withPermissions(Permissions.CAMERA) {
+ if (permissions.allGranted(Permissions.CAMERA)) {
+ request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
} else {
- null
+ request.deny()
}
- )
- fileUploadCallback = null
+ }
}
}
companion object {
private const val TAG = "JoykooScanner"
- private const val FILE_CHOOSER_REQUEST = 1001
}
}
diff --git a/app/src/main/java/mx/joykoo/scanner/Permissions.kt b/app/src/main/java/mx/joykoo/scanner/Permissions.kt
new file mode 100644
index 0000000..43762c7
--- /dev/null
+++ b/app/src/main/java/mx/joykoo/scanner/Permissions.kt
@@ -0,0 +1,55 @@
+package mx.joykoo.scanner
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+
+/**
+ * Runtime permission prompts. Must be constructed in onCreate (it registers an
+ * activity-result launcher). Requests are asked up front at launch so the device can be
+ * set up once, and again on demand when a feature needs one that was denied.
+ */
+class Permissions(private val activity: AppCompatActivity) {
+
+ private var pending: (() -> Unit)? = null
+
+ private val launcher = activity.registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) {
+ val action = pending
+ pending = null
+ action?.invoke()
+ }
+
+ fun isGranted(permission: String): Boolean =
+ ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED
+
+ fun allGranted(permissions: Array): Boolean = permissions.all(::isGranted)
+
+ /** Prompts for any missing [permissions], then runs [action] whatever the user chose;
+ * [action] checks [isGranted] itself and degrades if something was denied. */
+ fun withPermissions(permissions: Array, action: () -> Unit = {}) {
+ val missing = permissions.filterNot(::isGranted)
+ if (missing.isEmpty()) {
+ action()
+ return
+ }
+ pending = action
+ launcher.launch(missing.toTypedArray())
+ }
+
+ fun requestStartupPermissions() = withPermissions(CAMERA + STORAGE)
+
+ companion object {
+ val CAMERA = arrayOf(Manifest.permission.CAMERA)
+
+ /** Writing to shared Downloads/Pictures needs a permission only before Android 10. */
+ val STORAGE: Array =
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P)
+ arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ else emptyArray()
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1d329fb..96518d1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -25,6 +25,10 @@
Modo kiosco activado
Modo kiosco activado (fijación de pantalla: el dispositivo no está configurado como propietario)
Modo kiosco desactivado
+ Descargando %1$s…
+ Guardado en Descargas: %1$s
+ No se pudo descargar el archivo
+ Se necesita permiso de almacenamiento para guardar archivos
Sin Wi-Fi
sin internet
%1$d%%