Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix #119
@@ -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
|
||||||
|
|||||||
+33
-17
@@ -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(
|
||||||
|
|||||||
+25
-16
@@ -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>
|
|
||||||
Reference in New Issue
Block a user