feat(settings): in-app update from About tab (issue #867) (#875)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-10 20:02:42 +03:00
1 parent a9c3cd0691
commit f931565c0d
12 files changed
+894 -4

No files matched your search

@@ -0,0 +1,190 @@
package com.m57.hermescontrol.ui.settings
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.update.AppUpdateChecker
import com.m57.hermescontrol.data.update.isNewerVersion
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.IOException
/** UI state of the About-tab in-app updater (issue #867). */
sealed interface AppUpdateState {
/** Nothing shown yet (no check run in this session). */
data object Idle : AppUpdateState
/** A check is in flight. */
data object Checking : AppUpdateState
/** Latest release equals the installed version. */
data class UpToDate(val latestTag: String) : AppUpdateState
/** A newer release with an APK asset exists. */
data class UpdateAvailable(
val latestTag: String,
val apkUrl: String,
val sizeBytes: Long,
) : AppUpdateState
/** APK download in progress; [progress] is 0..1. */
data class Downloading(val progress: Float) : AppUpdateState
/** Download finished and the system package installer was launched. */
data class Installing(val latestTag: String) : AppUpdateState
/** Install-from-unknown-sources not granted for this app yet. */
data object NeedsUnknownSourcesPermission : AppUpdateState
data class Error(val message: String) : AppUpdateState
}
/**
* Drives the in-app self-update flow (issue #867): check the GitHub
* releases API, download the release APK, and hand it to the system package
* installer. The installer launch uses the application context
* (FLAG_ACTIVITY_NEW_TASK), so no Activity is required.
*
* A silent check runs once per installed version (guarded by the
* `updateCheckDoneForVersion` app pref) — that's what puts the "Update
* available" badge on the About row without the user tapping anything.
*/
class AppUpdateViewModel(
application: Application,
private val checker: AppUpdateChecker = AppUpdateChecker(),
private val currentVersion: String = BuildConfig.VERSION_NAME,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val installIntentFactory: (Uri) -> Intent = ::buildInstallIntent,
) : AndroidViewModel(application) {
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
init {
// Silent first-launch check: once per installed version. Marked done
// after the attempt completes (success or failure) so a failed check
// can retry on the next open, but a dead network can't spam the API
// on every visit.
if (AuthManager.getUpdateCheckDoneForVersion() != currentVersion) {
checkForUpdate()
}
}
/** Manual check from the About row. */
fun checkForUpdate() {
if (_state.value is AppUpdateState.Checking) return
_state.value = AppUpdateState.Checking
viewModelScope.launch(ioDispatcher) {
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
val result =
try {
checker.fetchLatestRelease()
} catch (e: IOException) {
_state.value = AppUpdateState.Error(NETWORK_ERROR)
return@launch
} catch (e: Exception) {
_state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR)
return@launch
}
val info =
result ?: run {
_state.value = AppUpdateState.Error(NO_RELEASE_ERROR)
return@launch
}
val apk =
info.apkAsset ?: run {
_state.value = AppUpdateState.Error(NO_APK_ERROR)
return@launch
}
_state.value =
if (isNewerVersion(info.tagName, currentVersion)) {
AppUpdateState.UpdateAvailable(
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
}
}
}
/** Download the release APK and launch the system installer. */
fun startUpdate() {
val available = _state.value as? AppUpdateState.UpdateAvailable ?: return
if (!canRequestInstalls()) {
_state.value = AppUpdateState.NeedsUnknownSourcesPermission
return
}
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
_state.value = AppUpdateState.Downloading(0f)
viewModelScope.launch(ioDispatcher) {
val downloaded =
checker.downloadApk(available.apkUrl, dest) { progress ->
_state.value = AppUpdateState.Downloading(progress)
}
if (!downloaded) {
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
return@launch
}
_state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest)
}
}
private fun canRequestInstalls(): Boolean =
try {
getApplication<Application>().packageManager.canRequestPackageInstalls()
} catch (e: Exception) {
false
}
private fun launchInstaller(apkFile: File) {
try {
val context = getApplication<Application>()
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile,
)
context.startActivity(installIntentFactory(uri))
} catch (e: Exception) {
_state.value = AppUpdateState.Error(INSTALLER_ERROR)
}
}
private companion object {
const val APK_FILE_NAME = "hermes-update.apk"
val NETWORK_ERROR = "Network error — check your connection"
val GENERIC_CHECK_ERROR = "Couldn't check for updates"
val NO_RELEASE_ERROR = "No release found yet"
val NO_APK_ERROR = "Release has no APK asset"
val DOWNLOAD_ERROR = "Download failed — tap to retry"
val INSTALLER_ERROR = "Couldn't open the installer"
}
}
/** MIME type of an installable APK (issue #867). */
internal const val APK_MIME = "application/vnd.android.package-archive"
/**
* The system-installer intent for a downloaded APK: ACTION_VIEW with the
* package-archive mime, a read grant for the FileProvider URI, and
* NEW_TASK (launched from the application context).
*/
internal fun buildInstallIntent(uri: Uri): Intent =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, APK_MIME)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
@@ -1,5 +1,8 @@
package com.m57.hermescontrol.ui.settings
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -19,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -195,8 +199,10 @@ internal fun SettingsBehaviorPage(
@Composable
internal fun SettingsAboutPage(
onBack: () -> Unit,
viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
viewModel: AppUpdateViewModel = viewModel(),
) {
val updateState by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
HermesScaffold(
title = { Text(stringResource(R.string.settings_sec_about)) },
navigationIcon = NavIcon.Back(onBack),
@@ -210,7 +216,19 @@ internal fun SettingsAboutPage(
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
AboutSection()
AboutSection(
updateState = updateState,
onCheckUpdate = viewModel::checkForUpdate,
onStartUpdate = viewModel::startUpdate,
onOpenInstallSettings = {
val intent =
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
},
)
}
}
}
@@ -1,11 +1,17 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -14,11 +20,17 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.settings.AppUpdateState
import com.m57.hermescontrol.ui.settings.InfoRow
import com.m57.hermescontrol.ui.settings.SectionCard
@Composable
internal fun AboutSection() {
internal fun AboutSection(
updateState: AppUpdateState = AppUpdateState.Idle,
onCheckUpdate: () -> Unit = {},
onStartUpdate: () -> Unit = {},
onOpenInstallSettings: () -> Unit = {},
) {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -38,6 +50,12 @@ internal fun AboutSection() {
label = stringResource(R.string.settings_about_version),
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
)
UpdateRow(
state = updateState,
onCheckUpdate = onCheckUpdate,
onStartUpdate = onStartUpdate,
onOpenInstallSettings = onOpenInstallSettings,
)
InfoRow(
label = stringResource(R.string.settings_about_build),
value =
@@ -64,3 +82,100 @@ internal fun AboutSection() {
)
}
}
/**
* In-app update row (issue #867): tap to check GitHub releases; when a newer
* release exists, download + install from here. Idle/up-to-date/error states
* are tappable to re-check; actionable states show a labeled button.
*/
@Composable
private fun UpdateRow(
state: AppUpdateState,
onCheckUpdate: () -> Unit,
onStartUpdate: () -> Unit,
onOpenInstallSettings: () -> Unit,
) {
val tappable =
state is AppUpdateState.Idle ||
state is AppUpdateState.UpToDate ||
state is AppUpdateState.Error
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(enabled = tappable) { onCheckUpdate() },
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.settings_about_update),
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Spacer(modifier = Modifier.weight(1f))
when (state) {
is AppUpdateState.Idle -> {
ValueText(stringResource(R.string.settings_about_update_check))
}
is AppUpdateState.Checking -> {
ValueText(stringResource(R.string.settings_about_update_checking))
}
is AppUpdateState.UpToDate -> {
ValueText(stringResource(R.string.settings_about_update_uptodate, state.latestTag))
}
is AppUpdateState.UpdateAvailable -> {
ValueText(stringResource(R.string.settings_about_update_available, state.latestTag))
TextButton(onClick = onStartUpdate) {
Text(stringResource(R.string.settings_about_update_action))
}
}
is AppUpdateState.Downloading -> {
ValueText(
stringResource(
R.string.settings_about_update_downloading,
(state.progress * 100).toInt(),
),
)
LinearProgressIndicator(
progress = { state.progress },
modifier = Modifier.width(80.dp).padding(start = 8.dp),
)
}
is AppUpdateState.Installing -> {
ValueText(stringResource(R.string.settings_about_update_installing, state.latestTag))
}
is AppUpdateState.NeedsUnknownSourcesPermission -> {
ValueText(stringResource(R.string.settings_about_update_allow_sources))
TextButton(onClick = onOpenInstallSettings) {
Text(stringResource(R.string.settings_about_update_open_settings))
}
}
is AppUpdateState.Error -> {
ValueText(state.message)
TextButton(onClick = onCheckUpdate) {
Text(stringResource(R.string.settings_about_update_retry))
}
}
}
}
}
@Composable
private fun ValueText(text: String) {
Text(
text = text,
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
)
}