Skip to content

perf(startup): cut the wait between launch and the wallet tab - #1505

Merged
bmc08gt merged 17 commits into
code/cashfrom
perf/startup
Sep 20, 2026
Merged

bmc08gt merged 17 commits into
code/cashfrom
perf/startup

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Launching into the wallet tab with a logged-in account took about 2.4s to content on a warm start.
Most of that was one thing, and it was not where I first looked.

The dominant cost: BIP39 key derivation

UserManager.establish() derives the account key on every launch, and that runs 2048 rounds of
HMAC-SHA512 in pure Kotlin. It blocks AuthState reaching Ready, the app holds AppRoute.Loading
until Ready, and the wallet composable is not entered until roughly 2.0s in.

Android has a BoringSSL-backed provider for exactly this. PBKDF2SHA512 becomes a facade over an
expect/actual: androidMain routes to SecretKeyFactory("PBKDF2WithHmacSHA512"), and the
pure-Kotlin implementation stays as PBKDF2SHA512Reference, still compiled on every target and
still the definition of correct output.

Toggling only the Android actual, same device and same build:

derive process start → AuthState.Ready
pure Kotlin 502 / 527 / 743 ms 1751, 1760, 1762, 1761, 1857 ms
platform provider 27 / 29 / 39 ms 1575, 1418, 1264, 1241, 1254 ms

Median start-to-Ready moves 1761 → 1264 ms, which matches the derivation delta — nothing else
moved. These are debug-build numbers on an emulator; JIT warm-up favours the reference path, so the
release delta will be smaller.

am start -W TotalTime is unchanged at ~1300 ms, and that is expected: the first frame is the
Loading screen, drawn before derivation finishes. The saving is entirely in Loading → wallet.

derivePlatform returns null whenever it cannot promise an identical answer and the facade falls
back, which is what makes the fast path safe to take silently — a platform that declines only costs
time. Android declines a non-ASCII password, because the password crosses the JCA boundary as a
char[] and providers have historically disagreed about encoding one. Those encodings coincide on
ASCII and only ASCII, so the guard makes the result provider-independent rather than resting on
whichever provider is installed. BIP39 word lists are ASCII, and a derive path's passphrase goes
into the salt as exact bytes, so the derivation this exists for always takes the fast path.

PBKDF2SHA512ParityTest pins the two paths together on the inputs the RFC vectors do not reach:
non-ASCII on either side, empty salt, short and over-block derived keys, and the 2048-round BIP39
shape.

The rest

  • fix(auth) — resumeFromLocalState published Ready only after getUserFlags returned, so a
    soft login waited on the network to show a screen it already had the state for. It now publishes
    from local state and refreshes flags on the manager's own scope.

  • perf(crypto) — PBKDF2's inner loop constructed a new HmacSHA512 per round, re-deriving the
    key schedule 2048 times. One instance now serves the whole block. This is in the reference
    implementation, so it is the path Apple targets still take.

  • perf(curve) — bonding-curve table entries decode on access instead of eagerly at load.

  • perf(ui) — AnimatedNumberText mounted the digit roller for every digit on first
    composition; it now mounts one only once that digit actually changes.

  • perf(startup) — auth graph construction moves off the main thread, targeting
    Application.onCreate. The emulator could not resolve this one — its run-to-run spread was wider
    than the effect — so I re-ran it on the Seeker against benchmarkRelease with the profile
    required, interleaved without/with for three rounds, medians of five iterations:

    round TTID without TTID with TTFD without TTFD with
    1 763.4 707.9 1250.2 1312.1
    2 705.4 679.9 1311.2 1300.6
    3 729.3 601.8 1266.9 1218.5

    Time to initial display is ahead in all three pairs, by 56, 26 and 128 ms. Time to full display
    flips sign across the pairs and averages +1 ms, so it is noise. That is the shape the change
    predicts: moving work out of Application.onCreate buys the first frame and nothing after it.

    The commit message carries this table. It previously quoted emulator debug numbers
    ("median 1265ms -> 1200ms") which were true of what they measured but read as an end-to-end claim
    the release A/B does not support, so the commit was reworded and the branch force-pushed.

What I ruled out

The spinner gate in WalletScreenContent looked like the culprit from log ordering and is not —
probed directly it costs 250–360 ms, and tokens are already hydrated from Room before the wallet
composes. Also ruled out: the three WalletViewModel combine arms, Firebase init (0–1 ms), gRPC
channel construction (~41 ms, READY long before it is needed), and Room token hydration (73–84 ms,
finished before Ready).

