Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions apps/flipcash/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import com.android.build.api.variant.BuildConfigField
import com.bugsnag.gradle.dsl.debug
import com.bugsnag.gradle.dsl.release
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
Expand All @@ -15,6 +16,7 @@ plugins {
alias(libs.plugins.secrets)
id("org.jetbrains.kotlin.plugin.compose")
alias(libs.plugins.kover)
alias(libs.plugins.androidx.baselineprofile)
}

fun gitVersionCode(): Int {
Expand Down Expand Up @@ -154,6 +156,23 @@ kotlin {
}
}

// Expose Compose testTags as resource-ids (for UiAutomator/Maestro/baseline-profile
// generation) on every variant EXCEPT the shipping `release` — keeps internal ids like
// `seed_input_field` out of the production wallet build. Read in App.kt as
// BuildConfig.UI_TESTABLE. Refs Bugsnag 6a46563b.
androidComponents {
onVariants { variant ->
variant.buildConfigFields?.put(
"UI_TESTABLE",
BuildConfigField(
"boolean",
(variant.name != "release").toString(),
"testTags exposed as resource-ids; false only for shipping release",
),
)
}
}

dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation(project(":services:flipcash-compose"))
Expand Down Expand Up @@ -294,6 +313,9 @@ dependencies {
implementation(libs.bugsnag)

implementation(libs.androidx.profileinstaller)
// Consumes the generated baseline profile from the benchmark module and packages it
// into the APK (assets/dexopt/baseline.prof). Refs Bugsnag 6a46563b.
baselineProfile(project(":apps:flipcash:benchmark"))

testImplementation(libs.junit)
testImplementation(libs.kotlin.test.junit)
Expand Down
3 changes: 3 additions & 0 deletions apps/flipcash/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
android:theme="@style/Theme.Code"
tools:targetApi="tiramisu">

<!-- Lets Macrobenchmark/simpleperf attach to a non-debuggable release build. -->
<profileable android:shell="true" />

<meta-data
android:name="com.bugsnag.android.API_KEY"
android:value="${BUGSNAG_API_KEY}" />
Expand Down
43 changes: 0 additions & 43 deletions apps/flipcash/app/src/main/baseline-prof.txt

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ internal fun App(
onRootReached = { /* handled by activity back press */ },
)

val semanticsModifier = if (BuildConfig.DEBUG) {
// UI_TESTABLE is true for debug + the profile-gen/benchmark variants and
// false for the shipping release, so testTags aren't exposed in production.
val semanticsModifier = if (BuildConfig.UI_TESTABLE) {
Modifier.semantics { testTagsAsResourceId = true }
} else Modifier

Expand Down
59,074 changes: 59,074 additions & 0 deletions apps/flipcash/app/src/release/generated/baselineProfiles/baseline-prof.txt

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/flipcash/benchmark/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id("com.android.test")
alias(libs.plugins.androidx.baselineprofile)
}

val contributorsSigningConfig = ContributorsSignatory(rootDir)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.StaleObjectException
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -47,6 +49,7 @@ class BaselineProfileGenerator {
if (onScanner) {
// Already logged in from a prior iteration
scannerJourney()
discoveryJourney()
walletJourney()
giveJourney()
menuJourney()
Expand Down Expand Up @@ -103,6 +106,88 @@ class BaselineProfileGenerator {
device.waitForIdle()
}

private fun MacrobenchmarkScope.discoveryJourney() {
// Open the Discover tab from the scanner bottom nav
device.wait(Until.findObject(By.text("Discover")), TIMEOUT)?.click()

device.wait(Until.findObject(By.res("discovery_leaderboard")), LOGIN_TIMEOUT)
device.waitForIdle()

// Open a token's info screen from the FRESH leaderboard first. (Tapping a row
// right after flinging is unreliable — the row is still settling — so do the
// token-info journey before scrolling the list.)
device.wait(Until.findObject(By.res("leaderboard_token_row")), TIMEOUT)?.click()
device.wait(Until.findObject(By.res("token_info_screen")), LOGIN_TIMEOUT)
device.waitForIdle()

// Scroll the token-info screen down until the market-cap chart's period tabs
// (the bottom-most element) are on screen — the chart sits at the very bottom.
scrollUntilVisible("token_info_screen", "market_cap_period_All", Direction.UP)

// Interact with the market-cap chart: scrub across it (highlights points), then
// toggle every time window (each reloads data + redraws the chart).
device.findObject(By.res("market_cap_chart"))?.let { chart ->
val b = chart.visibleBounds
val y = b.centerY()
device.drag(b.left + b.width() / 6, y, b.right - b.width() / 6, y, 40)
}
device.waitForIdle()
listOf("Week", "Month", "Year", "All", "Day").forEach { period ->
device.findObject(By.res("market_cap_period_$period"))?.click()
device.waitForIdle()
}

// Back to the leaderboard, then fling-scroll it so TokenLeaderboard /
// TokenMetricsRow / RankBadge composition + layout get compiled.
device.wait(Until.findObject(By.res("action_back")), TIMEOUT)?.click()
device.wait(Until.findObject(By.res("discovery_leaderboard")), TIMEOUT)
device.waitForIdle()
flingScroll("discovery_leaderboard", Direction.UP, 3) // scroll down through the list
flingScroll("discovery_leaderboard", Direction.DOWN, 2) // and back up

// Close discovery to the scanner
device.wait(Until.findObject(By.res("action_close")), TIMEOUT)?.click()
device.wait(Until.findObject(By.res("scanner_view")), TIMEOUT)
device.waitForIdle()
}

/**
* Fling a scrollable [times], re-finding it each iteration. A Compose LazyColumn
* recomposes on fling, invalidating a held UiObject2 — so we re-query and tolerate
* a StaleObjectException rather than reusing a stale reference.
*/
private fun MacrobenchmarkScope.flingScroll(resId: String, direction: Direction, times: Int) {
repeat(times) {
val scrollable = device.findObject(By.res(resId)) ?: return
try {
scrollable.setGestureMargin(device.displayWidth / 5)
scrollable.fling(direction)
} catch (_: StaleObjectException) {
// View recomposed mid-fling; the next iteration re-finds it.
}
device.waitForIdle()
}
}

/** Fling [scrollableResId] in [direction] (re-finding each time) until [targetResId] appears. */
private fun MacrobenchmarkScope.scrollUntilVisible(
scrollableResId: String,
targetResId: String,
direction: Direction,
maxFlings: Int = 6,
) {
repeat(maxFlings) {
if (device.hasObject(By.res(targetResId))) return
val scrollable = device.findObject(By.res(scrollableResId)) ?: return
try {
scrollable.setGestureMargin(device.displayWidth / 5)
scrollable.fling(direction)
} catch (_: StaleObjectException) {
}
device.waitForIdle()
}
}

private fun MacrobenchmarkScope.walletJourney() {
// Open wallet sheet
device.wait(Until.findObject(By.text("Wallet")), TIMEOUT)?.click()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.flipcash.benchmark

import androidx.benchmark.macro.BaselineProfileMode
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.MacrobenchmarkScope
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.Direction
import androidx.test.uiautomator.StaleObjectException
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Frame-timing benchmark for scrolling the Discover-tab token leaderboard — the screen
* that ANR'd on a low-end device (Bugsnag 6a46563b). Compares an unprofiled build against
* one compiled from the baseline profile so the profile's effect on first-scroll jank is
* measurable. Headline metric: P95 frameOverrunMs (negative = on time).
*
* Requires an already-authenticated device (run the BaselineProfileGenerator once, or
* log in, first — the setup navigates from a cold launch to the Discover leaderboard).
* On an emulator, results are suppressed-but-runnable (see the module's suppressErrors);
* report medians from a physical low-end device for real numbers.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class LeaderboardScrollBenchmark {

@get:Rule
val rule = MacrobenchmarkRule()

@Test
fun scrollNoCompilation() = scroll(CompilationMode.None())

@Test
fun scrollBaselineProfile() =
scroll(CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require))

private fun scroll(compilationMode: CompilationMode) {
rule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(FrameTimingMetric()),
compilationMode = compilationMode,
iterations = 5,
setupBlock = {
pressHome()
startActivityAndWait()
// Navigate to the Discover leaderboard (device must be authenticated).
device.wait(Until.findObject(By.text("Discover")), TIMEOUT)?.click()
device.wait(Until.findObject(By.res("discovery_leaderboard")), LIST_TIMEOUT)
device.waitForIdle()
},
) {
// Re-find each fling: the LazyColumn recomposes on scroll, which would
// otherwise invalidate a held UiObject2 (StaleObjectException).
repeat(3) { flingLeaderboard(Direction.UP) }
repeat(3) { flingLeaderboard(Direction.DOWN) }
}
}

private fun MacrobenchmarkScope.flingLeaderboard(direction: Direction) {
val list = device.findObject(By.res("discovery_leaderboard")) ?: return
try {
list.setGestureMargin(device.displayWidth / 5)
list.fling(direction)
} catch (_: StaleObjectException) {
// recomposed mid-fling; next call re-finds
}
device.waitForIdle()
}

companion object {
private const val PACKAGE_NAME = "com.flipcash.app.android"
private const val TIMEOUT = 5_000L
private const val LIST_TIMEOUT = 15_000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.getcode.manager.BottomBarManager
import com.getcode.opencode.model.financial.HolderMetrics
import com.getcode.opencode.model.financial.MintMetadata
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.base58
import com.getcode.opencode.model.financial.VmMetadata
import com.getcode.opencode.model.ui.DiscoverCategory
import com.getcode.opencode.model.ui.WindowedRange
Expand Down Expand Up @@ -99,7 +100,9 @@ private fun LoadedPopular_Preview() {
TokenDiscoveryScreenContent(
state = TokenDiscoveryViewModel.State(
category = DiscoverCategory.Popular,
tokens = Loadable.Loaded(sampleTokens())
tokens = Loadable.Loaded(
sampleTokens().map { LeaderboardEntry(key = it.address.base58(), token = it) }
)
)
) { }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.getcode.opencode.controllers.CurrencyController
import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.ui.DiscoverCategory
import com.getcode.solana.keys.Mint
import com.getcode.solana.keys.base58
import com.getcode.util.resources.ResourceHelper
import com.flipcash.libs.coroutines.DispatcherProvider
import com.getcode.manager.BottomBarManager
Expand All @@ -26,6 +27,17 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject

/**
* A leaderboard row's token paired with its precomputed LazyColumn key. The key is
* the token's base58 address (BigInteger encoding); computing it once here at load
* keeps it out of the `itemsIndexed { key = … }` hot path, which Compose re-runs
* over the viewport's nearby range on scroll. Refs Bugsnag 6a46563b.
*/
internal data class LeaderboardEntry(
val key: String,
val token: Token,
)

@HiltViewModel
internal class TokenDiscoveryViewModel @Inject constructor(
private val currencyController: CurrencyController,
Expand All @@ -42,7 +54,7 @@ internal class TokenDiscoveryViewModel @Inject constructor(
data class State(
val createEnabled: Boolean = false,
val category: DiscoverCategory? = null,
val tokens: Loadable<List<Token>> = Loadable.Loading(),
val tokens: Loadable<List<LeaderboardEntry>> = Loadable.Loading(),
val minimumHolderAmount: Fiat = 10.toFiat(),
)

Expand All @@ -55,7 +67,7 @@ internal class TokenDiscoveryViewModel @Inject constructor(
val fromUser: Boolean = false
) : Event

data class OnTokensUpdated(val loadable: Loadable<List<Token>>) : Event
data class OnTokensUpdated(val loadable: Loadable<List<LeaderboardEntry>>) : Event

data class LoadTokensForCategory(val category: DiscoverCategory) : Event
data object Refresh : Event
Expand Down Expand Up @@ -97,8 +109,9 @@ internal class TokenDiscoveryViewModel @Inject constructor(
.map { it.category }
.map { currencyController.discoverTokens(it) }
.onResult(
onSuccess = {
dispatchEvent(Event.OnTokensUpdated(Loadable.Loaded(it)))
onSuccess = { tokens ->
val entries = tokens.map { LeaderboardEntry(key = it.address.base58(), token = it) }
dispatchEvent(Event.OnTokensUpdated(Loadable.Loaded(entries)))
},
onError = { error ->
dispatchEvent(
Expand Down
Loading
Loading