Repository navigation
perf(startup): cut the wait between launch and the wallet tab - #1505
Merged
Merged
Conversation
bmc08gt
force-pushed
the
perf/startup
branch
from
September 20, 2026 14:34
625b12f to
0c76af9
Compare
`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
force-pushed
the
perf/startup
branch
from
September 20, 2026 15:03
0c76af9 to
260b94f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ofHMAC-SHA512 in pure Kotlin. It blocks
AuthStatereachingReady, the app holdsAppRoute.Loadinguntil
Ready, and the wallet composable is not entered until roughly 2.0s in.Android has a BoringSSL-backed provider for exactly this.
PBKDF2SHA512becomes a facade over anexpect/actual:androidMainroutes toSecretKeyFactory("PBKDF2WithHmacSHA512"), and thepure-Kotlin implementation stays as
PBKDF2SHA512Reference, still compiled on every target andstill the definition of correct output.
Toggling only the Android
actual, same device and same build:AuthState.ReadyMedian start-to-
Readymoves 1761 → 1264 ms, which matches the derivation delta — nothing elsemoved. These are debug-build numbers on an emulator; JIT warm-up favours the reference path, so the
release delta will be smaller.
am start -WTotalTime is unchanged at ~1300 ms, and that is expected: the first frame is theLoading screen, drawn before derivation finishes. The saving is entirely in Loading → wallet.
derivePlatformreturns null whenever it cannot promise an identical answer and the facade fallsback, 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 onASCII 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.
PBKDF2SHA512ParityTestpins 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)—resumeFromLocalStatepublishedReadyonly aftergetUserFlagsreturned, so asoft 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 newHmacSHA512per round, re-deriving thekey 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)—AnimatedNumberTextmounted the digit roller for every digit on firstcomposition; it now mounts one only once that digit actually changes.
perf(startup)— auth graph construction moves off the main thread, targetingApplication.onCreate. The emulator could not resolve this one — its run-to-run spread was widerthan the effect — so I re-ran it on the Seeker against
benchmarkReleasewith the profilerequired, interleaved without/with for three rounds, medians of five iterations:
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.onCreatebuys 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
WalletScreenContentlooked 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
WalletViewModelcombine arms, Firebase init (0–1 ms), gRPCchannel 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.onUserLoggedInreset the sync state toUnknownunconditionally, and it runs atevery 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 afetchTokenAccountsround trip —1127 ms measured — before the landing tab could draw, on every cold start. The comment in
RealSessionControllerdescribes wanting the first fetch to have a head start, not wanting it togate the tab.
"A fetch has succeeded for this account" now persists, in the
selected-tokenpreference storerather than a new Room table, because
TokenCoordinator.reset()already clears that store andAuthManager.resetStateForUser()calls it on logout — so the flag's lifetime is exactly oneaccount'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 anunresolved 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
isAwaitingTokensalone. The two things the activity wait bought are both keptwithout 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.
ReportDrawnWhenand thewallet_contentbenchmark anchor moved to the same condition, so thetrace 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.
onAppInForegroundhas two callers —ON_RESUMEfrom thecomposition and the transition into
AuthState.Ready— and at process start they race, becausethe lifecycle observer is not auth-gated and
LifecycleRegistryreplays state to a newlyregistered 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 fromcache before the composition registers and both passes are authenticated. Only
poll()and thefeed catch-up survive that; the other seven fetch again. Now guarded on auth first, then a
compareAndSetfor the pass, cleared on background and on logout.Cannot update tokens: no authenticated userat ERROR every launch, with the fetch behind itdropped. Not the network-connected flow, as I first recorded — it is the balance poller's first
tick.
UserManager.setpublishesReadyto collectors before the cluster reachesTokenCoordinator, which it does by event bus on another dispatcher, and the poller starts on theReadyedge with no initial delay. When it lost that race it dropped the one fetch the wallet tabwaits 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.
PassphraseCredentialManager.loginestablishes the entropy to authenticate, then
AuthManager.loginestablishes the same entropyagain. I had recorded this as harmless because of a seed cache; there is no seed cache — neither
MnemonicCodenorDerivedKeymemoises — so the second call ran BIP39's PBKDF2 again in full.establishnow returns early for the entropy already established.InternalEventRepositoryfanned out with stdlibSet.onEach, which is synchronous, so each of the three listeners ran to completion before thenext started and the last one visited waited out the other two. One coroutine each under a
coroutineScope, which keeps the completion and cancellation semantics.Application.onCreate.CameraXInitializerposts toDispatchers.IOand returns, and androidx.startup runs it fromInitializationProvider, beforeApplication.onCreaterather than inside it. The 59 ms isIO-thread time in a span that overlaps the launch window. No change.
scripts/benchmark.shalso named a Gradle task that does not exist(
connectedBenchmarkAndroidTest; the real one isconnectedBenchmarkReleaseAndroidTest). It nowprints 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:sha512and:libs:currency-math:discrete-curveare both exported throughSharedCore, and iOS runs them:PBKDF.deriveKeyreachesPBKDF2SHA512throughSharedCoreKit,and
DiscreteBondingCurve.swiftsits on the curve module. Nothing here changes iOS today, becauseiOS pins a published
flipcash-shared-core-spm— but the HMAC reuse is a free speedup for iOS thenext time shared-core is published, since the reference is the path Apple targets take.
appleMaindeclines the platform provider for now.CCKeyDerivationPBKDFwould do there whatConscrypt 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,ScannerScreenandOnboardingFlowScreen, both calling iton the activity from inside a
LaunchedEffect. That fires as soon as the composable enters the treeand bypasses
FullyDrawnReporter's pending count — but the larger problem is that on a logged-inlaunch neither composable is entered, because the app lands on the wallet. Installing the pre-change
benchmarkReleaseAPK and cold-starting it twice produces zeroFully drawnlines in logcat.The wallet now registers
ReportDrawnWhen { !isAwaitingTokens && !isAwaitingActivity }, keyed tothe 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 reporterrather 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
BaselineProfileGeneratordecided whether it was logged in withBy.res("scanner_view"). That tagbelongs 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 tabthe 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 forWalletScreenContent,BalanceHeaderorTokenCardStack. Regenerating with the probe fixed takesbalance 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:
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 wasnot 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:
nonMinifiedReleasemerges only the library profilesand no
com/flipcashrules at all, so it cannot be used to measure a profile's effect. Everythingabove is
benchmarkRelease.StartupBenchmarkalso 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.