The invariant that nothing on the network should block launch through to the tabs held on a warm
start and not otherwise, and the exception turned out to be wider than a fresh login.

TokenCoordinator.onUserLoggedIn reset the sync state to Unknown unconditionally, and it runs at
every launch rather than only at a real login. The wallet's gate is tokens.isEmpty() && syncState == Unknown, so any account holding zero tokens waited out a fetchTokenAccounts round trip —
1127 ms measured — before the landing tab could draw, on every cold start. The comment in
RealSessionController describes wanting the first fetch to have a head start, not wanting it to
gate the tab.

"A fetch has succeeded for this account" now persists, in the selected-token preference store
rather than a new Room table, because TokenCoordinator.reset() already clears that store and
AuthManager.resetStateForUser() calls it on logout — so the flag's lifetime is exactly one
account's signed-in period, with no migration. The fetch still runs at every launch; it just no
longer holds the tab. Only a genuinely first fetch for an account gates anything now, and that one
is still behind the login spinner.

The wallet tab waited on more than it draws

The gate was isAwaitingTokens || isAwaitingActivity — a resolved balance held behind an
unresolved history. Tokens are what the balance and the card deck are made of, so there is no
drawing the tab without them; the activity feed is a separate read that can fill in underneath.

The gate is now isAwaitingTokens alone. The two things the activity wait bought are both kept
without it. The recent-activity preview draws when there are rows to draw, and by then tokens have
resolved, so a convert row still titles itself from both mints rather than falling back to the
server's bare "Converted". The tutorial keeps the wait explicitly, because it is the one section
that would be wrong rather than late if drawn from a still-empty cache: the milestones are a read
of a local cache, and an established account signing in would be shown the new-user tutorial until
its history arrived.

ReportDrawnWhen and the wallet_content benchmark anchor moved to the same condition, so the
trace stays open exactly as long as the report does. Time to full display before and after this
commit is not comparable
— the report now lands earlier in the launch, so any time to full
display taken before this commit is measuring a longer span.

Secondary defects

Five turned up alongside the main investigation. Three of them, as I first logged them, did not
survive reading the code, so the corrections are part of the finding.

  • The foreground refresh ran twice. onAppInForeground has two callers — ON_RESUME from the
    composition and the transition into AuthState.Ready — and at process start they race, because
    the lifecycle observer is not auth-gated and LifecycleRegistry replays state to a newly
    registered observer. I had recorded that eight of its nine calls were unguarded; they are not,
    every one checks canAccessAuthenticatedApis. Guarded is not deduped, though: resolve auth from
    cache before the composition registers and both passes are authenticated. Only poll() and the
    feed catch-up survive that; the other seven fetch again. Now guarded on auth first, then a
    compareAndSet for the pass, cleared on background and on logout.
  • Cannot update tokens: no authenticated user at ERROR every launch, with the fetch behind it
    dropped.
    Not the network-connected flow, as I first recorded — it is the balance poller's first
    tick. UserManager.set publishes Ready to collectors before the cluster reaches
    TokenCoordinator, which it does by event bus on another dispatcher, and the poller starts on the
    Ready edge with no initial delay. When it lost that race it dropped the one fetch the wallet tab
    waits on; the coordinator's own network-connected collector re-triggered once the cluster arrived,
    which masked it as a round trip's delay rather than a stuck tab. A null cluster on that edge means
    "not yet", so it now waits for one, bounded at five seconds, and the trace fires only when it is
    true.
  • The account keys were derived twice per login. PassphraseCredentialManager.login
    establishes the entropy to authenticate, then AuthManager.login establishes the same entropy
    again. I had recorded this as harmless because of a seed cache; there is no seed cache — neither
    MnemonicCode nor DerivedKey memoises — so the second call ran BIP39's PBKDF2 again in full.
    establish now returns early for the entropy already established.
  • Session listeners were notified in sequence. InternalEventRepository fanned out with stdlib
    Set.onEach, which is synchronous, so each of the three listeners ran to completion before the
    next started and the last one visited waited out the other two. One coroutine each under a
    coroutineScope, which keeps the completion and cancellation semantics.
  • CameraX warm-up is not a defect. I had it down as ~59 ms inside Application.onCreate.
    CameraXInitializer posts to Dispatchers.IO and returns, and androidx.startup runs it from
    InitializationProvider, before Application.onCreate rather than inside it. The 59 ms is
    IO-thread time in a span that overlaps the launch window. No change.

