diff --git a/CHANGELOG.md b/CHANGELOG.md index d282a8c9..5809d07d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,16 +15,31 @@ release version + date and opens a fresh `[Unreleased]`. ## [Unreleased] +### Fixed + +- **The "Update available" notice and Settings now always show the same + version** — after "Check now" in Settings the Overview notice could still + offer an older release (for example the stable v0.1.46 while Settings offered + the newer beta v0.1.47-beta1), and reopening Settings could show the older + release again. NOVA now keeps one latest-version result that the notice, the + update sheet and Settings all show. It follows the "Include pre-releases" + switch: turning it off never leaves a beta on screen, and turning it on offers + the newest beta again as soon as the check is done. A check that fails keeps + the version found before, and Settings says that the check failed. When + several releases are newer than yours, the newest one is offered (#331). + ## [0.1.47-beta1] - 2026-11-13 Beta: NOVA no longer installs updates itself. It still tells you when a new version is out and shows the release notes, but "Download" now opens the release file in your browser. Please open the update sheet that opens from the "Update available" notice and look at the "Download" and "Release page" buttons, and tell us whether the download and the install from the downloaded file work for you. This beta itself is still installed by the old installer of 0.1.46, so Google Play Protect may show its warning one last time; from this version on, the next update arrives as a browser download. ### Added + - **The Play Protect notice is documented** — the README and the website now explain what Google Play Protect shows when you install or update NOVA, why, and how to check a download (SHA-256 and signing certificate). ### Changed + - **Updates are now downloaded in your browser instead of being installed inside NOVA** — NOVA still tells you when a new version is out and shows its release notes, the file size and its SHA-256. "Download" now lets your browser download @@ -43,6 +58,7 @@ Beta: NOVA no longer installs updates itself. It still tells you when a new vers Adding a self-signed server now asks about its certificate only once, and the Overview shows dashes instead of zeros when it has no data. The 0.1.46 beta confirmed this on a real phone: the trust question appears once, the Overview shows dashes while offline, Uptime is a duration and disk rows have no dangling separator. ### Fixed + - **The Overview shows a dash instead of zeros when it has no data** — after the connection to your server dropped, the Array card read "0,0 / 0 TB used" with a half-filled bar, and CPU and Memory read "0,0%" and "0,0 GB". Values the app does @@ -70,6 +86,7 @@ Adding a self-signed server now asks about its certificate only once, and the Ov Live values on the Overview and the container numbers now recover by themselves after the connection to your server drops or goes quiet. The 0.1.45 beta confirmed this on a real phone with a real Wi-Fi change. ### Fixed + - **Live values recover by themselves after a connection drop** — when the connection to your server broke or went quiet (Wi-Fi switched off and on, the Unraid API restarted), the live cards on the Overview and the container @@ -82,6 +99,7 @@ Live values on the Overview and the container numbers now recover by themselves Nothing new in the app. The build tools and libraries behind it were brought up to date, and the 0.1.44 beta confirmed that nothing looks or behaves differently. ### Changed + - **Build tools and libraries updated** — Kotlin 2.4, Android Gradle Plugin 9.5, Jetpack Compose, okhttp 5.5, Apollo 5.2 and Tink 1.24 (among others) are newer. Nothing should look or behave differently. ## [0.1.43] - 2026-11-12 @@ -89,11 +107,13 @@ Nothing new in the app. The build tools and libraries behind it were brought up The parity card appears on a real server, a server that cannot be reached says so in plain words, and a robustness batch (double-tap Install, missing install-permission screen, VM list without zeros). It is also the first version that looks for updates at the project's new home, github.com/hellwand/nova. ### Changed + - **The project moved to github.com/hellwand/nova** — the app now looks for updates there, and the links in Settings (source code, releases, privacy policy) point to the new address (#270). ### Fixed + - **The parity card now appears while a parity operation runs or is paused** — on the Array and Overview screens it used to stay hidden on a real server. Because the server reports a parity check, a parity @@ -125,11 +145,13 @@ certificate and key-storage path, and an icon and screenshots for the store listing. ### Added + - **Icon and screenshots in the store listing** — the F-Droid listing now shows the NOVA icon and six screenshots (Overview, Array, Docker, VMs, container details and notifications) (#212, thanks @licaon-kter). ### Changed + - **Container details tabs are accessible** — the Info/Logs/Ports/Volumes tabs now use the standard Material tab bar, so screen readers announce them as tabs with the selected one marked and the touch targets are at @@ -161,6 +183,7 @@ store listing. permission is no longer listed because the app does not declare it). ### Fixed + - **Network interfaces screen no longer shows blank hardware fields** — vendor, model, link speed and the virtual flag are only available with a third-party API plugin; rows without data are now left out instead of @@ -203,12 +226,14 @@ connection pill keeps showing Local/Remote when the server is unreachable (#217). ### Changed + - **Connection pill keeps the mode when offline** — when the server can't be reached, the top-bar pill now reads "Local · Offline" or "Remote · Offline" instead of just "Offline", so you can see which address the app is using. Tap it to switch. ### Fixed + - **Error messages name the actual cause** — "Can't reach the server" errors now show the underlying reason next to the generic message (e.g. "Error while reading JSON response (EOFException: …)"), @@ -222,6 +247,7 @@ the dashboard and Docker tab, a new temperature card, per-container resource stats, and a batch of Array-tab accuracy improvements. ### Added + - **Live, real-time server metrics** — the dashboard's CPU and Memory cards, the Network card's upload/download speed, and per-container CPU/memory on the Docker tab now update live in real time via the @@ -238,11 +264,13 @@ resource stats, and a batch of Array-tab accuracy improvements. started). ### Changed + - **Array tab separates Array from Cache/pool disks** — array disks and cache/pool disks now appear in distinct "Array" and "Cache" sections in both List and Grid layouts. ### Fixed + - **Sleeping disks show "Standby"** — spun-down array disks now show a moon icon and "Standby" instead of a misleading "0°" temperature. - **Disk errors are surfaced** — each disk card now shows its I/O error @@ -264,6 +292,7 @@ opt-in trust for local servers running HTTPS with a self-signed certificate (ADR-0041). ### Added + - **Add a server by entering just its address and flipping an SSL switch** — the old free-text URL field (where you had to type `http://` or `https://` yourself) is replaced with a host/IP field, an optional port field, and a simple SSL toggle. The app composes the URL for you. Existing saved servers are unaffected — no re-entry needed. You can also paste a full address (with `http://` or `https://`) and the app strips the scheme for you. - **Connect to a local server that uses a self-signed HTTPS certificate** — a new "Trust self-signed certificate (local only)" switch appears in the Local connection settings when SSL is on. Enable it, tap **Test Local**, and the app shows you the certificate's SHA-256 fingerprint; tap **Trust** once and the app remembers it. Re-testing a server reuses the stored pin automatically — no re-confirmation needed. If the certificate ever changes (e.g. you regenerated it), the app blocks and asks you to review and re-accept the new fingerprint. A **"Reset certificate"** button in settings forgets the stored pin. This trust is local-endpoint-only — remote connections always require a valid CA-signed certificate. @@ -273,6 +302,7 @@ Stable promotion of the 0.1.38 cycle, maintainer device-accepted. UI polish that makes the tap-actionable cards/rows visually obvious. ### Changed + - **Visible affordance hints on tap-actionable cards/rows** — adds chevron indicators across the app so it's obvious which cards expand or drill in. In-place-expand rows (Settings → Server plugins → Recent operations, Settings → Network interfaces → per-NIC details) gain a chevron-down that rotates to chevron-up when expanded. Drill-in cards (Docker containers in both list and grid layouts; VMs in both list and grid layouts) gain a trailing chevron-right matching the existing Settings convention. For grid tiles, the chevron sits in the bottom-right corner where it doesn't compete with content. No functional change — purely a "tap-here works" visual cue. ## [0.1.37] - 2026-05-21 @@ -283,6 +313,7 @@ by stripping AGP's "Dependency metadata" extra signing block from the published APK. No functional or user-visible change. ### Changed + - **Build: strip AGP's "Dependency metadata" extra signing block from the APK** — added the `dependenciesInfo { includeInApk = false; includeInBundle = false }` Gradle block (standard F-Droid compatibility shim). No functional impact on either flavor; no user-visible difference. Required because F-Droid's `check apk` rejects extra signing blocks beyond the standard JAR + APK signing schemes. Unblocks the in-flight F-Droid submission ([gitlab.com/fdroid/fdroiddata!38801](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/38801)). ## [0.1.36] - 2026-05-21 @@ -293,6 +324,7 @@ last GitHub-request paths in the `store` flavor — pre-work for the F-Droid metadata submission. ### Fixed + - **Store flavor truly makes zero GitHub requests at runtime** — the 0.1.35 distribution-flavors split (ADR-0040) guarded only the Settings → Updates UI. `MainViewModel.init` (every app launch via Overview) and `SettingsViewModel.init` (every Settings-open) still called `checkForUpdate()` / `checkNow()` unconditionally, and the Overview's update banner + `UpdateDialog` were rendered unconditionally. All three layers (both ViewModel init blocks + all network-touching methods + Overview banner + dialog) are now guarded by `BuildConfig.HAS_UPDATER`. The store flavor now genuinely contacts only the user's own Unraid server — matching the Privacy Policy and ADR-0040 promises. Defense-in-depth guards on `installUpdate`/`resetInstall`/`launchPermissionIntent` in both ViewModels. No behaviour change in the `direct` flavor. ## [0.1.35] - 2026-05-21 @@ -302,6 +334,7 @@ release opens the F-Droid + Play distribution path (per ADR-0040) and ships a formal Privacy Policy referenced from both app and stores. ### Added + - **Distribution flavors** — the app now builds in two product flavors: **`direct`** (status quo — GitHub Releases with the in-app updater intact) and **`store`** (F-Droid + future Play distribution, with the in-app updater UI hidden and `REQUEST_INSTALL_PACKAGES` excluded from the manifest). Same app from the user's perspective — same `applicationId`, same UI minus the Updates section in store builds. Per ADR-0040. - **Privacy Policy** — formal policy at [`docs/privacy/POLICY.md`](https://github.com/nofuturekid/nova/blob/main/docs/privacy/POLICY.md), linked from Settings → About (new "Privacy Policy ›" row between "Releases" and "Open-source licenses"); README "Privacy & data handling" section gains a link to the full policy. No behavioural change — formalises the existing data-handling model (API-key on-device-encrypted, no telemetry, no third-party services in either flavor). @@ -315,10 +348,12 @@ repo-rename housekeeping that closes out the 0.1.33 cycle's NOVA brand transition. ### Added + - **Settings → Server plugins** — read-only inventory of installed `.plg` files (top section), Unraid API modules with name/version/module flags (middle section), and the install-operation history with status pills and expandable per-operation output log (bottom section). Powered by the upstream `installedUnraidPlugins`, `plugins`, and `pluginInstallOperations` GraphQL queries. - **Settings → Network interfaces** — read-only NIC inventory with IPv4 + IPv6 addressing (address, netmask, gateway, DHCP-state), hardware info (MAC, vendor, model, link speed, virtual-flag), and a marker for the primary management interface. Live RX/TX byte counters are not included — Unraid's GraphQL API doesn't expose those yet (tracked upstream). ### Changed + - **Settings → About** — regrouped the two GitHub links ("Source code" and the new "Releases") as paired SettingRow entries directly above "Open-source licenses"; the trademark disclaimer moved to the bottom of the card as footer text. - **Repo rename housekeeping** — `nofuturekid/UnraidControl → nofuturekid/nova` (the final step of ADR-0039). README badge URLs, Live Interactive Preview URL, ADR-0033 reference URLs, in-app About URLs, and the `UpdateRepository.REPO` constant all flip to the new repo path. @@ -331,9 +366,11 @@ Renamed from `UnraidControl` to **NOVA** for compliance with the Lime Technology The Android `applicationId` changed from `net.unraidcontrol.app` to `io.github.nofuturekid.nova`. From Android's view, NOVA is a new app — your old UnraidControl install stays on `0.1.33-beta2` and no longer receives updates. **Install NOVA fresh from the [Releases page](https://github.com/nofuturekid/nova/releases)**, re-enter your server's API key, and uninstall the old app. Server entries and theme preferences live in per-app DataStore and don't migrate — this is a one-time clean break. ### Added + - **Settings → About** — app name + version, GPL v3 + source-code link, full Unraid® trademark attribution paragraph (per ADR-0038 / §6), and an open-source license list (powered by AboutLibraries). ### Changed + - **Renamed to NOVA — NAS Operations Viewer Anywhere.** Brand mark, `applicationId`, Kotlin package, AGP namespace, Apollo generated package, `app_name`, Application class (`NovaApp`), splash/Material theme (`Theme.Nova`), launcher icon (sparkle 4-pointed-star placeholder), local APK download filename, release-asset name (`NOVA-vX.Y.Z.apk`), and README / HANDOFF prose all flip to NOVA. Repo URL stays at `nofuturekid/UnraidControl` for this Stable — the repo rename follows in a separate post-Stable step (Step 4 of the ADR-0039 plan). - **Deprecation banner** shipped one cycle ago in `0.1.33-beta2` so existing users were warned about the non-auto-updating applicationId change before it landed. @@ -345,10 +382,12 @@ accessibility batch, resilient action launchers, and an install double-tap guard. ### Changed + - Notifications Unread/Archived tabs now use the non-deprecated Material 3 tab row; the selected-tab indicator follows the accent colour. ### Fixed + - The Settings screen now scrolls on short screens (and in landscape), so no content is clipped on small devices. - On-accent text and icons now use the semantic Material 3 colour token, @@ -363,6 +402,7 @@ double-tap guard. ignored (triage #22). ### Accessibility + - The Settings toggles now use the Material 3 Switch, so screen readers announce them with the correct switch role and on/off state. @@ -373,18 +413,21 @@ This release delivers actionable notifications, a security-hardening pass for app updates and data-at-rest, and a batch of code-review fixes. ### Added + - The notifications sheet now has Unread and Archived tabs showing all notifications (including info-level ones). You can mark notifications as read or archived, restore archived ones, delete them individually, archive all unread at once, or delete all archived at once. ### Changed + - The notifications indicator in the top bar now shows a bell icon instead of an information (ⓘ) symbol, and its badge counts every unread notification (info-level ones are no longer omitted, so the number matches the list). ### Security + - App updates are now verified before install: the downloaded APK must match both the expected byte size and a constant-time SHA-256 of the GitHub release asset, the download URL must be HTTPS, and a mismatch @@ -400,6 +443,7 @@ for app updates and data-at-rest, and a batch of code-review fixes. instead of a misleading "Missing API key". (ADR-0035, amends ADR-0024) ### Fixed + - Switching servers no longer briefly shows the previous server's data under the new server's name. - The notifications list now loads correctly against real Unraid servers @@ -430,6 +474,7 @@ now built on standard Material 3 components throughout, with an accessibility and visual-consistency pass. ### Fixed + - App update started from the Settings screen no longer interferes with the update state shown on the main screen. - Overview sparklines no longer show a spurious dip roughly once a minute. @@ -438,6 +483,7 @@ accessibility and visual-consistency pass. — they are now correctly tinted (accent when selected, muted otherwise). ### Changed + - Cards, icon-only buttons, progress bars, buttons, dialogs and text fields across the app are now rebuilt on the standard Material 3 components instead of custom ones. This is an internal modernisation diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/UpdateInfo.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/UpdateInfo.kt index a523f840..be7ffa76 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/UpdateInfo.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/UpdateInfo.kt @@ -54,3 +54,33 @@ sealed interface UpdateState { /** Check failed (network, parse, rate-limit). */ data class Error(val message: String) : UpdateState } + +/** + * What the app knows about the latest release, for the CURRENT "Include + * pre-releases" setting. The Overview notice, its sheet and every line and + * button of the Settings update section render from this one value. + */ +data class UpdateStatus( + /** + * Last good outcome of a check made under the current setting: + * [UpdateState.UpToDate] or [UpdateState.Available]. [UpdateState.Checking] + * means nothing is known yet for this setting. Never [UpdateState.Error]: + * a failed check does not replace the last good result, see [failure]. + */ + val latest: UpdateState = UpdateState.Checking, + /** Message of the most recent check if it failed, null if it succeeded. */ + val failure: String? = null, + /** + * True while a check runs. The held [latest] stays as it was meanwhile + * (the Overview notice keeps showing it); Settings shows its progress + * text from this flag. + */ + val checking: Boolean = false, +) { + /** + * The release the Overview notice offers: the latest known newer release + * unless the user dismissed exactly this tag; null means no notice. + */ + fun notice(dismissedTag: String?): UpdateInfo? = + (latest as? UpdateState.Available)?.info?.takeIf { it.tag != dismissedTag } +} diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/update/UpdateRepository.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/update/UpdateRepository.kt index f7c75419..b09eba4f 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/data/update/UpdateRepository.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/update/UpdateRepository.kt @@ -85,7 +85,8 @@ class UpdateRepository @Inject constructor() { /** * Decides from the Releases API response [body] which release, if - * any, is newer than [currentVersion]. Drafts are never offered; + * any, is the newest one above [currentVersion], whatever order + * GitHub lists them in. Drafts are never offered; * pre-releases only when [includePrereleases]. A release without * an APK asset is still offered (its link is the release page). */ @@ -101,7 +102,8 @@ class UpdateRepository @Inject constructor() { .filter { !it.draft } .filter { includePrereleases || !it.prerelease } .mapNotNull { rel -> parseVersion(rel.tagName)?.let { it to rel } } - .firstOrNull { (v, _) -> v > current } + .filter { (v, _) -> v > current } + .maxByOrNull { (v, _) -> v } ?: return UpdateState.UpToDate val apk = rel.assets.firstOrNull { it.name.endsWith(".apk") } // Defence in depth: the browser is only sent to this repository's diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/update/UpdateTracker.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/update/UpdateTracker.kt new file mode 100644 index 00000000..c0bdf854 --- /dev/null +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/update/UpdateTracker.kt @@ -0,0 +1,180 @@ +package io.github.nofuturekid.nova.data.update + +import io.github.nofuturekid.nova.BuildConfig +import io.github.nofuturekid.nova.data.model.UpdateState +import io.github.nofuturekid.nova.data.model.UpdateStatus +import io.github.nofuturekid.nova.data.repository.SettingsRepository +import io.github.nofuturekid.nova.di.ApplicationScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The single place that holds the latest known release. The automatic check + * at app start, the check when Settings opens, "Check now" and the check + * after the "Include pre-releases" switch all go through [check] and store + * their result here; the Overview notice, its sheet and the Settings update + * section all render [status]. Before this there were two copies (one per + * ViewModel) that could not see each other. + * + * Checks run in the application scope, not in the scope of the screen that + * asked: leaving Settings while the check from app start is still in flight + * must not lose its answer. A requester that goes away only stops waiting. + * + * The tracker also follows the "Include pre-releases" switch itself: when the + * setting changes it checks again, so the answer for the new setting arrives + * even if the user leaves Settings right after flipping the switch. Rapid + * flipping makes one check, not one per flip (see [SETTING_DEBOUNCE_MS]). + * + * In memory only, on purpose: a stored result would have to be thrown away + * on every app update and every switch change, and the check at app start + * refreshes it anyway. + */ +@Singleton +class UpdateTracker internal constructor( + private val scope: CoroutineScope, + private val fetch: suspend (includePrereleases: Boolean) -> UpdateState, + private val includePrereleases: Flow, + private val recordCheck: suspend () -> Unit, + followsTheSetting: Boolean, +) { + @Inject + constructor( + updates: UpdateRepository, + settings: SettingsRepository, + @ApplicationScope scope: CoroutineScope, + ) : this( + scope = scope, + fetch = updates::check, + includePrereleases = settings.includePrereleases, + recordCheck = { settings.setLastUpdateCheck(System.currentTimeMillis()) }, + followsTheSetting = BuildConfig.HAS_UPDATER, // the store flavor makes no update calls + ) + + /** A good result and the pre-release setting it was computed under. */ + private data class Held(val includePrereleases: Boolean, val result: UpdateState) + + private data class Snapshot( + /** Number of the newest check started; only that check may store its result. */ + val run: Int = 0, + val held: Held? = null, + val failure: String? = null, + /** The newest check has not finished yet. */ + val checking: Boolean = false, + ) + + private class Run(val number: Int, val includePrereleases: Boolean, val done: Deferred) + + private val snapshot = MutableStateFlow(Snapshot()) + + /** Decides whether a request joins the running check; guards [running]. */ + private val requests = Mutex() + private var running: Run? = null + + /** + * The latest known result for the setting that is current NOW. A result + * computed under the other setting is not shown (it would offer a + * pre-release to a stable-only user, or hide one from a beta user) + * until the check that follows the switch has stored a fresh one. + * [UpdateStatus.checking] says a check is running; the held result stays. + */ + val status: Flow = combine(snapshot, includePrereleases) { s, include -> + UpdateStatus( + latest = s.held?.takeIf { it.includePrereleases == include }?.result + ?: UpdateState.Checking, + failure = s.failure, + checking = s.checking, + ) + }.distinctUntilChanged() + + // Last in the property list: the launched job reads every property above. + init { + if (followsTheSetting) scope.launch { checkWhenTheSettingChanges() } + } + + /** + * Asks for a check and returns when it has stored its answer. The check + * itself runs in the application scope: cancelling the caller only stops + * the waiting. The pre-release setting is read here, when the check is + * requested, never from UI state. + * + * A request made while a check under the SAME setting is running joins it + * instead of making a second network call. A request under a different + * setting starts a new check; the older one still ends, but its answer is + * not stored (only the newest started check may store), so a result + * computed under the old setting is never the one that stays. A failed + * check keeps the last good result and records the failure. + */ + suspend fun check() { + val run = requests.withLock { + val include = includePrereleases.first() + running?.takeIf { it.includePrereleases == include && it.done.isActive } + ?: start(include).also { running = it } + } + run.done.await() + } + + private fun start(include: Boolean): Run { + val number = snapshot + .updateAndGet { it.copy(run = it.run + 1, failure = null, checking = true) }.run + return Run(number, include, scope.async { execute(number, include) }) + } + + /** + * A change of the setting (not the value it starts with, and not another + * write to the store that leaves it as it was) asks for a check. The + * debounce is what bounds the network calls: only the value the user stops + * on is checked. (collectLatest alone would not bound them, a check keeps + * running when the one waiting for it is replaced; it is here so a slow + * check under the old setting does not delay the one for the new setting.) + */ + @OptIn(FlowPreview::class) + private suspend fun checkWhenTheSettingChanges() { + includePrereleases.distinctUntilChanged().drop(1).debounce(SETTING_DEBOUNCE_MS).collectLatest { check() } + } + + private suspend fun execute(run: Int, include: Boolean) { + try { + val result = fetch(include) + snapshot.update { s -> + when { + s.run != run -> s + result is UpdateState.Error -> s.copy(failure = result.message) + else -> s.copy(held = Held(include, result), failure = null) + } + } + recordCheck() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Never leave the row on "Checking…" or crash the awaiting screens. + // The held result stays; only the newest check may report. + snapshot.update { if (it.run == run) it.copy(failure = e.message ?: "Network error") else it } + } finally { + // Also on cancellation or an exception: the flag must not stay on. + snapshot.update { if (it.run == run) it.copy(checking = false) else it } + } + } + + internal companion object { + /** Quiet time after the last flip of the switch before the check starts. */ + const val SETTING_DEBOUNCE_MS = 300L + } +} diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/di/AppModule.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/di/AppModule.kt index d8c02044..4b9a2c00 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/di/AppModule.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/di/AppModule.kt @@ -1,13 +1,24 @@ package io.github.nofuturekid.nova.di import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object AppModule { // Repositories + stores are constructor-injected with @Singleton — Hilt - // discovers them automatically. This module exists so additional bindings - // (qualifiers, factories) can land here later without re-wiring callers. + // discovers them automatically. This module holds the bindings that + // constructor injection cannot express (qualifiers, factories). + + /** Lives as long as the process; a failure in one job does not cancel the others. */ + @Provides + @Singleton + @ApplicationScope + fun applicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) } diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/di/ApplicationScope.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/di/ApplicationScope.kt new file mode 100644 index 00000000..392da2de --- /dev/null +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/di/ApplicationScope.kt @@ -0,0 +1,12 @@ +package io.github.nofuturekid.nova.di + +import javax.inject.Qualifier + +/** + * The application-wide CoroutineScope, provided by [AppModule]. Work that must + * outlive the screen that asked for it (a ViewModel's scope is cancelled when + * its screen is left) runs here. + */ +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationScope diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainScreen.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainScreen.kt index 08eab153..874d9473 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainScreen.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainScreen.kt @@ -142,7 +142,7 @@ fun MainScreen( val pullState = rememberPullToRefreshState() var refreshing by remember { mutableStateOf(false) } - val updateState by vm.updateState.collectAsStateWithLifecycle() + val updateStatus by vm.updateStatus.collectAsStateWithLifecycle() val dismissedTag by vm.dismissedUpdateTag.collectAsStateWithLifecycle() var showUpdateDialog by remember { mutableStateOf(false) } @@ -184,12 +184,12 @@ fun MainScreen( ) if (BuildConfig.HAS_UPDATER) { - val update = updateState - if (update is UpdateState.Available && update.info.tag != dismissedTag) { + val notice = updateStatus.notice(dismissedTag) + if (notice != null) { UpdateBanner( - info = update.info, + info = notice, onTap = { showUpdateDialog = true }, - onDismiss = { vm.dismissUpdate(update.info.tag) }, + onDismiss = { vm.dismissUpdate(notice.tag) }, ) } } @@ -529,7 +529,7 @@ fun MainScreen( } if (BuildConfig.HAS_UPDATER) { - val u = updateState + val u = updateStatus.latest if (showUpdateDialog && u is UpdateState.Available) { UpdateDialog( info = u.info, diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt index a84137a4..11799987 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -38,14 +37,14 @@ import io.github.nofuturekid.nova.data.model.Notifications import io.github.nofuturekid.nova.data.model.Server import io.github.nofuturekid.nova.data.model.ServerInfo import io.github.nofuturekid.nova.data.model.Temperature -import io.github.nofuturekid.nova.data.model.UpdateState +import io.github.nofuturekid.nova.data.model.UpdateStatus import io.github.nofuturekid.nova.data.model.Vm import io.github.nofuturekid.nova.data.repository.ActionFailedException import io.github.nofuturekid.nova.data.repository.DomainState import io.github.nofuturekid.nova.data.repository.ServerRepository import io.github.nofuturekid.nova.data.repository.SettingsRepository import io.github.nofuturekid.nova.data.repository.UnraidRepository -import io.github.nofuturekid.nova.data.update.UpdateRepository +import io.github.nofuturekid.nova.data.update.UpdateTracker import javax.inject.Inject data class MainUi( @@ -63,7 +62,7 @@ class MainViewModel @Inject constructor( private val servers: ServerRepository, private val unraid: UnraidRepository, private val settings: SettingsRepository, - private val updates: UpdateRepository, + private val updateTracker: UpdateTracker, ) : ViewModel() { // ── Resilient action seam (triage #19, ADR-0037) ────────────────── @@ -272,8 +271,8 @@ class MainViewModel @Inject constructor( // ── Update check (the app only reports; the user downloads and // installs the file in the browser, ADR-0046) ─────────────────── - private val _updateState = MutableStateFlow(UpdateState.Checking) - val updateState: StateFlow = _updateState.asStateFlow() + val updateStatus: StateFlow = updateTracker.status + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UpdateStatus()) val dismissedUpdateTag: StateFlow = settings.dismissedUpdateTag .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) @@ -321,10 +320,7 @@ class MainViewModel @Inject constructor( fun checkForUpdate() = viewModelScope.launch { if (!BuildConfig.HAS_UPDATER) return@launch - _updateState.value = UpdateState.Checking - val include = settings.includePrereleases.first() - _updateState.value = updates.check(include) - settings.setLastUpdateCheck(System.currentTimeMillis()) + updateTracker.check() } fun dismissUpdate(tag: String) = viewModelScope.launch { diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/settings/SettingsScreen.kt index 432b44e7..27445d69 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/settings/SettingsScreen.kt @@ -54,8 +54,9 @@ import io.github.nofuturekid.nova.BuildConfig import io.github.nofuturekid.nova.data.local.LayoutMode import io.github.nofuturekid.nova.data.model.AppSettings import io.github.nofuturekid.nova.data.model.UpdateState +import io.github.nofuturekid.nova.data.model.UpdateStatus import io.github.nofuturekid.nova.data.repository.SettingsRepository -import io.github.nofuturekid.nova.data.update.UpdateRepository +import io.github.nofuturekid.nova.data.update.UpdateTracker import io.github.nofuturekid.nova.ui.components.BtnVariant import io.github.nofuturekid.nova.ui.components.Pill import io.github.nofuturekid.nova.ui.components.SectionLabel @@ -65,8 +66,6 @@ import io.github.nofuturekid.nova.ui.components.UnraidButton import io.github.nofuturekid.nova.ui.components.UnraidCard import io.github.nofuturekid.nova.ui.components.UnraidIconButton import io.github.nofuturekid.nova.ui.components.unraidSwitchColors -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import io.github.nofuturekid.nova.ui.screens.update.UpdateDialog import io.github.nofuturekid.nova.ui.theme.AccentSwatches import io.github.nofuturekid.nova.ui.theme.Density @@ -87,7 +86,7 @@ data class SettingsUi( @HiltViewModel class SettingsViewModel @Inject constructor( private val repo: SettingsRepository, - private val updates: UpdateRepository, + private val updateTracker: UpdateTracker, ) : ViewModel() { private val layouts = combine( repo.dockerView, repo.vmsView, repo.arrayView, @@ -108,8 +107,9 @@ class SettingsViewModel @Inject constructor( ), ) - private val _checkState = MutableStateFlow(UpdateState.Checking) - val checkState: StateFlow = _checkState.asStateFlow() + /** The one shared update result, the same one the Overview notice shows. */ + val updateStatus: StateFlow = updateTracker.status + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UpdateStatus()) init { // Refresh the snapshot when the user opens Settings so they see the @@ -127,15 +127,13 @@ class SettingsViewModel @Inject constructor( repo.setIncludePrereleases(value) // Reset dismissal so a previously-hidden update becomes visible again. repo.setDismissedUpdateTag(null) - // Re-check immediately so the user sees the right answer for the new toggle. - checkNow() + // No check here: the UpdateTracker follows the setting itself, so the + // answer for the new toggle also arrives when Settings is left now. } fun checkNow() = viewModelScope.launch { if (!BuildConfig.HAS_UPDATER) return@launch - _checkState.value = UpdateState.Checking - _checkState.value = updates.check(state.value.includePrereleases) - repo.setLastUpdateCheck(System.currentTimeMillis()) + updateTracker.check() } } @@ -149,7 +147,7 @@ fun SettingsScreen( ) { val t = UnraidTheme.colors val ui by vm.state.collectAsState() - val checkState by vm.checkState.collectAsState() + val updateStatus by vm.updateStatus.collectAsState() var showUpdateDialog by remember { mutableStateOf(false) } Column( @@ -267,9 +265,17 @@ fun SettingsScreen( style = MaterialTheme.typography.labelLarge, ) } - val cs = checkState + val cs = updateStatus.latest SettingRow(label = "Latest available") { - when (cs) { + // While a check runs the row says so; the held result + // stays in the Overview notice and on the button + // below. A failed check keeps the last good result + // (the button still names it) but the row says so. + if (updateStatus.checking) { + Text("Checking…", color = t.muted, style = MaterialTheme.typography.bodyMedium) + } else if (updateStatus.failure != null) { + Text("Couldn't check", color = t.danger, style = MaterialTheme.typography.bodyMedium) + } else when (cs) { is UpdateState.Checking -> Text("Checking…", color = t.muted, style = MaterialTheme.typography.bodyMedium) is UpdateState.UpToDate -> @@ -454,7 +460,7 @@ fun SettingsScreen( } } - val cs2 = checkState + val cs2 = updateStatus.latest if (showUpdateDialog && cs2 is UpdateState.Available) { UpdateDialog( info = cs2.info, diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/update/UpdateRepositoryTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/update/UpdateRepositoryTest.kt index 507755a4..b6258e82 100644 --- a/app/src/test/kotlin/io/github/nofuturekid/nova/data/update/UpdateRepositoryTest.kt +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/update/UpdateRepositoryTest.kt @@ -101,6 +101,20 @@ class UpdateRepositoryTest { assertEquals(UpdateState.UpToDate, evaluate(body, false, "0.1.46")) } + /** + * WHY: "Latest available" must be the newest release, not the first one + * GitHub happens to list (the list is ordered by creation date, so a + * patch for an older line can come before the newest release). + */ + @Test + fun theNewestReleaseWinsWhateverOrderGitHubListsThem() { + val patchFirst = json(release("v0.1.46"), release("v0.1.47")) + assertEquals("0.1.47", available(evaluate(patchFirst, false, "0.1.45")).version) + + val betaFirst = json(release("v0.1.47-beta1", prerelease = true), release("v0.1.47")) + assertEquals("0.1.47", available(evaluate(betaFirst, true, "0.1.46")).version) + } + /** * WHY: ADR-0034 #1 fail-closed. A link rewritten to plain HTTP must not * reach the browser, whether it is the APK or the release page. diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/update/UpdateTrackerTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/update/UpdateTrackerTest.kt new file mode 100644 index 00000000..3c020c4d --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/update/UpdateTrackerTest.kt @@ -0,0 +1,575 @@ +package io.github.nofuturekid.nova.data.update + +import io.github.nofuturekid.nova.data.model.UpdateInfo +import io.github.nofuturekid.nova.data.model.UpdateState +import io.github.nofuturekid.nova.data.model.UpdateStatus +import io.github.nofuturekid.nova.data.update.UpdateRepository.Companion.evaluate +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * Since 0.1.47 the update notice and its sheet are the only way users learn + * about updates (ADR-0046). On 2026-11-13 a phone showed the Overview notice + * for v0.1.46 while Settings offered v0.1.47-beta1, and Settings itself fell + * back to v0.1.46 after being reopened. These tests pin the rules that keep + * every surface on ONE result: the tracker's status. + * + * The scenario is the one from that day: v0.1.46-beta1 installed, the stable + * v0.1.46 and the newer pre-release v0.1.47-beta1 both published. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class UpdateTrackerTest { + + private val installed = "0.1.46-beta1" + + private fun release(tag: String, prerelease: Boolean) = """ + {"tag_name":"$tag","draft":false,"prerelease":$prerelease, + "html_url":"https://github.com/hellwand/nova/releases/tag/$tag", + "body":"notes","published_at":"2026-11-13T10:00:00Z","assets":[]} + """.trimIndent() + + private val bothReleases = + "[" + release("v0.1.47-beta1", true) + "," + release("v0.1.46", false) + "]" + + /** What GitHub plus the real evaluation answer for each switch position. */ + private val github: suspend (Boolean) -> UpdateState = { include -> + evaluate(bothReleases, include, installed) + } + + private class Fixture( + scope: CoroutineScope, + val include: MutableStateFlow, + fetch: suspend (Boolean) -> UpdateState, + followsTheSetting: Boolean, + ) { + var recorded = 0 + var fetches = 0 + var recordFails: Exception? = null + val tracker = UpdateTracker( + scope, { inc -> fetches++; fetch(inc) }, include, + { recordFails?.let { throw it }; recorded++ }, followsTheSetting, + ) + suspend fun status(): UpdateStatus = tracker.status.first() + } + + /** The tracker runs its checks in the application scope; here a scope of the test that outlives the requesters. */ + private fun TestScope.fixture( + includePrereleases: Boolean, + followsTheSetting: Boolean = false, + fetch: suspend (Boolean) -> UpdateState, + ) = Fixture(backgroundScope, MutableStateFlow(includePrereleases), fetch, followsTheSetting) + + private fun tagOf(state: UpdateState): String { + assertTrue("expected Available, was $state", state is UpdateState.Available) + return (state as UpdateState.Available).info.tag + } + + /** + * WHY: the bug. Settings showed the fresh result and the notice kept an + * older one. Two separate consumers (standing in for the two view models + * that collect the status) are already listening when ONE of them asks + * for a check; both must then see that check's release. + */ + @Test + fun aCheckStoresOneResultThatEverySurfaceReads() = runTest { + val f = fixture(includePrereleases = true, fetch = github) + val notice = f.tracker.status.stateIn(backgroundScope, SharingStarted.Eagerly, UpdateStatus()) + val settingsLine = f.tracker.status.stateIn(backgroundScope, SharingStarted.Eagerly, UpdateStatus()) + + f.tracker.check() // asked from the Settings side only + runCurrent() + + assertEquals("v0.1.47-beta1", tagOf(settingsLine.value.latest)) + assertEquals(settingsLine.value, notice.value) + assertEquals("v0.1.47-beta1", notice.value.notice(dismissedTag = null)?.tag) + } + + /** + * WHY: the Settings screen started its check from a UI state that still + * held the default "off" before the real setting had loaded, so with the + * switch on it asked for stable releases only (and showed v0.1.46). + * The tracker must read the setting when the check runs. + */ + @Test + fun theCheckUsesTheSettingAsItIsWhenItRuns() = runTest { + val asked = mutableListOf() + val f = fixture(includePrereleases = false) { include -> asked += include; UpdateState.UpToDate } + + f.include.value = true + f.tracker.check() + f.include.value = false + f.tracker.check() + + assertEquals(listOf(true, false), asked) + } + + /** + * WHY: a result computed with pre-releases on must not stay on screen + * after the user turns them off (a stable-only user would be offered a + * beta), and the stable result must not hide the beta after turning them + * on. The old result is hidden at once; the fresh one replaces it. + */ + @Test + fun switchingPrereleasesNeverLeavesTheOtherSettingsResultOnScreen() = runTest { + val f = fixture(includePrereleases = true, fetch = github) + f.tracker.check() + assertEquals("v0.1.47-beta1", tagOf(f.status().latest)) + + f.include.value = false + val afterSwitchOff = f.status() + assertEquals(UpdateState.Checking, afterSwitchOff.latest) + assertNull(afterSwitchOff.notice(null)) + f.tracker.check() + assertEquals("v0.1.46", tagOf(f.status().latest)) + + f.include.value = true + assertEquals(UpdateState.Checking, f.status().latest) + assertNull(f.status().notice(null)) + f.tracker.check() + assertEquals("v0.1.47-beta1", tagOf(f.status().latest)) + } + + /** + * WHY: a user who never switched pre-releases on must never be offered + * one, even when the only newer release is a pre-release. + */ + @Test + fun aStableOnlyUserIsNeverOfferedAPrerelease() = runTest { + val onlyBeta = "[" + release("v0.1.47-beta1", true) + "]" + val f = fixture(includePrereleases = false) { include -> evaluate(onlyBeta, include, "0.1.46") } + + f.tracker.check() + + assertEquals(UpdateState.UpToDate, f.status().latest) + assertNull(f.status().notice(null)) + } + + /** + * WHY: no notice for a release that is not newer than the installed + * build (a beta user who already runs the newest pre-release). + */ + @Test + fun aResultThatIsNotNewerThanTheInstalledVersionShowsNoNotice() = runTest { + val f = fixture(includePrereleases = true) { include -> + evaluate(bothReleases, include, "0.1.47-beta1") + } + + f.tracker.check() + + assertEquals(UpdateState.UpToDate, f.status().latest) + assertNull(f.status().notice(null)) + } + + /** + * WHY: the user turns "Include pre-releases" on while the check made + * under the old setting is still in flight. That check is the slow one + * and answers last; its result belongs to the old setting and must not + * be the one that stays. The check under the new setting is the one the + * user is waiting for. + */ + @Test + fun aSettingChangeMidFlightEndsWithTheResultOfTheNewSetting() = runTest { + val answers = ArrayDeque>() + val f = fixture(includePrereleases = false) { answers.removeFirst().await() } + val slowOld = CompletableDeferred().also { answers.addLast(it) } + val fastNew = CompletableDeferred().also { answers.addLast(it) } + + val older = launch { f.tracker.check() } + runCurrent() + f.include.value = true + val newer = launch { f.tracker.check() } + runCurrent() + assertEquals("a second call under the new setting", 2, f.fetches) + fastNew.complete(github(true)) + runCurrent() + assertEquals("v0.1.47-beta1", tagOf(f.status().latest)) + + slowOld.complete(github(false)) + runCurrent() + older.join() + newer.join() + + val end = f.status() + assertEquals("v0.1.47-beta1", tagOf(end.latest)) + assertEquals("the newest check is done, the old one must not keep the flag on", false, end.checking) + } + + /** + * WHY: the opposite order of the test above. The superseded check ends + * FIRST while the check under the new setting still runs. Its end must + * not clear the in-progress flag of the newer check (the row would stop + * saying "Checking…" too early) and its result must not be stored. + */ + @Test + fun aSupersededCheckThatEndsFirstDoesNotClearTheFlagOfTheCheckStillRunning() = runTest { + val answers = ArrayDeque>() + val f = fixture(includePrereleases = false) { answers.removeFirst().await() } + val slowOld = CompletableDeferred().also { answers.addLast(it) } + val fastNew = CompletableDeferred().also { answers.addLast(it) } + + val older = launch { f.tracker.check() } + runCurrent() + f.include.value = true + val newer = launch { f.tracker.check() } + runCurrent() + slowOld.complete(github(false)) + runCurrent() + older.join() + + val whileNewIsRunning = f.status() + assertEquals("the newer check is still running", true, whileNewIsRunning.checking) + assertEquals("the old setting's result is not stored", UpdateState.Checking, whileNewIsRunning.latest) + + fastNew.complete(github(true)) + runCurrent() + newer.join() + + val end = f.status() + assertEquals(false, end.checking) + assertEquals("v0.1.47-beta1", tagOf(end.latest)) + } + + /** + * WHY: if the check itself breaks (not a clean "Error" answer but an + * exception, e.g. the write of the check time fails), the row used to + * stay on "Checking…" forever and the exception reached every screen + * that waited, which crashes the app. The requester returns normally, + * the progress ends, the failure is shown and the last good result stays. + */ + @Test + fun aCheckThatThrowsEndsTheProgressKeepsTheResultAndDoesNotReachTheRequester() = runTest { + var broken = false + val f = fixture(includePrereleases = true) { include -> + if (broken) throw IOException("connection reset") else github(include) + } + f.tracker.check() + + broken = true + f.tracker.check() // must return normally + + val failed = f.status() + assertEquals(false, failed.checking) + assertEquals("connection reset", failed.failure) + assertEquals("v0.1.47-beta1", tagOf(failed.latest)) + + broken = false + f.tracker.check() + assertNull(f.status().failure) + } + + /** WHY: same as above for the other step of a check, and for an exception without a message. */ + @Test + fun aFailingWriteOfTheCheckTimeDoesNotLeaveTheRowOnChecking() = runTest { + val f = fixture(includePrereleases = true, fetch = github) + f.recordFails = IllegalStateException() + + f.tracker.check() // must return normally + + val s = f.status() + assertEquals(false, s.checking) + assertEquals("Network error", s.failure) + } + + /** + * WHY: the app-start check is still in flight when the user opens and + * leaves Settings. Everything a screen starts used to be cancelled with + * the screen, and the answer was thrown away. Leaving a screen must not + * cancel a check: the result is stored although nobody is waiting. + */ + @Test + fun aCheckStillStoresItsResultWhenTheRequesterIsCancelledMidFlight() = runTest { + val answer = CompletableDeferred() + val f = fixture(includePrereleases = true) { answer.await() } + + val screen = launch { f.tracker.check() } + runCurrent() + screen.cancel() + runCurrent() + answer.complete(github(true)) + runCurrent() + + assertEquals("v0.1.47-beta1", tagOf(f.status().latest)) + assertEquals("v0.1.47-beta1", f.status().notice(null)?.tag) + assertEquals(1, f.recorded) + } + + /** + * WHY: app start and the Settings screen both ask within moments. The + * same setting means the same answer: the second request waits for the + * running check instead of making a second network call (GitHub's + * unauthenticated rate limit is per hour). + */ + @Test + fun twoRequestsInARowMakeOneNetworkCall() = runTest { + val answer = CompletableDeferred() + val f = fixture(includePrereleases = true) { answer.await() } + + val first = launch { f.tracker.check() } + runCurrent() + val second = launch { f.tracker.check() } + runCurrent() + answer.complete(github(true)) + runCurrent() + first.join() + second.join() + + assertEquals(1, f.fetches) + assertEquals("v0.1.47-beta1", tagOf(f.status().latest)) + } + + /** + * WHY: a tap on "Check now" looked like nothing happened once the row + * kept the last result. While a check runs the status says so, with the + * held result still in place for the Overview notice; the flag is gone + * when the result is in. + */ + @Test + fun aRunningCheckIsVisibleWhileTheHeldResultStays() = runTest { + val next = ArrayDeque>() + val f = fixture(includePrereleases = true) { next.removeFirst().await() } + val first = CompletableDeferred().also { next.addLast(it) } + val second = CompletableDeferred().also { next.addLast(it) } + assertEquals(false, f.status().checking) + + val a = launch { f.tracker.check() } + runCurrent() + assertEquals(true, f.status().checking) + first.complete(github(true)) + runCurrent() + a.join() + assertEquals(false, f.status().checking) + + val b = launch { f.tracker.check() } + runCurrent() + val during = f.status() + assertEquals(true, during.checking) + assertEquals("v0.1.47-beta1", during.notice(null)?.tag) + second.complete(UpdateState.UpToDate) + runCurrent() + b.join() + val after = f.status() + assertEquals(false, after.checking) + assertEquals(UpdateState.UpToDate, after.latest) + } + + /** + * WHY: a failed check must end the "Checking…" state (otherwise the row + * would say so forever) and still keep the last good result. + */ + @Test + fun aFailedCheckEndsTheProgressAndKeepsTheLastGoodResult() = runTest { + val next = ArrayDeque>() + val f = fixture(includePrereleases = true) { next.removeFirst().await() } + next.addLast(CompletableDeferred(github(true))) + f.tracker.check() + val failing = CompletableDeferred().also { next.addLast(it) } + + val b = launch { f.tracker.check() } + runCurrent() + assertEquals(true, f.status().checking) + failing.complete(UpdateState.Error("Network error")) + runCurrent() + b.join() + + val s = f.status() + assertEquals(false, s.checking) + assertEquals("Network error", s.failure) + assertEquals("v0.1.47-beta1", tagOf(s.latest)) + } + + /** + * WHY: a flaky network must not make a known update disappear from the + * notice. The last good result stays; Settings still learns that the + * latest check failed, and the next good check clears the failure. + */ + @Test + fun aFailedCheckKeepsTheLastGoodResultAndReportsTheFailure() = runTest { + var answer: UpdateState = github(true) + val f = fixture(includePrereleases = true) { answer } + f.tracker.check() + + answer = UpdateState.Error("GitHub HTTP 403") + f.tracker.check() + val failed = f.status() + assertEquals("v0.1.47-beta1", tagOf(failed.latest)) + assertEquals("v0.1.47-beta1", failed.notice(null)?.tag) + assertEquals("GitHub HTTP 403", failed.failure) + + answer = github(true) + f.tracker.check() + assertNull(f.status().failure) + } + + /** WHY: with nothing known yet a failure must still be visible, not "Checking…" forever. */ + @Test + fun aFailureWithoutAnyGoodResultIsReported() = runTest { + val f = fixture(includePrereleases = true) { UpdateState.Error("Network error") } + + f.tracker.check() + + val s = f.status() + assertEquals(UpdateState.Checking, s.latest) + assertEquals("Network error", s.failure) + assertNull(s.notice(null)) + } + + /** + * WHY: a good result computed under the other setting must not be + * "kept" by a failed check that follows the switch. + */ + @Test + fun aFailedCheckAfterTheSwitchDoesNotBringBackTheOtherSettingsResult() = runTest { + var answer: UpdateState = github(true) + val f = fixture(includePrereleases = true) { answer } + f.tracker.check() + + f.include.value = false + answer = UpdateState.Error("Network error") + f.tracker.check() + + assertEquals(UpdateState.Checking, f.status().latest) + assertNull(f.status().notice(null)) + } + + /** WHY: "Last check" in Settings must move after every check, good or failed. */ + @Test + fun everyCheckRecordsItsTime() = runTest { + var answer: UpdateState = UpdateState.UpToDate + val f = fixture(includePrereleases = false) { answer } + + f.tracker.check() + answer = UpdateState.Error("Network error") + f.tracker.check() + + assertEquals(2, f.recorded) + } + + /** + * WHY: the user flips "Include pre-releases" and leaves Settings at + * once. Nobody asks for a check any more, yet the Overview notice must + * get the answer for the new setting: the tracker checks by itself, once + * the user has stopped flipping. + */ + @Test + fun aChangeOfTheSettingStartsACheckWithoutAnyRequester() = runTest { + val f = fixture(includePrereleases = false, followsTheSetting = true, fetch = github) + f.tracker.check() + assertEquals("v0.1.46", tagOf(f.status().latest)) + + f.include.value = true + runCurrent() + advanceTimeBy(UpdateTracker.SETTING_DEBOUNCE_MS - 1) + runCurrent() + assertEquals("not before the user has stopped flipping", 1, f.fetches) + advanceTimeBy(1) + runCurrent() + + assertEquals(2, f.fetches) + assertEquals("v0.1.47-beta1", tagOf(f.status().latest)) + assertEquals("v0.1.47-beta1", f.status().notice(null)?.tag) + } + + /** + * WHY: every call counts against GitHub's unauthenticated rate limit. + * Flipping the switch several times in a row makes ONE call, for the + * position the user stopped on. + */ + @Test + fun flippingTheSettingQuicklyMakesOneNetworkCall() = runTest { + val asked = mutableListOf() + val f = fixture(includePrereleases = false, followsTheSetting = true) { include -> + asked += include + github(include) + } + runCurrent() + + for (position in listOf(true, false, true)) { + f.include.value = position + advanceTimeBy(UpdateTracker.SETTING_DEBOUNCE_MS / 3) + runCurrent() + } + advanceTimeBy(UpdateTracker.SETTING_DEBOUNCE_MS) + runCurrent() + + assertEquals(listOf(true), asked) + assertEquals("v0.1.47-beta1", tagOf(f.status().latest)) + } + + /** + * WHY: no check without a real change. The value the setting starts + * with is not a change, and the store re-emits the setting whenever ANY + * setting is written (for example the dismissed tag that the switch + * itself resets). + */ + @Test + fun noCheckIsStartedWithoutAChangeOfTheSetting() = runTest { + val store = MutableSharedFlow(replay = 1).also { it.tryEmit(false) } + var fetches = 0 + UpdateTracker( + backgroundScope, { fetches++; UpdateState.UpToDate }, store, {}, followsTheSetting = true, + ) + runCurrent() + advanceTimeBy(10 * UpdateTracker.SETTING_DEBOUNCE_MS) + runCurrent() + assertEquals("the starting value is no change", 0, fetches) + + store.emit(false) // another key was written, the setting is as before + advanceTimeBy(10 * UpdateTracker.SETTING_DEBOUNCE_MS) + runCurrent() + assertEquals("a write that leaves the setting alone is no change", 0, fetches) + + store.emit(true) + advanceTimeBy(UpdateTracker.SETTING_DEBOUNCE_MS) + runCurrent() + assertEquals("a real change is", 1, fetches) + } + + /** WHY: the store flavor has no updater and must make no update calls, not even after a switch change. */ + @Test + fun withoutAnUpdaterAChangeOfTheSettingMakesNoCall() = runTest { + val f = fixture(includePrereleases = false, followsTheSetting = false, fetch = github) + runCurrent() + + f.include.value = true + advanceTimeBy(10 * UpdateTracker.SETTING_DEBOUNCE_MS) + runCurrent() + + assertEquals(0, f.fetches) + } + + /** + * WHY: the notice is one decision made in one place: nothing for "up to + * date" or while checking, nothing for a tag the user dismissed, and a + * newer tag is offered again after an earlier one was dismissed. + */ + @Test + fun theNoticeFollowsTheResultAndTheDismissal() { + fun info(tag: String) = UpdateInfo( + version = tag.removePrefix("v"), tag = tag, releaseUrl = "https://github.com/hellwand/nova/releases/tag/$tag", + isPrerelease = false, releaseNotes = "", + ) + assertNull(UpdateStatus(UpdateState.UpToDate).notice(null)) + assertNull(UpdateStatus(UpdateState.Checking).notice(null)) + val available = UpdateStatus(UpdateState.Available(info("v0.1.47"))) + assertNotNull(available.notice(null)) + assertNull(available.notice(dismissedTag = "v0.1.47")) + assertEquals("v0.1.47", available.notice(dismissedTag = "v0.1.46")?.tag) + } +}