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 @@
+
+
+
+
+