scripts/benchmark.sh also named a Gradle task that does not exist
(connectedBenchmarkAndroidTest; the real one is connectedBenchmarkReleaseAndroidTest). It now
prints all four metrics and refuses to run with more than one device attached, which matters because
the profile generator sends a real chat message and pulls a bill.

Reaches iOS

:libs:encryption:sha512 and :libs:currency-math:discrete-curve are both exported through
SharedCore, and iOS runs them: PBKDF.deriveKey reaches PBKDF2SHA512 through SharedCoreKit,
and DiscreteBondingCurve.swift sits on the curve module. Nothing here changes iOS today, because
iOS pins a published flipcash-shared-core-spm — but the HMAC reuse is a free speedup for iOS the
next time shared-core is published, since the reference is the path Apple targets take.

appleMain declines the platform provider for now. CCKeyDerivationPBKDF would do there what
Conscrypt does here, but the Kotlin/Native cost is unmeasured and an Android number does not stand
in for it.

Time to full display was never reported at all

reportFullyDrawn() had two call sites, ScannerScreen and OnboardingFlowScreen, both calling it
on the activity from inside a LaunchedEffect. That fires as soon as the composable enters the tree
and bypasses FullyDrawnReporter's pending count — but the larger problem is that on a logged-in
launch neither composable is entered, because the app lands on the wallet. Installing the pre-change
benchmarkRelease APK and cold-starting it twice produces zero Fully drawn lines in logcat.

The wallet now registers ReportDrawnWhen { !isAwaitingTokens && !isAwaitingActivity }, keyed to
the same gate that chooses between the spinner and the content, so the report stays open until there
is something to look at. The other two switch to ReportDrawn(), which goes through the reporter
rather than around it, so landing on the scanner later cannot close a report the wallet still holds.

The baseline profile did not cover the wallet

BaselineProfileGenerator decided whether it was logged in with By.res("scanner_view"). That tag
belongs to the scanner, which stopped being the landing tab when the v2 wallet arrived, so the probe
read false on an already-authenticated device and every iteration took the login branch. The
authenticated journeys never ran. It now probes nav_wallet — the tab bar, present whichever tab
the launch restores, and only once authenticated.

The profile that fell out of the broken generator was last collected 2026-07-02, before the v2
wallet landed. It carried six rules for com.flipcash.app.balance, all Hilt-generated, and none for
WalletScreenContent, BalanceHeader or TokenCardStack. Regenerating with the probe fixed takes
balance to 333, messenger 819 → 1716 and tokens 889 → 1347. Scanner drops 227 → 52, which follows
from the same change: it is now reached by a tab switch rather than by launch.

Measured on a Seeker, benchmarkRelease, Partial(Require), medians of five iterations per run.
The two profiles were interleaved over five rounds, with the arm order reversed for the last two so
that a warming device cannot favour whichever one runs second:

round TTID prev TTID this TTFD prev TTFD this
1 716 703 1191 1167
2 699 696 1104 1142
3 657 735 1158 1215
4 689 669 1141 1091
5 727 690 1203 1188

The mean paired delta is +1 ms on both metrics, against a 60-70 ms spread inside either arm. The
regenerated profile does not move startup either way. Having a profile still does —
CompilationMode.None() measured 769 ms to initial display against 696 here — though that pair was
not interleaved, so read it as a scale rather than a delta.

An earlier run of this same comparison put the regenerated profile 190, 77 and 42 ms ahead on time
to full display. Those rounds ran before the wallet's loading gate was split, when the report landed
after the full data load. Moving the report earlier moved it in front of most of what the new wallet
rules cover, which is where that win went. The reason to keep the regeneration is that the profile
now matches the code that ships, not a startup number.

Worth knowing before re-running any of this: nonMinifiedRelease merges only the library profiles
and no com/flipcash rules at all, so it cannot be used to measure a profile's effect. Everything
above is benchmarkRelease.

StartupBenchmark also gained a bounded wait on the wallet's content. startActivityAndWait()
returns at first display, so without it the trace can stop before the wallet reports and time to
full display goes missing from the results.

