Save downloads and camera photos to the device, prompt for permissions
All checks were successful
APK-BUILD/pipeline/head This commit looks good

- 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 <noreply@anthropic.com>
This commit is contained in:
caspar 2026-10-12 13:51:51 -04:00
parent 4401519166
commit d1b8c34fe1
7 changed files with 491 additions and 29 deletions

View File

@ -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: `<input type=file>` 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

View File

@ -4,6 +4,14 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Camera for photo uploads and in-page camera (getUserMedia). -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Saving downloads/photos to shared storage; not needed from Android 10 (MediaStore). -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="true"

View File

@ -12,6 +12,9 @@ object AppConfig {
"joykoo.mx",
)
fun isAllowedHost(host: String): Boolean =
ALLOWED_HOSTS.any { host == it || host.endsWith(".$it") }
/** Number of rapid Back presses required to exit the app when there is no WebView history. */
const val BACK_PRESS_EXIT_COUNT = 3

View File

@ -0,0 +1,377 @@
package mx.joykoo.scanner
import android.app.Activity
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Base64
import android.util.Log
import android.webkit.CookieManager
import android.webkit.MimeTypeMap
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient.FileChooserParams
import android.webkit.WebView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Saves files the site downloads into the device's Downloads folder, and serves
* `<input type=file>` 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<Long>()
private var blobCounter = 0
private var uploadCallback: ValueCallback<Array<Uri>>? = 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 (<input type=file>) ─────────────────────────────────────
/** Wire to WebChromeClient.onShowFileChooser. */
fun onShowFileChooser(callback: ValueCallback<Array<Uri>>, 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<String>?): 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()
}
}

View File

@ -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<Array<Uri>>? = 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<Array<Uri>>,
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
}
}

View File

@ -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<String>): 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<String>, 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<String> =
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P)
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
else emptyArray()
}
}

View File

@ -25,6 +25,10 @@
<string name="kiosk_enabled">Modo kiosco activado</string>
<string name="kiosk_enabled_pinning">Modo kiosco activado (fijación de pantalla: el dispositivo no está configurado como propietario)</string>
<string name="kiosk_disabled">Modo kiosco desactivado</string>
<string name="download_started">Descargando %1$s…</string>
<string name="download_done">Guardado en Descargas: %1$s</string>
<string name="download_failed">No se pudo descargar el archivo</string>
<string name="download_permission_denied">Se necesita permiso de almacenamiento para guardar archivos</string>
<string name="status_no_wifi">Sin Wi-Fi</string>
<string name="status_no_internet">sin internet</string>
<string name="status_battery">%1$d%%</string>