From 3234c923a331102a70ba588a85fce7f4f4b8e5f1 Mon Sep 17 00:00:00 2001 From: screentinker Date: Thu, 13 Aug 2026 14:32:34 -0500 Subject: [PATCH] An APK download with no external storage went nowhere, silently (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getExternalFilesDir() returns null whenever external storage is unavailable, and on a signage panel that is not exotic: no emulated volume, a vendor ROM that never mounts one, an ejected card, storage still unmounted early in boot. Both APK download paths did: File(context.getExternalFilesDir(DIRECTORY_DOWNLOADS), name) Java's File(File, String) treats a null parent as "no parent" and silently yields a RELATIVE path, so the download targeted `ScreenTinker-x.y.z.apk` in the process working directory — `/` — which is not writable. The write threw, the generic catch swallowed it, and the caller reported only "failed to download or failed signature verification". That message is why this was expensive to find. The HTTP request SUCCEEDS (the server logs a served download at the exact moment of each failure), so it does not look like a download problem; the signing key is fine, so it does not look like a verification problem; and because nothing is ever written there is no partial file to find. It also never recovers — every attempt fails the same way, forever. installFromUrl (the dashboard "Push an APK" button) carried the identical line, so the obvious workaround for a panel in this state was broken by the same bug. Both now use apkDir(), which falls back to internal storage. filesDir cannot be unmounted: if it is gone the app is not running. file_paths.xml gains a for the same directory. The silent PackageInstaller path streams the file itself and needs nothing there, but the intent-based install FALLBACK resolves it through FileProvider and would throw "Failed to find configured root" — turning an already-degraded panel into one that cannot install at all. ⚠️ This cannot reach an affected panel over the air: the broken download path IS the delivery mechanism, and Push APK shares the bug. A panel already in this state needs one manual install to escape it. Root cause is inferred from converging evidence on an Android 9 panel (HTTP served at each failure, no APK anywhere on the device, the app's own external files dir denied to its own UID, both paths failing identically, signing verified at parity against the release it already installed). Handling a null return is correct regardless — it must never become a relative path. --- .../player/service/UpdateChecker.kt | 31 +++++++++++++++++-- android/app/src/main/res/xml/file_paths.xml | 7 +++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt index d29af58..fb465ad 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt @@ -311,10 +311,35 @@ class UpdateChecker(private val context: Context) { // Returns TRUE only when a verified APK is in hand and an install has been launched (the // caller may then count an attempt); FALSE on any download/verify failure — the caller must // NOT count those, so a transient network problem can't burn a healthy device's budget. #139 + /* + * Where a downloaded APK is staged. + * + * getExternalFilesDir() returns NULL whenever external storage is not mounted/available — and + * on a signage panel that is not exotic: no emulated volume, a vendor ROM that never mounts one, + * an SD card ejected, storage still unmounted early in boot. + * + * The bug this replaces: `File(context.getExternalFilesDir(...), name)`. Java's File(File,String) + * treats a NULL parent as "no parent" and silently produces a RELATIVE path, so the download + * targeted `ScreenTinker-x.y.z.apk` in the process working directory — `/` — which is not + * writable. The write threw, the generic catch swallowed it, and the caller reported only + * "failed to download or failed signature verification". Nothing was ever written, so there was + * no partial file to find and nothing in the message pointed at storage. It fails on EVERY + * attempt, forever, on an affected panel — and identically for the pushed-APK path, which had + * the same line. + * + * Internal storage always exists, so fall back to it. It costs nothing when external is present. + * NOTE: the intent-based install fallback resolves this file through FileProvider, so + * res/xml/file_paths.xml must expose this directory too — see the entry there. + */ + private fun apkDir(): File { + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.let { return it } + Log.w(TAG, "External storage unavailable — staging APKs in internal storage instead") + return File(context.filesDir, "Download").apply { mkdirs() } + } + private fun downloadAndInstall(url: String, version: String): Boolean { try { - val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "ScreenTinker-$version.apk") + val apkFile = File(apkDir(), "ScreenTinker-$version.apk") // #139: reuse a previously-downloaded, verified APK for this version instead of // re-pulling ~8.7 MB every cycle. The file also stays on disk as the artifact for a @@ -378,7 +403,7 @@ class UpdateChecker(private val context: Context) { try { val base = url.substringAfterLast('/').substringBefore('?').ifBlank { "app.apk" } val fileName = "pushed-" + (if (base.endsWith(".apk")) base else "$base.apk") - val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName) + val apkFile = File(apkDir(), fileName) if (apkFile.exists()) apkFile.delete() val response = client.newCall(Request.Builder().url(url).build()).execute() if (!response.isSuccessful) { Log.e(TAG, "installFromUrl: download failed ${response.code}"); return@Thread } diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml index 0b40055..c09bcd7 100644 --- a/android/app/src/main/res/xml/file_paths.xml +++ b/android/app/src/main/res/xml/file_paths.xml @@ -2,4 +2,11 @@ + +