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()) } + } +}