@bmc08gt bmc08gt self-assigned this Sep 19, 2026
@github-actions github-actions Bot added area: crypto Solana, keys, encryption, signing area: auth Login, session, access keys, identity area: ui Compose UI, theme, components, resources area: network gRPC, connectivity, API, exchange rates area: tokens Token accounts, balances, token info labels Sep 19, 2026
`DiscreteCurveTables` parsed both 3.36MB tables up front into a
`List<BigInteger>`. `BigInteger` is immutable and the decode shifted one byte at
a time, so each of the 420,002 entries cost roughly 50 allocations: ~21 million
short-lived objects per cold start, plus 420,002 boxed entries retained for the
life of the process.

`DiscreteBondingCurveInitializer` runs this on `Dispatchers.IO`, so it never
blocked the first frame. It did saturate the shared coroutine pool. simpleperf
over a cold start (in-app, 500Hz, 12s, 27409 samples) put the worker pool at
38.93% of samples, 2.65x the main thread, with
`ionspin...BigInteger.<init>` a top self-time symbol there and
`Jit::MaybeDoOnStackReplacement` at 14.83% - OSR firing on exactly this kind of
tight byte loop. The login path's key derivation runs on that same pool.

Holding the raw bytes and decoding an entry in `get()` removes the parse. The
engine reaches these tables through binary searches that touch about 18 of the
210,001 entries per query, so nearly all of that work was never needed.
Replicating the old parse against the real `.bin` files on a warm desktop JVM
measured 322-407ms for the two tables, which is a lower bound for cold ART on a
device.

`CurveTableDecodeTest` compares every entry against the old algorithm across
both tables, on top of the existing vector and monotonicity suites.
A soft login published `AuthState.Ready` only after awaiting `getUserFlags()`,
and `MainRoot.buildNavGraphForLaunch` returns null for every state but Ready. A
relaunch of an account that is already signed in therefore held the splash
screen on a gRPC round trip, under `retryable(maxRetries = 3, delayDuration =
2.seconds)` - about 5ms on a good connection, four seconds plus RPC time on a
bad one. A neighbouring call in the same launch window logged "Failed to get a
success after 3 attempts in 7138 ms".

Nothing in that call was needed to decide where the login lands.
`profileCoordinator.restore()` and `userFlags.restoreFlags()` run first and
replay the previous session's profile and flags from DataStore, and the
access-key and onboarding markers come from credential storage. So resolve the
state from what is already local, then refresh the flags behind it on the
manager's own scope - not the calling scope, which `login()` joins before
returning.

The refresh still gets the last word: if the server says registration never
finished, the login lands back on onboarding, where awaiting the call would have
put it. That correction is skipped when the state has moved on in the meantime,
so it cannot pull a user out of a step they have since started.

The two resolution branches collapse into one `resolveAuthState`. They differed
only in reading `isRegistered` and `requiresIapForRegistration`, which absent
flags now answer the way the offline branch already did.
AnimatedNumberText wrapped every character in an AnimatedContent from its
first composition, so a number that never moves still paid for the roller.
The wallet deck shows 41 balances and composes all of them inside one
LazyColumn item, putting that cost on the main thread before the first frame.

A digit now renders as a plain Text until it actually changes; the roller
mounts on the change, seeded with the outgoing digit so the first roll still
animates. Measured by timing 41 cards' composition under Robolectric: the
animated balance text goes from 29.7ms to 10.0ms, the whole deck from 42.8ms
to 26.8ms.

Mounting the roller and moving it have to land in separate frames. Effects
run inside the frame that composed them, so writing both in one pass settles
the AnimatedContent on the new digit and it snaps instead of rolling; the
tests cover the first change for that reason.
Each of the 2048 rounds constructed a fresh HmacSHA512 and so re-derived the
key schedule from the password every time. doFinal already resets the instance
to the keyed state it was built in, so one instance covers the whole block.

Derivation of a 12-word seed drops from 3.57ms to 2.73ms on a host JVM. Seeds
are cached per phrase and passphrase, so this runs once or twice per process —
worth having, but not a startup lever on its own.

Output is unchanged: the RFC vectors at 1 and 4096 rounds and the SLIP-0010
derivation vectors still pass, on the JVM and on macosArm64.
FlipcashApp field-injects AuthManager, which sits on top of eighteen controllers
and coordinators, so Application.onCreate builds that whole graph before the
first frame. Measured on emulator-5554, field injection was 171-197ms of a
174-200ms onCreate, and 130-181ms of that was authManager alone.

