From c2cb08a563d37563176b3cc4eab1e27dc69e2ef4 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:00:12 +0300 Subject: [PATCH] fix: recognize stable APK releases as newer than release candidates (#1184) --- .../ui/common/AppUpdatePrereleaseTest.kt | 47 +++++++++++++ .../data/update/AppUpdateChecker.kt | 67 ++++++++++++++----- .../data/update/AppUpdateCheckerTest.kt | 64 +++++++++++++++++- .../data/update/UpdateNoticeManagerTest.kt | 21 ++++++ .../ui/settings/AppUpdateViewModelTest.kt | 15 +++++ 5 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/common/AppUpdatePrereleaseTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/common/AppUpdatePrereleaseTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/AppUpdatePrereleaseTest.kt new file mode 100644 index 00000000..f2c62e86 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/AppUpdatePrereleaseTest.kt @@ -0,0 +1,47 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.m57.hermescontrol.data.update.AppUpdateState +import com.m57.hermescontrol.data.update.isNewerVersion +import com.m57.hermescontrol.theme.HermesControlTheme +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AppUpdatePrereleaseTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun stableReleaseShowsDialogForHyphenRc() = assertStablePrompt("1.25.0-rc.1") + + @Test + fun stableReleaseShowsDialogForDotRc() = assertStablePrompt("1.25.rc.1") + + private fun assertStablePrompt(installed: String) { + val latest = "v1.25" + assertTrue(isNewerVersion(latest, installed)) + assertFalse(isNewerVersion(latest, "1.25")) + compose.setContent { + HermesControlTheme { + if (isNewerVersion(latest, installed)) { + AppUpdateDialog( + state = AppUpdateState.UpdateAvailable(latest, "https://example.com/test.apk", 1024L), + onDismiss = {}, + onStartUpdate = {}, + onCancelDownload = {}, + onNeverAskAgain = {}, + onOpenSettings = {}, + ) + } + } + } + compose.onNodeWithText(latest, substring = true).assertIsDisplayed() + } +} 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 index 5dcf78b6..b20c3a22 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt @@ -36,31 +36,64 @@ data class UpdateInfo( 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. + * True when [latest] is strictly newer than [current] (issue #867). + * Compare the numeric core first, padding missing parts with zero, then + * prerelease identifiers. Stable outranks prerelease for the same core. + * Accept both "1.25.0-rc.1" and legacy "1.25.rc.1"; ignore build metadata. + * Invalid versions never claim 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 + val a = parseVersion(latest) ?: return false + val b = parseVersion(current) ?: return false + for (i in 0 until maxOf(a.core.size, b.core.size)) { + val comparison = a.core.getOrElse(i) { 0 }.compareTo(b.core.getOrElse(i) { 0 }) + if (comparison != 0) return comparison > 0 } - return false + if (a.prerelease.isEmpty()) return b.prerelease.isNotEmpty() + if (b.prerelease.isEmpty()) return false + for (i in 0 until minOf(a.prerelease.size, b.prerelease.size)) { + val comparison = comparePrereleaseIdentifier(a.prerelease[i], b.prerelease[i]) + if (comparison != 0) return comparison > 0 + } + return a.prerelease.size > b.prerelease.size } -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 - } +private data class ParsedVersion( + val core: List, + val prerelease: List, +) + +private val VERSION_PATTERN = + Regex( + """^([0-9]+(?:\.[0-9]+)*)(?:(?:-|\.(?=[a-zA-Z]))([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*))?(?:\+[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)?$""", + ) + +private fun parseVersion(version: String): ParsedVersion? { + val match = VERSION_PATTERN.matchEntire(normalizedVersion(version)) ?: return null + val core = match.groupValues[1].split('.').map { it.toIntOrNull() ?: return null } + val prerelease = + match.groupValues[2] + .takeIf { it.isNotEmpty() } + ?.split('.') + .orEmpty() + return ParsedVersion(core, prerelease) +} + +private fun comparePrereleaseIdentifier( + a: String, + b: String, +): Int { + val aNumeric = a.all { it in '0'..'9' } + val bNumeric = b.all { it in '0'..'9' } + if (aNumeric != bNumeric) return if (aNumeric) -1 else 1 + if (!aNumeric) return a.compareTo(b) + // Compare arbitrarily large RC numbers without integer overflow. + val x = a.trimStart('0') + val y = b.trimStart('0') + return x.length.compareTo(y.length).takeIf { it != 0 } ?: x.compareTo(y) } /** 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 index 3ffbe871..e45dfa29 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt @@ -35,12 +35,36 @@ class AppUpdateCheckerTest { } @Test - fun isNewerVersion_nonNumericSuffixCountsAsZero() { + fun isNewerVersion_newerCoreOutranksDevBuild() { // 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_preReleaseSuffixesSortBelowStableBase() { + // A versionName built from a release-candidate tag ("1.25.0-rc.1", + // or the dot-form "1.25.rc.1") must see the stable release of the + // same base as a NEWER update. Previously the "rc" segment parsed + // as 0 with a stray trailing segment, so an installed rc compared + // GREATER than the stable tag and the update prompt never fired. + assertTrue(isNewerVersion("v1.25", "1.25.rc.1")) + assertTrue(isNewerVersion("v1.25", "1.25.0-rc.1")) + assertTrue(isNewerVersion("v1.25", "1.25-rc.9")) + assertTrue(isNewerVersion("v1.26.0", "1.25.0-rc.2")) + assertTrue(isNewerVersion("v1.28.1", "1.28.0-rc.3")) + assertTrue(isNewerVersion("1.25.0-rc.3", "1.25.0-rc.2")) + // Equal versions — including rc == rc — must stay silent. + assertFalse(isNewerVersion("v1.25", "1.25")) + assertFalse(isNewerVersion("v1.25.0-rc.2", "1.25.0-rc.2")) + // A stable install must not be prompted by a same-base rc release. + assertFalse(isNewerVersion("v1.25.0-rc.2", "1.25.0")) + // Numeric growth still wins over any pre-release marker. + assertTrue(isNewerVersion("v1.26", "1.25.0-rc.5")) + // Local dev builds still see any real release as newer. + assertTrue(isNewerVersion("v1.21.0", "1.0-dev")) + } + @Test fun isNewerVersion_unparseableNeverClaimsUpdate() { assertFalse(isNewerVersion("not-a-version", "1.21.0")) @@ -48,6 +72,44 @@ class AppUpdateCheckerTest { assertFalse(isNewerVersion("", "1.21.0")) } + @Test + fun isNewerVersion_comparesCoreBeforePrerelease() { + assertTrue(isNewerVersion("1.25.1-rc.1", "1.25")) + assertFalse(isNewerVersion("1.25", "1.25.1-rc.1")) + assertTrue(isNewerVersion("1.25.0.1", "1.25-rc.9")) + assertFalse(isNewerVersion("1.24.9", "1.25.rc.1")) + assertFalse(isNewerVersion("1.25", "1.25.0")) + assertFalse(isNewerVersion("1.25.0-rc.1", "1.25.rc.1")) + assertFalse(isNewerVersion("1.25.rc.1", "1.25.0-rc.1")) + } + + @Test + fun isNewerVersion_ordersPrereleaseIdentifiers() { + val versions = listOf("1.25-alpha", "1.25-alpha.1", "1.25-beta", "1.25-rc.2", "1.25-rc.10", "1.25") + versions.zipWithNext().forEach { (older, newer) -> + assertTrue("$newer > $older", isNewerVersion(newer, older)) + assertFalse("$older < $newer", isNewerVersion(older, newer)) + } + assertTrue(isNewerVersion("1.25-rc.99999999999999999999", "1.25-rc.10")) + assertTrue(isNewerVersion("1.25-rc.a", "1.25-rc.10")) + assertTrue(isNewerVersion("1.25", "1.25-dev")) + } + + @Test + fun isNewerVersion_ignoresBuildMetadata() { + assertFalse(isNewerVersion("1.25+build.2", "1.25+build.1")) + assertFalse(isNewerVersion("1.25", "1.25+build.1")) + assertTrue(isNewerVersion(" v1.25+build.2 ", "1.25-rc.1+build.3")) + } + + @Test + fun isNewerVersion_rejectsMalformedVersionsOnEitherSide() { + listOf("garbage", "1..25", "1.25-", "1.25+", "1.25_rc.1", "1.25!", "99999999999999999999").forEach { + assertFalse(it, isNewerVersion(it, "1.25")) + assertFalse(it, isNewerVersion("1.25", it)) + } + } + @Test fun normalizedVersion_stripsLeadingV() { assertEquals("1.21.0", normalizedVersion("v1.21.0")) diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt index 9edd89d5..f19c5ab2 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt @@ -131,6 +131,27 @@ class UpdateNoticeManagerTest { assertNull(UpdateNoticeManager.noticeTag(currentVersion)) } + @Test + fun checkOnLaunch_stableReleasePromptsInstalledRc() = + runTest { + val checker = mockk() + coEvery { checker.fetchLatestRelease() } returns updateInfo(tag = "v1.25") + + UpdateNoticeManager.checkOnLaunch(checker, "1.25.0-rc.1", testDispatcher) + advanceUntilIdle() + + assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable) + assertEquals("v1.25", UpdateNoticeManager.noticeTag("1.25.0-rc.1")) + verify { AuthManager.setLastKnownLatestTag("v1.25") } + } + + @Test + fun noticeTag_stableReleasePromptsDotRcFromPersistedCache() { + every { AuthManager.getLastKnownLatestTag() } returns "v1.25" + assertEquals("v1.25", UpdateNoticeManager.noticeTag("1.25.rc.1")) + assertNull(UpdateNoticeManager.noticeTag("1.25")) + } + @Test fun checkOnLaunch_upToDateWhenSameVersion() = runTest { 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 index 8c9d72be..34522fbe 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt @@ -166,6 +166,21 @@ class AppUpdateViewModelTest { // ── Manual check ──────────────────────────────────────────────────── + @Test + fun checkForUpdate_stableReleasePromptsInstalledRc() = + runTest { + coEvery { checker.fetchLatestRelease() } returns updateInfo(tag = "v1.25") + val vm = AppUpdateViewModel(app, checker, "1.25.rc.1", testDispatcher) + advanceUntilIdle() + + vm.checkForUpdate() + advanceUntilIdle() + + assertTrue(vm.state.value is AppUpdateState.UpdateAvailable) + assertEquals("v1.25", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag) + assertEquals(vm.state.value, AppUpdateCache.state.value) + } + @Test fun checkForUpdate_upToDateWhenSameVersion() = runTest {