feat(update): silent self-update via PackageInstaller session — #2438
android / Build + lint + test (push) Successful in 3m54s

Replaces the ACTION_VIEW + application/vnd.android.package-archive handoff
with a PackageInstaller session, and declares
UPDATE_PACKAGES_WITHOUT_USER_ACTION so the update can land with no confirm
dialog at all.

The platform grants the silent path when the installer opts in via
setRequireUserAction(USER_ACTION_NOT_REQUIRED), the installed app targets
API 29+, the installer holds that permission, and the target is the
installer itself. Minstrel updating Minstrel satisfies all four. Where it
can't be granted — anything pre-S — the platform returns
STATUS_PENDING_USER_ACTION and we show its dialog instead, so this degrades
rather than failing.

Prior art: Mihon, which is out-of-store and self-updating and whose updates
are quiet for exactly this reason. It also confirmed REQUEST_INSTALL_PACKAGES
is not what draws install warnings — Mihon declares it too.

No setRequestUpdateOwnership(true), despite it reading like the obvious
declaration for a self-updater. Ownership can only be claimed on initial
installation (a no-op on update) and additionally wants the privileged
ENFORCE_UPDATE_OWNERSHIP permission. It's an API for app stores claiming the
apps they install.

Also: the install now has an outcome. The old path fired an intent and
assumed, so a failure and a user declining were indistinguishable. Sessions
report back, so InstallOutcome distinguishes Installed / Cancelled / Failed,
and cancelling returns to IDLE rather than showing an error — the user chose
it. DOWNLOADING and INSTALLING became separate stages because the install
half now genuinely waits, and "Downloading…" through a confirm dialog is a
lie.

The FileProvider and res/xml/file_paths.xml are gone. They existed only to
expose the cached APK as a content:// URI for the old intent; a session
takes a stream. Nothing else used that authority.

Two judgement calls worth naming:

- The pending-user-action intent is only launched if it resolves to a system
  component. Below API 34 a dynamically registered receiver can't declare
  itself unexported, so another app can broadcast at us, and an unchecked
  startActivity on an attacker-supplied extra would be an escalation
  primitive. The real confirm activity is a system app, so the check costs
  the legitimate path nothing.
- Cancellation unregisters the receiver but deliberately does NOT abandon the
  session. By then it's committed, and killing an install because the user
  navigated away from the banner misreads their intent.

