diff --git a/CLAUDE.md b/CLAUDE.md index d2a2c02..52f7b47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ All configurable values are in `AppConfig.kt`: - `ADMIN_BARCODE` — scanning this value (Intent mode) opens the admin dialog - `KIOSK_BARCODE` — scanning this value (Intent mode) toggles kiosk lockdown - `RENDERER_CRASH_LIMIT` / `RENDERER_CRASH_WINDOW_MS` — WebView crash auto-recovery limit +- `FILE_OPEN_FALLBACK_APPS` — app launched by a download's "Abrir" when nothing opens that file type (also add it to `` in the manifest) ## Navigation Rules @@ -88,7 +89,7 @@ All configurable values are in `AppConfig.kt`: ## 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. +- 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, and records the `` file name (WebView does not pass it on; without it files save as `downloadfile.csv`). Saved to Downloads, then an "Archivo guardado" dialog offers **Abrir**: the app registered for the type, else the app in `AppConfig.FILE_OPEN_FALLBACK_APPS` (CSV → CSV Editor from F-Droid, which has no open-with support) is launched. Fallback apps must also be declared under `` in the manifest (Android 11+ package visibility). - Uploads: `` image fields show an in-app "Tomar foto / Elegir de la galería" dialog (the system chooser hid the camera behind the gallery picker); `capture` opens the camera directly. If camera permission is denied, a dialog offers app Settings or the file picker. Photos are saved to Pictures/Joykoo. - In-page camera (`getUserMedia`) is granted to allowed hosts only, video only. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1b5bf3a..66b7693 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,12 @@ + + + + + in AndroidManifest.xml. */ + val FILE_OPEN_FALLBACK_APPS = mapOf( + "csv" to "tech.brianrayburn.csv_editor", + ) + /** Press Enter after a broadcast scan is inserted, like the keystroke-mode suffix. */ const val SCANNER_SEND_ENTER = true diff --git a/app/src/main/java/mx/joykoo/scanner/FileTransfers.kt b/app/src/main/java/mx/joykoo/scanner/FileTransfers.kt index b8962d7..3f43fe0 100644 --- a/app/src/main/java/mx/joykoo/scanner/FileTransfers.kt +++ b/app/src/main/java/mx/joykoo/scanner/FileTransfers.kt @@ -30,6 +30,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import org.json.JSONObject +import org.json.JSONTokener import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -90,22 +91,44 @@ class FileTransfers( /** 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) } } + val save: (String) -> Unit = when { + url.startsWith("blob:") -> { name -> readBlob(url, name, mimeType) } + url.startsWith("data:") -> { name -> saveDataUrl(url, name) } + isAllowedHttps(url) -> { name -> enqueueDownload(url, userAgent, name, 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) + if (!permissions.allGranted(Permissions.STORAGE)) { + toast(R.string.download_permission_denied) + return@withPermissions + } + // WebView drops the of page-made files, so ask the page + // (BLOB_HOOK_JS records it on click); the server's disposition is the fallback. + siteFileName(url) { siteName -> + save(siteName ?: fileNameFor(url, contentDisposition, mimeType)) + } } } + private fun siteFileName(url: String, callback: (String?) -> Unit) { + webView().evaluateJavascript(""" + (function(url) { + var names = window.__jkDownloadNames, name = names && names[url]; + if (names) delete names[url]; + return name || ''; + })(${JSONObject.quote(url)}); + """.trimIndent()) { json -> + val name = (try { JSONTokener(json).nextValue() as? String } catch (_: Exception) { null }) + ?.let(::sanitizeFileName) + callback(name?.takeIf { it.isNotBlank() }) + } + } + + private fun sanitizeFileName(name: String) = name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim() + private fun enqueueDownload(url: String, userAgent: String, fileName: String, mimeType: String?) { try { val request = DownloadManager.Request(Uri.parse(url)).apply { @@ -130,7 +153,11 @@ class FileTransfers( 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)) + showSaved( + title, + downloadManager.getUriForDownloadedFile(id), + downloadManager.getMimeTypeForDownloadedFile(id) ?: "application/octet-stream" + ) } else { Log.e(TAG, "Download $id ended with status $status") toast(R.string.download_failed) @@ -230,21 +257,67 @@ class FileTransfers( values.clear() values.put(MediaStore.Downloads.IS_PENDING, 0) resolver.update(uri, values, null, null) + // MediaStore renames duplicates ("name (1).csv"); show the name actually used. + showSaved(displayName(uri) ?: fileName, uri, mimeType) } 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) + MediaScannerConnection.scanFile(activity, arrayOf(file.path), arrayOf(mimeType)) { _, uri -> + handler.post { showSaved(file.name, uri, mimeType) } + } } - 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 displayName(uri: Uri): String? = + activity.contentResolver.query(uri, arrayOf(MediaStore.Downloads.DISPLAY_NAME), null, null, null) + ?.use { if (it.moveToFirst()) it.getString(0) else null } + + /** Tells the user where the file went and offers to open it. */ + private fun showSaved(name: String, uri: Uri?, mimeType: String) { + if (activity.isFinishing) return + val dialog = AlertDialog.Builder(activity) + .setTitle(R.string.download_saved_title) + .setMessage(activity.getString(R.string.download_saved_message, name)) + .setNegativeButton(R.string.download_close, null) + if (uri != null) dialog.setPositiveButton(R.string.download_open) { _, _ -> openFile(name, uri, mimeType) } + dialog.show() + } + + /** + * Opens the file in whatever app handles its type. Many file types (CSV on these + * scanners) have no such app, so AppConfig.FILE_OPEN_FALLBACK_APPS names an app to + * launch instead, where the user opens the file from Descargas themselves. + */ + private fun openFile(name: String, uri: Uri, mimeType: String) { + val view = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mimeType.substringBefore(';')) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + try { + activity.startActivity(view) + return + } catch (_: ActivityNotFoundException) { + Log.i(TAG, "No viewer for $mimeType, trying fallback app") + } + + // An implicit MAIN/LAUNCHER intent never matches: startActivity adds CATEGORY_DEFAULT. + val fallback = AppConfig.FILE_OPEN_FALLBACK_APPS[name.substringAfterLast('.', "").lowercase()] + val launch = fallback?.let { activity.packageManager.getLaunchIntentForPackage(it) } + if (launch != null) { + activity.startActivity(launch) + toast(activity.getString(R.string.download_open_manually, name)) + return + } + Log.w(TAG, "No app opens $name (fallback ${fallback ?: "none"} not installed)") + toast(R.string.download_no_app) + } + private fun uniqueFile(dir: File, name: String): File { var file = File(dir, name) var n = 1 @@ -260,9 +333,9 @@ class FileTransfers( // 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("[\\\\/:*?\"<>|]"), "_") + ).let(::sanitizeFileName) if (name.contains('.')) return name - val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } + val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it.substringBefore(';')) } return if (ext != null) "$name.$ext" else name } @@ -405,7 +478,8 @@ class FileTransfers( 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. */ + * revocation, so a download whose URL the site revokes immediately can still be read, + * and records the `download` file name of clicked links. */ val BLOB_HOOK_JS = """ (function() { if (window.__jkBlobs) return; @@ -419,6 +493,26 @@ class FileTransfers( URL.revokeObjectURL = function(url) { setTimeout(function() { delete blobs[url]; revoke.call(URL, url); }, 60000); }; + // File names from , which WebView does not pass on. + // Covers a.click() and dispatchEvent on detached links, and real taps. + var names = window.__jkDownloadNames = {}; + function remember(a) { + if (a && a.hasAttribute('download') && a.href) names[a.href] = a.getAttribute('download'); + } + var click = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function() { + remember(this); + return click.apply(this, arguments); + }; + var dispatch = EventTarget.prototype.dispatchEvent; + HTMLAnchorElement.prototype.dispatchEvent = function(ev) { + if (ev && ev.type === 'click') remember(this); + return dispatch.apply(this, arguments); + }; + document.addEventListener('click', function(e) { + var a = e.target && e.target.closest && e.target.closest('a[download]'); + if (a) remember(a); + }, true); })(); """.trimIndent() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 26a6d51..2bb92ea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,7 +26,12 @@ 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 + Archivo guardado + Se guardó en la carpeta Descargas:\n%1$s + Abrir + Cerrar + Abra Descargas › %1$s + No hay ninguna app para abrir este archivo No se pudo descargar el archivo Se necesita permiso de almacenamiento para guardar archivos Agregar foto