None of it needs the main thread. AuthManager.init only launches a coroutine, so
the login state resolves asynchronously either way. The four fields that nothing
in onCreate reads synchronously are now dagger.Lazy, resolved on
Dispatchers.Default.

That required moving five constructor-time ProcessLifecycleOwner registrations.
LifecycleRegistry.addObserver rejects any thread but the main one, so a direct
call in GrpcApi's init pinned construction of every gRPC Api -- and the graph
hanging off them -- back onto the startup path. They post to the main thread
now; addObserver replays the current state, so a registration that lands after
the process is up still warms the channel.

Measured on a Seeker against benchmarkRelease, this commit and its parent
interleaved for three rounds each, because the device heats over a session and
both conditions drift upward as it does. Time to initial display is ahead in all
three pairs: 763 -> 708, 705 -> 680, 729 -> 602 ms. Time to full display flips
sign across those same pairs and averages within a millisecond of zero, which is
the shape to expect. Displacing work out of Application.onCreate buys the first
frame, not what follows it: MainActivity needs part of the same graph and now
waits on the background thread for it.
BIP39 seed derivation runs 2048 rounds of HMAC-SHA512 in pure Kotlin, and on a
cold process that measured 497-705ms. It lands in front of everything: the soft
login path waits on key derivation, AuthState cannot reach Ready until it
finishes, and the app holds AppRoute.Loading until Ready. The wallet tab is not
composed for another second after that.

Conscrypt does the same derivation in 17-29ms, measured in the same process on
the same inputs, with byte-identical output on every run. Toggling only the
Android actual, on one device and one build: derive 502/527/743ms -> 27/29/39ms,
and process start to AuthState.Ready 1761ms -> 1264ms at the median.

So PBKDF2SHA512 becomes a facade over an expect/actual: androidMain routes to
SecretKeyFactory("PBKDF2WithHmacSHA512"), and the pure-Kotlin implementation
stays as PBKDF2SHA512Reference, still compiled on every target and still the
definition of correct output.

derivePlatform returns null whenever it cannot promise an identical answer and
the facade falls back, which is what makes the fast path safe to take silently
-- a platform that declines only costs time. Android declines a non-ASCII
password, because the password crosses the JCA boundary as a char[] and
providers have historically disagreed about encoding one (UTF-8 vs Latin-1 vs
8-bit truncation). Those encodings coincide on ASCII and only ASCII, so the
guard makes the result provider-independent rather than resting on whichever
provider happens to be installed. BIP39 word lists are ASCII, and a derive
path's passphrase goes into the salt, which crosses as exact bytes, so the
derivation this exists for always takes the fast path.

Apple targets decline, which leaves iOS exactly where it was: FlipcashCore's
PBKDF.deriveKey reaches this object through SharedCoreKit, so iOS has been
deriving its BIP39 seed in pure Kotlin since it moved off CommonCrypto, and
still is. CCKeyDerivationPBKDF would do there what Conscrypt does here, but the
Kotlin/Native cost is unmeasured and an Android measurement does not stand in
for it.

PBKDF2SHA512ParityTest pins the two paths together on the inputs the RFC
vectors do not reach -- non-ASCII on either side, empty salt, short and
over-block derived keys, and the 2048-round BIP39 shape. On a target with no
platform implementation it compares the reference against itself; on one with
it, it is the conformance check.
The only reportFullyDrawn() calls were in ScannerScreen and OnboardingFlowScreen,
and both ran Activity.reportFullyDrawn() from a LaunchedEffect. A logged-in
launch lands on the wallet tab, so neither composable was composed and the
activity never reported at all: installing the pre-change build and cold-starting
it twice produced no "Fully drawn" line in logcat. Time-to-full-display was
missing for every authenticated launch, in macrobenchmark and in anything else
that reads the activity's report.

The wallet now registers a ReportDrawnWhen tied to its own loading gate, so the
report waits for the content below the spinner rather than for the frame the
composable entered composition. The scanner and onboarding keep a report for the
launches that land on them, but go through ReportDrawn() so that reaching one of
those tabs later cannot close a report another destination is still holding open.

Three cold starts on a nonMinifiedRelease build: TTID 483/546/595ms,
TTFD 633/758/764ms.
BaselineProfileGenerator decided whether it was already authenticated by probing
By.res("scanner_view"). A launch restores the last used tab and otherwise lands
on the wallet, so that probe reads false on a logged-in device: every iteration
took the login branch and the authenticated journeys never ran. The profile it
produced covered onboarding and little else.

