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

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

No files matched your search

@@ -0,0 +1,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()) }
}
}