ai-app: a phone interface to Claude Code and llama.cpp sessions
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.
This commit is contained in:
commit
b172c464ea
100 files changed
+31795
No files matched your search
@@ -0,0 +1,202 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
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
|
||||
import androidx.core.net.toUri
|
||||
import com.example.wgapplink.EnrollmentScanActivity
|
||||
import com.google.zxing.client.android.Intents
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanIntentResult
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
|
||||
/**
|
||||
* Server address and token. The normal path is the "Scan QR code" button below, which decodes the
|
||||
* server's terminal QR itself; 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) }
|
||||
|
||||
val scanLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
|
||||
// Null contents means the user backed out of the scanner -- not an
|
||||
// error, so nothing to report.
|
||||
val contents = result.contents ?: return@rememberLauncherForActivityResult
|
||||
val settings = parseEnrollmentUri(contents.toUri())
|
||||
if (settings == null) {
|
||||
error = "Not a valid enrollment code"
|
||||
} else {
|
||||
saveServerSettings(context, settings)
|
||||
onSaved(settings)
|
||||
}
|
||||
}
|
||||
|
||||
val requestCamera =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
scanLauncher.launch(enrollmentScanOptions())
|
||||
} else {
|
||||
error =
|
||||
"Scanning needs the camera. Grant it in the system settings, " +
|
||||
"or type the host, port and token in below."
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
// Leading, where a back arrow points at what it returns to. Trailing it would put a
|
||||
// left-pointing arrow at the right edge, aimed across the title it sits beside.
|
||||
//
|
||||
// Absent rather than disabled on first run, which is the one place this app lets a
|
||||
// control come and go: there is no screen underneath yet, so a Back here would not be
|
||||
// a capability being withheld but a promise it could not keep.
|
||||
if (onBack != null) {
|
||||
GlyphButton(BACK_GLYPH, "Back", onBack)
|
||||
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
||||
}
|
||||
Text(
|
||||
"Server",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"The easy way: run ai-server on the backend and scan the QR it prints. " +
|
||||
"Or type the same values here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
// Hold the camera permission before the scanner starts.
|
||||
// Letting its activity ask on our behalf is what the
|
||||
// library does by default, and it opens the camera without
|
||||
// waiting for the answer: the first-ever scan comes up as
|
||||
// a live preview with "Sorry, the Android camera
|
||||
// encountered a problem" over it, and works on the second
|
||||
// try. Nothing is wrong with the camera, so nothing should
|
||||
// say there is.
|
||||
if (
|
||||
context.checkSelfPermission(Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
scanLauncher.launch(enrollmentScanOptions())
|
||||
} else {
|
||||
requestCamera.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Scan QR code")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How the enrollment QR is scanned, in one place because two callers reach it -- straight from the
|
||||
* button when the camera permission is already held, and from the permission result when it has
|
||||
* just been granted.
|
||||
*
|
||||
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
|
||||
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a
|
||||
* dark-themed terminal it comes out as a photographic negative the scanner silently never matches.
|
||||
* Which way round it renders is the terminal's business, not something this app should depend on.
|
||||
* The mixed decoder alternates normal and inverted frames, costing half the frame rate at each
|
||||
* polarity and nothing else.
|
||||
*/
|
||||
private fun enrollmentScanOptions(): ScanOptions =
|
||||
ScanOptions()
|
||||
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
.setCaptureActivity(EnrollmentScanActivity::class.java)
|
||||
// Follow the phone, not the library's landscape pin.
|
||||
.setOrientationLocked(false)
|
||||
.addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
|
||||
Reference in new issue
Block a user