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}") + } } }