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()})" } }