From be5c3504c8b0e9dff32d0aad42f6f5f34530e22b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 4 Jun 2026 15:49:51 -0400 Subject: [PATCH] feat(perf): add baseline profile, macrobenchmark module, and benchmark script - Add benchmark module with StartupBenchmark (4 compilation modes) and BaselineProfileGenerator (pre-auth + authenticated user journeys) - Add benchmark build type (non-debuggable debug) to app for accurate measurements - Add scripts/benchmark.sh with device targeting (-d), test class filter (-t), and .env sourcing for SEED_PHRASE - Handle auth state persistence across BaselineProfileRule iterations - Use IME input for Compose TextField compatibility in login flow --- .gitignore | 2 +- apps/flipcash/app/build.gradle.kts | 8 + apps/flipcash/app/src/main/baseline-prof.txt | 43 +++++ apps/flipcash/benchmark/build.gradle.kts | 52 ++++++ .../benchmark/src/main/AndroidManifest.xml | 3 + .../benchmark/BaselineProfileGenerator.kt | 166 ++++++++++++++++++ .../flipcash/benchmark/StartupBenchmark.kt | 52 ++++++ gradle/libs.versions.toml | 6 + scripts/benchmark.sh | 82 +++++++++ .../opencode/model/core/errors/Errors.kt | 11 +- .../core/errors/SubmitIntentErrorTest.kt | 16 ++ settings.gradle.kts | 1 + 12 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/app/src/main/baseline-prof.txt create mode 100644 apps/flipcash/benchmark/build.gradle.kts create mode 100644 apps/flipcash/benchmark/src/main/AndroidManifest.xml create mode 100644 apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt create mode 100644 apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/StartupBenchmark.kt create mode 100755 scripts/benchmark.sh diff --git a/.gitignore b/.gitignore index 72c311035..9d539889a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ /local.properties .idea/ .DS_Store -/build +**/build/ /captures .externalNativeBuild .cxx diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index eb69774cc..d974fbc28 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -84,6 +84,12 @@ android { ) } } + create("benchmark") { + initWith(getByName("debug")) + isDebuggable = false + signingConfig = signingConfigs.getByName("contributors") + matchingFallbacks += listOf("debug") + } } compileOptions { @@ -272,6 +278,8 @@ dependencies { implementation(libs.timber) implementation(libs.bugsnag) + implementation(libs.androidx.profileinstaller) + testImplementation(libs.junit) testImplementation(libs.kotlin.test.junit) } diff --git a/apps/flipcash/app/src/main/baseline-prof.txt b/apps/flipcash/app/src/main/baseline-prof.txt new file mode 100644 index 000000000..5ef9fde79 --- /dev/null +++ b/apps/flipcash/app/src/main/baseline-prof.txt @@ -0,0 +1,43 @@ +# Baseline profile for Flipcash — covers cold start and Compose rendering. +# Generated profiles from :apps:flipcash:benchmark will replace this file. + +# Application + Activity startup +HSPLcom/flipcash/app/FlipcashApp;->onCreate()V +HSPLcom/flipcash/app/MainActivity;->onCreate(Landroid/os/Bundle;)V + +# Hilt dependency injection (startup-critical) +HSPLdagger/hilt/android/internal/**;->**(**)** +HSPLcom/flipcash/app/FlipcashApp_GeneratedInjector;->**(**)** +HSPLcom/flipcash/app/MainActivity_GeneratedInjector;->**(**)** + +# Jetpack Compose runtime (critical for first frame) +HSPLandroidx/compose/runtime/**;->**(**)** +HSPLandroidx/compose/ui/**;->**(**)** +HSPLandroidx/compose/foundation/**;->**(**)** +HSPLandroidx/compose/material/**;->**(**)** +HSPLandroidx/compose/animation/**;->**(**)** + +# Compose layout and drawing +HSPLandroidx/compose/ui/platform/AndroidComposeView;->**(**)** +HSPLandroidx/compose/ui/node/**;->**(**)** + +# Navigation +HSPLandroidx/navigation/**;->**(**)** + +# Lifecycle +HSPLandroidx/lifecycle/**;->**(**)** +HSPLandroidx/activity/**;->**(**)** + +# Kotlinx coroutines (startup flows) +HSPLkotlinx/coroutines/**;->**(**)** + +# Kotlinx serialization (used in session/config deserialization) +HSPLkotlinx/serialization/**;->**(**)** + +# Coil image loading +HSPLcoil3/**;->**(**)** + +# App theme and UI setup +HSPLcom/getcode/theme/**;->**(**)** +HSPLcom/getcode/ui/core/**;->**(**)** +HSPLcom/getcode/ui/components/**;->**(**)** diff --git a/apps/flipcash/benchmark/build.gradle.kts b/apps/flipcash/benchmark/build.gradle.kts new file mode 100644 index 000000000..cac705b55 --- /dev/null +++ b/apps/flipcash/benchmark/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id("com.android.test") +} + +val contributorsSigningConfig = ContributorsSignatory(rootProject) + +android { + namespace = "com.flipcash.benchmark" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = 29 + targetSdk = libs.versions.android.targetSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "EMULATOR" + } + + signingConfigs { + create("contributors") { + storeFile = contributorsSigningConfig.keystore + storePassword = contributorsSigningConfig.keystorePassword + keyAlias = contributorsSigningConfig.keyAlias + keyPassword = contributorsSigningConfig.keyPassword + } + } + + buildTypes { + create("benchmark") { + isDebuggable = true + signingConfig = signingConfigs.getByName("contributors") + matchingFallbacks += listOf("debug") + } + } + + compileOptions { + sourceCompatibility(libs.versions.android.java.get()) + targetCompatibility(libs.versions.android.java.get()) + } + + kotlin { + jvmToolchain(libs.versions.android.java.get().toInt()) + } + + targetProjectPath = ":apps:flipcash:app" + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +dependencies { + implementation(libs.androidx.benchmark.macro.junit4) + implementation(libs.androidx.test.runner) + implementation(libs.androidx.junit) +} diff --git a/apps/flipcash/benchmark/src/main/AndroidManifest.xml b/apps/flipcash/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9a40236b9 --- /dev/null +++ b/apps/flipcash/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt b/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt new file mode 100644 index 000000000..7652ffa5b --- /dev/null +++ b/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt @@ -0,0 +1,166 @@ +package com.flipcash.benchmark + +import androidx.benchmark.macro.junit4.BaselineProfileRule +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.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Generates a baseline profile by exercising critical user journeys. + * + * Pass a seed phrase via instrumentation args to enable authenticated flows: + * ``` + * -Pandroid.testInstrumentationRunnerArguments.SEED_PHRASE="word1 word2 ..." + * ``` + * + * Without a seed phrase, only the pre-auth startup path is profiled. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class BaselineProfileGenerator { + + @get:Rule + val rule = BaselineProfileRule() + + private val seedPhrase: String? + get() = InstrumentationRegistry.getArguments().getString("SEED_PHRASE") + + @Test + fun generateBaselineProfile() { + rule.collect( + packageName = PACKAGE_NAME, + ) { + // Cold start — app init, Hilt DI, Compose runtime bootstrap + pressHome() + 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 + scannerJourney() + walletJourney() + giveJourney() + menuJourney() + } else { + val seed = seedPhrase + if (seed.isNullOrBlank()) { + preAuthJourney() + } else { + login(seed) + scannerJourney() + walletJourney() + giveJourney() + menuJourney() + } + } + } + } + + private fun MacrobenchmarkScope.preAuthJourney() { + // Navigate to seed input — exercises nav transitions, text input composables + device.wait(Until.findObject(By.res("login_button")), TIMEOUT)?.click() + device.waitForIdle() + + // Back to onboarding — exercises pop transition + device.pressBack() + device.waitForIdle() + } + + private fun MacrobenchmarkScope.login(seed: String) { + // Tap "Log in" + device.wait(Until.findObject(By.res("login_button")), TIMEOUT)?.click() + + // Wait for seed input field and tap to focus + device.wait(Until.findObject(By.res("seed_input_field")), TIMEOUT)?.click() + device.waitForIdle() + + // Type seed phrase via IME — UiObject2.text doesn't work with Compose TextField + val escaped = seed.replace(" ", "%s") + device.executeShellCommand("input text $escaped") + device.waitForIdle() + + // Wait for seed validation to enable the button, then confirm + 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) + device.waitForIdle() + } + + private fun MacrobenchmarkScope.scannerJourney() { + // Scanner is the home screen — let it fully render + device.wait(Until.findObject(By.res("scanner_view")), TIMEOUT) + device.waitForIdle() + } + + private fun MacrobenchmarkScope.walletJourney() { + // Open wallet sheet + device.wait(Until.findObject(By.text("Wallet")), TIMEOUT)?.click() + device.wait(Until.findObject(By.res("wallet_screen")), TIMEOUT) + device.waitForIdle() + + // Open token info + device.wait(Until.findObject(By.text("Float")), TIMEOUT)?.click() + device.wait(Until.findObject(By.res("token_info_screen")), TIMEOUT) + device.waitForIdle() + + // Back to wallet + device.pressBack() + device.waitForIdle() + + // Close sheet — swipe down to return to scanner + dismissSheet() + } + + private fun MacrobenchmarkScope.giveJourney() { + // Open give/cash screen + device.wait(Until.findObject(By.text("Give")), TIMEOUT)?.click() + device.wait(Until.findObject(By.res("cash_screen")), TIMEOUT) + device.waitForIdle() + + // Close sheet + dismissSheet() + } + + private fun MacrobenchmarkScope.menuJourney() { + // Open menu + device.wait(Until.findObject(By.res("menu_button")), TIMEOUT)?.click() + device.wait(Until.findObject(By.res("menu_screen")), TIMEOUT) + device.waitForIdle() + + // Close sheet + dismissSheet() + } + + private fun MacrobenchmarkScope.dismissSheet() { + // Swipe from mid-screen downward to dismiss bottom sheet. + // Avoid starting near the top to prevent pulling the notification panel. + device.swipe( + device.displayWidth / 2, + device.displayHeight / 3, + device.displayWidth / 2, + device.displayHeight * 3 / 4, + 10, + ) + device.wait(Until.findObject(By.res("scanner_view")), TIMEOUT) + device.waitForIdle() + } + + companion object { + private const val PACKAGE_NAME = "com.flipcash.app.android" + private const val TIMEOUT = 5_000L + private const val LOGIN_TIMEOUT = 15_000L + } +} + +private typealias MacrobenchmarkScope = androidx.benchmark.macro.MacrobenchmarkScope diff --git a/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/StartupBenchmark.kt b/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/StartupBenchmark.kt new file mode 100644 index 000000000..919801368 --- /dev/null +++ b/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/StartupBenchmark.kt @@ -0,0 +1,52 @@ +package com.flipcash.benchmark + +import androidx.benchmark.macro.BaselineProfileMode +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingLegacyMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@LargeTest +class StartupBenchmark { + + @get:Rule + val rule = MacrobenchmarkRule() + + @Test + fun startupNoCompilation() = benchmark(CompilationMode.None()) + + @Test + fun startupPartialCompilation() = benchmark( + CompilationMode.Partial( + baselineProfileMode = BaselineProfileMode.Disable, + warmupIterations = 3, + ) + ) + + @Test + fun startupBaselineProfile() = benchmark( + CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require) + ) + + @Test + fun startupFullCompilation() = benchmark(CompilationMode.Full()) + + private fun benchmark(compilationMode: CompilationMode) { + rule.measureRepeated( + packageName = "com.flipcash.app.android", + metrics = listOf(StartupTimingLegacyMetric()), + compilationMode = compilationMode, + iterations = 5, + startupMode = StartupMode.COLD, + ) { + pressHome() + startActivityAndWait() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 297112835..f2200698b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,6 +66,8 @@ lib-phone-number-port = "9.0.31" lib-phone-number-google = "9.0.31" zxing = "3.5.4" +androidx-benchmark-macro = "1.4.0" +androidx-profileinstaller = "1.4.1" androidx-test-runner = "1.7.0" junit = "4.13.2" androidx-junit = "1.3.0" @@ -270,6 +272,10 @@ rinku = { module = "dev.theolm:rinku", version.ref = "rinku" } rinku-compose = { module = "dev.theolm:rinku-compose-ext", version.ref = "rinku" } event-bus = { module = "io.github.hoc081098:channel-event-bus", version.ref = "event-bus" } +# Baseline Profile / Benchmark +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark-macro" } +androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "androidx-profileinstaller" } + # Testing androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh new file mode 100755 index 000000000..4e99989a8 --- /dev/null +++ b/scripts/benchmark.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: scripts/benchmark.sh [-t TestClass] [-d SERIAL] [seed phrase words...]" + echo "" + echo " -t CLASS Run a specific test class (e.g. StartupBenchmark, BaselineProfileGenerator)" + echo " -d SERIAL Target a specific device (adb serial or emulator name)" + echo " seed ... Seed phrase for authenticated user journeys (overrides .env)" + echo "" + echo " SEED_PHRASE can also be set in .env or .env.local" + echo "" + echo "Examples:" + echo " scripts/benchmark.sh # all benchmarks, seed from .env" + echo " scripts/benchmark.sh -t StartupBenchmark # startup only, no auth" + echo " scripts/benchmark.sh -d emulator-5554 -t BaselineProfileGenerator" + exit 1 +} + +# Source .env files for SEED_PHRASE and other config +for envfile in .env .env.local; do + [[ -f "$envfile" ]] && set -a && source "$envfile" && set +a +done + +TEST_CLASS="" +DEVICE_SERIAL="" + +while getopts "t:d:h" opt; do + case $opt in + t) TEST_CLASS="$OPTARG" ;; + d) DEVICE_SERIAL="$OPTARG" ;; + h) usage ;; + *) usage ;; + esac +done +shift $((OPTIND - 1)) + +# CLI args override .env +if [[ $# -gt 0 ]]; then + SEED_PHRASE="$*" +fi + +GRADLE_ARGS=( + :apps:flipcash:benchmark:connectedBenchmarkAndroidTest + --no-configuration-cache + -x lint +) + +if [[ -n "$TEST_CLASS" ]]; then + GRADLE_ARGS+=("-Pandroid.testInstrumentationRunnerArguments.class=com.flipcash.benchmark.$TEST_CLASS") +fi + +if [[ -n "${SEED_PHRASE:-}" ]]; then + GRADLE_ARGS+=("-Pandroid.testInstrumentationRunnerArguments.SEED_PHRASE=$SEED_PHRASE") +fi + +# Target a specific device so benchmarks don't run on multiple connected devices +if [[ -n "$DEVICE_SERIAL" ]]; then + export ANDROID_SERIAL="$DEVICE_SERIAL" +fi + +./gradlew "${GRADLE_ARGS[@]}" + +# Print results if available +RESULTS_DIR="apps/flipcash/benchmark/build/outputs/connected_android_test_additional_output" +BENCHMARK_JSON=$(find "$RESULTS_DIR" -name "*benchmarkData.json" 2>/dev/null | head -1) + +if [[ -n "$BENCHMARK_JSON" ]]; then + echo "" + echo "=== Benchmark Results ===" + python3 -c " +import json, sys +data = json.load(open('$BENCHMARK_JSON')) +for b in data.get('benchmarks', []): + m = b['metrics'] + startup = m.get('startupMs', {}) + fd = m.get('fullyDrawnMs', {}) + print(f\"{b['name']}:\") + if startup: print(f\" TTID: min={startup['minimum']:.0f}ms median={startup['median']:.0f}ms max={startup['maximum']:.0f}ms\") + if fd: print(f\" TTFD: min={fd['minimum']:.0f}ms median={fd['median']:.0f}ms max={fd['maximum']:.0f}ms\") +" 2>/dev/null || cat "$BENCHMARK_JSON" +fi diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt index 7abef714a..ae0bd6bef 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt @@ -115,7 +115,16 @@ sealed class SubmitIntentError( override val cause: Throwable? = null ) : CodeServerError(message, cause) { data class InvalidIntent(private val reasons: List) : - SubmitIntentError(message = reasons.joinToString()), NotifiableError + SubmitIntentError(message = reasons.joinToString()), ConditionallyNotifiable { + val isPaymentNoOp: Boolean + get() = reasons.any { it.contains("payment is a no-op") } + + val isExpected: Boolean + get() = isPaymentNoOp + + override val isNotifiable: Boolean + get() = !isExpected + } data class Signature(private val details: List = emptyList()) : SubmitIntentError(message = buildString { diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt index 5253d8b3b..1e9ce3cda 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt @@ -239,6 +239,22 @@ class SubmitIntentErrorTest { assertFalse(error.isAccountAlreadyOpened) } + @Test + fun invalidIntentWithPaymentNoOpIsNotNotifiable() { + val error = SubmitIntentError.InvalidIntent(listOf("payment is a no-op")) + assertTrue(error.isPaymentNoOp) + assertTrue(error.isExpected) + assertFalse(error.isNotifiable) + } + + @Test + fun invalidIntentWithOtherReasonIsNotifiable() { + val error = SubmitIntentError.InvalidIntent(listOf("bad amount")) + assertFalse(error.isPaymentNoOp) + assertFalse(error.isExpected) + assertTrue(error.isNotifiable) + } + @Test fun otherWrausesCause() { val cause = RuntimeException("root cause") diff --git a/settings.gradle.kts b/settings.gradle.kts index 952432485..72bfc574b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ rootProject.name = "Flipcash" include( // app containers ":apps:flipcash:app", + ":apps:flipcash:benchmark", // flipcash modules ":apps:flipcash:core",