diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f977425a..bfb9edb9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
{
SettingsAboutPage(
onBack = { NavigationController.goBack() },
- viewModel = viewModel { SettingsViewModel() },
)
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt
index 5ca41990..8616b737 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt
@@ -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,
)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
index 64905294..23d28756 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
@@ -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) }
+ }
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt
new file mode 100644
index 00000000..7131ac38
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt
@@ -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 = emptyList(),
+) {
+ @Serializable
+ data class Asset(
+ val name: String = "",
+ val size: Long = 0L,
+ val browserDownloadUrl: String = "",
+ )
+
+ /** The release APK asset — release.yml ships `hermes-mobile-.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? {
+ 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(json)
+ }.getOrNull()
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt
new file mode 100644
index 00000000..bb915a9f
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt
@@ -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.Idle)
+ val state: StateFlow = _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().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().packageManager.canRequestPackageInstalls()
+ } catch (e: Exception) {
+ false
+ }
+
+ private fun launchInstaller(apkFile: File) {
+ try {
+ val context = getApplication()
+ 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)
+ }
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt
index 96ecaabe..6b42c352 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt
@@ -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)
+ },
+ )
}
}
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt
index 3e0614ce..74dd4514 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt
@@ -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,
+ ),
+ )
+}
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 0b7ba3a3..fbf07d21 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -154,6 +154,17 @@
디버그
릴리즈
커밋
+ 업데이트
+ 업데이트 확인
+ 확인 중…
+ 최신 버전 (v%1$s)
+ 업데이트 가능: v%1$s
+ 업데이트
+ 다운로드 %1$d%%
+ 설치 중 v%1$s…
+ 이 소스의 설치 허용
+ 설정 열기
+ 다시 시도
저장
프로필 추가
프로필 편집
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 58eea04f..6c5a3937 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -174,6 +174,17 @@
Debug
Release
Commit
+ Update
+ Check for updates
+ Checking…
+ Up to date (v%1$s)
+ Update available: v%1$s
+ Update
+ Downloading %1$d%%
+ Installing v%1$s…
+ Allow installs from this source
+ Open settings
+ Retry
%1$d saved
diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt
new file mode 100644
index 00000000..3ffbe871
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt
@@ -0,0 +1,116 @@
+package com.m57.hermescontrol.data.update
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Pure logic of the in-app updater (issue #867): version comparison and
+ * GitHub release JSON parsing. No Android/network dependencies.
+ */
+class AppUpdateCheckerTest {
+ // ── Version comparison ──────────────────────────────────────────────
+
+ @Test
+ fun isNewerVersion_stripsLeadingV() {
+ assertTrue(
+ "release tags carry a leading v; versionName does not",
+ isNewerVersion("v1.22.0", "1.21.0"),
+ )
+ assertFalse(
+ "same version must not count as newer",
+ isNewerVersion("v1.21.0", "1.21.0"),
+ )
+ }
+
+ @Test
+ fun isNewerVersion_comparesNumericSegments() {
+ assertTrue(isNewerVersion("1.21.0", "1.2"))
+ assertTrue(isNewerVersion("2.0.0", "1.99.99"))
+ assertFalse(isNewerVersion("1.2", "1.21.0"))
+ assertFalse(isNewerVersion("1.21.0", "1.21.0"))
+ }
+
+ @Test
+ fun isNewerVersion_nonNumericSuffixCountsAsZero() {
+ // Local dev default "1.0-dev" must still see a real release as newer.
+ assertTrue(isNewerVersion("1.21.0", "1.0-dev"))
+ assertTrue(isNewerVersion("v1.21.0", "1.0-dev"))
+ }
+
+ @Test
+ fun isNewerVersion_unparseableNeverClaimsUpdate() {
+ assertFalse(isNewerVersion("not-a-version", "1.21.0"))
+ assertFalse(isNewerVersion("1.21.0", ""))
+ assertFalse(isNewerVersion("", "1.21.0"))
+ }
+
+ @Test
+ fun normalizedVersion_stripsLeadingV() {
+ assertEquals("1.21.0", normalizedVersion("v1.21.0"))
+ assertEquals("1.21.0", normalizedVersion("1.21.0"))
+ }
+
+ // ── Release JSON parsing ────────────────────────────────────────────
+
+ @Test
+ fun parseUpdateInfo_picksApkAsset() {
+ val json =
+ """
+ {
+ "tag_name": "v1.22.0",
+ "assets": [
+ {
+ "name": "version.txt",
+ "size": 12,
+ "browser_download_url": "https://github.com/Hy4ri/hermes-mobile/releases/download/v1.22.0/version.txt"
+ },
+ {
+ "name": "hermes-mobile-v1.22.0.apk",
+ "size": 12345678,
+ "browser_download_url": "https://github.com/Hy4ri/hermes-mobile/releases/download/v1.22.0/hermes-mobile-v1.22.0.apk"
+ }
+ ]
+ }
+ """.trimIndent()
+
+ val info = parseUpdateInfo(json)
+ assertNotNull(info)
+ assertEquals("v1.22.0", info!!.tagName)
+ val apk = info.apkAsset
+ assertNotNull("the .apk asset must be selected", apk)
+ assertEquals("hermes-mobile-v1.22.0.apk", apk!!.name)
+ assertEquals(12345678L, apk.size)
+ assertTrue(apk.browserDownloadUrl.endsWith(".apk"))
+ }
+
+ @Test
+ fun parseUpdateInfo_noApkAsset_yieldsNullApk() {
+ val json =
+ """
+ {
+ "tag_name": "v1.22.0",
+ "assets": [
+ {
+ "name": "version.txt",
+ "size": 12,
+ "browser_download_url": "https://github.com/Hy4ri/hermes-mobile/releases/download/v1.22.0/version.txt"
+ }
+ ]
+ }
+ """.trimIndent()
+
+ val info = parseUpdateInfo(json)
+ assertNotNull(info)
+ assertNull(info!!.apkAsset)
+ }
+
+ @Test
+ fun parseUpdateInfo_malformedJson_yieldsNull() {
+ assertNull(parseUpdateInfo("not json at all"))
+ assertNull(parseUpdateInfo(""))
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt
new file mode 100644
index 00000000..05b855b4
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt
@@ -0,0 +1,273 @@
+package com.m57.hermescontrol.ui.settings
+
+import android.app.Application
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import androidx.core.content.FileProvider
+import com.m57.hermescontrol.data.local.AuthManager
+import com.m57.hermescontrol.data.update.AppUpdateChecker
+import com.m57.hermescontrol.data.update.UpdateInfo
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.io.File
+import java.io.IOException
+
+/**
+ * AppUpdateViewModel state machine (issue #867): silent first-launch check,
+ * check/update flows, the unknown-sources gate, and the installer launch.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class AppUpdateViewModelTest {
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var app: Application
+ private lateinit var packageManager: PackageManager
+ private lateinit var checker: AppUpdateChecker
+
+ private val currentVersion = "1.21.0"
+
+ private fun updateInfo(
+ tag: String = "v1.22.0",
+ apkName: String = "hermes-mobile-v1.22.0.apk",
+ ): UpdateInfo =
+ UpdateInfo(
+ tagName = tag,
+ assets =
+ listOf(
+ UpdateInfo.Asset(
+ name = apkName,
+ size = 12345678L,
+ browserDownloadUrl = "https://example.com/$apkName",
+ ),
+ ),
+ )
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ mockkObject(AuthManager)
+ every { AuthManager.getUpdateCheckDoneForVersion() } returns null
+ every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
+
+ app = mockk(relaxed = true)
+ every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
+ every { app.packageName } returns "com.m57.hermescontrol"
+ packageManager = mockk(relaxed = true)
+ every { app.packageManager } returns packageManager
+ every { packageManager.canRequestPackageInstalls() } returns true
+
+ checker = mockk()
+ coEvery { checker.fetchLatestRelease() } returns updateInfo()
+ coEvery { checker.downloadApk(any(), any(), any()) } returns true
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ Dispatchers.resetMain()
+ }
+
+ private fun createViewModel(): AppUpdateViewModel = AppUpdateViewModel(app, checker, currentVersion, testDispatcher)
+
+ // ── Silent first-launch check ───────────────────────────────────────
+
+ @Test
+ fun init_runsSilentCheckWhenNeverChecked() =
+ runTest {
+ val vm = createViewModel()
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) { checker.fetchLatestRelease() }
+ assertEquals(
+ AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/hermes-mobile-v1.22.0.apk", 12345678L),
+ vm.state.value,
+ )
+ coVerify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
+ }
+
+ @Test
+ fun init_skipsCheckWhenAlreadyCheckedForThisVersion() =
+ runTest {
+ every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
+
+ val vm = createViewModel()
+ advanceUntilIdle()
+
+ coVerify(exactly = 0) { checker.fetchLatestRelease() }
+ assertEquals(AppUpdateState.Idle, vm.state.value)
+ }
+
+ @Test
+ fun init_rechecksOnVersionBump() =
+ runTest {
+ every { AuthManager.getUpdateCheckDoneForVersion() } returns "1.20.0"
+
+ val vm = createViewModel()
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) { checker.fetchLatestRelease() }
+ }
+
+ // ── Manual check ────────────────────────────────────────────────────
+
+ @Test
+ fun checkForUpdate_upToDateWhenSameVersion() =
+ runTest {
+ coEvery { checker.fetchLatestRelease() } returns updateInfo(tag = "v1.21.0")
+
+ val vm = createViewModel()
+ advanceUntilIdle()
+ assertEquals(AppUpdateState.UpToDate("v1.21.0"), vm.state.value)
+ }
+
+ @Test
+ fun checkForUpdate_networkFailure_surfacesError() =
+ runTest {
+ coEvery { checker.fetchLatestRelease() } throws IOException("boom")
+
+ val vm = createViewModel()
+ advanceUntilIdle()
+
+ val state = vm.state.value
+ assertTrue(state is AppUpdateState.Error)
+ assertEquals("Network error — check your connection", (state as AppUpdateState.Error).message)
+ }
+
+ @Test
+ fun checkForUpdate_noReleaseYet_surfacesError() =
+ runTest {
+ coEvery { checker.fetchLatestRelease() } returns null
+
+ val vm = createViewModel()
+ advanceUntilIdle()
+
+ val state = vm.state.value
+ assertTrue(state is AppUpdateState.Error)
+ assertEquals("No release found yet", (state as AppUpdateState.Error).message)
+ }
+
+ @Test
+ fun checkForUpdate_releaseWithoutApk_surfacesError() =
+ runTest {
+ coEvery { checker.fetchLatestRelease() } returns UpdateInfo(tagName = "v1.22.0")
+
+ val vm = createViewModel()
+ advanceUntilIdle()
+
+ val state = vm.state.value
+ assertTrue(state is AppUpdateState.Error)
+ assertEquals("Release has no APK asset", (state as AppUpdateState.Error).message)
+ }
+
+ @Test
+ fun checkForUpdate_marksDoneEvenWhenCheckFails() =
+ runTest {
+ coEvery { checker.fetchLatestRelease() } throws IOException("boom")
+
+ val vm = createViewModel()
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
+ }
+
+ // ── Update flow ─────────────────────────────────────────────────────
+
+ @Test
+ fun startUpdate_downloadsAndLaunchesInstaller() =
+ runTest {
+ val uri = mockk()
+ mockkStatic(FileProvider::class)
+ every { FileProvider.getUriForFile(any(), any(), any()) } returns uri
+
+ // android.content.Intent can't be constructed in a plain JVM test,
+ // so the intent factory is injected; assert the VM launches
+ // exactly the intent the factory built for the APK URI.
+ val intentMock = mockk()
+ val capturedIntents = mutableListOf()
+ every { app.startActivity(capture(capturedIntents)) } returns Unit
+
+ val vm =
+ AppUpdateViewModel(app, checker, currentVersion, testDispatcher) { intentMock }
+ advanceUntilIdle()
+ assertTrue(vm.state.value is AppUpdateState.UpdateAvailable)
+
+ vm.startUpdate()
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) {
+ checker.downloadApk("https://example.com/hermes-mobile-v1.22.0.apk", any(), any())
+ }
+ assertEquals(AppUpdateState.Installing("v1.22.0"), vm.state.value)
+ assertTrue("installer must be launched", capturedIntents.isNotEmpty())
+ assertTrue("launched intent must be the factory's", capturedIntents[0] === intentMock)
+ verify { FileProvider.getUriForFile(any(), "com.m57.hermescontrol.fileprovider", any()) }
+ }
+
+ @Test
+ fun startUpdate_withoutUnknownSourcesPermission_opensGate() =
+ runTest {
+ every { packageManager.canRequestPackageInstalls() } returns false
+
+ val vm = createViewModel()
+ advanceUntilIdle()
+
+ vm.startUpdate()
+ advanceUntilIdle()
+
+ assertEquals(AppUpdateState.NeedsUnknownSourcesPermission, vm.state.value)
+ coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
+ }
+
+ @Test
+ fun startUpdate_downloadFailure_surfacesErrorAndRetries() =
+ runTest {
+ coEvery { checker.downloadApk(any(), any(), any()) } returns false
+
+ val vm = createViewModel()
+ advanceUntilIdle()
+
+ vm.startUpdate()
+ advanceUntilIdle()
+
+ val state = vm.state.value
+ assertTrue(state is AppUpdateState.Error)
+ assertEquals("Download failed — tap to retry", (state as AppUpdateState.Error).message)
+ // Retry path: tapping the row re-runs the check, which recovers.
+ vm.checkForUpdate()
+ advanceUntilIdle()
+ assertTrue(vm.state.value is AppUpdateState.UpdateAvailable)
+ }
+
+ @Test
+ fun startUpdate_ignoredWhenStateIsNotUpdateAvailable() =
+ runTest {
+ every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
+
+ val vm = createViewModel()
+ advanceUntilIdle()
+ assertEquals(AppUpdateState.Idle, vm.state.value)
+
+ vm.startUpdate()
+ advanceUntilIdle()
+
+ coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
+ }
+}