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",