package com.example.aiapp import android.content.ClipData import android.content.ClipboardManager import android.content.Context import androidx.core.content.getSystemService import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong /** * Counters and timers for the work the transcript does, for the readout behind the debug button. * * Here because the emulator cannot answer the question this is for. Its own scroll sits at the same * frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost * is under the floor of what it can measure. Counts do not have that problem: how many times a row * was composed, or a reply parsed, is the same number on any machine, and it is the number that * says whether the work is proportional to what is on screen or to everything ever loaded. * * Always on rather than behind a build flag. What is measured is an atomic increment on paths that * already allocate lists and parse markdown, and a counter that is only compiled into the build * nobody is holding when it is slow is not an instrument. */ object DebugStats { private val counts = ConcurrentHashMap() private val nanos = ConcurrentHashMap() private val worst = ConcurrentHashMap() private fun at(map: ConcurrentHashMap, name: String) = map.computeIfAbsent(name) { AtomicLong() } fun count(name: String, by: Long = 1) { at(counts, name).addAndGet(by) } /** Keeps [name] at the largest value it has been given, for a high-water mark. */ fun atLeast(name: String, value: Long) { val slot = at(counts, name) while (true) { val had = slot.get() if (value <= had || slot.compareAndSet(had, value)) break } } /** Records one occurrence of [name] that took [elapsed] nanoseconds. */ fun record(name: String, elapsed: Long) { count(name) at(nanos, name).addAndGet(elapsed) val slot = at(worst, name) while (true) { val had = slot.get() if (elapsed <= had || slot.compareAndSet(had, elapsed)) break } } fun timed(name: String, body: () -> T): T { val started = System.nanoTime() try { return body() } finally { record(name, System.nanoTime() - started) } } fun reset() { counts.clear() nanos.clear() worst.clear() } /** One line per counter: how many, how long in total, and the worst single one. */ fun lines(): List = counts.keys.sorted().map { name -> val n = counts[name]?.get() ?: 0 val total = nanos[name]?.get() ?: 0 if (total == 0L) " $name: $n" else " $name: $n, ${ms(total)}ms total, ${ms(total / n.coerceAtLeast(1))}ms mean," + " ${ms(worst[name]?.get() ?: 0)}ms worst" } /** How long everything named [name] took in total, or zero if it never happened. */ fun nanosOf(name: String): Long = nanos[name]?.get() ?: 0 private fun ms(nanos: Long) = "%.1f".format(nanos / 1_000_000.0) } /** * How much of the frame's draw phase is this app's own work, and how much is not. * * The draw phase is where Compose's measurement lands as well as its recording -- the platform * calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three * different things is high. The transcript times its own measure, placement and recording, and this * is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout, * which grows with how many nodes are alive rather than how many are on screen. * * Per frame rather than in total, because the budget it has to fit in is per frame. The recordings * are not themselves per-frame, so these are shares of an average frame. */ fun drawAccounting(drawNanos: Long, frames: Int): List { if (frames == 0 || drawNanos == 0L) return emptyList() val measure = DebugStats.nanosOf("measure: the whole transcript") val place = DebugStats.nanosOf("place: the whole transcript") // The rows and blocks record *inside* this one, so adding them too would count them twice. val record = DebugStats.nanosOf("draw: the whole transcript") val ours = measure + place + record val rest = (drawNanos - ours).coerceAtLeast(0) fun per(n: Long) = "%.2f".format(n / 1_000_000.0 / frames) return listOf( " draw phase ${per(drawNanos)}ms per frame, of which:", " the transcript: ${per(ours)}ms" + " (measure ${per(measure)}, place ${per(place)}, record ${per(record)})", " everything else: ${per(rest)}ms" + " (${if (drawNanos == 0L) "n/a" else "${rest * 100 / drawNanos}%"})", ) } /** * Everything the debug button copies: what the device is, what the transcript is holding, where the * frames went, and what the app did to produce them. * * Written for somebody to paste into a conversation, so it is plain text with the units on every * number -- a report whose reader has to ask what the columns mean costs another round trip. */ fun debugReport( device: String, transcript: List, frames: List, accounting: List, crash: String?, ): String = buildString { appendLine("ai-app render report") appendLine(device) appendLine() // First, because a crash outranks every timing below it and the reader should not have to // scroll past two screens of counters to find out the app fell over. if (crash != null) { appendLine("last crash:") crash.trimEnd().lines().forEach { appendLine(" $it") } appendLine() } appendLine("transcript:") transcript.forEach { appendLine(it) } appendLine() appendLine("frames:") frames.forEach { appendLine(it) } appendLine() if (accounting.isNotEmpty()) { appendLine("where the draw phase went:") accounting.forEach { appendLine(it) } appendLine() } appendLine("work since this was last copied:") val work = DebugStats.lines() if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) } } /** Puts [text] on the clipboard under [label], which is what the system offers as its name. */ fun Context.copyToClipboard(label: String, text: String) { getSystemService()?.setPrimaryClip(ClipData.newPlainText(label, text)) }