From 76f1fb7e7a3bc4e155b24dd9dbe05aae509790c6 Mon Sep 17 00:00:00 2001 From: screentinker Date: Thu, 13 Aug 2026 20:49:20 -0500 Subject: [PATCH] Stage the APK wherever the device will actually take it (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A panel could not update, forever, while downloading content perfectly well. Its content cache writes to INTERNAL storage and works; the APK path insisted on EXTERNAL storage, which on that device is present but unwritable — so the download died at outputStream(), before a single byte, roughly one second after the command. The server logged the request as served, the client reported "failed to download or failed signature verification", and nothing pointed at a directory. apkDir() previously asked canWrite(), believed the answer, and returned external. The alpha12 preflight then PROVED the directory with a real write and refused — correctly diagnosing the problem and still not updating, because it had no way to choose somewhere else. A fallback that only reports is not a fallback. apkStagingDir() now walks candidates and returns the first that genuinely accepts bytes: internal /data/data//files/Download — always mounted, always writable external the old location — kept, it survives uninstall cache /data/data//cache/Download files the app's files dir, no subdirectory to create Internal is first because it cannot fail: it is this app's own private directory, and if it is unwritable the app is not running. External is a convenience — visible for a manual install, survives uninstall — and it is the one that breaks, so it is no longer the default. Every candidate is proven by writing a probe byte and deleting it, never by asking canWrite(), which returns true on volumes that then refuse the write. That is precisely how this hid. If no candidate works, the failure names every one it tried and why, instead of the first excuse. The pushed-APK path uses the same cascade; it shared the fault and reported none of it. ⚠️ This cannot reach a panel already stuck: the broken path is the delivery mechanism, and Push APK shares it. Such a panel needs ONE manual install, after which it is permanently self-healing. --- .../player/service/UpdateChecker.kt | 72 +++++++++++-------- 1 file changed, 42 insertions(+), 30 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 bfbae4f..ba01b13 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 @@ -343,26 +343,43 @@ class UpdateChecker(private val context: Context) { * 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 { - // Non-null is not the same as usable. A returned path can still be missing, unwritable, or - // on a volume that has since gone away — and every one of those produced the same opaque - // "failed to download" as a genuine network fault, which is what made this expensive to - // diagnose. Prove the directory before choosing it, and fall back if it does not hold up. - val ext = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) - if (ext != null && (ext.exists() || ext.mkdirs()) && ext.canWrite()) return ext - if (ext != null) Log.w(TAG, "External APK dir unusable (exists=${ext.exists()} writable=${ext.canWrite()}) — using internal") - else Log.w(TAG, "External storage unavailable — staging APKs in internal storage instead") - return File(context.filesDir, "Download").apply { mkdirs() } + /* + * Where to stage a downloaded APK — the FIRST location that actually accepts bytes. + * + * Internal app storage is tried first and is effectively guaranteed: /data/data//files is + * this app's own private directory, always mounted, always writable. If it is not, the app is + * not running. External storage is only a convenience (it survives uninstall and is visible for + * a manual install), and it is the one that fails — it can be absent, unmounted, present but + * unwritable, or report canWrite() = true and then refuse the write anyway. + * + * ⚠️ Each candidate is PROVEN with a real write, not asked. The previous version asked + * canWrite(), believed the answer, and then died at outputStream() — before a single byte — so + * the update failed instantly and reported it as a download problem. Every fallback in the world + * is useless if the first choice is trusted rather than tested. + * + * Returns the directory, or null with every reason it could not find one, so the operator gets + * the full picture instead of the first excuse. + */ + private fun apkStagingDir(needBytes: Long): Pair { + val candidates = LinkedHashMap() + candidates["internal"] = File(context.filesDir, "Download") + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.let { candidates["external"] = it } + candidates["cache"] = File(context.cacheDir, "Download") + candidates["files"] = context.filesDir // last resort: no subdirectory to create + + val reasons = StringBuilder() + for ((name, dir) in candidates) { + val problem = apkDirProblem(dir, needBytes) + if (problem == null) { + if (name != "internal") Log.w(TAG, "Staging APK in $name (${dir.absolutePath})") + return dir to name + } + if (reasons.isNotEmpty()) reasons.append("; ") + reasons.append("$name ${problem}") + } + return null to reasons.toString() } - /* - * Can we actually put a ~9MB file here, right now? - * - * Checked BEFORE the download rather than discovered as an exception during the write, so the - * failure names the real condition — a read-only volume, a missing directory, a full disk — - * instead of surfacing as a generic IOException three layers up. Returns null when fine, or the - * reason it is not. - */ private fun apkDirProblem(dir: File, needBytes: Long): String? { if (!dir.exists() && !dir.mkdirs()) return "cannot create ${dir.absolutePath}" if (!dir.isDirectory) return "${dir.absolutePath} is not a directory" @@ -386,13 +403,11 @@ class UpdateChecker(private val context: Context) { private fun downloadAndInstall(url: String, version: String): Boolean { try { - val dir = apkDir() - // Preflight the destination. If the box cannot hold the file, say THAT — rather than - // letting it surface later as a truncated write or a generic IOException, which is - // indistinguishable from a network problem in the message the operator sees. - apkDirProblem(dir, 9L * 1024 * 1024)?.let { - lastFailure = "cannot stage the update — $it" - Log.e(TAG, "APK staging unavailable: $it") + // Find somewhere that will actually take the file, before asking the network for it. + val (dir, whereOrWhy) = apkStagingDir(9L * 1024 * 1024) + if (dir == null) { + lastFailure = "nowhere to stage the update — $whereOrWhy" + Log.e(TAG, "APK staging unavailable: $whereOrWhy") return false } val apkFile = File(dir, "ScreenTinker-$version.apk") @@ -464,11 +479,8 @@ 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 dir = apkDir() - apkDirProblem(dir, 9L * 1024 * 1024)?.let { - Log.e(TAG, "installFromUrl: cannot stage — $it") - return@Thread - } + val (dir, whyNot) = apkStagingDir(9L * 1024 * 1024) + if (dir == null) { Log.e(TAG, "installFromUrl: nowhere to stage — $whyNot"); return@Thread } val apkFile = File(dir, fileName) if (apkFile.exists()) apkFile.delete() val response = client.newCall(Request.Builder().url(url).build()).execute()