ai-app: a phone interface to Claude Code and llama.cpp sessions
A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here.
This commit is contained in:
commit
b172c464ea
100 files changed
+31795
No files matched your search
@@ -0,0 +1,155 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.FrameMetrics
|
||||
import android.view.Window
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* How long each frame took, and which phase of it, taken from the platform rather than from a frame
|
||||
* counter of our own.
|
||||
*
|
||||
* The point of splitting it up is that "the scroll is laggy" has two completely different causes
|
||||
* and one appearance. If the layout-and-measure and draw figures are small and the total is large,
|
||||
* the time is going into rasterising and compositing, and no amount of doing less work per row will
|
||||
* move it. If they are large, the work per row is the problem and it is ours to fix. Guessing
|
||||
* between those two is how a day gets spent rewriting the half that was already fast.
|
||||
*
|
||||
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
|
||||
* broken down into the parts the UI thread is responsible for -- handling input, running
|
||||
* animations, measuring and laying out, recording the draw -- and the parts after it.
|
||||
*/
|
||||
class FrameStats {
|
||||
private val total = ArrayList<Long>()
|
||||
private val waited = ArrayList<Long>()
|
||||
private val input = ArrayList<Long>()
|
||||
private val animation = ArrayList<Long>()
|
||||
private val layout = ArrayList<Long>()
|
||||
private val draw = ArrayList<Long>()
|
||||
private val sync = ArrayList<Long>()
|
||||
private val issue = ArrayList<Long>()
|
||||
private val swap = ArrayList<Long>()
|
||||
private val gpu = ArrayList<Long>()
|
||||
private var since = System.currentTimeMillis()
|
||||
|
||||
@Synchronized
|
||||
fun add(metrics: FrameMetrics) {
|
||||
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
||||
if (metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 1L) return
|
||||
if (total.size >= CAP) return
|
||||
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
|
||||
// How long the frame waited for the UI thread to be free before it could start. Reported
|
||||
// because the phases otherwise do not add up to the total, and the gap is the interesting
|
||||
// part: it is the frame being held up by work that is not the frame's.
|
||||
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
|
||||
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
|
||||
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
|
||||
layout += metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION)
|
||||
draw += metrics.getMetric(FrameMetrics.DRAW_DURATION)
|
||||
sync += metrics.getMetric(FrameMetrics.SYNC_DURATION)
|
||||
issue += metrics.getMetric(FrameMetrics.COMMAND_ISSUE_DURATION)
|
||||
swap += metrics.getMetric(FrameMetrics.SWAP_BUFFERS_DURATION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
gpu += metrics.getMetric(FrameMetrics.GPU_DURATION)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
||||
it.clear()
|
||||
}
|
||||
since = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun lines(refreshHz: Float): List<String> {
|
||||
if (total.isEmpty()) return listOf(" no frames recorded -- scroll first, then press this")
|
||||
val seconds = (System.currentTimeMillis() - since) / 1000.0
|
||||
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
|
||||
val late = total.count { it / 1_000_000.0 > budget }
|
||||
return listOf(
|
||||
" ${total.size} frames over ${"%.1f".format(seconds)}s" +
|
||||
" at ${"%.0f".format(refreshHz)}Hz (${"%.1f".format(budget)}ms budget)",
|
||||
" late: $late (${percent(late, total.size)})" +
|
||||
if (total.size >= CAP) " [capped]" else "",
|
||||
phase("total ", total),
|
||||
phase("waited", waited),
|
||||
phase("input ", input),
|
||||
phase("anim ", animation),
|
||||
phase("layout", layout),
|
||||
phase("draw ", draw),
|
||||
phase("sync ", sync),
|
||||
phase("issue ", issue),
|
||||
phase("swap ", swap),
|
||||
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
||||
}
|
||||
|
||||
/** How long the frames recorded here spent in their draw phase, and how many there were. */
|
||||
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
||||
|
||||
private fun phase(name: String, samples: List<Long>): String {
|
||||
val sorted = samples.sorted()
|
||||
return " $name p50 ${at(sorted, 50)} p90 ${at(sorted, 90)} p99 ${at(sorted, 99)}"
|
||||
}
|
||||
|
||||
private fun at(sorted: List<Long>, percentile: Int): String {
|
||||
if (sorted.isEmpty()) return "-"
|
||||
val index = (sorted.size - 1) * percentile / 100
|
||||
return "%.1fms".format(sorted[index] / 1_000_000.0)
|
||||
}
|
||||
|
||||
private fun percent(part: Int, whole: Int) = "%.1f%%".format(100.0 * part / whole)
|
||||
|
||||
private companion object {
|
||||
/** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */
|
||||
const val CAP = 20_000
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame timings for as long as this screen is on it.
|
||||
*
|
||||
* The listener is handed its own thread because the platform calls it for every frame and the
|
||||
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberFrameStats(): FrameStats {
|
||||
val stats = remember { FrameStats() }
|
||||
val window = LocalContext.current.activity()?.window
|
||||
DisposableEffect(window) {
|
||||
if (window == null) return@DisposableEffect onDispose {}
|
||||
val thread = HandlerThread("frame-stats").apply { start() }
|
||||
val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ ->
|
||||
stats.add(metrics)
|
||||
}
|
||||
window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper))
|
||||
onDispose {
|
||||
window.removeOnFrameMetricsAvailableListener(listener)
|
||||
thread.quitSafely()
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
/** The activity behind a composable's context, which is what owns the window. */
|
||||
fun Context.activity(): Activity? {
|
||||
var context: Context? = this
|
||||
while (context is ContextWrapper) {
|
||||
if (context is Activity) return context
|
||||
context = context.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** What the display is actually refreshing at, so "late" is measured against the real budget. */
|
||||
fun Context.refreshHz(): Float =
|
||||
@Suppress("DEPRECATION") (activity()?.windowManager?.defaultDisplay?.refreshRate ?: 60f)
|
||||
Reference in new issue
Block a user