Untestable here: no androidTest source set and no Robolectric, so the
gesture-level behaviour is operator on-device verification.
This commit is contained in:
2026-08-04 16:19:24 -04:00
parent 8b630e71ca
commit a4b6f22d86
9 changed files with 410 additions and 90 deletions
+14 -9
View File
@@ -8,7 +8,16 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
is what lets that install happen with NO confirm dialog. The platform
grants the silent path only when the installer opts in via
SessionParams.setRequireUserAction(USER_ACTION_NOT_REQUIRED), the
installed app targets API 29+, the installer holds this permission, and
the target is the installer itself — all true here, since Minstrel is
updating Minstrel. See update/data/SelfUpdateSession.kt. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
@@ -48,15 +57,11 @@
</intent-filter> </intent-filter>
</service> </service>
<provider <!-- The FileProvider that used to live here existed solely to expose the
android:name="androidx.core.content.FileProvider" downloaded update APK as a content:// URI for the old ACTION_VIEW
android:authorities="${applicationId}.fileprovider" install intent. A PackageInstaller session takes a stream instead,
android:exported="false" so both the provider and res/xml/file_paths.xml are gone — nothing
android:grantUriPermissions="true"> else in the app ever used that authority. -->
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- On-demand WorkManager initialization: MinstrelApplication <!-- On-demand WorkManager initialization: MinstrelApplication
implements Configuration.Provider and supplies the implements Configuration.Provider and supplies the
@@ -6,22 +6,27 @@ import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateRepository import com.fabledsword.minstrel.update.data.UpdateRepository
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.isVersionNewer import com.fabledsword.minstrel.update.data.isVersionNewer
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject import javax.inject.Inject
/** /**
* One of three terminal states the Check-for-updates button surfaces. * One of three terminal states the Check-for-updates button surfaces.
* `Idle` is the pre-check state; `Latest` means the installed build * `Idle` is the pre-check state; `Latest` means the installed build
* matches or exceeds the server's bundled APK; `UpdateAvailable` * matches or exceeds the server's bundled APK; `UpdateAvailable`
* surfaces an "Install vX.Y.Z" button that downloads + launches the * surfaces an "Install vX.Y.Z" button that downloads the APK and
* system installer via [ApkInstaller]. * installs it via [ApkInstaller].
*/ */
sealed interface UpdateCheckResult { sealed interface UpdateCheckResult {
data object Idle : UpdateCheckResult data object Idle : UpdateCheckResult
@@ -33,7 +38,7 @@ sealed interface UpdateCheckResult {
data class AboutUiState( data class AboutUiState(
val installedVersion: String = BuildConfig.VERSION_NAME, val installedVersion: String = BuildConfig.VERSION_NAME,
val isChecking: Boolean = false, val isChecking: Boolean = false,
val isInstalling: Boolean = false, val installStage: InstallStage = InstallStage.IDLE,
val installMessage: String? = null, val installMessage: String? = null,
val result: UpdateCheckResult = UpdateCheckResult.Idle, val result: UpdateCheckResult = UpdateCheckResult.Idle,
) )
@@ -43,9 +48,9 @@ data class AboutUiState(
* [UpdateRepository.getLatest], compares versus the build's * [UpdateRepository.getLatest], compares versus the build's
* VERSION_NAME via [isVersionNewer], and reports the terminal state. * VERSION_NAME via [isVersionNewer], and reports the terminal state.
* When an update is available, [install] downloads the APK via * When an update is available, [install] downloads the APK via
* [ApkInstaller] and hands it to the system installer — routing the * [ApkInstaller] and installs it — routing the user to the "install
* user to the "install unknown apps" settings page first when that * unknown apps" settings page first when that permission hasn't been
* permission hasn't been granted. * granted.
*/ */
@HiltViewModel @HiltViewModel
class AboutCardViewModel @Inject constructor( class AboutCardViewModel @Inject constructor(
@@ -75,7 +80,7 @@ class AboutCardViewModel @Inject constructor(
} }
fun install(info: UpdateInfo) { fun install(info: UpdateInfo) {
if (internal.value.isInstalling) return if (internal.value.installStage.isBusy()) return
if (!installer.canInstall()) { if (!installer.canInstall()) {
installer.requestInstallPermission() installer.requestInstallPermission()
internal.update { internal.update {
@@ -84,21 +89,32 @@ class AboutCardViewModel @Inject constructor(
return return
} }
viewModelScope.launch { viewModelScope.launch {
internal.update { it.copy(isInstalling = true, installMessage = null) } internal.update {
runCatching { installer.downloadApk(info.apkUrl) } it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
.onSuccess { apk ->
installer.launchInstall(apk)
internal.update { it.copy(isInstalling = false) }
} }
val apk = download(info.apkUrl)
if (apk != null) {
// The install half now suspends on the platform's verdict, so it
// gets its own stage — reporting "Downloading…" through it would
// be a lie once a confirm dialog is on screen.
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
val outcome = installer.install(apk)
internal.update {
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
}
}
}
}
private suspend fun download(apkUrl: String): File? =
runCatching { installer.downloadApk(apkUrl) }
.onFailure { e -> .onFailure { e ->
val why = ErrorCopy.fromThrowable(e)
internal.update { internal.update {
it.copy( it.copy(
isInstalling = false, installStage = InstallStage.ERROR,
installMessage = "Couldn't download update: $why", installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
) )
} }
} }
} .getOrNull()
}
} }
@@ -58,6 +58,8 @@ import com.fabledsword.minstrel.nav.ServerUrl
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.theme.ThemeMode import com.fabledsword.minstrel.theme.ThemeMode
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.isBusy
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -381,7 +383,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
UpdateCheckLine(result = state.result) UpdateCheckLine(result = state.result)
Button( Button(
onClick = viewModel::checkForUpdates, onClick = viewModel::checkForUpdates,
enabled = !state.isChecking && !state.isInstalling, enabled = !state.isChecking && !state.installStage.isBusy(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (state.isChecking) { if (state.isChecking) {
@@ -393,7 +395,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
if (available != null) { if (available != null) {
InstallButton( InstallButton(
version = available.info.version, version = available.info.version,
isInstalling = state.isInstalling, stage = state.installStage,
onClick = { viewModel.install(available.info) }, onClick = { viewModel.install(available.info) },
) )
} }
@@ -407,16 +409,22 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
} }
@Composable @Composable
private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) { private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
Button( Button(
onClick = onClick, onClick = onClick,
enabled = !isInstalling, enabled = !stage.isBusy(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (isInstalling) { if (stage.isBusy()) {
ButtonSpinner() ButtonSpinner()
} }
Text(if (isInstalling) "Downloading…" else "Install $version") Text(
when (stage) {
InstallStage.DOWNLOADING -> "Downloading…"
InstallStage.INSTALLING -> "Installing…"
else -> "Install $version"
},
)
} }
} }
@@ -5,7 +5,6 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -17,27 +16,26 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val APK_FILENAME = "minstrel-update.apk" private const val APK_FILENAME = "minstrel-update.apk"
private const val APK_MIME = "application/vnd.android.package-archive"
/** /**
* Downloads the server-bundled APK and hands it to Android's package * Downloads the server-bundled APK and installs it over ourselves.
* installer. Mirrors Flutter's `update/installer.dart` — the native
* side that the Flutter MethodChannel delegated to.
* *
* The download goes through the shared [OkHttpClient] so it inherits * The download goes through the shared [OkHttpClient] so it inherits
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is * the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
* server-relative, e.g. `/api/client/apk`). The APK lands in the * server-relative, e.g. `/api/client/apk`). The APK lands in the
* cache dir, exposed to the system installer via the app's * cache dir; [SelfUpdateSession] streams it from there into a
* FileProvider content:// URI. * [android.content.pm.PackageInstaller] session.
* *
* On Android O+ the user must have granted "install unknown apps" * On Android O+ the user must have granted "install unknown apps"
* for Minstrel; [canInstall] reports it and [requestInstallPermission] * for Minstrel; [canInstall] reports it and [requestInstallPermission]
* opens the relevant settings screen. * opens the relevant settings screen. That grant is still required with
* the session API — silent *updates* don't imply silent *permission*.
*/ */
@Singleton @Singleton
class ApkInstaller @Inject constructor( class ApkInstaller @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val session: SelfUpdateSession,
) { ) {
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) { suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
val request = Request.Builder() val request = Request.Builder()
@@ -61,19 +59,13 @@ class ApkInstaller @Inject constructor(
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
context.packageManager.canRequestPackageInstalls() context.packageManager.canRequestPackageInstalls()
/** Hand the downloaded APK to the system installer's confirm dialog. */ /**
fun launchInstall(apk: File) { * Install [apk] over ourselves, suspending until the platform decides.
val uri: Uri = FileProvider.getUriForFile( *
context, * Note for callers: on a successful silent install this never returns —
"${context.packageName}.fileprovider", * the process is replaced. Don't treat the absence of a verdict as failure.
apk, */
) suspend fun install(apk: File): InstallOutcome = session.run(apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, APK_MIME)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
/** Open the "install unknown apps" settings page for Minstrel. */ /** Open the "install unknown apps" settings page for Minstrel. */
fun requestInstallPermission() { fun requestInstallPermission() {
@@ -0,0 +1,68 @@
package com.fabledsword.minstrel.update.data
/**
* Terminal verdict from the platform on a self-update install (#2438).
*
* The old `ACTION_VIEW` handoff had no verdict at all — we fired an intent and
* assumed. A [PackageInstaller][android.content.pm.PackageInstaller] session
* reports back, so "declined" and "failed" stop looking identical.
*/
sealed interface InstallOutcome {
/**
* The platform completed the install.
*
* Rarely observed on a self-update: our process is replaced the moment the
* new APK lands, so the coroutine awaiting this usually dies before it
* resumes. Modelled anyway — silently relying on being killed would make
* the success path invisible to anyone reading this.
*/
data object Installed : InstallOutcome
/** The user declined the platform's confirm dialog. Not an error. */
data object Cancelled : InstallOutcome
/** The platform refused. [reason] is its own message, where it gave one. */
data class Failed(val reason: String?) : InstallOutcome
}
/**
* Where an install has got to, for the two surfaces that show it: the shell's
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
* Settings About card.
*
* DOWNLOADING and INSTALLING are deliberately distinct. They used to be one
* state because the install half was fire-and-forget and took no time from our
* side; now that we await the platform's verdict, collapsing them would leave
* the UI claiming "Downloading…" through an install that can sit on a confirm
* dialog indefinitely.
*/
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
/** True while an install is underway and a second tap should do nothing. */
fun InstallStage.isBusy(): Boolean =
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
/**
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
* rather than ERROR — the user chose it, so presenting it as a failure would
* be a lie with a red tint.
*/
fun InstallOutcome.stage(): InstallStage = when (this) {
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
is InstallOutcome.Failed -> InstallStage.ERROR
}
/**
* User-facing copy for an outcome; null when there is nothing worth saying.
*
* Lives beside the outcome rather than in either UI package because two
* separate screens surface the same verdicts and must not drift — the same
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
* outside the UI layer.
*/
fun InstallOutcome.message(): String? = when (this) {
InstallOutcome.Installed -> null
InstallOutcome.Cancelled -> "Update cancelled."
is InstallOutcome.Failed -> reason?.let { "Couldn't install update: $it" }
?: "Couldn't install update."
}
@@ -0,0 +1,220 @@
package com.fabledsword.minstrel.update.data
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.IntentSender
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
private const val STAGED_APK_NAME = "minstrel-update"
/** Whole-file write: openWrite takes a Long offset, and Kotlin won't widen 0. */
private const val WRITE_FROM_START = 0L
/** Our own broadcast, delivered by the platform via the session's IntentSender. */
private const val RESULT_ACTION = "com.fabledsword.minstrel.INSTALL_RESULT"
/**
* Installs an APK over ourselves through a [PackageInstaller] session (#2438).
*
* Split from [ApkInstaller] because the two halves are different work — one
* speaks HTTP, the other speaks to the package manager — and the session half
* carries a receiver, a PendingIntent and version-gated params that would
* crowd the downloader out of its own file.
*
* ## Why a session, rather than the ACTION_VIEW intent this replaced
*
* Two reasons, and the second is the one that matters to users.
*
* The old path fired `ACTION_VIEW` at an `application/vnd.android.package-archive`
* URI and hoped. It could not report an outcome, so a failed install and a
* user who declined looked identical — see [InstallOutcome].
*
* More importantly, a session is where the platform lets a self-updater say it
* is one. [PackageInstaller.SessionParams.setRequireUserAction] with
* `USER_ACTION_NOT_REQUIRED`, paired with the `UPDATE_PACKAGES_WITHOUT_USER_ACTION`
* manifest permission, is the sanctioned way to update with **no dialog at
* all**. The platform grants that when all of: the installer opts in (here),
* the installed app targets API 29+ (we're on 36), the installer holds the
* permission (we do), and the target is the installer itself or something it
* first installed (we are updating ourselves). All four hold.
*
* ## What is deliberately absent
*
* No `setRequestUpdateOwnership(true)`. It reads like the right declaration for
* a self-updater and it is not: ownership can only be claimed on **initial**
* installation — setting it on an update is documented as a no-op — and it also
* wants the privileged `ENFORCE_UPDATE_OWNERSHIP` permission. It exists for app
* stores claiming the apps they install, not for an app updating itself.
*/
@Singleton
class SelfUpdateSession @Inject constructor(
@ApplicationContext private val context: Context,
) {
/**
* Stage [apk] and hand it to the platform, suspending until a terminal
* verdict arrives.
*
* Never returns on the happy path when the install is silent: the platform
* replaces this process the moment the new APK lands, so the coroutine dies
* rather than resuming. Callers must treat that as success, not a hang.
*/
suspend fun run(apk: File): InstallOutcome {
val staged = withContext(Dispatchers.IO) { runCatching { stage(apk) } }
return staged.fold(
onSuccess = { sessionId -> awaitCommit(sessionId) },
onFailure = { InstallOutcome.Failed(it.message) },
)
}
/** Open a session, stream the APK in, return the session id. */
private fun stage(apk: File): Int {
val installer = context.packageManager.packageInstaller
val sessionId = installer.createSession(newParams())
installer.openSession(sessionId).use { session ->
session.openWrite(STAGED_APK_NAME, WRITE_FROM_START, apk.length()).use { sink ->
apk.inputStream().use { source -> source.copyTo(sink) }
// fsync before the session closes: the platform validates the
// staged bytes at commit, and buffered tail bytes read as a
// truncated APK.
session.fsync(sink)
}
}
return sessionId
}
// Explicit `params.` receivers rather than an apply {} block: lintVitalRelease
// runs on assembleRelease, and NewApi is easier for it to reason about when
// the guarded call has a named receiver instead of an implicit one.
private fun newParams(): PackageInstaller.SessionParams {
val params = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
)
params.setAppPackageName(context.packageName)
params.setInstallReason(PackageManager.INSTALL_REASON_USER)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// The whole point of this class. Pre-S there is no such API, so the
// confirm dialog is unavoidable there — degrade, don't fail.
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
return params
}
/**
* Commit the session and wait for the platform to report back.
*
* A pending-user-action status is *not* terminal — the platform is asking us
* to show its dialog, and the real verdict arrives in a second broadcast
* once the user decides. So the receiver stays registered across it.
*/
private suspend fun awaitCommit(sessionId: Int): InstallOutcome =
suspendCancellableCoroutine { continuation ->
val installer = context.packageManager.packageInstaller
val receiver = object : BroadcastReceiver() {
override fun onReceive(unused: Context, intent: Intent) {
val status = intent.getIntExtra(
PackageInstaller.EXTRA_STATUS,
PackageInstaller.STATUS_FAILURE,
)
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
confirmWithUser(intent)
} else {
context.unregisterReceiver(this)
val why = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
if (continuation.isActive) continuation.resume(outcomeOf(status, why))
}
}
}
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(RESULT_ACTION),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
continuation.invokeOnCancellation {
// Stop listening, but deliberately do NOT abandon the session.
// Cancellation here means our caller's scope died — the user
// navigated away, or the VM cleared — and by this point the
// session is already committed. The user asked for this install;
// killing it because nobody is watching the banner any more
// would be the wrong reading of their intent.
runCatching { context.unregisterReceiver(receiver) }
}
runCatching {
installer.openSession(sessionId).use { it.commit(resultSender(sessionId)) }
}.onFailure { error ->
// Resuming normally means invokeOnCancellation never fires, so
// clean up the staged session here or it sits until it expires.
runCatching { context.unregisterReceiver(receiver) }
runCatching { installer.abandonSession(sessionId) }
if (continuation.isActive) {
continuation.resume(InstallOutcome.Failed(error.message))
}
}
}
private fun resultSender(sessionId: Int): IntentSender {
// Scoped to our own package so the broadcast can't be answered elsewhere.
val intent = Intent(RESULT_ACTION).setPackage(context.packageName)
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// The platform writes its status extras into this intent, so it has
// to stay mutable — FLAG_IMMUTABLE would arrive with none of them.
flags = flags or PendingIntent.FLAG_MUTABLE
}
// Session id as the request code keeps concurrent sessions from
// colliding on FLAG_UPDATE_CURRENT.
return PendingIntent.getBroadcast(context, sessionId, intent, flags).intentSender
}
/**
* Show the platform's own confirm dialog, which arrives as an extra.
*
* The system-app check is not ceremony. Below API 34 a dynamically
* registered receiver cannot declare itself unexported, so another app on
* the device can broadcast [RESULT_ACTION] at us — and calling
* `startActivity` on an attacker-supplied extra would hand it whatever we
* can reach. The genuine confirm activity belongs to the platform
* installer, so demanding a system component costs the real path nothing.
*/
private fun confirmWithUser(result: Intent) {
val pending = IntentCompat.getParcelableExtra(
result,
Intent.EXTRA_INTENT,
Intent::class.java,
) ?: return
if (isPlatformActivity(pending)) {
context.startActivity(pending.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
private fun isPlatformActivity(intent: Intent): Boolean {
val flags = intent.resolveActivityInfo(context.packageManager, 0)
?.applicationInfo
?.flags
?: 0
val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
return (flags and systemFlags) != 0
}
private fun outcomeOf(status: Int, message: String?): InstallOutcome = when (status) {
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Installed
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
else -> InstallOutcome.Failed(message)
}
}
@@ -28,6 +28,8 @@ import com.composables.icons.lucide.Download
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.UpdateInfo import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.isBusy
/** /**
* Shell-level soft banner that nudges an available update. Renders * Shell-level soft banner that nudges an available update. Renders
@@ -79,7 +81,7 @@ private fun BannerBody(
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp), .padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
) { ) {
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss) BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
if (stage == InstallStage.DOWNLOADING) { if (stage.isBusy()) {
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -124,8 +126,17 @@ private fun BannerRow(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) { TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install") // Downloading and installing are separate words because they're now
// separate waits — the install half suspends on the platform, which
// may be sitting on a confirm dialog.
Text(
when (stage) {
InstallStage.DOWNLOADING -> "Downloading…"
InstallStage.INSTALLING -> "Installing…"
else -> "Install"
},
)
} }
IconButton(onClick = onDismiss) { IconButton(onClick = onDismiss) {
Icon( Icon(
@@ -5,7 +5,11 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateBannerController import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -13,13 +17,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L private const val SHARE_STOP_TIMEOUT_MS = 5_000L
/** Install lifecycle for the banner's Install button. */
enum class InstallStage { IDLE, DOWNLOADING, ERROR }
data class UpdateBannerUiState( data class UpdateBannerUiState(
val info: UpdateInfo? = null, val info: UpdateInfo? = null,
val stage: InstallStage = InstallStage.IDLE, val stage: InstallStage = InstallStage.IDLE,
@@ -28,9 +30,9 @@ data class UpdateBannerUiState(
/** /**
* Thin VM over [UpdateBannerController]. Surfaces the available update * Thin VM over [UpdateBannerController]. Surfaces the available update
* and runs the download → system-install handoff via [ApkInstaller], * and runs the download → install handoff via [ApkInstaller], mirroring
* mirroring the About card's flow (route to "install unknown apps" * the About card's flow (route to "install unknown apps" settings first
* settings first when the permission is missing). * when the permission is missing).
*/ */
@HiltViewModel @HiltViewModel
class UpdateBannerViewModel @Inject constructor( class UpdateBannerViewModel @Inject constructor(
@@ -38,7 +40,7 @@ class UpdateBannerViewModel @Inject constructor(
private val installer: ApkInstaller, private val installer: ApkInstaller,
) : ViewModel() { ) : ViewModel() {
private val installState = MutableStateFlow(IdleInstall) private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
val uiState: StateFlow<UpdateBannerUiState> = val uiState: StateFlow<UpdateBannerUiState> =
combine(controller.available, installState) { info, install -> combine(controller.available, installState) { info, install ->
@@ -52,7 +54,7 @@ class UpdateBannerViewModel @Inject constructor(
fun dismiss(version: String) = controller.dismiss(version) fun dismiss(version: String) = controller.dismiss(version)
fun install(info: UpdateInfo) { fun install(info: UpdateInfo) {
if (installState.value.stage == InstallStage.DOWNLOADING) return if (installState.value.stage.isBusy()) return
if (!installer.canInstall()) { if (!installer.canInstall()) {
installer.requestInstallPermission() installer.requestInstallPermission()
installState.value = InstallSnapshot( installState.value = InstallSnapshot(
@@ -63,21 +65,28 @@ class UpdateBannerViewModel @Inject constructor(
} }
viewModelScope.launch { viewModelScope.launch {
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null) installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
runCatching { installer.downloadApk(info.apkUrl) } val apk = download(info.apkUrl)
.onSuccess { apk -> if (apk != null) {
installer.launchInstall(apk) // Await the platform's verdict rather than firing an intent and
installState.value = IdleInstall // assuming it worked. On a silent install this suspends until
// the process is replaced, so the line below is only reached
// when the install did NOT simply succeed.
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
val outcome = installer.install(apk)
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
} }
}
}
private suspend fun download(apkUrl: String): File? =
runCatching { installer.downloadApk(apkUrl) }
.onFailure { e -> .onFailure { e ->
installState.value = InstallSnapshot( installState.value = InstallSnapshot(
InstallStage.ERROR, InstallStage.ERROR,
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}", "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
) )
} }
} .getOrNull()
}
} }
private data class InstallSnapshot(val stage: InstallStage, val message: String?) private data class InstallSnapshot(val stage: InstallStage, val message: String?)
private val IdleInstall = InstallSnapshot(InstallStage.IDLE, null)
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- The downloaded update APK lives in the app cache dir; the
FileProvider exposes just that directory to the system
installer via a content:// URI. -->
<cache-path
name="updates"
path="." />
</paths>