Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
079e92a
perf(curve): decode bonding-curve table entries on access
bmc08gt Sep 18, 2026
6b96e46
fix(auth): reach Ready on soft login without waiting on getUserFlags
bmc08gt Sep 18, 2026
b5565a9
perf(ui): mount the digit roller only once a digit changes
bmc08gt Sep 18, 2026
3cba1fc
perf(crypto): reuse one HMAC across PBKDF2 rounds
bmc08gt Sep 18, 2026
835546a
perf(startup): build the auth graph off the main thread
bmc08gt Sep 18, 2026
792e56c
perf(crypto): derive PBKDF2 through the platform provider on Android
bmc08gt Sep 19, 2026
2e6b495
fix(startup): report fully drawn from the landing destination
bmc08gt Sep 19, 2026
9284a37
fix(benchmark): detect a logged-in launch by the tab bar
bmc08gt Sep 19, 2026
0f0145d
chore(benchmark): regenerate the baseline profile
bmc08gt Sep 20, 2026
f73048a
test(benchmark): hold the startup trace open until the wallet draws
bmc08gt Sep 20, 2026
93fda60
perf(wallet): draw the tab as soon as the tokens resolve
bmc08gt Sep 20, 2026
a3b2106
fix(tokens): stop gating the wallet tab on a fetch that already succe…
bmc08gt Sep 20, 2026
088227b
fix(benchmark): point the script at a task that exists
bmc08gt Sep 20, 2026
7323df0
fix(session): run the foreground refresh once per foreground
bmc08gt Sep 20, 2026
cfc2f90
perf(session): deliver login events to all listeners at once
bmc08gt Sep 20, 2026
6503a40
fix(tokens): wait out a landing login instead of dropping the first r…
bmc08gt Sep 20, 2026
260b94f
perf(auth): derive the account keys once per login
bmc08gt Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,40 @@ import com.flipcash.app.bills.share.TipCodePreviewCache
import com.getcode.opencode.repositories.EventRepository
import com.getcode.utils.trace
import dev.bmcreations.phantom.connect.PhantomSdk
import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltAndroidApp
class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader.Factory {

/**
* Held lazily, and resolved off the main thread in [onCreate].
*
* AuthManager sits on top of eighteen controllers and coordinators, and resolving it builds that
* whole graph -- 130-180ms on the main thread, before the first frame, measured on an emulator.
* None of it has to happen here: [AuthManager.init] only launches a coroutine, so the login state
* resolves asynchronously either way.
*/
@Inject
lateinit var authManager: AuthManager
lateinit var authManager: Lazy<AuthManager>

/**
* Resolved for their side effects rather than used here, so they are resolved off the main thread
* alongside [authManager].
*/
@Inject
lateinit var eventRepository: EventRepository
lateinit var eventRepository: Lazy<EventRepository>

@Inject
lateinit var preferredCurrencyController: PreferredCurrencyController
lateinit var preferredCurrencyController: Lazy<PreferredCurrencyController>

@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var workerFactory: Lazy<HiltWorkerFactory>

@Inject
lateinit var activityProvider: ActivityProvider
Expand All @@ -45,14 +62,20 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader.

override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.setWorkerFactory(workerFactory.get())
.build()


private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

override fun onCreate() {
super.onCreate()
PhantomSdk.init(this)
authManager.init()
startupScope.launch {
authManager.get().init()
eventRepository.get()
preferredCurrencyController.get()
}

// Track the foreground Activity so the tip-code share preview can render offscreen.
registerActivityLifecycleCallbacks(activityProvider)
Expand Down
35,005 changes: 19,764 additions & 15,241 deletions apps/flipcash/app/src/release/generated/baselineProfiles/baseline-prof.txt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ class BaselineProfileGenerator {
startActivityAndWait()
device.waitForIdle()

// After login, app data persists across iterations so subsequent
// iterations launch directly into the scanner (already authenticated).
val onScanner = device.hasObject(By.res("scanner_view"))

if (onScanner) {
// Already logged in from a prior iteration
// App data persists across iterations, so after the first login every later
// iteration starts authenticated. Probe the tab bar, not a specific tab: a launch
// restores whichever tab was last used, and the default landing tab is the wallet,
// so keying off scanner_view here reported "logged out" on a logged-in device and
// sent every iteration down the login branch.
val loggedIn = device.hasObject(By.res("nav_wallet"))

if (loggedIn) {
authenticatedJourneys()
} else {
val seed = seedPhrase
Expand Down Expand Up @@ -88,8 +90,8 @@ class BaselineProfileGenerator {
device.wait(Until.hasObject(By.res("login_confirm_button").enabled(true)), TIMEOUT)
device.findObject(By.res("login_confirm_button"))?.click()

// Wait for scanner screen
device.wait(Until.findObject(By.res("scanner_view")), LOGIN_TIMEOUT)
// Wait for the authenticated shell — the tab bar, whichever tab it lands on.
device.wait(Until.findObject(By.res("nav_wallet")), LOGIN_TIMEOUT)
device.waitForIdle()
}

Expand All @@ -104,9 +106,8 @@ class BaselineProfileGenerator {
}

private fun MacrobenchmarkScope.scannerJourney() {
// Scanner is the home screen — let it fully render
device.wait(Until.findObject(By.res("scanner_view")), TIMEOUT)
device.waitForIdle()
// Switch to the scanner rather than assuming the launch landed there, then let it render.
returnToScanner()
}

private fun MacrobenchmarkScope.chatJourney() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import androidx.benchmark.macro.BaselineProfileMode
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingLegacyMetric
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
Expand Down Expand Up @@ -40,13 +43,23 @@ class StartupBenchmark {
private fun benchmark(compilationMode: CompilationMode) {
rule.measureRepeated(
packageName = "com.flipcash.app.android",
metrics = listOf(StartupTimingLegacyMetric()),
// Legacy reads the ActivityManager logcat lines; StartupTimingMetric reads the
// trace and is the one that reports timeToFullDisplayMs.
metrics = listOf(StartupTimingMetric(), StartupTimingLegacyMetric()),
compilationMode = compilationMode,
iterations = 5,
startupMode = StartupMode.COLD,
) {
pressHome()
startActivityAndWait()
// startActivityAndWait returns at first display. Without this the trace can stop
// before the wallet reports fully drawn and TTFD goes missing. Bounded, so a launch
// that lands somewhere else (logged out) costs one timeout rather than hanging.
device.wait(Until.hasObject(By.res("wallet_content")), FULL_DISPLAY_TIMEOUT)
}
}

private companion object {
const val FULL_DISPLAY_TIMEOUT = 10_000L
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.flipcash.app.balance.internal

import androidx.activity.compose.ReportDrawnWhen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand Down Expand Up @@ -30,6 +31,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -80,19 +82,26 @@ internal fun WalletScreenContent(
tokenState: SelectTokenViewModel.State,
dispatchEvent: (WalletViewModel.Event) -> Unit,
) {
// One loading state for the whole tab. The balance, the card deck, and the activity preview
// arrive from three independent sources; letting each stage itself meant the tab assembled in
// pieces -- a spinner inside the header while the body below it had already decided, from a
// still-empty cache, that this was a brand-new account and drawn the tutorial. Nothing renders
// until all three can be drawn together, and BalanceHeader's own spinner is consequently dead
// code -- the header is only ever drawn once the balance has resolved.
// Time-to-full-display for a logged-in launch. This tab is where the app lands, and the gate
// below means "landed" is not the same frame as "composed" -- registering a reporter here holds
// the activity's report open until the content below the gate has actually been drawn. Keyed to
// the same condition as that gate: what the launch is finished for is the balance and the deck.
ReportDrawnWhen { !tokenState.isAwaitingTokens }

// The tab waits on the token set and nothing else. Tokens are what the balance and the card deck
// are made of, so there is no drawing the tab without them; the activity preview and the tutorial
// are separate reads that arrive later and fill in underneath.
//
// The two waits are independent races, and the activity preview *reads* the token cache: a
// convert row titles itself "USDF -> Dad Cash" from both mints' metadata and falls back to the
// server's bare "Converted" until they resolve. Post-login the feed regularly won that race, so
// the tab drew converts as "Converted" and then re-titled them once tokens landed. Waiting on
// both (see State.isAwaitingTokens / isAwaitingActivity) means the section is drawn once, resolved.
if (tokenState.isAwaitingTokens || balanceState.isAwaitingActivity) {
// Waiting on the activity feed here as well (the earlier gate was `isAwaitingTokens ||
// isAwaitingActivity`) held a resolved balance behind an unresolved history. The two things that
// gate bought are both kept below without the wait: the activity section draws only once there
// are rows to draw, and by then tokens have resolved, so a convert row titles itself
// "USDF -> Dad Cash" from both mints rather than falling back to the server's bare "Converted";
// and the tutorial, the one thing that would be *wrong* rather than late if drawn from a
// still-empty cache -- telling an established account it is new -- keeps the activity wait
// explicitly. BalanceHeader's own spinner stays dead code: the header is only ever drawn once
// the balance has resolved.
if (tokenState.isAwaitingTokens) {
Box(
modifier = Modifier
.fillMaxSize()
Expand Down Expand Up @@ -153,7 +162,12 @@ internal fun WalletScreenContent(

LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
// Only ever in the tree past the loading gate above, so it is the anchor for "the wallet
// drew its content" -- what the startup benchmark waits on before it stops the trace. Same
// condition as ReportDrawnWhen above, so the trace stays open exactly as long as the report.
modifier = Modifier
.fillMaxSize()
.testTag("wallet_content"),
contentPadding = PaddingValues(
top = CodeTheme.dimens.inset,
start = CodeTheme.dimens.inset,
Expand Down Expand Up @@ -188,25 +202,31 @@ internal fun WalletScreenContent(
Spacer(Modifier.height(CodeTheme.dimens.grid.x6))
}

balanceState.onboardingItems?.takeIf { !balanceState.isNewUserTutorialComplete }?.let { items ->
item {
NewUserTutorial(
modifier = Modifier.fillMaxWidth()
.padding(bottom = CodeTheme.dimens.grid.x5),
title = stringResource(R.string.title_tipOnboarding),
items = items,
) { item ->
when (item) {
is TutorialItem.AddMoney -> {
dispatchEvent(WalletViewModel.Event.PresentDepositOptions)
}
is TutorialItem.ScanTipCard -> {
dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Main.Scanner))
// The milestones are a read of a local cache that starts empty on a fresh login, so an
// established account would be shown the new-user tutorial until its history arrived. This
// is the one section that has to wait the feed out (see State.isAwaitingActivity) -- drawing
// it early is wrong, not merely early.
balanceState.onboardingItems
?.takeIf { !balanceState.isAwaitingActivity && !balanceState.isNewUserTutorialComplete }
?.let { items ->
item {
NewUserTutorial(
modifier = Modifier.fillMaxWidth()
.padding(bottom = CodeTheme.dimens.grid.x5),
title = stringResource(R.string.title_tipOnboarding),
items = items,
) { item ->
when (item) {
is TutorialItem.AddMoney -> {
dispatchEvent(WalletViewModel.Event.PresentDepositOptions)
}
is TutorialItem.ScanTipCard -> {
dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Main.Scanner))
}
}
}
}
}
}

tokenState.tokens
?.let { tokens ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,18 @@ internal class WalletViewModel @Inject constructor(
get() = !isTipMilestoneResolved || onboardingItems?.all { it.isCompleted } != false

/**
* Whether the activity half of the tab is still settling.
* Whether the onboarding milestones are still settling.
*
* Both the milestones and the recent-activity preview are reads of a *local cache* that
* starts empty on a fresh login, so neither can be trusted until the feed has been
* reconciled with the server at least once. Without this an established account signing in
* was shown the new-user tutorial for as long as its history took to arrive. Local rows
* short-circuit the wait: if there is already activity to draw, there is nothing to
* mistake for a new account — and neither is a held balance, which is a live read of the
* account rather than of the cache.
* The milestones are a read of a *local cache* that starts empty on a fresh login, so they
* cannot be trusted until the feed has been reconciled with the server at least once.
* Without this an established account signing in was shown the new-user tutorial for as
* long as its history took to arrive. Local rows short-circuit the wait: if there is
* already activity to draw, there is nothing to mistake for a new account — and neither is
* a held balance, which is a live read of the account rather than of the cache.
*
* Read by the tutorial and nothing else. The recent-activity preview needs no such wait —
* it draws when there are rows to draw — and the tab as a whole waits only on the token
* set, which is what the balance and the card deck are made of.
*
* Scoped to the activity feed on purpose. The *chat* cache backing the tip milestone
* settles separately and far later — its hydration waits on a per-conversation backfill —
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.flipcash.app.login

import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.ReportDrawn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
Expand Down Expand Up @@ -370,10 +370,11 @@ private fun LoginStepContent(seed: String?) {
val navigator = LocalCodeNavigator.current
var visible by remember { mutableStateOf(false) }
var loginRequested by remember { mutableStateOf(false) }
val activity = LocalActivity.current

// The landing destination for a launch that is not logged in.
ReportDrawn()

LaunchedEffect(Unit) {
activity?.reportFullyDrawn()
visible = true
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
package com.flipcash.app.scanner

import androidx.activity.compose.LocalActivity
import androidx.activity.compose.ReportDrawn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.flipcash.app.scanner.internal.Scanner

@Composable
fun ScannerScreen() {
val activity = LocalActivity.current
LaunchedEffect(Unit) {
activity?.reportFullyDrawn()
}
// Only a report for the launch that lands *here* -- a deeplink into scan, or scan as the
// restored tab. Through the reporter rather than Activity.reportFullyDrawn(), so that arriving
// on this tab later cannot close a report another destination is still holding open.
ReportDrawn()
Scanner()
}
Loading
Loading