Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt
T
irisandClaude Fable 5.1 a8d24553d5 app: bench v2 -- a real fling, typing and keyboard phases
Iris's ask after using the Compose bench build on her phone: the old
scroll phase used animateScrollBy, which can only ever cover the fixed
distance/time it's given, so it never flings the way a real fast swipe
does. BenchRun.run now has four phases: fling (8 flings out + 8 back
through the list's own FlingBehavior at 12,000px/s), stream (unchanged),
type (600 fixed characters into the real composer TextFieldValue, then
deleted, to exercise wrapping and the transcript being pushed upward),
and keyboard (five show/hide cycles via WindowInsetsControllerCompat,
each confirmed by isImeVisible rather than assumed).

FrameStats.markPhase/phaseLines slice the same FrameMetrics recording
by phase rather than running a second recorder; debugReport gains a
phaseFrames section ahead of the existing whole-run frames/accounting/
work sections, which are otherwise unchanged.

Also fixes a pre-existing, unrelated break in MainActivity.kt's
benchSessionSummary() -- missing several SessionSummary constructor
arguments from an earlier change -- since it blocked compileBenchKotlin
outright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:40:59 -04:00

247 lines
12 KiB
Kotlin

package com.example.aiapp
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.layout
import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() {
// Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the
// stored settings.
private var settingsVersion by mutableIntStateOf(0)
// The session a notification tap asked for, or null if nothing has. The serial is what makes a
// second tap on the same session's notification a second request: without it the two compare
// equal and the composition below has nothing to react to.
private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
private var opens = 0
// What another app shared into this one, for the same reason and with the same serial.
private var shareRequest by mutableStateOf<ShareRequest?>(null)
private var shares = 0
// Registered up front since permission launchers must be registered before the activity reaches
// STARTED.
private val requestLocalNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
/**
* The service starts either way, and posts nothing if this is refused.
*
* Deliberately not gated on the answer: the permission can be granted later from Android's own
* settings, and a service that only ever started at the moment it was granted would stay down
* until the app was launched again.
*/
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Before anything else that could throw, so the first crash of a launch is caught too.
installCrashLog(this)
// 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.
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
AiAppColors.background.luminance() > 0.5f
// Android 17+ silently drops local-network traffic without this; requested up front because
// a denial is invisible at the socket layer (it just times out).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
handleIntent(intent)
// After enrollment, so a first launch that arrives with a token starts the service with
// something to connect to rather than stopping it and waiting for the next launch.
NotificationService.sync(this)
setContent {
// Selection colours with the theme rather than at each place text is drawn: the
// transcript is one selection container, and a selection that ran from a reply into the
// code block under it would otherwise change colour halfway.
MaterialTheme(colorScheme = AiAppColors) {
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
// Timed like the transcript times itself, and for the same reason:
// the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter.
Modifier.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record(
"measure: the app root",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
DebugStats.record(
"place: the app root",
System.nanoTime() - placing,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: the app root",
System.nanoTime() - started,
)
}
.fillMaxSize()
.statusBarsPadding()
// The gesture strip at the bottom of most phones. Without it
// the send row sits under the swipe area, where a tap is as
// likely to navigate away as to press a button.
//
// No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every
// screen's entire tree per frame. Each screen takes the
// keyboard itself, so the per-frame cost is scoped to what
// actually moves.
.navigationBarsPadding()
) {
AppRoot(settingsVersion, openRequest, shareRequest)
}
}
}
}
}
}
/** 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,
effort = null,
takesEffort = false,
imported = false,
notify = false,
autoResume = false,
autoResumeMessage = "",
resumeAt = null,
cwd = null,
contextTokens = null,
maxImageEdge = null,
usageProvider = null,
status = "idle",
lastActivity = 0.0,
subagents = 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) {
super.onNewIntent(intent)
handleIntent(intent)
}
/**
* The one place an incoming intent is sorted into what it means.
*
* Three things arrive this way -- a share from another app, and an `aiapp://` URI that is
* either an enrollment code or a notification naming a session. The URIs are told apart by host
* rather than by two entry points, so a further kind is a branch here.
*/
private fun handleIntent(intent: Intent?) {
intent ?: return
sharedContent(intent, shares + 1)?.let { shared ->
shares = shared.serial
shareRequest = shared
return
}
val uri = intent.data ?: return
val sessionId = notifiedSessionId(uri)
if (sessionId != null) {
opens++
openRequest = SessionOpenRequest(sessionId, opens)
return
}
val settings = parseEnrollmentUri(uri)
if (settings == null) {
Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show()
return
}
saveServerSettings(this, settings)
settingsVersion++
// Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the
// moment the old one stops being it.
NotificationService.sync(this)
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
}
}