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

+1
View File
@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".HermesControlApp"
@@ -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,
),
)
}
+11
View File
@@ -154,6 +154,17 @@
<string name="settings_about_debug">디버그</string>
<string name="settings_about_release">릴리즈</string>
<string name="settings_about_commit">커밋</string>
<string name="settings_about_update">업데이트</string>
<string name="settings_about_update_check">업데이트 확인</string>
<string name="settings_about_update_checking">확인 중…</string>
<string name="settings_about_update_uptodate">최신 버전 (v%1$s)</string>
<string name="settings_about_update_available">업데이트 가능: v%1$s</string>
<string name="settings_about_update_action">업데이트</string>
<string name="settings_about_update_downloading">다운로드 %1$d%%</string>
<string name="settings_about_update_installing">설치 중 v%1$s…</string>
<string name="settings_about_update_allow_sources">이 소스의 설치 허용</string>
<string name="settings_about_update_open_settings">설정 열기</string>
<string name="settings_about_update_retry">다시 시도</string>
<string name="action_save">저장</string>
<string name="settings_action_add_profile">프로필 추가</string>
<string name="settings_action_edit_profile">프로필 편집</string>
+11
View File
@@ -174,6 +174,17 @@
<string name="settings_about_debug">Debug</string>
<string name="settings_about_release">Release</string>
<string name="settings_about_commit">Commit</string>
<string name="settings_about_update">Update</string>
<string name="settings_about_update_check">Check for updates</string>
<string name="settings_about_update_checking">Checking…</string>
<string name="settings_about_update_uptodate">Up to date (v%1$s)</string>
<string name="settings_about_update_available">Update available: v%1$s</string>
<string name="settings_about_update_action">Update</string>
<string name="settings_about_update_downloading">Downloading %1$d%%</string>
<string name="settings_about_update_installing">Installing v%1$s…</string>
<string name="settings_about_update_allow_sources">Allow installs from this source</string>
<string name="settings_about_update_open_settings">Open settings</string>
<string name="settings_about_update_retry">Retry</string>
<!-- Settings list summaries (drill-down) -->
<string name="settings_summary_profiles">%1$d saved</string>
@@ -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(""))
}
}
@@ -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<Uri>()
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<Intent>()
val capturedIntents = mutableListOf<Intent>()
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()) }
}
}