Phase 1 app: session list, session screen, spawn, QR enrollment over pinned TLS
Compose app mirroring local-updater's stack (single :androidApp module, pinned CA, HttpURLConnection transport) plus what this app needs on top: a bearer token sealed with an Android Keystore AES-GCM key, an aiapp://enroll intent filter so scanning the server's terminal QR with the stock camera enrolls the phone with no QR library, an SSE client that resumes by transcript cursor, and a transcript renderer folding the common event model into user bubbles, streaming text, collapsible tool cards, and answerable question cards. Verified on the tdep emulator against the real server: enrollment deep link, list, spawn, streamed echo turn, question answer round trip, tool card expansion, adjustResize keyboard behavior. Build is warning-clean (compose.* accessors replaced with direct dependencies). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
967fc814ab
commit
213bc72b64
24 files changed
+2086
No files matched your search
@@ -0,0 +1,110 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Server address and token. The normal path is scanning the server's
|
||||
* terminal QR (which lands in MainActivity and never shows this screen);
|
||||
* these fields are the fallback for typing the same three values by hand.
|
||||
* [onBack] is null on first run, when there is nothing to go back to.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
existing: ServerSettings?,
|
||||
onSaved: (ServerSettings) -> Unit,
|
||||
onBack: (() -> Unit)?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
|
||||
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
|
||||
// Never pre-filled from the stored token: this screen shouldn't be a
|
||||
// way to read the credential back off the device.
|
||||
var token by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Server",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (onBack != null) {
|
||||
TextButton(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"The easy way: run ai-server on the backend and scan the QR it prints " +
|
||||
"with the phone's camera. Or type the same values here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { port = it },
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Button(onClick = {
|
||||
val portNumber = port.trim().toIntOrNull()
|
||||
val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" }
|
||||
when {
|
||||
host.isBlank() -> error = "Host is required"
|
||||
portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535"
|
||||
effectiveToken.isEmpty() -> error = "Token is required -- scan the server's QR or paste it"
|
||||
else -> {
|
||||
val settings = ServerSettings(host.trim(), portNumber, effectiveToken)
|
||||
saveServerSettings(context, settings)
|
||||
onSaved(settings)
|
||||
}
|
||||
}
|
||||
}) { Text("Save") }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user