diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt index 21a21dcc5..5382e2876 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt @@ -22,6 +22,7 @@ import android.os.Build import androidx.core.content.ContextCompat import java.util.UUID import org.matrix.vector.manager.BuildConfig +import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.logE import org.matrix.vector.manager.logW import org.matrix.vector.manager.R @@ -72,11 +73,68 @@ object LaunchShortcut { .onFailure { logW("actions: pin support query failed", it) } .getOrDefault(false) + /** + * Whether *some* launcher on this device holds the shortcut. + * + * Not the same question as whether the reader can see it, which is [isPinnedHere]. The pin flag + * is a property of the shortcut and not of the launcher that asked for it, so this stays true + * for a launcher that has since been replaced. + */ fun isPinned(context: Context): Boolean = runCatching { manager(context)?.pinnedShortcuts.orEmpty().any { it.id == ID } } .onFailure { logW("actions: pinned shortcut query failed", it) } .getOrDefault(false) + /** + * Whether the shortcut is on the home screen the reader is actually looking at. + * + * Installing a different launcher does not carry pinned shortcuts across — the new one starts + * with an empty home screen — but [isPinned] keeps saying yes, because the platform records the + * pin on the shortcut rather than on the pair and only lets the active launcher read the + * per-launcher sets. So the row offering to create one showed a tick over a home screen with no + * Vector on it, and there was no way to ask for another: #883. + * + * The launchers that have pinned it are therefore remembered on this side, in + * SettingsRepository. A device with nothing recorded is one that pinned the shortcut before this + * was written, or by some route that never came back through [request]; rather than tell that + * reader their shortcut is missing, the launcher they are on now is adopted as its owner, which + * is almost certainly true and makes the *next* launcher change detectable. + */ + fun isPinnedHere(context: Context): Boolean { + if (!isPinned(context)) return false + // No answer is not a mismatch. A device whose default home cannot be resolved is not one we + // may tell that its shortcut has gone. + val launcher = currentLauncher(context) ?: return true + val settings = ServiceLocator.settings + val known = settings.shortcutLaunchers() + if (known.isEmpty()) { + settings.noteShortcutLauncher(launcher) + return true + } + return launcher in known + } + + /** + * The package drawing the home screen, or null when the device will not say which. + * + * Null covers two cases that must both be read as "do not know": the query failing, and it + * resolving to the platform's own chooser, which is what a device with several launchers and no + * default answers. Neither is evidence that the shortcut is somewhere the reader cannot see. + */ + fun currentLauncher(context: Context): String? = + runCatching { + context.packageManager + .resolveActivity( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), + PackageManager.MATCH_DEFAULT_ONLY, + ) + ?.activityInfo + ?.packageName + ?.takeIf { it != RESOLVER_PACKAGE } + } + .onFailure { logW("actions: current launcher query failed", it) } + .getOrNull() + /** * Asks the launcher to pin the shortcut, calling [onPinned] if and when it does. * @@ -90,7 +148,19 @@ object LaunchShortcut { if (!isParasitic(context)) return false val shortcut = build(context) ?: return false return runCatching { - manager(context)?.requestPinShortcut(shortcut, callback(context, onPinned)) == true + val confirmed = + callback(context) { + // Recorded here rather than when the request is made, because the launcher + // may refuse or the reader may dismiss its dialog, and a launcher noted as + // holding a shortcut it never took would suppress the offer for good. Read + // again rather than captured: this runs after the launcher's own dialog, + // which is long enough for the default home to have changed. + currentLauncher(context)?.let { + ServiceLocator.settings.noteShortcutLauncher(it) + } + onPinned() + } + manager(context)?.requestPinShortcut(shortcut, confirmed) == true } .onFailure { logE("actions: pin shortcut request failed", it) } .getOrDefault(false) @@ -244,6 +314,9 @@ object LaunchShortcut { /** The synthesised entry every package has, used as the shortcut's publishing activity on Q+. */ private const val APP_DETAILS_ACTIVITY = "android.app.AppDetailsActivity" +/** What CATEGORY_HOME resolves to when the device has several launchers and no default. */ +private const val RESOLVER_PACKAGE = "android" + /** Held by the system and by nothing installable, so only the platform can confirm a pin. */ private const val CONFIRMATION_PERMISSION = "android.permission.CREATE_USERS" diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt index aa226dc6b..927f2944f 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt @@ -2,6 +2,7 @@ package org.matrix.vector.manager.data.repository import android.content.Context import android.content.SharedPreferences +import java.time.LocalDate import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -323,6 +324,77 @@ class SettingsRepository(context: Context) { _launcherPromptDismissed.value = true } + /** + * Which launchers are known to be holding a pinned Vector shortcut. + * + * The platform will not say. `ShortcutManager.getPinnedShortcuts` answers for *any* launcher at + * once — the pin flag lives on the shortcut, not on the pair — and the per-launcher sets are + * only readable by a caller that is itself the active launcher. So a device that pinned the + * shortcut, then installed a different launcher, is told it already has one while its home + * screen has nothing on it, which is what #883 reported. + * + * A set rather than a single package because pinning on a second launcher does not unpin the + * first, and someone who keeps two and switches between them should not be offered a shortcut + * they already have on both. What the set cannot represent is a shortcut *removed* from one of + * several launchers holding it: nothing tells us which one lost it, and the platform still + * reports the shortcut pinned. That row will read as done until the last copy is gone. + */ + fun shortcutLaunchers(): Set = + prefs.getStringSet("shortcut_launchers", emptySet()).orEmpty().toSet() + + fun noteShortcutLauncher(packageName: String) { + val known = shortcutLaunchers() + if (packageName in known) return + // A set of our own: `getStringSet` hands back the instance the preferences hold, which the + // platform documents as not ours to modify. + prefs.edit().putStringSet("shortcut_launchers", HashSet(known + packageName)).apply() + } + + // --- the status badge's own hint ---------------------------------------------------------- + + /** + * How many times *today* the status badge was used to open System status. + * + * The badge is the only way to those settings, and nothing about a tick says so — #856. The + * header answers that by having the tick turn into a gear now and then, and this is what stops + * it: a reader who has opened the page several times today plainly knows where it is, and a gear + * that keeps appearing after that is noise on the one part of the header whose job is to report + * a state. How many is several is HomeViewModel's to say — this only counts. + * + * Counted per day rather than for good because the hint costs nothing to offer again and the + * knowledge does fade — and because a count that only ever grows would retire the hint on the + * strength of an afternoon spent on that page months ago. The day is stored beside the count and + * a stale one reads as zero, so no reset has to run at midnight. + */ + private val _statusBadgeOpens = MutableStateFlow(statusBadgeOpensToday()) + val statusBadgeOpens: StateFlow = _statusBadgeOpens.asStateFlow() + + fun noteStatusBadgeOpened() { + val today = LocalDate.now().toEpochDay() + // Against the stored day, not against the flow: a session left open across midnight holds + // yesterday's count in memory, and adding to it would carry it into today. + val next = + if (prefs.getLong("status_badge_day", 0L) == today) _statusBadgeOpens.value + 1 else 1 + prefs.edit().putLong("status_badge_day", today).putInt("status_badge_opens", next).apply() + _statusBadgeOpens.value = next + } + + /** + * Re-reads the count against today's date. + * + * Called when Home is opened, which is the only moment the hint can start running again, and is + * what lets a session that has crossed midnight — parasitically rare, since the host process is + * killed constantly, but free to handle — offer it afresh. + */ + fun refreshStatusBadgeOpens() { + _statusBadgeOpens.value = statusBadgeOpensToday() + } + + private fun statusBadgeOpensToday(): Int = + if (prefs.getLong("status_badge_day", 0L) == LocalDate.now().toEpochDay()) + prefs.getInt("status_badge_opens", 0) + else 0 + // --- Logs --- /** diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt index 51b85b60c..cbcacf783 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt @@ -1,6 +1,8 @@ package org.matrix.vector.manager.ui.components import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState @@ -28,13 +30,17 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.PriorityHigh import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,11 +48,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import kotlin.random.Random +import kotlinx.coroutines.delay import org.matrix.vector.manager.R import org.matrix.vector.manager.ui.components.ambience.AmbienceKind import org.matrix.vector.manager.ui.components.ambience.AmbientSurface @@ -92,6 +102,8 @@ fun StatusHeader( hasUpdate: Boolean, onOpenUpdate: () -> Unit, ambience: AmbienceKind, + /** Whether the badge should still be showing that it opens something. See [StatusIndicator]. */ + hintStatus: Boolean, onOpenStatus: () -> Unit, onOpenAppearance: () -> Unit, onOpenLanguage: () -> Unit, @@ -188,6 +200,7 @@ fun StatusHeader( StatusIndicator( state = state, tint = onContainer, + hint = hintStatus, onClick = onOpenStatus, contentDescription = stringResource(R.string.status_open_details), ) @@ -297,11 +310,21 @@ fun StatusHeader( * * When active it breathes: a slow, low-amplitude pulse that reads as "running" at a glance, and * stops dead in the other two states, so stillness itself carries meaning. + * + * It is also the door to the System status page — the settings for how to open Vector are behind it + * and nothing else leads there — and a tick does not look like a door. So while [hint] is set the + * tick turns into a gear for ten seconds every thirty, which is the one symbol everybody already + * reads as "there are settings here", and turns back. See #856. + * + * Only the tick. The other three states are being *reported*, urgently in two of them, and a badge + * that wanders off into a gear while it is saying the framework is not running would be trading the + * message for the hint. */ @Composable private fun StatusIndicator( state: FrameworkState, tint: Color, + hint: Boolean, onClick: () -> Unit, contentDescription: String, ) { @@ -335,20 +358,105 @@ private fun StatusIndicator( FrameworkState.Checking -> null } + val hinting = hint && state == FrameworkState.Active + var asGear by remember { mutableStateOf(false) } + // An Animatable rather than a target the composition sets, because a turn has to be able to + // *stop where it is*: the wheel that has just spun a full turn and drawn a still one next must + // hold that angle, and an animation driven from a remembered target would spring back to it. + val spin = remember { Animatable(0f) } + + LaunchedEffect(hinting) { + // Cancelled and restarted whenever the framework leaves or re-enters Active, which is also + // what puts the badge back to a tick mid-hint rather than leaving a gear over a red header. + if (!hinting) { + asGear = false + return@LaunchedEffect + } + while (true) { + delay(HINT_PERIOD_MS) + asGear = true + repeat(HINT_TURNS) { + // A coin per turn rather than a steady spin. A gear that simply rotates for ten + // seconds is decoration and the eye files it away as such by the second cycle; one + // that turns, stops, thinks and turns again reads as something being *operated*, + // and it is the stopping that makes the next turn worth looking at. + if (Random.nextBoolean()) { + spin.animateTo( + spin.value + FULL_TURN, + animationSpec = tween(HINT_TURN_MS, easing = LinearEasing), + ) + } else { + delay(HINT_TURN_MS.toLong()) + } + } + asGear = false + // Wound back into a single turn between hints, so an app left open for an afternoon + // does not accumulate an angle large enough to lose its own fraction. + spin.snapTo(spin.value.mod(FULL_TURN)) + } + } + + // One number for the whole tick-to-gear swap: what fades, what shrinks, what turns into what. + // Timed like the header's colour and corner transitions, since it is the same badge changing. + val morph by + animateFloatAsState(if (asGear) 1f else 0f, tween(MORPH_MS), label = "indicatorMorph") + Box( modifier = Modifier.size(52.dp) .scale(if (state == FrameworkState.Active) pulse else 1f) .clip(RoundedCornerShape(percent = corner.toInt())) - .background(tint.copy(alpha = 0.15f)) + // Lifted while the gear is out. The badge is asking to be pressed at that moment, + // and a fill a shade stronger is how every other control on the screen says so. + .background(tint.copy(alpha = lerp(RESTING_FILL, HINTING_FILL, morph))) .clickable(onClick = onClick) .semantics { this.contentDescription = contentDescription }, contentAlignment = Alignment.Center, ) { + // Both glyphs are laid out; `morph` decides which is visible. The transforms live in a + // `graphicsLayer` block, which re-runs in the draw phase when the state it reads changes, + // so the spin never invalidates the composition — which matters most for `spin`, whose + // value moves on every frame of a turn. if (icon != null) { // The label beside it already names the state, and the box carries the description, // so the glyph must not be announced a third time. - Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(26.dp)) + Icon( + icon, + contentDescription = null, + tint = tint, + modifier = + Modifier.size(26.dp).graphicsLayer { + alpha = 1f - morph + // Away rather than out: the tick shrinks and twists as the gear arrives + // over it, so the two read as one object changing rather than two swapped. + // The twist is the departing tick's alone, deliberately — a turn of the + // *gear* means a coin came up heads, and nothing else may spend one. + val leaving = lerp(1f, 0.6f, morph) + scaleX = leaving + scaleY = leaving + rotationZ = -MORPH_TURN * morph + }, + ) + } + if (state == FrameworkState.Active) { + Icon( + Icons.Rounded.Settings, + contentDescription = null, + tint = tint, + modifier = + Modifier.size(26.dp).graphicsLayer { + alpha = morph + val arriving = lerp(0.6f, 1f, morph) + scaleX = arriving + scaleY = arriving + // `spin` and nothing else. The wheel arrives and leaves at exactly the + // angle it is resting at, so every degree it ever turns through was asked + // for by a coin — which is the whole point of tossing one. It used to pick + // up the tick's twist on the way in and give it back on the way out, and + // that made the gear turn on every hint whatever the coins said. + rotationZ = spin.value + }, + ) } } } @@ -368,6 +476,37 @@ private fun Color.compositeOverSurface(): Color { /** One of the two stacked buttons beside the wordmark. */ private val ICON_BUTTON = 38.dp +// --- the badge's gear hint ------------------------------------------------------------------ +// Long enough apart that the header is a still object most of the time — this sits above whatever +// the reader came to Home to read — and long enough at a time to be noticed by someone who was +// looking elsewhere when it began. + +/** How long the badge rests as a tick between hints. */ +private const val HINT_PERIOD_MS = 30_000L + +/** Each hint is [HINT_TURNS] of these, so ten seconds as a gear. */ +private const val HINT_TURN_MS = 2_000 + +private const val HINT_TURNS = 5 + +private const val FULL_TURN = 360f + +/** The tick-to-gear cross-dissolve, timed like the header's colour and corner transitions. */ +private const val MORPH_MS = 420 + +/** + * How far the *tick* turns as it hands over, in degrees. Enough to read as a twist. + * + * Not applied to the gear. See the two `graphicsLayer` blocks: the gear's only source of rotation + * is the coin, so a hint whose five tosses all come up tails shows a wheel that never moves. + */ +private const val MORPH_TURN = 60f + +/** The badge's fill against the header, at rest and while it is asking to be pressed. */ +private const val RESTING_FILL = 0.15f + +private const val HINTING_FILL = 0.24f + /** * The height of the row the wordmark shares with those buttons. * diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt index 3be33eb41..f4f0e80df 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt @@ -148,6 +148,7 @@ fun HomeScreen( val ambienceKey by viewModel.headerAmbience.collectAsStateWithLifecycle() val presence by viewModel.presence.collectAsStateWithLifecycle() val promptDismissed by viewModel.launcherPromptDismissed.collectAsStateWithLifecycle() + val hintStatus by viewModel.statusBadgeHint.collectAsStateWithLifecycle() val context = LocalContext.current var showSplash by rememberSaveable { mutableStateOf(false) } var showAppearance by rememberSaveable { mutableStateOf(false) } @@ -158,8 +159,12 @@ fun HomeScreen( // The status screen has its own copy of this ViewModel — a nav destination is its own store — // so a shortcut pinned or an app installed from there is invisible to this one until it is - // asked again. Coming back to Home is when it is worth asking. - LaunchedEffect(Unit) { viewModel.refreshPresence() } + // asked again. Coming back to Home is when it is worth asking, and it is also the only moment + // the badge's hint can start running again, so today's tally of it is re-cut here too. + LaunchedEffect(Unit) { + viewModel.refreshPresence() + viewModel.refreshStatusBadgeHint() + } // Four taps on the wordmark, with the remaining count announced from the second. Two taps // could be an accident; past that the reader is clearly poking at it, so the app plays along @@ -312,7 +317,13 @@ fun HomeScreen( hasUpdate = frameworkUpdate.hasUpdate, onOpenUpdate = onOpenUpdate, ambience = AmbienceKind.from(ambienceKey), - onOpenStatus = onOpenStatus, + hintStatus = hintStatus, + onOpenStatus = { + // Counted before the navigation, not after arriving: the tap is what proves the + // badge was understood, and the page has other ways in that prove nothing. + viewModel.noteStatusBadgeOpened() + onOpenStatus() + }, onOpenAppearance = { showAppearance = true }, onOpenLanguage = { showLanguage = true }, onBrandTap = ::onBrandTap, diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt index d9d699658..725ff534d 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlin.random.Random @@ -87,6 +88,13 @@ data class ManagerPresence( /** Injected into the host rather than installed. False leaves nothing here to offer. */ val parasitic: Boolean = true, val shortcutSupported: Boolean = false, + /** + * A shortcut on the home screen the reader is looking at *now*. + * + * Deliberately narrower than "a shortcut exists": a pin does not follow the reader to a launcher + * they install later, so a device that has switched launchers has a route the platform still + * reports and a home screen with nothing on it — #883. See LaunchShortcut.isPinnedHere. + */ val shortcutPinned: Boolean = false, /** * Which copy of the manager is installed beside this one, if any. @@ -222,6 +230,34 @@ class HomeViewModel( val launcherPromptDismissed: StateFlow get() = ServiceLocator.settings.launcherPromptDismissed + /** + * Whether the status badge should still be pointing out that it opens something. + * + * The settings for how to open Vector are on that page and the badge is the only way to it, so a + * reader who has never tapped it has no reason to think a tick is a button — #856. The header + * makes the case by having the tick turn into a gear for a moment now and then, and this is what + * calls it off once it has been made. + */ + val statusBadgeHint: StateFlow = + ServiceLocator.settings.statusBadgeOpens + .map { it < STATUS_BADGE_HINT_OPENS } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + ServiceLocator.settings.statusBadgeOpens.value < STATUS_BADGE_HINT_OPENS, + ) + + /** + * Counts a badge tap that opened System status. + * + * Only the badge, not every arrival at that page: the hint is teaching where *this control* + * leads, so a visit that came from a notification or a deep link has not learnt it. + */ + fun noteStatusBadgeOpened() = ServiceLocator.settings.noteStatusBadgeOpened() + + /** Re-cuts today's badge count, so a session that crossed midnight offers the hint again. */ + fun refreshStatusBadgeHint() = ServiceLocator.settings.refreshStatusBadgeOpens() + fun refreshPresence() { val context = ServiceLocator.context _presence.update { @@ -234,7 +270,7 @@ class HomeViewModel( it.copy( parasitic = LaunchShortcut.isParasitic(context), shortcutSupported = LaunchShortcut.isSupported(context), - shortcutPinned = LaunchShortcut.isPinned(context), + shortcutPinned = LaunchShortcut.isPinnedHere(context), manager = ServiceLocator.managerInstaller.installedManager(), ) } @@ -311,11 +347,21 @@ class HomeViewModel( if (service != null) refreshToggles() } } - // Opening Home is not a reason to talk to GitHub. The page renders from disk every time - // and only occasionally goes and checks — the window it shows changes a few times a week - // at most, and the user's battery and their share of an anonymous rate limit are worth + // Returning to Home is not a reason to talk to GitHub. The page renders from disk every + // time and only occasionally goes and checks — the window it shows changes a few times a + // week at most, and the user's battery and their share of an anonymous rate limit are worth // more than redrawing identical rows. Pull-to-refresh is always there when they do want it. - val checkNow = Random.nextFloat() < REVALIDATE_PROBABILITY + // + // Opening the app *is* a reason, and the toss used to apply there too: four launches in five + // showed whatever was on disk, which after a while is a feed that has quietly stopped + // moving — and a cold start is exactly when it has had the longest to go stale. So the first + // Home of a process always checks, and the toss governs only the visits after it. Process + // scope rather than a timestamp because that is what "since the app was opened" means here: + // parasitically the host is `com.android.shell` and is killed constantly, but each of those + // deaths is also what makes the next arrival a first launch to the reader. + val firstThisProcess = !homeOpenedThisProcess + homeOpenedThisProcess = true + val checkNow = firstThisProcess || Random.nextFloat() >= FEED_PAUSE_PROBABILITY refreshFeed( if (checkNow) GitHubRepository.Freshness.Revalidate else GitHubRepository.Freshness.Cached @@ -631,8 +677,26 @@ class HomeViewModel( val historyStalled: StateFlow = _exhausted.asStateFlow() companion object { - /** How often opening Home actually goes and checks GitHub. */ - private const val REVALIDATE_PROBABILITY = 0.2f + /** How often *returning* to Home leaves the feed on what is already on disk. */ + private const val FEED_PAUSE_PROBABILITY = 0.8f + + /** + * True once Home has been built in this process, whatever the feed did about it. + * + * On the companion because that is exactly the scope wanted — one per process, shared by + * every HomeViewModel a session builds, and gone when the host is killed. Volatile because + * `init` runs on whichever thread built the ViewModel. + */ + @Volatile private var homeOpenedThisProcess = false + + /** + * How many times a day the badge has to be used before it stops explaining itself. + * + * Five is comfortably more than an accident and less than a sitting spent on that page. The + * count resets daily — see SettingsRepository.statusBadgeOpens — so this is not a budget + * that runs out for good. + */ + private const val STATUS_BADGE_HINT_OPENS = 5 val Factory = object : ViewModelProvider.Factory {