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" />
|
||||||
<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" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MinstrelApplication"
|
android:name=".MinstrelApplication"
|
||||||
@@ -39,5 +40,15 @@
|
|||||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
+43
-8
@@ -3,7 +3,9 @@ package com.fabledsword.minstrel.settings.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
|
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.UpdateRepository
|
import com.fabledsword.minstrel.update.data.UpdateRepository
|
||||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
import com.fabledsword.minstrel.update.data.isVersionNewer
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.
|
* 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`
|
||||||
* means the server has a newer build (rendered as "Install vX.Y.Z"
|
* surfaces an "Install vX.Y.Z" button that downloads + launches the
|
||||||
* in slice 2 of #25 — for now it just shows the version label).
|
* system installer via [ApkInstaller].
|
||||||
*/
|
*/
|
||||||
sealed interface UpdateCheckResult {
|
sealed interface UpdateCheckResult {
|
||||||
data object Idle : UpdateCheckResult
|
data object Idle : UpdateCheckResult
|
||||||
@@ -31,17 +33,24 @@ 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 installMessage: String? = null,
|
||||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backs the About card's "Check for updates" button. Calls
|
* Backs the About card's update controls. "Check for updates" calls
|
||||||
* [UpdateRepository.getLatest] on tap, 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
|
||||||
|
* [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
|
@HiltViewModel
|
||||||
class AboutCardViewModel @Inject constructor(
|
class AboutCardViewModel @Inject constructor(
|
||||||
private val repository: UpdateRepository,
|
private val repository: UpdateRepository,
|
||||||
|
private val installer: ApkInstaller,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(AboutUiState())
|
private val internal = MutableStateFlow(AboutUiState())
|
||||||
@@ -50,7 +59,7 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
fun checkForUpdates() {
|
fun checkForUpdates() {
|
||||||
if (internal.value.isChecking) return
|
if (internal.value.isChecking) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
internal.update { it.copy(isChecking = true) }
|
internal.update { it.copy(isChecking = true, installMessage = null) }
|
||||||
val installed = internal.value.installedVersion
|
val installed = internal.value.installedVersion
|
||||||
val result = runCatching { repository.getLatest() }
|
val result = runCatching { repository.getLatest() }
|
||||||
.map { latest ->
|
.map { latest ->
|
||||||
@@ -60,10 +69,36 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
UpdateCheckResult.Latest
|
UpdateCheckResult.Latest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.getOrElse { e ->
|
.getOrElse { e -> UpdateCheckResult.Error(ErrorCopy.fromThrowable(e)) }
|
||||||
UpdateCheckResult.Error(e.message ?: "Couldn't reach server")
|
|
||||||
}
|
|
||||||
internal.update { it.copy(isChecking = false, result = result) }
|
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
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -326,26 +328,65 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
UpdateCheckLine(result = state.result)
|
UpdateControls(state = state, viewModel = viewModel)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Composable
|
||||||
private fun UpdateCheckLine(result: UpdateCheckResult) {
|
private fun UpdateCheckLine(result: UpdateCheckResult) {
|
||||||
val text = when (result) {
|
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