From d46ee5d201e56ce93819a7dd2c88d2f87c5319a7 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sat, 8 Aug 2026 00:17:23 +0200 Subject: [PATCH 1/2] Show what the status badge opens, and reopen two things that closed too early MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small things on Home, all of the same shape: something the app knew but was not saying, or was saying once and then never again. The status badge is the only way to the System status page, and the settings for how to open Vector live there — but a tick does not look like a button, and #856 is a reader who went through all four pages without finding them. So while the framework is active the tick now turns into a gear for ten seconds every thirty, and turns back. The gear takes a coin every two seconds and either turns once or stands still, because a wheel that simply rotates is decoration and the eye stops seeing it, while one that starts and stops reads as something being operated. Only the tick: the other three states are reports, urgent 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. It retires after five badge taps in a day and is offered again tomorrow. Pinned shortcuts do not follow the reader to a launcher they install later, but `getPinnedShortcuts` keeps saying yes: the pin flag lives on the shortcut, not on the pair, and only the active launcher may read the per-launcher sets. So #883 was left looking at a tick over an empty home screen with no way to ask for another. The launchers that have pinned it are now remembered on this side, and a device on a launcher that is not among them is offered the shortcut again. A device with nothing recorded adopts the launcher it is on, so nobody is told their existing shortcut has vanished and the next change is still caught. And the feed: opening Home tossed a coin and four times in five showed whatever was on disk, which on a cold start is the moment it has had the longest to go stale. The first Home of a process now always checks; the toss governs only the visits after it. --- .../manager/data/repository/LaunchShortcut.kt | 75 +++++++++- .../data/repository/SettingsRepository.kt | 72 ++++++++++ .../manager/ui/components/StatusHeader.kt | 133 +++++++++++++++++- .../manager/ui/screens/home/HomeScreen.kt | 17 ++- .../manager/ui/screens/home/HomeViewModel.kt | 78 +++++++++- 5 files changed, 362 insertions(+), 13 deletions(-) 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..72c3dac20 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,100 @@ 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 turns as the gear arrives + // over it, so the two read as one object changing rather than two swapped. + 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 + // The arrival angle folds into the wheel's own, so a gear that starts + // turning immediately turns on from where it landed. + rotationZ = spin.value + MORPH_TURN * (1f - morph) + }, + ) } } } @@ -368,6 +471,32 @@ 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 each glyph turns while trading places, in degrees. Enough to read as a twist. */ +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 { From 1104aabcb74d144d6b9cd27bb56507d0205bb6bd Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sat, 8 Aug 2026 00:32:07 +0200 Subject: [PATCH 2/2] Let the coin decide every degree the gear turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wheel picked up the departing tick's 60° twist on the way in and gave it back on the way out, so it swept 120° over the course of every hint whatever the five tosses said — the coin only ever added turns on top of a rotation nobody had asked for. It now arrives and leaves at exactly the angle it is resting at, and the twist stays with the tick, which is not the glyph whose stillness carries a meaning. A hint whose tosses all come up tails is now a gear that does not move at all. That is one hint in thirty-two, which is what five independent coins buys: most hints still contain a turn, but none of them contains one that was not tossed for. --- .../manager/ui/components/StatusHeader.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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 72c3dac20..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 @@ -427,8 +427,10 @@ private fun StatusIndicator( modifier = Modifier.size(26.dp).graphicsLayer { alpha = 1f - morph - // Away rather than out: the tick shrinks and turns as the gear arrives + // 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 @@ -447,9 +449,12 @@ private fun StatusIndicator( val arriving = lerp(0.6f, 1f, morph) scaleX = arriving scaleY = arriving - // The arrival angle folds into the wheel's own, so a gear that starts - // turning immediately turns on from where it landed. - rotationZ = spin.value + MORPH_TURN * (1f - morph) + // `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 }, ) } @@ -489,7 +494,12 @@ 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 each glyph turns while trading places, in degrees. Enough to read as a twist. */ +/** + * 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. */