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

@@ -128,7 +128,6 @@ private fun appEntryProvider(
entry<SettingsAbout> {
SettingsAboutPage(
onBack = { NavigationController.goBack() },
viewModel = viewModel { SettingsViewModel() },
)
}
@@ -24,4 +24,8 @@ data class ServerStoreState(
// App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system",
// App version the silent update check (issue #867) last completed for.
// Null / mismatched with BuildConfig.VERSION_NAME → the About tab runs
// its one-time check again (re-check on app version bump).
val updateCheckDoneForVersion: String? = null,
)
@@ -623,4 +623,13 @@ object AuthManager {
fun setTypingEffectDelayMs(delayMs: Int) {
serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
}
// ── In-app update check (issue #867) ─────────────────────────────────
/** App version the silent update check last completed for (null = never). */
fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion
fun setUpdateCheckDoneForVersion(version: String) {
serverStore.update { it.copy(updateCheckDoneForVersion = version) }
}
}
@@ -0,0 +1,143 @@
package com.m57.hermescontrol.data.update
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
/**
* Latest release metadata from the GitHub releases API (issue #867) — the
* in-app self-update source. Fields map to the JSON via the app's shared
* snake_case [OkHttpProvider.json].
*/
@Serializable
data class UpdateInfo(
val tagName: String = "",
val assets: List<Asset> = emptyList(),
) {
@Serializable
data class Asset(
val name: String = "",
val size: Long = 0L,
val browserDownloadUrl: String = "",
)
/** The release APK asset — release.yml ships `hermes-mobile-<tag>.apk`. */
val apkAsset: Asset?
get() = assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) }
}
/** Strip a leading 'v' from a release tag ("v1.21.0" → "1.21.0"). */
fun normalizedVersion(tag: String): String = tag.trim().removePrefix("v")
/**
* True when [latest] is strictly newer than [current]. Numeric dot-segment
* comparison ("1.21.0" > "1.2"). Non-numeric suffixes (e.g. "1.0-dev")
* compare as 0; an unparseable version never claims an update exists.
*/
fun isNewerVersion(
latest: String,
current: String,
): Boolean {
val a = versionSegments(latest) ?: return false
val b = versionSegments(current) ?: return false
for (i in 0 until maxOf(a.size, b.size)) {
val x = a.getOrElse(i) { 0 }
val y = b.getOrElse(i) { 0 }
if (x != y) return x > y
}
return false
}
private fun versionSegments(version: String): List<Int>? {
val cleaned = normalizedVersion(version)
if (cleaned.isBlank()) return null
return cleaned.split('.').map { segment ->
// Leading numeric portion of each segment: "0-dev" → 0, "21" → 21.
segment.takeWhile { it.isDigit() }.toIntOrNull() ?: 0
}
}
/**
* Talks to the GitHub releases API and downloads the release APK (issue
* #867). Network methods are `open` so tests can fake them; the pure logic
* ([isNewerVersion], [parseUpdateInfo]) lives top-level for direct unit
* tests.
*/
open class AppUpdateChecker(
private val client: OkHttpClient = OkHttpProvider.base,
) {
/**
* Fetch the latest release metadata. Returns null when there is no
* release yet (404) or the body can't be parsed. Throws [IOException]
* on network failure so the caller can surface a friendly error.
*/
open suspend fun fetchLatestRelease(): UpdateInfo? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("https://api.github.com/repos/Hy4ri/hermes-mobile/releases/latest")
.header("Accept", "application/vnd.github+json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
val body = response.body?.string().orEmpty()
parseUpdateInfo(body)
}
}
/**
* Stream a release APK asset to [dest], reporting progress 0..1 via
* [onProgress]. True on success; false (with the partial file deleted)
* on any HTTP or I/O failure.
*/
open suspend fun downloadApk(
url: String,
dest: File,
onProgress: (Float) -> Unit,
): Boolean =
withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext false
val body = response.body ?: return@withContext false
val total = body.contentLength()
dest.outputStream().use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var read = 0L
body.byteStream().use { input ->
while (true) {
val n = input.read(buffer)
if (n == -1) break
out.write(buffer, 0, n)
read += n
if (total > 0) {
onProgress((read.toFloat() / total.toFloat()).coerceIn(0f, 1f))
}
}
}
}
true
}
} catch (e: Exception) {
dest.delete()
false
}
}
private companion object {
const val DEFAULT_BUFFER_SIZE = 8192
}
}
/** Parse a GitHub `releases/latest` JSON body into [UpdateInfo], or null. */
fun parseUpdateInfo(json: String): UpdateInfo? =
runCatching {
OkHttpProvider.json.decodeFromString<UpdateInfo>(json)
}.getOrNull()
@@ -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,
),
)
}