From e63de716cd81af61db4033c7c72fa61e6d01dc37 Mon Sep 17 00:00:00 2001 From: caspar Date: Mon, 12 Oct 2026 15:45:32 -0400 Subject: [PATCH] Keep the site's download file name and offer to open saved files WebView drops the name of page-made files, so downloads such as the Verificar entrega Odoo CSV were saved as downloadfile.csv. The download hook now records the name when the link is clicked and the app asks the page for it. After saving, a dialog shows the file name and offers Abrir, which uses the registered viewer or, for types nothing opens (CSV on the scanners), launches the app set in AppConfig.FILE_OPEN_FALLBACK_APPS. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- app/src/main/AndroidManifest.xml | 6 + .../main/java/mx/joykoo/scanner/AppConfig.kt | 8 ++ .../java/mx/joykoo/scanner/FileTransfers.kt | 120 ++++++++++++++++-- app/src/main/res/values/strings.xml | 7 +- 5 files changed, 129 insertions(+), 15 deletions(-) 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