Attachments were images only. Now any file can be attached: from the file chooser behind the "+" menu, or from Android's share sheet, which the app is now in. An image still goes to the model as a picture; anything else is stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`) and the Claude driver ends the message with `Attached file: /abs/path`, since the CLI reads files by path and a model cannot be shown a trace. The user-message field is renamed `images` -> `attachments` on both sides, with a serde alias reading the rows written before. A share arrives before anyone has said which session it is for, so it is held in AppRoot with a banner on the list until a session takes it; an open session takes it at once. Unreadable shares are reported beside the composer, not thrown. The tool card crashed the app when opened on a command holding a quoted glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers `x '*/a/*'` with a span whose end is before its start, and AnnotatedString refuses the range. Such spans are dropped; the library is the place for the fix. The echo driver gains `/bash <command>` so a card with a given command can be produced on the emulator. ui-sandbox.sh's token salvage read the tokens block's close only at a line start, ran past the compact `),],` the server writes, and copied `setups` into the new config twice, which the server then refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
196 lines
9.5 KiB
Kotlin
196 lines
9.5 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
|
|
|
|
// 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 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, shareRequest)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 rather than another
|
|
* intent to remember to handle.
|
|
*/
|
|
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()
|
|
}
|
|
}
|