Probe nav_wallet instead. The tab bar is present whichever tab is in front, and
only once authenticated. login() waits on the same anchor rather than on the
scanner, and scannerJourney() switches to the scanner through returnToScanner()
instead of assuming the launch landed there.
The checked-in profile was collected 2026-07-02, before the v2 wallet landed
(2026-08-10..08-19), and the only commit since deleted 315 rules without adding
any. It carried 6 rules for com.flipcash.app.balance, all Hilt-generated, and
none at all for WalletScreenContent, BalanceHeader or TokenCardStack -- the
composables on the screen a logged-in launch actually lands on.

Regenerated over 15 iterations with the generator's login probe fixed, so the
authenticated journeys ran. Balance rules go 6 -> 333, messenger 819 -> 1716,
tokens 889 -> 1347. Scanner drops 227 -> 52, which follows from the same change:
the app no longer cold-starts into the scanner, so it is now reached by a tab
switch rather than by launch.
startActivityAndWait returns at first display, so the trace could stop before
the wallet reported fully drawn and time to full display went missing from the
results. Wait on the wallet's content instead, bounded so a launch that lands
somewhere else costs one timeout rather than hanging.

The LazyColumn is the anchor for that wait because it only enters the tree past
the loading gate; the route-level wallet_screen tag wraps the spinner too, so it
appears before there is any content. Adds StartupTimingMetric next to the legacy
metric: the legacy one scrapes ActivityManager's logcat lines, the trace-based
one reports timeToInitialDisplayMs and timeToFullDisplayMs, and keeping both
makes a disagreement between them visible.
The wallet tab is where a logged-in launch lands, and it gated its entire
content on `isAwaitingTokens || isAwaitingActivity` -- one loading state
covering the token set and the activity feed together. The balance and the card
deck are made of tokens and read nothing from the feed, so a resolved balance
sat behind an unresolved history.

The gate is the token wait alone now. Both of the things the combined gate
bought are kept without it:

- The activity preview reads token metadata: a convert row titles itself
  "USDF -> Dad Cash" from both mints and falls back to the server's bare
  "Converted" until they resolve. It still cannot draw before the tokens do,
  because the whole list sits inside the token gate, and it draws only once
  there are rows to draw.
- The new-user tutorial reads a local cache that starts empty on a fresh login,
  so drawing it early tells an established account it is new. That is wrong
  rather than merely early, so it keeps the activity wait explicitly.

`ReportDrawnWhen` and the `wallet_content` benchmark anchor move to the same
condition, so time-to-full-display now means the balance and the deck are on
screen. Numbers taken against the previous reporter are not comparable to
numbers taken against this one.
…eded

An empty token map is ambiguous -- the cache starts empty on a fresh login, so
"holds nothing" and "haven't looked yet" read the same -- and `isAwaitingTokens`
resolves the ambiguity by waiting for `TokenSyncState` to leave `Unknown`. That
state was in-memory and reset on every `onUserLoggedIn`, which runs at every
launch, not only at a real login.

So an account that genuinely holds no tokens hit the ambiguous case at every
cold start and the wallet tab -- the screen a logged-in launch lands on -- sat
behind a `fetchTokenAccounts` round trip, measured at 1127 ms. Nothing on the
network should block a launch through to the tabs.

Whether a fetch has ever succeeded is a durable fact about the account, so it is
persisted alongside the selected mint, in a store `TokenCoordinator.reset()`
already clears at logout. A relaunch now seeds `Synced` from it and draws from
the cache; the refresh still runs, it just no longer gates the tab. Only the
first fetch after entering a seed phrase waits, and that one is the login
spinner rather than a launch.
`:apps:flipcash:benchmark:connectedBenchmarkAndroidTest` is not a task in this
module. The build type is `benchmark` and the variant is `benchmarkRelease`, so
the task is `connectedBenchmarkReleaseAndroidTest`; the script failed at
configuration for anyone who ran it.

Three other things it was missing:

- It printed only `startupMs`/`fullyDrawnMs`, the metrics the legacy logcat
  scraper emits. `StartupTimingMetric` reads the trace and emits
  `timeToInitialDisplayMs`/`timeToFullDisplayMs`, and the two disagree by a
  couple of hundred ms because they are timed from different origins. Both are
  printed now rather than one silently standing for the other.
