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:
@@ -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>
|
||||
|
||||
+43
-8
@@ -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>
|
||||
Reference in New Issue
Block a user