From 8f5635c489964707bc48a103106a2f1d96cc63eb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 01:18:49 -0400 Subject: [PATCH] feat(android): in-app APK update install (audit v3 #25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- android/app/src/main/AndroidManifest.xml | 11 +++ .../settings/ui/AboutCardViewModel.kt | 51 +++++++++-- .../minstrel/settings/ui/SettingsScreen.kt | 73 ++++++++++++---- .../minstrel/update/data/ApkInstaller.kt | 86 +++++++++++++++++++ android/app/src/main/res/xml/file_paths.xml | 9 ++ 5 files changed, 206 insertions(+), 24 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt create mode 100644 android/app/src/main/res/xml/file_paths.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7eaeb5d2..fec18bc4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + + + + + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt index 98a38aff..d6baff99 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt @@ -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", + ) + } + } + } + } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt index 15c3ba24..93e80afa 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt @@ -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) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt new file mode 100644 index 00000000..b27f6cfd --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt @@ -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) + } +} diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..3a881b06 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,9 @@ + + + + +