feat(update): redesign in-app updater with smart cooldown, release notes, and in-place dialog (#971)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-26 07:42:41 +03:00
1 parent 460472f631
commit 1f1ebf3597
14 files changed
+528 -54

No files matched your search

@@ -271,11 +271,11 @@ class PersistentCookieJarTest {
jar.useStore("cold-server")
}
delay(10)
// Wait for useStore to complete hydration
job.join()
val loaded = jar.loadForRequest("http://dash.local/api/status".toHttpUrl())
assertEquals(1, loaded.size)
assertEquals("cold-cookie-xyz", loaded[0].value)
job.join()
}
}
@@ -58,6 +58,10 @@ class UpdateNoticeManagerTest {
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
every { AuthManager.getLastKnownLatestTag() } returns null
every { AuthManager.setLastKnownLatestTag(any()) } returns Unit
every { AuthManager.getLastUpdateCheckTimestamp() } returns 0L
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
every { AuthManager.getDismissedUpdateTag() } returns null
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
}
@After
@@ -86,13 +90,15 @@ class UpdateNoticeManagerTest {
}
@Test
fun checkOnLaunch_skipsWhenAlreadyCheckedForThisVersion() =
fun checkOnLaunch_skipsWhenAlreadyCheckedWithin24Hours() =
runTest {
val now = 1000000000000L
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
every { AuthManager.getLastUpdateCheckTimestamp() } returns now - 1000L // 1 sec ago
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease() } returns updateInfo()
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now)
advanceUntilIdle()
coVerify(exactly = 0) { checker.fetchLatestRelease() }
@@ -100,6 +106,31 @@ class UpdateNoticeManagerTest {
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
}
@Test
fun checkOnLaunch_runsWhenCheckedMoreThan24HoursAgo() =
runTest {
val now = 1000000000000L
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
val older = now - (UpdateNoticeManager.CHECK_INTERVAL_MS + 1000L)
every { AuthManager.getLastUpdateCheckTimestamp() } returns older
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease() } returns updateInfo()
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now)
advanceUntilIdle()
coVerify(exactly = 1) { checker.fetchLatestRelease() }
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
}
@Test
fun noticeTag_returnsNullWhenTagDismissedByUser() {
every { AuthManager.getDismissedUpdateTag() } returns "v1.22.0"
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
fun checkOnLaunch_upToDateWhenSameVersion() =
runTest {
@@ -27,6 +27,7 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -70,6 +71,10 @@ class AppUpdateViewModelTest {
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
every { AuthManager.setLastKnownLatestTag(any()) } returns Unit
every { AuthManager.getLastUpdateCheckTimestamp() } returns 0L
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
every { AuthManager.getDismissedUpdateTag() } returns null
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
app = mockk(relaxed = true)
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
@@ -108,15 +113,20 @@ class AppUpdateViewModelTest {
}
@Test
fun init_skipsCheckWhenAlreadyCheckedForThisVersion() =
fun init_adoptsCachedResultWhenPresent() =
runTest {
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
AppUpdateCache.update(
AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/apk", 123L),
)
val vm = createViewModel()
advanceUntilIdle()
coVerify(exactly = 0) { checker.fetchLatestRelease() }
assertEquals(AppUpdateState.Idle, vm.state.value)
assertEquals(
AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/apk", 123L),
vm.state.value,
)
}
@Test
@@ -285,17 +295,42 @@ class AppUpdateViewModelTest {
}
@Test
fun startUpdate_ignoredWhenStateIsNotUpdateAvailable() =
fun cancelDownload_cancelsJobAndRestoresAvailableState() =
runTest {
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
val vm = createViewModel()
advanceUntilIdle()
assertEquals(AppUpdateState.Idle, vm.state.value)
// In-flight download
coEvery { checker.downloadApk(any(), any(), any()) } coAnswers {
kotlinx.coroutines.delay(10000)
true
}
vm.startUpdate()
testDispatcher.scheduler.advanceTimeBy(100)
assertTrue(vm.state.value is AppUpdateState.Downloading)
vm.cancelDownload()
advanceUntilIdle()
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
assertTrue(vm.state.value is AppUpdateState.UpdateAvailable)
}
@Test
fun dismissCurrentUpdate_persistsDismissedTagAndHidesDialog() =
runTest {
val vm = createViewModel()
advanceUntilIdle()
AppUpdateCache.showDialog()
assertTrue(AppUpdateCache.isDialogVisible)
vm.dismissCurrentUpdate()
advanceUntilIdle()
verify(exactly = 1) { AuthManager.setDismissedUpdateTag("v1.22.0") }
assertTrue(AppUpdateCache.dismissed)
assertFalse(AppUpdateCache.isDialogVisible)
}
}