From d1b8c34fe1115f6e84c4ff74bcee0fa2e764e5ba Mon Sep 17 00:00:00 2001 From: caspar Date: Mon, 12 Oct 2026 13:51:51 -0400 Subject: [PATCH] Save downloads and camera photos to the device, prompt for permissions - Downloads (https on allowed hosts, data: and blob: URLs) are saved to the Downloads folder, with a toast when they finish - Image upload fields offer the camera; photos are kept in Pictures/Joykoo - In-page camera (getUserMedia) is granted to allowed hosts only - Camera and storage (Android 9 and below) permissions are requested at first launch and again on demand Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 13 +- app/src/main/AndroidManifest.xml | 8 + .../main/java/mx/joykoo/scanner/AppConfig.kt | 3 + .../java/mx/joykoo/scanner/FileTransfers.kt | 377 ++++++++++++++++++ .../java/mx/joykoo/scanner/MainActivity.kt | 60 +-- .../java/mx/joykoo/scanner/Permissions.kt | 55 +++ app/src/main/res/values/strings.xml | 4 + 7 files changed, 491 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/mx/joykoo/scanner/FileTransfers.kt create mode 100644 app/src/main/java/mx/joykoo/scanner/Permissions.kt 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%%