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.
185 lines
9.0 KiB
Kotlin
185 lines
9.0 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.material3.MaterialTheme
|
|
import androidx.compose.material3.Surface
|
|
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
|
|
|
|
// 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 then stay
|
|
* down until the app was launched again -- which is the case notifications exist to avoid.
|
|
*/
|
|
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()
|
|
// Dark status-bar icons only over a light background, decided from the scheme rather
|
|
// than fixed. It was hardcoded to `true` -- dark icons -- which was right against the
|
|
// default light surface and became unreadable the moment the app wore Catppuccin Mocha.
|
|
// Asking the colour means a future palette change cannot reintroduce that: whatever
|
|
// `background` becomes, the icons follow it.
|
|
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 {
|
|
MaterialTheme(colorScheme = AiAppColors) {
|
|
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 -- every frame of the IME animation
|
|
// relays out and re-records this whole box.
|
|
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 -- measured above as most of
|
|
// the frame budget. Each screen takes the keyboard itself
|
|
// (AppRoot wraps the ordinary ones; the session screen moves
|
|
// only its composer and transcript), so the per-frame cost is
|
|
// scoped to what actually moves.
|
|
.navigationBarsPadding()
|
|
) {
|
|
AppRoot(settingsVersion, openRequest)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 `aiapp://` URI is sorted into what it means.
|
|
*
|
|
* Two things arrive this way -- an enrollment code and a notification naming a session -- and
|
|
* they are told apart by the URI's host rather than by two entry points, so a third kind is a
|
|
* branch here rather than another intent to remember to handle.
|
|
*/
|
|
private fun handleIntent(intent: Intent?) {
|
|
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()
|
|
}
|
|
}
|