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(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() } }