feat(android): in-app APK update install (audit v3 #25)

Wires the "Install vX.Y.Z" action the About card's update-check
teed up.

* ApkInstaller @Singleton — downloads the server APK via the shared
  OkHttpClient (inherits auth cookie + BaseUrlInterceptor host
  rewrite; apkUrl is server-relative) into cacheDir, then launches
  the system installer via a FileProvider content:// URI. canInstall()
  gates on PackageManager.canRequestPackageInstalls() on O+, and
  requestInstallPermission() opens the "install unknown apps" settings
  page when not yet granted.
* Manifest: REQUEST_INSTALL_PACKAGES permission + FileProvider
  (${applicationId}.fileprovider) + res/xml/file_paths.xml exposing
  the cache dir.
* AboutCardViewModel.install(info): permission check → download →
  launch, with isInstalling + installMessage state. Errors routed
  through ErrorCopy.
* About card shows an "Install vX.Y.Z" button under the check button
  when an update is available, "Downloading…" while in flight, and
  the install message line. Extracted UpdateControls / InstallButton /
  ButtonSpinner helpers to keep AboutCard under the length cap;
  added @file:Suppress(TooManyFunctions) for the settings-card density.

Closes audit v3 #25 — the last open parity item from the v3 sweep
aside from the offline-pool Home cards (#28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 01:18:49 -04:00
parent 5ce9ca9e62
commit 8f5635c489
5 changed files with 206 additions and 24 deletions
+11
View File
@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".MinstrelApplication"
@@ -39,5 +40,15 @@
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -3,7 +3,9 @@ package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.UpdateRepository
import com.fabledsword.minstrel.update.data.isVersionNewer
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -18,8 +20,8 @@ import javax.inject.Inject
* One of three terminal states the Check-for-updates button surfaces.
* `Idle` is the pre-check state; `Latest` means the installed build
* matches or exceeds the server's bundled APK; `UpdateAvailable`
* means the server has a newer build (rendered as "Install vX.Y.Z"
* in slice 2 of #25 — for now it just shows the version label).
* surfaces an "Install vX.Y.Z" button that downloads + launches the
* system installer via [ApkInstaller].
*/
sealed interface UpdateCheckResult {
data object Idle : UpdateCheckResult
@@ -31,17 +33,24 @@ sealed interface UpdateCheckResult {
data class AboutUiState(
val installedVersion: String = BuildConfig.VERSION_NAME,
val isChecking: Boolean = false,
val isInstalling: Boolean = false,
val installMessage: String? = null,
val result: UpdateCheckResult = UpdateCheckResult.Idle,
)
/**
* Backs the About card's "Check for updates" button. Calls
* [UpdateRepository.getLatest] on tap, compares versus the build's
* Backs the About card's update controls. "Check for updates" calls
* [UpdateRepository.getLatest], compares versus the build's
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
* When an update is available, [install] downloads the APK via
* [ApkInstaller] and hands it to the system installer — routing the
* user to the "install unknown apps" settings page first when that
* permission hasn't been granted.
*/
@HiltViewModel
class AboutCardViewModel @Inject constructor(
private val repository: UpdateRepository,
private val installer: ApkInstaller,
) : ViewModel() {
private val internal = MutableStateFlow(AboutUiState())
@@ -50,7 +59,7 @@ class AboutCardViewModel @Inject constructor(
fun checkForUpdates() {
if (internal.value.isChecking) return
viewModelScope.launch {
internal.update { it.copy(isChecking = true) }
internal.update { it.copy(isChecking = true, installMessage = null) }
val installed = internal.value.installedVersion
val result = runCatching { repository.getLatest() }
.map { latest ->
@@ -60,10 +69,36 @@ class AboutCardViewModel @Inject constructor(
UpdateCheckResult.Latest
}
}
.getOrElse { e ->
UpdateCheckResult.Error(e.message ?: "Couldn't reach server")
}
.getOrElse { e -> UpdateCheckResult.Error(ErrorCopy.fromThrowable(e)) }
internal.update { it.copy(isChecking = false, result = result) }
}
}
fun install(info: UpdateInfo) {
if (internal.value.isInstalling) return
if (!installer.canInstall()) {
installer.requestInstallPermission()
internal.update {
it.copy(installMessage = "Allow installs for Minstrel, then tap Install again.")
}
return
}
viewModelScope.launch {
internal.update { it.copy(isInstalling = true, installMessage = null) }
runCatching { installer.downloadApk(info.apkUrl) }
.onSuccess { apk ->
installer.launchInstall(apk)
internal.update { it.copy(isInstalling = false) }
}
.onFailure { e ->
val why = ErrorCopy.fromThrowable(e)
internal.update {
it.copy(
isInstalling = false,
installMessage = "Couldn't download update: $why",
)
}
}
}
}
}
@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions") // Settings screen + per-card helper composables
package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.clickable
@@ -326,26 +328,65 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
UpdateCheckLine(result = state.result)
Button(
onClick = viewModel::checkForUpdates,
enabled = !state.isChecking,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isChecking) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.size(8.dp))
}
Text(if (state.isChecking) "Checking…" else "Check for updates")
}
UpdateControls(state = state, viewModel = viewModel)
}
}
}
@Composable
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
UpdateCheckLine(result = state.result)
Button(
onClick = viewModel::checkForUpdates,
enabled = !state.isChecking && !state.isInstalling,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isChecking) {
ButtonSpinner()
}
Text(if (state.isChecking) "Checking…" else "Check for updates")
}
val available = state.result as? UpdateCheckResult.UpdateAvailable
if (available != null) {
InstallButton(
version = available.info.version,
isInstalling = state.isInstalling,
onClick = { viewModel.install(available.info) },
)
}
if (state.installMessage != null) {
Text(
text = state.installMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = !isInstalling,
modifier = Modifier.fillMaxWidth(),
) {
if (isInstalling) {
ButtonSpinner()
}
Text(if (isInstalling) "Downloading…" else "Install $version")
}
}
@Composable
private fun ButtonSpinner() {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.size(8.dp))
}
@Composable
private fun UpdateCheckLine(result: UpdateCheckResult) {
val text = when (result) {
@@ -0,0 +1,86 @@
package com.fabledsword.minstrel.update.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
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
* 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 auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
* server-relative, e.g. `/api/client/apk`). The APK lands in the
* cache dir, exposed to the system installer via the app's
* FileProvider content:// URI.
*
* On Android O+ the user must have granted "install unknown apps"
* for Minstrel; [canInstall] reports it and [requestInstallPermission]
* opens the relevant settings screen.
*/
@Singleton
class ApkInstaller @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
) {
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("http://placeholder.invalid$apkUrl")
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Download failed: HTTP ${response.code}")
}
val body = response.body ?: throw IOException("Empty download body")
val file = File(context.cacheDir, APK_FILENAME)
body.byteStream().use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
file
}
}
/** True when the system will accept an install intent without a permission detour. */
fun canInstall(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
context.packageManager.canRequestPackageInstalls()
/** Hand the downloaded APK to the system installer's confirm dialog. */
fun launchInstall(apk: File) {
val uri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
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. */
fun requestInstallPermission() {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
}
@@ -0,0 +1,9 @@
<?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>