- Nothing stopped a run against several attached devices, which is two sets of
  numbers from different hardware, and for `BaselineProfileGenerator` two runs
  of journeys that send a chat message and pull a bill on the signed-in account.
  The script refuses unless exactly one device is attached, and the usage text
  says what the generator does.
- `nonMinifiedRelease` merges only the library baseline profiles, so it carries
  none of the app's own rules -- a note next to the task name, because it is an
  easy variant to reach for and it reads as a no-profile build.
`onAppInForeground` has two callers — `ON_RESUME` from the composition and the
transition into `AuthState.Ready` — and at process start they race. The lifecycle
observer in `App.kt` is not auth-gated, and `LifecycleRegistry` replays the current
state to a newly registered observer, so `ON_RESUME` fires as soon as the composable
registers it. Whether that lands before or after auth resolves decides whether the
method runs once or twice: resolve auth from cache first and both passes are
authenticated, and the nine calls all fire again.

Every call is individually guarded by `canAccessAuthenticatedApis`, so the pre-auth
pass was always a no-op; what it was not is *deduped*. Of the nine, only `poll()`
(idempotent) and the feed catch-up (joins an in-flight job) survive a second pass
without re-fetching. The other seven — the sweep, user flags, payment links,
settings, pending feed items, the blocklist, the share check — each issue their
request again.

Guard on auth first, then take the pass with a `compareAndSet`. The order matters:
a pre-auth `ON_RESUME` that claimed the pass would close it against the
`AuthState.Ready` transition behind it and break login. Cleared on background and
on logout, so returning to the foreground and signing in as someone else both
refresh as they did before.
`InternalEventRepository` fanned `onUserLoggedIn` and `onBalanceUpdateRequested`
out with `Set.onEach`, which is the stdlib's synchronous extension: each
listener's suspend call ran to completion before the next one started. Three
listeners are bound — tokens, chat, contacts — and each begins by reading Room or
calling the server, so on the login path the last one to be visited waited out the
other two before it could start.

The multibinding is a `Set`, so nothing could have been relying on the order. Give
each listener its own coroutine under a `coroutineScope`, which keeps the
completion semantics the sequential loop had: the handler still returns once every
listener is done, and a listener that throws still cancels the batch.
…efresh

Every launch logged `Cannot update tokens: no authenticated user` at ERROR, and the
fetch behind it was silently dropped.

`UserManager.set` publishes `AuthState.Ready` to its collectors before the cluster
reaches `TokenCoordinator`: the cluster travels by event bus, on another dispatcher,
through `onUserLoggedIn`. `RealSessionController` starts the balance poller on the
`Ready` edge with no initial delay, so whether the session's first refresh finds a
cluster is a race between those two paths. When it lost, `updateTokens` read a null
cluster, logged the error and returned — dropping the one fetch the wallet tab is
waiting on. It was masked rather than fixed by the coordinator's own
network-connected collector, which re-triggers the update once the cluster does
arrive, so the cost was a round trip's delay rather than a stuck tab.

A null cluster on that edge means "not yet", not "nobody is signed in". Wait for it,
bounded at five seconds so a caller that really is logged out costs one timeout
rather than hanging, and still reaches the same trace — which is now only ever
logged when it is true.
A login derived them twice. `PassphraseCredentialManager.login` establishes the
entropy to authenticate; `AuthManager.login` then establishes the same entropy
again when that returns. `UserManager.establish` derived unconditionally, so the
second call ran `DerivedKey.derive` — BIP39's PBKDF2, deliberately slow, and the
largest single cost on the path to `Ready` — to arrive at the keys already in
state.

Return early when the entropy asked for is the one already established and a
cluster is present. `associate()` still runs on every call, so the analytics
identity is unchanged, and `clear()` nulls both fields, so signing out still forces
a fresh derivation.
@bmc08gt
bmc08gt merged commit 9a24d39 into code/cash Sep 20, 2026
4 checks passed
@bmc08gt
bmc08gt deleted the perf/startup branch September 23, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: auth Login, session, access keys, identity area: crypto Solana, keys, encryption, signing area: network gRPC, connectivity, API, exchange rates area: onboarding area: scanner QR/Kikcode scanning, camera area: session area: tokens Token accounts, balances, token info area: ui Compose UI, theme, components, resources

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant