Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Boolean>,
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<Unit>)

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<UpdateStatus> = 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
}
}
15 changes: 13 additions & 2 deletions app/src/main/kotlin/io/github/nofuturekid/nova/di/AppModule.kt
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down Expand Up @@ -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) },
)
}
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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) ──────────────────
Expand Down Expand Up @@ -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>(UpdateState.Checking)
val updateState: StateFlow<UpdateState> = _updateState.asStateFlow()
val updateStatus: StateFlow<UpdateStatus> = updateTracker.status
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UpdateStatus())

val dismissedUpdateTag: StateFlow<String?> = settings.dismissedUpdateTag
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading