fix: recognize stable APK releases as newer than release candidates (#1184)
This commit is contained in:
5 files changed
+196
-18
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
}
|
||||
private data class ParsedVersion(
|
||||
val core: List<Int>,
|
||||
val prerelease: List<String>,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -131,6 +131,27 @@ class UpdateNoticeManagerTest {
|
||||
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkOnLaunch_stableReleasePromptsInstalledRc() =
|
||||
runTest {
|
||||
val checker = mockk<AppUpdateChecker>()
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user