Compare commits
1 Commits
keyboard-s
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e63de716cd |
@ -71,6 +71,7 @@ All configurable values are in `AppConfig.kt`:
|
|||||||
- `ADMIN_BARCODE` — scanning this value (Intent mode) opens the admin dialog
|
- `ADMIN_BARCODE` — scanning this value (Intent mode) opens the admin dialog
|
||||||
- `KIOSK_BARCODE` — scanning this value (Intent mode) toggles kiosk lockdown
|
- `KIOSK_BARCODE` — scanning this value (Intent mode) toggles kiosk lockdown
|
||||||
- `RENDERER_CRASH_LIMIT` / `RENDERER_CRASH_WINDOW_MS` — WebView crash auto-recovery limit
|
- `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
|
## Navigation Rules
|
||||||
|
|
||||||
@ -88,7 +89,7 @@ All configurable values are in `AppConfig.kt`:
|
|||||||
## Files & Permissions
|
## Files & Permissions
|
||||||
|
|
||||||
- Permissions are requested at first launch (camera; storage on Android ≤ 9) and again when a feature needs one.
|
- 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.
|
- 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.
|
- In-page camera (`getUserMedia`) is granted to allowed hosts only, video only.
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,12 @@
|
|||||||
|
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
|||||||
@ -60,6 +60,14 @@ object AppConfig {
|
|||||||
"SCAN_BARCODE1", "scannerdata", "data",
|
"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. */
|
/** Press Enter after a broadcast scan is inserted, like the keystroke-mode suffix. */
|
||||||
const val SCANNER_SEND_ENTER = true
|
const val SCANNER_SEND_ENTER = true
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.json.JSONTokener
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@ -90,22 +91,44 @@ class FileTransfers(
|
|||||||
|
|
||||||
/** Wire to WebView.setDownloadListener. */
|
/** Wire to WebView.setDownloadListener. */
|
||||||
fun onDownloadStart(url: String, userAgent: String, contentDisposition: String?, mimeType: String?) {
|
fun onDownloadStart(url: String, userAgent: String, contentDisposition: String?, mimeType: String?) {
|
||||||
val fileName = fileNameFor(url, contentDisposition, mimeType)
|
val save: (String) -> Unit = when {
|
||||||
val save: () -> Unit = when {
|
url.startsWith("blob:") -> { name -> readBlob(url, name, mimeType) }
|
||||||
url.startsWith("blob:") -> { { readBlob(url, fileName, mimeType) } }
|
url.startsWith("data:") -> { name -> saveDataUrl(url, name) }
|
||||||
url.startsWith("data:") -> { { saveDataUrl(url, fileName) } }
|
isAllowedHttps(url) -> { name -> enqueueDownload(url, userAgent, name, mimeType) }
|
||||||
isAllowedHttps(url) -> { { enqueueDownload(url, userAgent, fileName, mimeType) } }
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.w(TAG, "Blocked download from $url")
|
Log.w(TAG, "Blocked download from $url")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
permissions.withPermissions(Permissions.STORAGE) {
|
permissions.withPermissions(Permissions.STORAGE) {
|
||||||
if (permissions.allGranted(Permissions.STORAGE)) save()
|
if (!permissions.allGranted(Permissions.STORAGE)) {
|
||||||
else toast(R.string.download_permission_denied)
|
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?) {
|
private fun enqueueDownload(url: String, userAgent: String, fileName: String, mimeType: String?) {
|
||||||
try {
|
try {
|
||||||
val request = DownloadManager.Request(Uri.parse(url)).apply {
|
val request = DownloadManager.Request(Uri.parse(url)).apply {
|
||||||
@ -130,7 +153,11 @@ class FileTransfers(
|
|||||||
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||||
val title = c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE))
|
val title = c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE))
|
||||||
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
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 {
|
} else {
|
||||||
Log.e(TAG, "Download $id ended with status $status")
|
Log.e(TAG, "Download $id ended with status $status")
|
||||||
toast(R.string.download_failed)
|
toast(R.string.download_failed)
|
||||||
@ -230,21 +257,67 @@ class FileTransfers(
|
|||||||
values.clear()
|
values.clear()
|
||||||
values.put(MediaStore.Downloads.IS_PENDING, 0)
|
values.put(MediaStore.Downloads.IS_PENDING, 0)
|
||||||
resolver.update(uri, values, null, null)
|
resolver.update(uri, values, null, null)
|
||||||
|
// MediaStore renames duplicates ("name (1).csv"); show the name actually used.
|
||||||
|
showSaved(displayName(uri) ?: fileName, uri, mimeType)
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
val file = uniqueFile(dir, fileName)
|
val file = uniqueFile(dir, fileName)
|
||||||
file.writeBytes(bytes)
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Saving $fileName failed", e)
|
Log.e(TAG, "Saving $fileName failed", e)
|
||||||
toast(R.string.download_failed)
|
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 {
|
private fun uniqueFile(dir: File, name: String): File {
|
||||||
var file = File(dir, name)
|
var file = File(dir, name)
|
||||||
var n = 1
|
var n = 1
|
||||||
@ -260,9 +333,9 @@ class FileTransfers(
|
|||||||
// guessFileName only understands http(s); blob/data names come from the disposition.
|
// guessFileName only understands http(s); blob/data names come from the disposition.
|
||||||
val name = URLUtil.guessFileName(
|
val name = URLUtil.guessFileName(
|
||||||
if (url.startsWith("http")) url else "", contentDisposition, mimeType
|
if (url.startsWith("http")) url else "", contentDisposition, mimeType
|
||||||
).replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
).let(::sanitizeFileName)
|
||||||
if (name.contains('.')) return name
|
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
|
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")
|
private val IMAGE_EXTENSIONS = setOf(".jpg", ".jpeg", ".png", ".webp", ".heic", ".gif")
|
||||||
|
|
||||||
/** Injected at document start: remembers blobs behind object URLs and delays
|
/** 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 = """
|
val BLOB_HOOK_JS = """
|
||||||
(function() {
|
(function() {
|
||||||
if (window.__jkBlobs) return;
|
if (window.__jkBlobs) return;
|
||||||
@ -419,6 +493,26 @@ class FileTransfers(
|
|||||||
URL.revokeObjectURL = function(url) {
|
URL.revokeObjectURL = function(url) {
|
||||||
setTimeout(function() { delete blobs[url]; revoke.call(URL, url); }, 60000);
|
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()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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_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="kiosk_disabled">Modo kiosco desactivado</string>
|
||||||
<string name="download_started">Descargando %1$s…</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_failed">No se pudo descargar el archivo</string>
|
||||||
<string name="download_permission_denied">Se necesita permiso de almacenamiento para guardar archivos</string>
|
<string name="download_permission_denied">Se necesita permiso de almacenamiento para guardar archivos</string>
|
||||||
<string name="upload_title">Agregar foto</string>
|
<string name="upload_title">Agregar foto</string>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user