Keep the site's download file name and offer to open saved files
All checks were successful
APK-BUILD/pipeline/head This commit looks good

WebView drops the <a download> 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 <noreply@anthropic.com>
This commit is contained in:
caspar 2026-10-12 15:45:32 -04:00
parent d22af8f256
commit e63de716cd
5 changed files with 129 additions and 15 deletions

View File

@ -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 `<queries>` 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 `<a download="name">` 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 `<queries>` in the manifest (Android 11+ package visibility).
- Uploads: `<input type=file>` 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.

View File

@ -13,6 +13,12 @@
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Apps in AppConfig.FILE_OPEN_FALLBACK_APPS: Android 11+ hides other apps unless they
are declared here, so launching them would fail with ActivityNotFoundException. -->
<queries>
<package android:name="tech.brianrayburn.csv_editor" />
</queries>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"

View File

@ -60,6 +60,14 @@ object AppConfig {
"SCAN_BARCODE1", "scannerdata", "data",
)
/** App launched by a saved download's "Abrir" button when no installed app registers to
* open that file type, keyed by extension; the user then opens the file from Descargas.
* CSV Editor (F-Droid) has no "open with" support, and nothing else opens .csv here.
* Each package must also be listed under <queries> 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

View File

@ -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 <a download="name"> 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 <a download="name">, 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()
}

View File

@ -26,7 +26,12 @@
<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_saved_title">Archivo guardado</string>
<string name="download_saved_message">Se guardó en la carpeta Descargas:\n%1$s</string>
<string name="download_open">Abrir</string>
<string name="download_close">Cerrar</string>
<string name="download_open_manually">Abra Descargas › %1$s</string>
<string name="download_no_app">No hay ninguna app para abrir este archivo</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="upload_title">Agregar foto</string>