Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@
android:resource="@xml/file_paths" />
</provider>

<!--
PackageInstaller session callback receiver for the in-app APK updater.
Without this receiver, STATUS_PENDING_USER_ACTION callbacks from the system
installer are dropped and the install never actually happens — which was the
root cause of issues #116, #99, #75 where the updater downloaded but never
installed the APK on Android 7+ devices.
The action is scoped to ${applicationId} so it's unique per flavor/install.
-->
<receiver
android:name=".updater.ApkInstallReceiver"
android:exported="false">
<intent-filter>
<action android:name="${applicationId}.INSTALL_COMPLETE" />
</intent-filter>
</receiver>

</application>

</manifest>
Expand Down
110 changes: 110 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
34 changes: 23 additions & 11 deletions app/src/main/kotlin/com/arflix/tv/updater/ApkInstaller.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}")
}
}
}
Loading