Home updating-veil rework: change-triggered, settle-driven, with refresh feedback #114

Merged
bvandeusen merged 4 commits from dev into main 2026-07-31 23:32:06 -04:00
3 changed files with 307 additions and 106 deletions
Showing only changes of commit 3acac985cd - Show all commits
@@ -87,6 +87,8 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.UpdateVeilController import com.fabledsword.minstrel.shared.UpdateVeilController
import com.fabledsword.minstrel.shared.VeilOutcome
import com.fabledsword.minstrel.shared.VeilSessionResult
import com.fabledsword.minstrel.shared.VeilSettleState import com.fabledsword.minstrel.shared.VeilSettleState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
@@ -100,7 +102,6 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@@ -128,8 +129,9 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
private const val RECENTLY_ADDED_GRID_ROWS = 2 private const val RECENTLY_ADDED_GRID_ROWS = 2
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440 private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// "Updating your mixes…" veil. Its lifetime is decided by // "Updating your mixes…" veil. UpdateVeilController decides both whether it
// UpdateVeilController watching the screen settle — not by a fixed delay, // appears at all — only when a refresh actually changes something — and how
// long it stays, by watching the screen settle rather than by a fixed delay,
// which lowered it while tiles and artwork were still landing (#2327). // which lowered it while tiles and artwork were still landing (#2327).
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through. // Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_WIPE_MS = 280 private const val VEIL_WIPE_MS = 280
@@ -138,8 +140,9 @@ private const val VEIL_SPINNER_DP = 22
private const val VEIL_SPINNER_STROKE_DP = 2 private const val VEIL_SPINNER_STROKE_DP = 2
private const val VEIL_LABEL_GAP_DP = 12 private const val VEIL_LABEL_GAP_DP = 12
// How long a manual pull keeps its own indicator while waiting for the // Backstop on how long a manual pull keeps its own indicator while waiting
// veil to take over, so the two don't both vanish for a frame mid-handoff. // for its successor — the veil, or the "already up to date" snackbar — so the
// two never both vanish for a frame mid-handoff.
private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
@@ -196,10 +199,13 @@ class HomeViewModel @Inject constructor(
initialValue = false, initialValue = false,
) )
private val poolMessages = Channel<String>(Channel.BUFFERED) private val snackbarMessages = Channel<String>(Channel.BUFFERED)
/** Transient snackbar messages from offline-pool taps. */ /**
val transientMessages: Flow<String> = poolMessages.receiveAsFlow() * Transient snackbar messages: offline-pool taps, playback failures, and
* the outcome of a refresh the user explicitly asked for.
*/
val transientMessages: Flow<String> = snackbarMessages.receiveAsFlow()
/** /**
* Copy for the most recent /home/index refresh failure; null once a * Copy for the most recent /home/index refresh failure; null once a
@@ -228,7 +234,7 @@ class HomeViewModel @Inject constructor(
OfflinePoolKind.LIKED -> shuffleSource.liked() OfflinePoolKind.LIKED -> shuffleSource.liked()
}.shuffled() }.shuffled()
if (tracks.isEmpty()) { if (tracks.isEmpty()) {
poolMessages.trySend("No cached ${kind.label} tracks yet") snackbarMessages.trySend("No cached ${kind.label} tracks yet")
} else { } else {
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}") player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
} }
@@ -265,14 +271,14 @@ class HomeViewModel @Inject constructor(
try { try {
val detail = libraryRepository.refreshAlbumDetail(albumId) val detail = libraryRepository.refreshAlbumDetail(albumId)
if (detail.tracks.isEmpty()) { if (detail.tracks.isEmpty()) {
poolMessages.trySend("This album has no tracks to play.") snackbarMessages.trySend("This album has no tracks to play.")
} else { } else {
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId") player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
} }
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught") e: Throwable,
) { ) {
poolMessages.trySend( snackbarMessages.trySend(
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}", "Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
) )
} }
@@ -290,14 +296,14 @@ class HomeViewModel @Inject constructor(
try { try {
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled() val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
if (tracks.isEmpty()) { if (tracks.isEmpty()) {
poolMessages.trySend("This artist has no tracks to play.") snackbarMessages.trySend("This artist has no tracks to play.")
} else { } else {
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId") player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
} }
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught") e: Throwable,
) { ) {
poolMessages.trySend( snackbarMessages.trySend(
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}", "Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
) )
} }
@@ -315,7 +321,7 @@ class HomeViewModel @Inject constructor(
suspend fun playPlaylist(playlist: PlaylistRef) { suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch { viewModelScope.launch {
playPlaylistShuffled(playlist, playlistsRepository, player) { playPlaylistShuffled(playlist, playlistsRepository, player) {
poolMessages.trySend(it) snackbarMessages.trySend(it)
} }
}.join() }.join()
} }
@@ -323,9 +329,8 @@ class HomeViewModel @Inject constructor(
/** /**
* Pulls /home/index, the playlists list and the system-playlist * Pulls /home/index, the playlists list and the system-playlist
* status. Returns true when the load-bearing /home/index pull * status. Returns true when the load-bearing /home/index pull
* succeeded — the veil controller retries on false, so this must * succeeded — the veil controller retries on false and reports the
* report failure rather than swallow it the way the fire-and-forget * outcome, so this must report failure rather than swallow it.
* [refresh] entry point does.
*/ */
private suspend fun runRefresh(): Boolean = coroutineScope { private suspend fun runRefresh(): Boolean = coroutineScope {
// /home/index is the load-bearing pull: its failure drives the // /home/index is the load-bearing pull: its failure drives the
@@ -352,19 +357,31 @@ class HomeViewModel @Inject constructor(
home.await() home.await()
} }
/** Unveiled refresh, for the Error state's explicit Retry button. */ /**
fun refresh(): Job = viewModelScope.launch { runRefresh() } * The Error state's explicit Retry button. User-initiated, so it gets
* the controller's retries and reports its outcome; over an empty cache
* there's no content to protect, so no veil goes up.
*/
fun retry() = veil.request(userInitiated = true)
/** /**
* Manual pull-to-refresh. Goes behind the veil like every other * Manual pull-to-refresh. Goes behind the veil like every other refresh
* refresh (operator call, 2026-07-31: the churn a pull causes is * (operator call, 2026-07-31: the churn a pull causes is identical to
* identical to the automatic paths, and a small spinner didn't hide * the automatic paths, and a small spinner didn't hide it).
* it). Suspends only long enough to hand the indicator off to the *
* veil; the refresh itself continues in the controller's session. * Suspends until the veil has taken over OR the session has finished,
* so the pull indicator hands off to exactly one successor: the veil if
* content changed, the "Already up to date" snackbar if it didn't. The
* timeout is only a backstop against a session that outlives it.
*/ */
suspend fun refreshFromPull() { suspend fun refreshFromPull() {
veil.request() val before = veil.finishedSessions.value
withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) { veil.visible.first { it } } veil.request(userInitiated = true)
withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) {
combine(veil.visible, veil.finishedSessions) { veiled, finished ->
veiled || finished != before
}.first { it }
}
} }
val uiState: StateFlow<UiState<HomeSections>> = val uiState: StateFlow<UiState<HomeSections>> =
@@ -458,14 +475,38 @@ class HomeViewModel @Inject constructor(
// right before the cache emits. // right before the cache emits.
uiState.value is UiState.Success || homeRepository.hasCachedIndex() uiState.value is UiState.Success || homeRepository.hasCachedIndex()
}, },
onSessionEnd = ::reportRefreshOutcome,
work = ::runRefresh, work = ::runRefresh,
) )
/** /**
* True while the "Updating your mixes…" veil should be raised. The * Tells the user how a refresh *they asked for* went, in the one case
* controller holds it until Home actually settles — sections swapped, * the veil can't: when nothing changed there's no veil to see, and a
* tiles hydrated, artwork loaded — instead of for a fixed delay after * pull that produces no visible response at all reads as broken.
* the network pull returns (issue #2327). *
* Only user-initiated sessions say anything. The same outcome from a
* background check — the initial load, the 03:00 rebuild, a reconnect —
* is noise, and "Already up to date" on every launch would be worse
* than silence (operator's call, 2026-07-31).
*/
private fun reportRefreshOutcome(result: VeilSessionResult) {
if (!result.userInitiated) return
when (result.outcome) {
// The veil was the feedback.
VeilOutcome.CHANGED -> return
VeilOutcome.UNCHANGED -> snackbarMessages.trySend("Already up to date")
VeilOutcome.FAILED -> snackbarMessages.trySend("Couldn't check for updates")
}
}
/**
* True while the "Updating your mixes…" veil should be raised.
*
* Raised only when a refresh actually changes what's on screen, and then
* held until Home settles — tiles hydrated, artwork loaded — instead of
* for a fixed delay after the network pull returns (issue #2327). A
* refresh that returns what's already cached shows no veil at all;
* [reportRefreshOutcome] tells the user instead, if they asked.
*/ */
val isUpdating: StateFlow<Boolean> = veil.visible val isUpdating: StateFlow<Boolean> = veil.visible
@@ -589,7 +630,7 @@ private fun HomeStateCrossfade(
is UiState.Error -> ErrorRetry( is UiState.Error -> ErrorRetry(
title = "Couldn't load home", title = "Couldn't load home",
message = s.message, message = s.message,
onRetry = { viewModel.refresh() }, onRetry = { viewModel.retry() },
) )
is UiState.Success -> HomeSuccessContent( is UiState.Success -> HomeSuccessContent(
sections = s.data, sections = s.data,
@@ -12,8 +12,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean
// Once raised, the veil stays up at least this long. Without a floor a // Once raised, the veil stays up at least this long. Without a floor a
// no-op refresh wipes on and straight back off, which reads as a glitch. // no-op refresh wipes on and straight back off, which reads as a glitch.
@@ -61,9 +63,33 @@ data class VeilSettleState(
val quiescent: Boolean, val quiescent: Boolean,
) )
/** What a finished session did, so callers can report it if they want. */
enum class VeilOutcome {
/** Content changed, and the veil covered the churn. */
CHANGED,
/** The refresh worked, but nothing on screen moved — already current. */
UNCHANGED,
/** Every attempt failed. */
FAILED,
}
/** /**
* Drives an "updating" overlay from *observed content settling* rather * One session's result, plus whether a user explicitly asked for it.
* than from a fixed delay. *
* [userInitiated] is what lets a caller tell feedback from noise: a user
* who pulled to refresh is owed an answer even when the answer is "nothing
* changed", while the same outcome from a background check is noise.
*/
data class VeilSessionResult(
val outcome: VeilOutcome,
val userInitiated: Boolean,
)
/**
* Drives an "updating" overlay from *observed content change and settling*
* rather than from a fixed delay.
* *
* The problem this replaces: a veil held for `refresh().join() + 500ms` * The problem this replaces: a veil held for `refresh().join() + 500ms`
* lowers while the screen is still moving, because finishing the network * lowers while the screen is still moving, because finishing the network
@@ -73,31 +99,44 @@ data class VeilSettleState(
* two overlapping refreshes finishes first, wiping the veil off mid-update * two overlapping refreshes finishes first, wiping the veil off mid-update
* (issue #2327). * (issue #2327).
* *
* So instead: raise, run [work], then hold until [settleSignal] reports * So instead: run [work], raise only if the content actually changes, then
* the screen has stopped changing for [VeilTimings.quietMs] AND is * hold until [settleSignal] reports the screen has stopped changing for
* quiescent — bounded below by [VeilTimings.minHoldMs] so it can never * [VeilTimings.quietMs] AND is quiescent — bounded below by
* flash, and above by [VeilTimings.maxHoldMs] so it can never strand. * [VeilTimings.minHoldMs] so it can never flash, and above by
* [VeilTimings.maxHoldMs] so it can never strand.
*
* The raise is deliberately *reactive*: a refresh that returns what's
* already on screen — the common case on a launch over a warm cache —
* raises nothing at all, because a veil over an unchanged screen hides
* nothing and only delays first paint. The cost is that the veil arrives
* one emission after the change, so a single atomic content swap shows
* through; everything messier that follows it (tile hydration, then
* artwork) still lands behind the veil.
* *
* Overlapping triggers extend the running session instead of racing it, * Overlapping triggers extend the running session instead of racing it,
* so the veil stays up continuously rather than lowering and re-raising. * so the veil stays up continuously rather than lowering and re-raising.
* *
* Failure is quiet by design: [work] gets [VeilTimings.attempts] tries * Failure is quiet at this layer: [work] gets [VeilTimings.attempts] tries
* behind the veil, and if they all fail the veil simply wipes off over * behind the veil, and if they all fail the veil simply wipes off over the
* the cached content with no error surfaced. Giving up here ends only * cached content. Giving up ends only *this* session — it sets no latch and
* *this* session — it sets no latch and blocks nothing, so the caller's * blocks nothing, so the caller's own recovery paths (reconnect re-pull,
* own recovery paths (reconnect re-pull, freshness sweeps, the next * freshness sweeps, the next event, a manual pull) keep retrying afterwards
* event, a manual pull) keep retrying afterwards exactly as before. * exactly as before. Callers that want to surface a failure can do it from
* [onSessionEnd] instead.
* *
* @param work one refresh attempt; returns true when it succeeded. * @param work one refresh attempt; returns true when it succeeded.
* @param shouldVeil sampled at session start — "is there cached content * @param shouldVeil sampled at session start — "is there cached content
* this refresh is about to overwrite?". False means a cold load, where * this refresh is about to overwrite?". False means a cold load, where
* a skeleton is the right affordance, and the work runs unveiled. * a skeleton is the right affordance, and the work runs unveiled.
* @param onSessionEnd called once per finished session, on the controller's
* coroutine. Use it for user-facing feedback the veil itself can't give.
*/ */
class UpdateVeilController( class UpdateVeilController(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val settleSignal: Flow<VeilSettleState>, private val settleSignal: Flow<VeilSettleState>,
private val shouldVeil: suspend () -> Boolean, private val shouldVeil: suspend () -> Boolean,
private val timings: VeilTimings = VeilTimings(), private val timings: VeilTimings = VeilTimings(),
private val onSessionEnd: (VeilSessionResult) -> Unit = {},
private val work: suspend () -> Boolean, private val work: suspend () -> Boolean,
) { ) {
private val visibleInternal = MutableStateFlow(false) private val visibleInternal = MutableStateFlow(false)
@@ -105,10 +144,25 @@ class UpdateVeilController(
/** True while the veil should be drawn over the screen. */ /** True while the veil should be drawn over the screen. */
val visible: StateFlow<Boolean> = visibleInternal.asStateFlow() val visible: StateFlow<Boolean> = visibleInternal.asStateFlow()
private val finishedInternal = MutableStateFlow(0)
/**
* Increments as each session ends. Lets a caller wait for "this
* refresh is done" without knowing whether a veil ever went up —
* a pull-to-refresh indicator needs exactly that, since an unchanged
* refresh never raises one.
*/
val finishedSessions: StateFlow<Int> = finishedInternal.asStateFlow()
// Conflated: a burst of triggers (reconnect + rebuild event arriving // Conflated: a burst of triggers (reconnect + rebuild event arriving
// together) collapses into one follow-up pass, not a queue of them. // together) collapses into one follow-up pass, not a queue of them.
private val requests = Channel<Unit>(Channel.CONFLATED) private val requests = Channel<Unit>(Channel.CONFLATED)
// Sticky across a conflated burst: conflation drops the older token, so
// the "a user asked for this" bit can't ride on it. If ANY coalesced
// trigger was the user's, the session still owes them an answer.
private val userAsked = AtomicBoolean(false)
init { init {
// One consumer, so sessions are serialised by construction: two // One consumer, so sessions are serialised by construction: two
// triggers can never each own a piece of the veil's state. // triggers can never each own a piece of the veil's state.
@@ -124,28 +178,31 @@ class UpdateVeilController(
* Ask for a refresh. Safe to call from any trigger at any rate — * Ask for a refresh. Safe to call from any trigger at any rate —
* calls arriving during a session extend it rather than starting a * calls arriving during a session extend it rather than starting a
* competing one. * competing one.
*
* @param userInitiated true when a person explicitly asked (pull to
* refresh, a Retry button), which is what [VeilSessionResult] carries
* through to [onSessionEnd].
*/ */
fun request() { fun request(userInitiated: Boolean = false) {
if (userInitiated) userAsked.set(true)
requests.trySend(Unit) requests.trySend(Unit)
} }
private suspend fun runSession() { private suspend fun runSession() {
// Sampled at both ends of the work: a trigger folded in mid-session
// (see [drainWork]) may have been the user's, and they're still owed
// an answer for it.
val askedAtStart = userAsked.getAndSet(false)
if (!shouldVeil()) { if (!shouldVeil()) {
drainWork() // Cold load: the skeleton is the right affordance, so no veil.
// Succeeding here did change the screen — from nothing to
// something — so it reports CHANGED, never "already up to date".
val ok = drainWork()
finish(succeeded = ok, changed = ok, userInitiated = askedAtStart)
return return
} }
// Raise only once there's content on screen to hide. Over a warm
// cache that's within a frame or two of here — long before the
// network pull lands — so the churn still gets covered. But on a
// genuinely cold load, content appears only *because* this work
// produced it, and veiling that would delay first paint to hide
// nothing.
val raised = CompletableDeferred<Unit>() val raised = CompletableDeferred<Unit>()
val raiser = scope.launch { val raiser = scope.launch { raiseWhenContentChanges(raised) }
settleSignal.first { it.hasContent }
visibleInternal.value = true
raised.complete(Unit)
}
// Floor and ceiling are measured from the raise, not the request, // Floor and ceiling are measured from the raise, not the request,
// so a late raise still gets its full no-flash minimum. // so a late raise still gets its full no-flash minimum.
val floor = scope.launch { val floor = scope.launch {
@@ -157,12 +214,16 @@ class UpdateVeilController(
delay(timings.maxHoldMs) delay(timings.maxHoldMs)
visibleInternal.value = false visibleInternal.value = false
} }
var succeeded = false
try { try {
drainWork() succeeded = drainWork()
// Always wait for the settle, never conditionally on `visible`: // Always wait for the settle, never conditionally on `visible`:
// work that finishes without suspending would otherwise reach // work that finishes without suspending would otherwise reach
// here before the raiser has been dispatched, tear the session // here before the raiser has been dispatched, tear the session
// down, and leave the churn uncovered. // down, and leave the churn uncovered. This wait is also what
// makes `raised.isCompleted` below a trustworthy "did anything
// change?" — a change landing just after the pull returns still
// gets seen.
withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() } withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() }
// Honour the no-flash minimum before lowering. Deliberately in // Honour the no-flash minimum before lowering. Deliberately in
// the try and not the finally: on cancellation the scope is // the try and not the finally: on cancellation the scope is
@@ -175,23 +236,64 @@ class UpdateVeilController(
ceiling.cancel() ceiling.cancel()
floor.cancel() floor.cancel()
visibleInternal.value = false visibleInternal.value = false
finish(
succeeded = succeeded,
changed = raised.isCompleted,
userInitiated = askedAtStart,
)
} }
} }
private suspend fun drainWork() { /**
do { * Raises the veil the moment the screen's content differs from what was
runWorkWithRetries() * already on it — and never, if this refresh turns out to be a no-op.
// A trigger that arrived mid-session gets folded into this one. *
} while (requests.tryReceive().isSuccess) * The baseline is the first state that HAS content, not simply the first
* state: over a warm cache the cached rows paint a moment after the
* session starts, and treating that first paint as "a change" would veil
* every launch, which is the whole thing this avoids.
*/
private suspend fun raiseWhenContentChanges(raised: CompletableDeferred<Unit>) {
val baseline = settleSignal.first { it.hasContent }
settleSignal.first { it.hasContent && it.contentKey != baseline.contentKey }
visibleInternal.value = true
raised.complete(Unit)
} }
private suspend fun runWorkWithRetries() { private fun finish(succeeded: Boolean, changed: Boolean, userInitiated: Boolean) {
val outcome = when {
!succeeded -> VeilOutcome.FAILED
changed -> VeilOutcome.CHANGED
else -> VeilOutcome.UNCHANGED
}
onSessionEnd(
VeilSessionResult(
outcome = outcome,
// Fold in a mid-session request from the user.
userInitiated = userInitiated || userAsked.getAndSet(false),
),
)
finishedInternal.update { it + 1 }
}
/** True when the refresh eventually succeeded. */
private suspend fun drainWork(): Boolean {
var succeeded: Boolean
do {
succeeded = runWorkWithRetries()
// A trigger that arrived mid-session gets folded into this one.
} while (requests.tryReceive().isSuccess)
return succeeded
}
private suspend fun runWorkWithRetries(): Boolean {
repeat(timings.attempts) { attempt -> repeat(timings.attempts) { attempt ->
if (work()) return if (work()) return true
if (attempt < timings.attempts - 1) { if (attempt < timings.attempts - 1) {
delay(timings.retryBackoffMs * (attempt + 1)) delay(timings.retryBackoffMs * (attempt + 1))
} }
} }
return false
} }
/** /**
@@ -15,7 +15,6 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
private const val WORK_MS = 1_000L private const val WORK_MS = 1_000L
private const val SLOW_WORK_MS = 5_000L
private const val CHURN_ROUNDS = 5 private const val CHURN_ROUNDS = 5
private const val ART_IN_FLIGHT = 3 private const val ART_IN_FLIGHT = 3
private const val SUCCEED_ON_ATTEMPT = 3 private const val SUCCEED_ON_ATTEMPT = 3
@@ -29,9 +28,9 @@ private const val QUIET_WINDOWS_TO_OUTLAST = 3
private const val DRAIN_MS = 3_000L private const val DRAIN_MS = 3_000L
/** /**
* The veil's job is to stay up until the screen has stopped moving. Each * The veil's job is to go up only when content actually changes, and then to
* test pins one of the ways the previous fixed-delay implementation * stay up until the screen has stopped moving. Each test pins one of the ways
* lowered it too early (issue #2327). * the original fixed-delay implementation got that wrong (issue #2327).
* *
* The controller is built on `backgroundScope` throughout: its consumer * The controller is built on `backgroundScope` throughout: its consumer
* loop runs forever, so hanging it off the test's own scope would stop * loop runs forever, so hanging it off the test's own scope would stop
@@ -64,7 +63,7 @@ class UpdateVeilControllerTest {
VeilSettleState(contentKey = key, hasContent = hasContent, quiescent = art == 0) VeilSettleState(contentKey = key, hasContent = hasContent, quiescent = art == 0)
} }
/** Something visibly changed — re-arms the quiet window. */ /** Content visibly changed — what the veil exists to cover. */
fun churn() { fun churn() {
state.value = state.value.copy(first = state.value.first + 1) state.value = state.value.copy(first = state.value.first + 1)
} }
@@ -81,16 +80,18 @@ class UpdateVeilControllerTest {
private fun TestScope.controllerOn( private fun TestScope.controllerOn(
screen: FakeScreen, screen: FakeScreen,
shouldVeil: suspend () -> Boolean = { true }, shouldVeil: suspend () -> Boolean = { true },
onSessionEnd: (VeilSessionResult) -> Unit = {},
work: suspend () -> Boolean, work: suspend () -> Boolean,
) = UpdateVeilController( ) = UpdateVeilController(
scope = backgroundScope, scope = backgroundScope,
settleSignal = screen.signal, settleSignal = screen.signal,
shouldVeil = shouldVeil, shouldVeil = shouldVeil,
timings = timings, timings = timings,
onSessionEnd = onSessionEnd,
work = work, work = work,
) )
/** Records every visibility transition, so a blink can't hide. */ /** Records every visibility transition, so an extra raise can't hide. */
private fun TestScope.recordVisibility(controller: UpdateVeilController): List<Boolean> { private fun TestScope.recordVisibility(controller: UpdateVeilController): List<Boolean> {
val seen = mutableListOf<Boolean>() val seen = mutableListOf<Boolean>()
backgroundScope.launch { controller.visible.collect { seen.add(it) } } backgroundScope.launch { controller.visible.collect { seen.add(it) } }
@@ -104,6 +105,9 @@ class UpdateVeilControllerTest {
controller.request() controller.request()
runCurrent() runCurrent()
// The pull's write lands: content changed, so the veil goes up.
screen.churn()
runCurrent()
assertTrue(controller.visible.value, "veil is up while the screen is still moving") assertTrue(controller.visible.value, "veil is up while the screen is still moving")
// Tiles hydrating one after another, each inside the quiet window. // Tiles hydrating one after another, each inside the quiet window.
@@ -126,6 +130,9 @@ class UpdateVeilControllerTest {
screen.artLoading(ART_IN_FLIGHT) screen.artLoading(ART_IN_FLIGHT)
controller.request() controller.request()
runCurrent()
screen.churn()
runCurrent()
// Well past the quiet window and the floor — but art is still in // Well past the quiet window and the floor — but art is still in
// flight, so lowering now would show the covers popping in. // flight, so lowering now would show the covers popping in.
advanceTimeBy(timings.minHoldMs + timings.quietMs * QUIET_WINDOWS_TO_OUTLAST) advanceTimeBy(timings.minHoldMs + timings.quietMs * QUIET_WINDOWS_TO_OUTLAST)
@@ -137,43 +144,53 @@ class UpdateVeilControllerTest {
} }
@Test @Test
fun `veil holds through failed attempts and their retries`() = runTest { fun `retries quietly, then veils the churn the successful attempt produces`() = runTest {
val screen = FakeScreen() val screen = FakeScreen()
var attempts = 0 var attempts = 0
val controller = controllerOn(screen) { val controller = controllerOn(screen) {
attempts++ attempts++
attempts >= SUCCEED_ON_ATTEMPT // fail twice, succeed on the third val succeeded = attempts >= SUCCEED_ON_ATTEMPT // fail twice
// Only a pull that worked writes anything.
if (succeeded) screen.churn()
succeeded
} }
val seen = recordVisibility(controller) val seen = recordVisibility(controller)
controller.request() controller.request()
runCurrent() runCurrent()
assertTrue(controller.visible.value) // A failed pull changes nothing, so there is nothing to hide yet —
advanceTimeBy(timings.retryBackoffMs + 1) // the retries happen with no veil at all.
assertTrue(controller.visible.value, "veil stays up across the backoff") assertFalse(controller.visible.value, "no veil over a pull that changed nothing")
advanceTimeBy(DRAIN_MS) advanceTimeBy(DRAIN_MS)
assertEquals(SUCCEED_ON_ATTEMPT, attempts, "retries until the pull succeeds") assertEquals(SUCCEED_ON_ATTEMPT, attempts, "retries until the pull succeeds")
assertEquals(listOf(false, true, false), seen, "raised once, lowered once") assertEquals(
listOf(false, true, false),
seen,
"the veil went up once, over the churn the retry finally produced",
)
} }
@Test @Test
fun `giving up lowers the veil quietly and does not block later requests`() = runTest { fun `giving up is silent, reports FAILED, and does not block later requests`() = runTest {
val screen = FakeScreen() val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
var attempts = 0 var attempts = 0
var succeed = false var succeed = false
val controller = controllerOn(screen) { val controller = controllerOn(screen, onSessionEnd = { results += it }) {
attempts++ attempts++
succeed succeed
} }
controller.request() controller.request(userInitiated = true)
advanceTimeBy(DRAIN_MS) advanceTimeBy(DRAIN_MS)
assertEquals(timings.attempts, attempts, "exhausts its attempts") assertEquals(timings.attempts, attempts, "exhausts its attempts")
assertFalse(controller.visible.value, "gives up silently") assertFalse(controller.visible.value, "no veil — a failed pull changed nothing")
assertEquals(VeilOutcome.FAILED, results.single().outcome)
assertTrue(results.single().userInitiated, "the user asked, so they're owed an answer")
// The operator's requirement: giving up must not latch anything off // Giving up must not latch anything off — the reconnect-driven
// — the reconnect-driven recovery still gets to try again later. // recovery still gets to try again later.
succeed = true succeed = true
controller.request() controller.request()
advanceTimeBy(DRAIN_MS) advanceTimeBy(DRAIN_MS)
@@ -187,6 +204,7 @@ class UpdateVeilControllerTest {
val controller = controllerOn(screen) { val controller = controllerOn(screen) {
started++ started++
delay(WORK_MS) delay(WORK_MS)
screen.churn()
true true
} }
val seen = recordVisibility(controller) val seen = recordVisibility(controller)
@@ -208,7 +226,10 @@ class UpdateVeilControllerTest {
@Test @Test
fun `a never-settling screen still releases the veil at the ceiling`() = runTest { fun `a never-settling screen still releases the veil at the ceiling`() = runTest {
val screen = FakeScreen() val screen = FakeScreen()
val controller = controllerOn(screen) { true } val controller = controllerOn(screen) {
screen.churn()
true
}
screen.artLoading(1) // an image that never completes screen.artLoading(1) // an image that never completes
controller.request() controller.request()
@@ -217,40 +238,77 @@ class UpdateVeilControllerTest {
} }
@Test @Test
fun `a cold load runs unveiled`() = runTest { fun `an unchanged refresh never raises the veil and reports UNCHANGED`() = runTest {
val screen = FakeScreen() val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
// Succeeds without writing anything — the common case on a launch
// over a warm cache, where the server returns what's already cached.
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
val seen = recordVisibility(controller)
controller.request(userInitiated = true)
advanceTimeBy(DRAIN_MS)
assertEquals(listOf(false), seen, "a veil over an unchanged screen would hide nothing")
assertEquals(VeilOutcome.UNCHANGED, results.single().outcome)
assertTrue(results.single().userInitiated, "so the caller can say 'already up to date'")
}
@Test
fun `cached content painting is not mistaken for a change`() = runTest {
// Warm cache that hasn't painted yet: the rows arrive a moment after
// the session starts. Treating that first paint as churn would veil
// every single launch.
val screen = FakeScreen(hasContent = false)
val controller = controllerOn(screen) { true }
controller.request()
runCurrent()
screen.contentAppears()
advanceTimeBy(DRAIN_MS)
assertFalse(controller.visible.value, "first paint is not churn")
}
@Test
fun `a background trigger coalescing with the user's does not swallow their answer`() =
runTest {
val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
// Conflation drops the older token, so the "a user asked" bit
// cannot ride on it — it's tracked separately for exactly this.
controller.request(userInitiated = true)
controller.request()
advanceTimeBy(DRAIN_MS)
assertTrue(
results.first().userInitiated,
"the user's request must not be conflated away",
)
}
@Test
fun `a cold load runs unveiled and is never reported as already up to date`() = runTest {
val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
var ran = false var ran = false
val controller = controllerOn( val controller = controllerOn(
screen, screen,
shouldVeil = { false }, // empty cache: the skeleton owns this shouldVeil = { false }, // empty cache: the skeleton owns this
onSessionEnd = { results += it },
) { ) {
ran = true ran = true
true true
} }
controller.request() controller.request(userInitiated = true)
advanceTimeBy(DRAIN_MS) advanceTimeBy(DRAIN_MS)
assertTrue(ran, "the refresh still happens") assertTrue(ran, "the refresh still happens")
assertFalse(controller.visible.value, "but no veil over a skeleton") assertFalse(controller.visible.value, "but no veil over a skeleton")
} // It went from nothing to something — that IS a change.
assertEquals(VeilOutcome.CHANGED, results.single().outcome)
@Test
fun `veil waits for content to paint before raising`() = runTest {
val screen = FakeScreen(hasContent = false)
val controller = controllerOn(screen) {
delay(SLOW_WORK_MS)
true
}
controller.request()
advanceTimeBy(WORK_MS)
assertFalse(controller.visible.value, "nothing to hide until content is up")
screen.contentAppears()
runCurrent()
assertTrue(controller.visible.value, "raises the moment cached content paints")
advanceTimeBy(SLOW_WORK_MS + DRAIN_MS)
assertFalse(controller.visible.value)
} }
} }