From eb9be2fa7e93c7797a973243c0369d9107d86152 Mon Sep 17 00:00:00 2001 From: Arvin Date: Sun, 5 Apr 2026 14:41:37 +0200 Subject: [PATCH] fix: in-app updater downloads but never installs (#116, #99, #75) PR #95 switched the PackageInstaller session callback from getActivity to getBroadcast against action `com.arvio.tv.INSTALL_COMPLETE`, but no BroadcastReceiver was ever registered for that action. On Android 7+ the PackageInstaller session API delivers its result by firing the supplied PendingIntent with STATUS_PENDING_USER_ACTION and an EXTRA_INTENT containing the system install-confirmation Activity the app must startActivity() to actually show the "Install?" screen. With no receiver, the session commit succeeded silently, the callback went nowhere, and the user saw "Installing update..." followed by nothing. The APK sat in cache and the old process kept running. This has been broken across multiple versions (1.9.3 through 1.9.73) and directly blocks users from receiving any other fixes we ship. Changes: - New `ApkInstallReceiver` that handles STATUS_PENDING_USER_ACTION by launching the system confirm Activity with NEW_TASK + CLEAR_TOP + GRANT URI permission, and surfaces failure statuses as user-visible toasts instead of silently dropping them. - Register the receiver in AndroidManifest.xml with an intent filter using `${applicationId}.INSTALL_COMPLETE` so the action name is unique per build flavor (play / sideload / staging) and can't collide with other ARVIO installs on the same device. - Derive the broadcast action in `ApkInstaller` from `context.packageName` at runtime via `ApkInstallReceiver.actionFor()`, replacing the hard-coded `com.arvio.tv.INSTALL_COMPLETE` string that was wrong for the `.staging` build flavor. - Use `context.applicationContext` when constructing the PendingIntent and only pass FLAG_MUTABLE on API 31+ (it has no effect on older APIs but keeps the lint clean). - Wrap the ACTION_VIEW fallback path in a try/catch so it no longer crashes on Chinese Android TV forks whose non-AOSP installer rejects the standard Intent. Closes #116 Closes #99 Closes #75 --- app/src/main/AndroidManifest.xml | 16 +++ .../arflix/tv/updater/ApkInstallReceiver.kt | 110 ++++++++++++++++++ .../com/arflix/tv/updater/ApkInstaller.kt | 34 ++++-- 3 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf3ac815b..cd84d14c5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -99,6 +99,22 @@ android:resource="@xml/file_paths" /> + + + + + + + diff --git a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt new file mode 100644 index 000000000..81653754c --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt @@ -0,0 +1,110 @@ +package com.arflix.tv.updater + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import android.util.Log +import android.widget.Toast + +/** + * Handles PackageInstaller session callbacks for the in-app APK updater. + * + * The Android [PackageInstaller] session API requires user confirmation for non-privileged + * apps. It delivers the result by firing the supplied PendingIntent with + * [PackageInstaller.EXTRA_STATUS] == [PackageInstaller.STATUS_PENDING_USER_ACTION] and an + * [Intent.EXTRA_INTENT] containing the system install-confirmation Activity. Without a + * receiver to pick that up and start the confirm Activity, the "Installing update..." flow + * hangs forever and no install ever happens — which is exactly what was reported in + * issues #116, #99, and #75 for versions 1.9.3 through 1.9.73. + */ +class ApkInstallReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999) + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + // The system needs user confirmation — launch the confirm Activity. + val confirmIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_INTENT) + } + + if (confirmIntent == null) { + Log.e(TAG, "STATUS_PENDING_USER_ACTION without EXTRA_INTENT — cannot prompt user.") + return + } + + confirmIntent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + try { + context.startActivity(confirmIntent) + } catch (e: Exception) { + // Some Android TV forks (particularly Chinese AOSP variants) don't + // handle the system confirm intent correctly. Log but don't crash. + Log.e(TAG, "Failed to launch install confirmation Activity: ${e.message}", e) + showToast(context, "Update install requires manual confirmation. Please install from Downloads.") + } + } + + PackageInstaller.STATUS_SUCCESS -> { + Log.i(TAG, "Update installed successfully.") + // No toast needed — the new APK is installing/replacing the running process. + } + + PackageInstaller.STATUS_FAILURE, + PackageInstaller.STATUS_FAILURE_ABORTED, + PackageInstaller.STATUS_FAILURE_BLOCKED, + PackageInstaller.STATUS_FAILURE_CONFLICT, + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE, + PackageInstaller.STATUS_FAILURE_INVALID, + PackageInstaller.STATUS_FAILURE_STORAGE -> { + Log.e(TAG, "Update install failed: status=$status message=$message") + val userMessage = when (status) { + PackageInstaller.STATUS_FAILURE_ABORTED -> "Update cancelled." + PackageInstaller.STATUS_FAILURE_BLOCKED -> "Update blocked by system policy." + PackageInstaller.STATUS_FAILURE_CONFLICT -> "Update conflicts with installed version. Try uninstalling first." + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Update not compatible with this device." + PackageInstaller.STATUS_FAILURE_INVALID -> "Update package is invalid or corrupted." + PackageInstaller.STATUS_FAILURE_STORAGE -> "Not enough storage to install update." + else -> message ?: "Update install failed." + } + showToast(context, userMessage) + } + + else -> { + Log.w(TAG, "Unexpected PackageInstaller status=$status message=$message") + } + } + } + + private fun showToast(context: Context, text: String) { + try { + Toast.makeText(context.applicationContext, text, Toast.LENGTH_LONG).show() + } catch (_: Exception) { + // Receiver may not have a main looper in some paths; swallow silently. + } + } + + companion object { + private const val TAG = "ApkInstallReceiver" + + /** + * The broadcast action used for PackageInstaller session callbacks. Derived from the + * applicationId at runtime so it's unique per build flavor (e.g. `.staging`) and + * cannot collide with other installs of ARVIO on the same device. + */ + fun actionFor(context: Context): String { + return "${context.packageName}.INSTALL_COMPLETE" + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstaller.kt b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstaller.kt index 8912391f5..c2eb9745c 100644 --- a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstaller.kt +++ b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstaller.kt @@ -120,11 +120,19 @@ object ApkInstaller { // Use a broadcast PendingIntent instead of activity — works reliably // on Android TV where the Application context isn't an Activity. - val intent = Intent("com.arvio.tv.INSTALL_COMPLETE") + // The action is per-applicationId so it's unique per flavor / install. + // ApkInstallReceiver (registered in the manifest) picks up the callback and + // launches the system install-confirmation Activity on STATUS_PENDING_USER_ACTION, + // without which the session commit succeeds silently but no install ever happens. + val intent = Intent(ApkInstallReceiver.actionFor(context)) .setPackage(context.packageName) - val pendingIntent = PendingIntent.getBroadcast( - context, sessionId, intent, + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val pendingIntent = PendingIntent.getBroadcast( + context.applicationContext, sessionId, intent, flags ) session.commit(pendingIntent.intentSender) @@ -135,13 +143,17 @@ object ApkInstaller { } } - // Fallback: classic ACTION_VIEW install - val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.fileprovider", apkFile) - val intent = Intent(Intent.ACTION_VIEW) - .setDataAndType(uri, "application/vnd.android.package-archive") - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - context.startActivity(intent) + // Fallback: classic ACTION_VIEW install (used when the session path throws OR on API < 21). + try { + val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.fileprovider", apkFile) + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + context.startActivity(intent) + } catch (e: Exception) { + System.err.println("[ApkInstaller] Fallback ACTION_VIEW install failed: ${e.message}") + } } }