diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt new file mode 100644 index 0000000..c1802f1 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt @@ -0,0 +1,108 @@ +package com.example.aiapp + +import android.content.Context +import java.util.concurrent.CopyOnWriteArrayList + +/** + * P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process + * fake of the backend, so the `bench` build type can drive a real session screen -- the real + * [TranscriptSource], the real fold, the real paging -- with no server and no network permission. + * + * Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else + * in this build compiles it in but never calls it, since Kotlin has no per-build-type source set + * that both [MainActivity] (which every variant compiles) and this can share without one. + * + * The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through + * ordinary `java.net.URL`, exactly as they would talk to a real server. A + * [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every + * `https://` connection to that host and answers from this object's in-memory event log instead of + * opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen, + * the fold, uniqueItems) never learns the difference. + */ +object BenchFixture { + const val FIXTURE_HOST = "bench.fixture.invalid" + const val FIXTURE_PORT = 1 + + /** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */ + private const val BACKLOG_COUNT = 3200 + + val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench") + + /** The session id every bench run opens; nothing else in this build ever mints one. */ + const val SESSION_ID = "bench-fixture-session" + + /** + * The whole transcript, seq order, growing as [pushLive] is called during the streaming phase. + * Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a + * live frame agree on what has "already happened" -- the same thing a real server's own + * transcript file guarantees. + */ + private val log = CopyOnWriteArrayList>() + + /** The events not yet appended to [log] -- the streaming phase's own source. */ + private var streamTail: List> = emptyList() + + private val images = mutableMapOf() + + @Volatile private var loaded = false + + /** + * Parses the bundled fixture once. Safe to call more than once; only the first does anything. + */ + @Synchronized + fun ensureLoaded(context: Context) { + if (loaded) return + val lines = + context.assets.open("transcript.jsonl").bufferedReader().readLines().filter { + it.isNotBlank() + } + val parsed = lines.map { it to parseSeqEvent(it) } + log.addAll(parsed.take(BACKLOG_COUNT)) + streamTail = parsed.drop(BACKLOG_COUNT) + for (name in listOf("bench1.png", "bench2.png")) { + images[name] = context.assets.open(name).readBytes() + } + loaded = true + } + + /** The events the streaming phase has left to send. */ + fun remainingStreamEvents(): Int = streamTail.size + + /** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */ + fun pushNextLiveEvent(): Boolean { + val next = streamTail.firstOrNull() ?: return false + streamTail = streamTail.drop(1) + log.add(next) + return true + } + + /** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */ + @Synchronized + fun resetToBacklog(context: Context) { + loaded = false + log.clear() + ensureLoaded(context) + } + + fun fileBytes(name: String): ByteArray? = images[name] + + /** + * Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays. + */ + fun linesAfter(after: Long): List = + log.filter { it.second.seq > after }.map { it.first } + + /** + * One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores + * `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and + * what the benchmark exercises is the fold and the paging, not the server's row-joining, which + * client-core's own port tracks separately (CLIENT_CORE.md). + */ + fun page(before: Long?, limit: Int, after: Long?): List { + val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L) + val candidates = log.filter { + it.second.seq < upper && (after == null || it.second.seq > after) + } + return candidates.takeLast(limit).map { it.first } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt new file mode 100644 index 0000000..d9ff4c9 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt @@ -0,0 +1,181 @@ +package com.example.aiapp + +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLStreamHandler +import java.net.URLStreamHandlerFactory +import java.security.Principal +import java.security.cert.Certificate +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLPeerUnverifiedException +import org.json.JSONArray + +/** + * Installs the process-wide interception [BenchFixture] needs. Idempotent and safe to call more + * than once; the JDK only allows [URL.setURLStreamHandlerFactory] to be called successfully once + * per process, and a second real call throws -- so this guards it rather than relying on every + * caller to remember. + * + * Scoped to [BenchFixture.FIXTURE_HOST]: any other `https://` URL falls through to the platform's + * ordinary handler, so this only ever changes behaviour for the one host the bench build invents. + */ +@Synchronized +fun installFixtureNetworkOnce() { + if (installed) return + installed = true + URL.setURLStreamHandlerFactory( + URLStreamHandlerFactory { protocol -> + if (protocol != "https") null + else + object : URLStreamHandler() { + override fun openConnection(url: URL): HttpURLConnection = + if (url.host == BenchFixture.FIXTURE_HOST) FixtureConnection(url) + else + // The bench build makes no other https call -- this factory is + // installed only in FIXTURE_MODE (MainActivity) -- so there is + // deliberately no delegate to a platform handler here: once a + // URLStreamHandlerFactory is installed there is no supported way to + // ask the JDK for its own default handler back, and re-entering this + // same factory for the fallback would recurse forever rather than + // reach one. + throw java.io.IOException( + "bench build's fixture network has no route to https host " + + "${url.host} -- only ${BenchFixture.FIXTURE_HOST} is served" + ) + } + } + ) +} + +private var installed = false + +/** + * Answers one request against [BenchFixture] instead of opening a socket. Implements just enough of + * [HttpsURLConnection] for [requestFromServer] and [Sse] to work unmodified: both only call + * `connect`/`disconnect`, set a handful of request properties they never need answered, and read + * `responseCode` and `inputStream`. + */ +private class FixtureConnection(url: URL) : HttpsURLConnection(url) { + private var input: InputStream? = null + private var writer: Thread? = null + + override fun connect() { + if (input != null) return + input = route(url.path, url.query) + } + + override fun disconnect() { + writer?.interrupt() + try { + input?.close() + } catch (_: IOException) {} + } + + override fun usingProxy() = false + + override fun getResponseCode(): Int { + connect() + return 200 + } + + override fun getInputStream(): InputStream { + connect() + return input!! + } + + override fun getErrorStream(): InputStream? = null + + // Nothing here reads any of these; implemented only because HttpsURLConnection declares them + // abstract. A fixture never negotiates real TLS, so each says exactly that rather than + // fabricating a plausible-looking certificate. + override fun getCipherSuite() = "none (bench fixture, no TLS)" + + override fun getLocalCertificates(): Array? = null + + override fun getServerCertificates(): Array = + throw SSLPeerUnverifiedException("bench fixture connection presents no certificate") + + override fun getPeerPrincipal(): Principal = + throw SSLPeerUnverifiedException("bench fixture connection presents no certificate") + + override fun getLocalPrincipal(): Principal? = null + + /** + * [path] is `/sessions/{id}/...`; everything else this build's fixture is asked for is a bug. + */ + private fun route(path: String, query: String?): InputStream { + val params = + (query ?: "") + .split("&") + .filter { it.contains('=') } + .associate { + val (k, v) = it.split("=", limit = 2) + k to java.net.URLDecoder.decode(v, "UTF-8") + } + return when { + path.endsWith("/transcript") -> { + val lines = + BenchFixture.page( + before = params["before"]?.toLongOrNull(), + limit = params["limit"]?.toIntOrNull() ?: 80, + after = params["after"]?.toLongOrNull(), + ) + val body = JSONArray(lines.map { org.json.JSONObject(it) }) + ByteArrayInputStream(body.toString().toByteArray()) + } + path.endsWith("/events") -> openEventsStream(params["after"]?.toLongOrNull() ?: 0L) + path.contains("/files/") -> { + val name = path.substringAfterLast("/files/") + val bytes = + BenchFixture.fileBytes(name) + ?: throw IOException("bench fixture has no file named $name") + ByteArrayInputStream(bytes) + } + else -> throw IOException("bench fixture has no route for $path") + } + } + + /** + * A live SSE body: [BenchFixture.linesAfter] replayed immediately, then polled every 50ms for + * anything [BenchFixture.pushNextLiveEvent] has added since -- the same shape a real backend's + * backlog-then-follow gives [Sse], just polled instead of woken, which is a fixture's business + * rather than something worth a condition variable for. + */ + private fun openEventsStream(after: Long): InputStream { + val pipeIn = PipedInputStream(1 shl 16) + val pipeOut = PipedOutputStream(pipeIn) + var sent = after + val thread = Thread { + try { + while (!Thread.currentThread().isInterrupted) { + val fresh = BenchFixture.linesAfter(sent) + for (line in fresh) { + pipeOut.write("data: $line\n\n".toByteArray()) + pipeOut.flush() + sent = org.json.JSONObject(line).getLong("seq") + } + Thread.sleep(50) + } + } catch (_: InterruptedException) { + // disconnect() -- the ordinary way this ends. + } catch (_: IOException) { + // The reader side (Sse) closed its end. + } finally { + try { + pipeOut.close() + } catch (_: IOException) {} + } + } + .also { + it.isDaemon = true + it.start() + } + writer = thread + return pipeIn + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt new file mode 100644 index 0000000..48ca39b --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt @@ -0,0 +1,143 @@ +package com.example.aiapp + +import android.content.Context +import android.os.BatteryManager +import android.os.Process +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.lazy.LazyListState +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * P0's scripted benchmark, run in-process instead of by a shell script: the phone has no usable + * system tracing (this-machine-android's skill) and no agent can drive it, so the same scroll loop + * and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace` are reproduced + * here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build + * (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason + * [BenchFixture]'s doc comment gives. + */ +object BenchRun { + /** transcript-bench.sh's default: 6 cycles of 4 swipes each, 900px over 200ms, 500ms apart. */ + private const val CYCLES = 6 + private const val SWIPE_PX = 900f + private const val SWIPE_MS = 200 + private const val SWIPE_PAUSE_MS = 500L + + /** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */ + private const val STREAM_EVENTS_PER_SEC = 20 + private const val STREAM_SECONDS = 20 + + /** + * Scrolls, then streams, then returns the extra report lines P0 asked for (CPU time, peak RSS, + * battery current) -- [FrameStats] and [DebugStats] are reset first, exactly as + * `copyRenderReport` resets them, so the two accountings cover the same stretch of work. + */ + suspend fun run( + context: Context, + scope: CoroutineScope, + listState: LazyListState, + ): List { + FrameStats.reset() + DebugStats.reset() + val cpuStartMs = Process.getElapsedCpuTime() + + val battery = BatterySampler(context) + // Launched in the caller's scope rather than a fresh coroutineScope{} here, which would + // suspend this function until the sampler job ended -- and it only ends when told to. + val samplerJob = scope.launch { + while (isActive) { + battery.sample() + delay(1000) + } + } + + // The swipe loop: transcript-bench.sh's four swipes per cycle are two drags toward newer + // content and two back, so a cycle returns to where it started and the whole loop measures + // steady-state scrolling rather than travelling somewhere new each time. + repeat(CYCLES) { + repeat(2) { + listState.animateScrollBy(SWIPE_PX, tween(SWIPE_MS)) + delay(SWIPE_PAUSE_MS) + } + repeat(2) { + listState.animateScrollBy(-SWIPE_PX, tween(SWIPE_MS)) + delay(SWIPE_PAUSE_MS) + } + } + + // Pinned to the newest end before streaming starts, the way stream-bench.sh's "Jump to + // latest" tap is -- a reply streamed into a list parked further back arrives off-screen and + // the report would show nothing happened. + listState.scrollToItem(0) + + var sent = 0 + val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS + while (sent < total && BenchFixture.remainingStreamEvents() > 0) { + BenchFixture.pushNextLiveEvent() + sent++ + delay(1000L / STREAM_EVENTS_PER_SEC) + } + // Lets the last few deltas land and draw before the report is read. + delay(300) + + samplerJob.cancel() + val cpuMs = Process.getElapsedCpuTime() - cpuStartMs + val rssLine = peakRssLine() + val batteryLine = battery.finish() + + return listOf( + " scroll: $CYCLES cycles (${CYCLES * 4} swipes), streamed $sent/$total fixture events", + " process CPU time over this run: ${cpuMs}ms", + rssLine, + batteryLine, + ) + } + + /** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */ + private fun peakRssLine(): String { + val kb = + try { + File("/proc/self/status") + .readLines() + .firstOrNull { it.startsWith("VmHWM:") } + ?.trim() + ?.removePrefix("VmHWM:") + ?.trim() + ?.removeSuffix("kB") + ?.trim() + ?.toLongOrNull() + } catch (_: Exception) { + null + } + return " peak RSS: " + + (kb?.let { "${it}kB" } ?: "unavailable (/proc/self/status unreadable)") + } +} + +/** + * Samples [BatteryManager.BATTERY_PROPERTY_CURRENT_NOW] (microamps) once a second for the length of + * a run. The property returns `Int.MIN_VALUE` on hardware that does not support it -- most + * emulators -- and that is reported as "unavailable" rather than folded into an average with the + * real samples, which would silently understate every number after it. See UI_RULES: never present + * an inferred value as a measured one. + */ +private class BatterySampler(context: Context) { + private val manager = context.getSystemService(BatteryManager::class.java) + private val samples = mutableListOf() + + fun sample() { + val value = manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) + if (value != null && value != Int.MIN_VALUE) samples.add(value) + } + + fun finish(): String { + if (samples.isEmpty()) return " battery current: unavailable on this device" + val meanUa = samples.sum() / samples.size + return " battery current: mean ${meanUa}µA over ${samples.size} samples" + + " (min ${samples.min()}, max ${samples.max()})" + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt index d1d7b48..695cf2c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt @@ -127,6 +127,13 @@ fun debugReport( frames: List, accounting: List, crash: String?, + /** + * P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on + * every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section + * heading only appears when there is something to put under it, so an ordinary copy from the + * render-report button reads exactly as it did before this existed. + */ + extra: List = emptyList(), ): String = buildString { appendLine("ai-app render report") appendLine(device) @@ -152,6 +159,11 @@ fun debugReport( appendLine("work since this was last copied:") val work = DebugStats.lines() if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) } + if (extra.isNotEmpty()) { + appendLine() + appendLine("bench:") + extra.forEach { appendLine(it) } + } } /** Puts [text] on the clipboard under [label], which is what the system offers as its name. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt index f16b3c5..7bfb8ae 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -66,6 +66,35 @@ class MainActivity : ComponentActivity() { // Transparent status bar on every version; the Surface below paints through underneath it // and content insets itself. Same reasoning as dev-updater's MainActivity. enableEdgeToEdge() + + // The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session + // screen against BenchFixture's in-process fake backend, with no enrollment, no network + // permission, and no notification prompt -- none of them mean anything with no server and + // no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built + // to talk to a real backend is made to talk to this instead. Still needs the same + // status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the + // platform's own default from Android 15 on this app's targetSdk, with or without the call + // above, so skipping the padding here put the header's own buttons under the status bar -- + // there to look at, but not there for `ui-trace`'s tap-by-label to land on. + if (BuildConfig.FIXTURE_MODE) { + installFixtureNetworkOnce() + BenchFixture.ensureLoaded(this) + setContent { + MaterialTheme(colorScheme = AiAppColors) { + Surface(modifier = Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) { + SessionScreen( + settings = BenchFixture.settings, + summary = benchSessionSummary(), + onBack = { finish() }, + onFiles = {}, + ) + } + } + } + } + return + } // Dark status-bar icons only over a light background, decided from the scheme rather than // fixed. It was hardcoded to `true`, which was right against the default light surface and // became unreadable the moment the app wore Catppuccin Mocha. @@ -147,6 +176,26 @@ class MainActivity : ComponentActivity() { } } + /** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */ + private fun benchSessionSummary() = + SessionSummary( + id = BenchFixture.SESSION_ID, + setup = "bench", + setupName = "bench", + provider = "bench", + title = "P0 benchmark", + model = null, + keepsOwnTranscript = false, + permissionMode = null, + imported = false, + notify = false, + cwd = null, + contextTokens = null, + maxImageEdge = null, + status = "idle", + lastActivity = 0.0, + ) + // launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open, // lands here rather than in a second activity instance. override fun onNewIntent(intent: Intent) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 12f2aff..1d674cf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1191,7 +1191,12 @@ fun SessionScreen( // the bench scripts keep working when this moves again. They pressed it at a hand-measured // coordinate until 2026-09-03, and anything that moved the header made that tap land on // whatever now sat there -- reporting a number that was never measured. - val copyRenderReport = { + // Shared by the ordinary "Copy" button and (bench build only) "Run benchmark": what differs + // between them is only whether there is a [extra] section, built by BenchRun.run beforehand -- + // everything about assembling, copying and logging the report is exactly the same act either + // way, and a second copy of it beside `onRunBenchmark` below would be the two silently + // disagreeing about what "the report" contains the first time either one changed. + fun buildAndCopyReport(extra: List = emptyList()) { val report = debugReport( device = @@ -1213,6 +1218,7 @@ fun SessionScreen( accounting = FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) }, crash = lastCrash(context), + extra = extra, ) context.copyToClipboard("ai-app render report", report) // Also to the log, so a session driving the app over adb can read the same report the @@ -1226,6 +1232,20 @@ fun SessionScreen( DebugStats.reset() Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show() } + val copyRenderReport = { buildAndCopyReport() } + // Bench build only: P0's scripted scroll-and-stream benchmark (BenchRun.kt), against the + // fixture session opened below instead of a real server. Null everywhere else -- see + // [SessionSettingsDialog]'s onRunBenchmark. + val runBenchmark: (() -> Unit)? = + if (BuildConfig.FIXTURE_MODE) { + { + settingsOpen = false + scope.launch { + val extra = BenchRun.run(context, scope, listState) + buildAndCopyReport(extra) + } + } + } else null Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { Row( @@ -1869,6 +1889,7 @@ fun SessionScreen( }, onDismiss = { settingsOpen = false }, onCopyRenderReport = copyRenderReport, + onRunBenchmark = runBenchmark, ) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt index 4e116b2..ad44f41 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -66,6 +66,13 @@ fun SessionSettingsDialog( * measures is that screen's own state. */ onCopyRenderReport: () -> Unit, + /** + * Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on + * every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than + * always-present-but-disabled: this has no meaning at all outside the bench build, and a + * control with nothing behind it on every other build is not a state worth drawing. + */ + onRunBenchmark: (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() var name by remember(sessionId) { mutableStateOf(title) } @@ -317,6 +324,21 @@ fun SessionSettingsDialog( Text("Render timings", modifier = Modifier.weight(1f)) TextButton(onClick = onCopyRenderReport) { Text("Copy") } } + // Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because + // ui-trace and the emulator smoke run find it by that label, the same way every + // other control here is found -- see AGENTS.md's "Driving the UI". + onRunBenchmark?.let { run -> + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.width(8.dp)) + Text("P0 benchmark", modifier = Modifier.weight(1f)) + TextButton(onClick = run) { Text("Run benchmark") } + } + } } }, // Disabled rather than absent while there is nothing to save: a button that comes and goes