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,366 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Telling somebody a session wants them, when they are not looking at the app.
|
||||
*
|
||||
* This is a **foreground service**, which on Android is the only way to keep a connection open
|
||||
* while the app is closed -- there has been no such thing as a long-lived background service since
|
||||
* Android 8. It is what Syncthing does for the same reason. Discord is not a counter-example: it
|
||||
* gets a push from Google's servers, which would mean this backend talking to Google about
|
||||
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
|
||||
*
|
||||
* The cost Android charges for it is a notification of its own that cannot be dismissed. That is
|
||||
* made as quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no
|
||||
* sound, shows no status-bar icon, and sits at the bottom of the shade -- the same arrangement
|
||||
* Syncthing's "hide the persistent notification" option produces. It is not hidden outright,
|
||||
* because it cannot be and because it should not be: it is the honest indicator that something is
|
||||
* holding a connection open.
|
||||
*/
|
||||
class NotificationService : Service() {
|
||||
@Volatile private var stream: HttpURLConnection? = null
|
||||
@Volatile private var stopping = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val settings = loadServerSettings(this)
|
||||
if (settings == null) {
|
||||
// Nothing to connect to. Stopping rather than idling: a service
|
||||
// holding no connection still costs the ongoing notification,
|
||||
// which would then be announcing work that is not happening.
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Through ServiceCompat so the type is stated once and ignored on
|
||||
// the versions that predate types, rather than branching here.
|
||||
ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType())
|
||||
thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) }
|
||||
// Restarted if Android kills it, which is the whole point: the
|
||||
// window this covers is exactly the one where nobody is watching.
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopping = true
|
||||
stream?.disconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the backend's notification stream, reconnecting until stopped.
|
||||
*
|
||||
* A dropped connection is the ordinary case here rather than an error -- a phone changes
|
||||
* networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and
|
||||
* forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you
|
||||
* whether anything happened" on a phone in somebody's pocket is noise about a condition they
|
||||
* cannot act on, and the session list already says what is waiting when they next look.
|
||||
*/
|
||||
private fun follow(settings: ServerSettings) {
|
||||
while (!stopping) {
|
||||
try {
|
||||
readStream(settings)
|
||||
} catch (_: IOException) {
|
||||
// Deliberate: see above.
|
||||
}
|
||||
if (stopping) return
|
||||
try {
|
||||
Thread.sleep(RECONNECT_DELAY_MS)
|
||||
} catch (_: InterruptedException) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readStream(settings: ServerSettings) {
|
||||
val connection =
|
||||
URL("${settings.baseUrl}/notifications").openConnection() as HttpURLConnection
|
||||
stream = connection
|
||||
try {
|
||||
connection.applyPinnedTls()
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
// No read timeout, for the reason EventStream gives: between
|
||||
// notifications there is nothing to read, possibly for hours.
|
||||
connection.readTimeout = 0
|
||||
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
|
||||
connection.setRequestProperty("Accept", "text/event-stream")
|
||||
if (connection.responseCode != 200) {
|
||||
throw IOException("HTTP ${connection.responseCode} for the notification stream")
|
||||
}
|
||||
val reader = connection.inputStream.bufferedReader()
|
||||
val data = StringBuilder()
|
||||
while (!stopping) {
|
||||
val line = reader.readLine() ?: break
|
||||
when {
|
||||
line.isEmpty() -> {
|
||||
if (data.isNotEmpty()) show(parseNotification(data.toString()))
|
||||
data.clear()
|
||||
}
|
||||
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
|
||||
else -> {} // comments (keep-alives) and ids: nothing to do
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
stream = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One notification per session, replacing that session's previous one.
|
||||
*
|
||||
* Keyed by session id rather than accumulating: two sessions wanting attention are two things
|
||||
* to know about, but one session that finished and then asked a question is one thing -- the
|
||||
* question. A stack of stale rows for the same conversation is how a notification drawer
|
||||
* becomes something to clear rather than read.
|
||||
*/
|
||||
private fun show(notification: SessionNotification) {
|
||||
// Nothing to tell somebody about the session they are reading. The transcript in front of
|
||||
// them is already saying it, and a sound over the top of it would be this app announcing
|
||||
// what the screen is showing.
|
||||
if (isOnScreen(notification.sessionId)) return
|
||||
// The app is up: it says this itself, as a banner over whatever screen they are on. See
|
||||
// [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an
|
||||
// app that already showed you each one is a drawer nobody reads.
|
||||
if (handOver(notification)) return
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
// Two different noes, and both are answers rather than faults: the runtime permission
|
||||
// refused, and notifications switched off for the app in Android's own settings. Neither
|
||||
// is reported anywhere -- the person said no, and saying it back to them through the
|
||||
// channel they closed is not available anyway.
|
||||
//
|
||||
// The permission only exists from Android 13. Asking an older version about it gets
|
||||
// "denied" for a name it does not know, which read as the person having said no -- so
|
||||
// every notification on Android 12 and below was silently dropped. Before 13 the
|
||||
// switch in Android's own settings, checked below, is the whole of the answer.
|
||||
val allowed =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!allowed || !manager.areNotificationsEnabled()) {
|
||||
return
|
||||
}
|
||||
val open =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
sessionIntent(this, notification.sessionId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val built =
|
||||
NotificationCompat.Builder(this, ALERT_CHANNEL)
|
||||
.setContentTitle(notification.title)
|
||||
.setContentText(attentionLine(notification.kind))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.setWhen((notification.at * 1000).toLong())
|
||||
.setShowWhen(true)
|
||||
.build()
|
||||
manager.notify(notification.sessionId, ALERT_ID, built)
|
||||
}
|
||||
|
||||
/**
|
||||
* The type Android 14+ requires a foreground service to declare, and nothing before it.
|
||||
*
|
||||
* Named behind a version check rather than passed as a constant: the value is inlined at
|
||||
* compile time and would be handed to platforms that have no concept of it, which is exactly
|
||||
* the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do
|
||||
* not apply.
|
||||
*/
|
||||
private fun foregroundType(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
private fun ongoingNotification(): Notification =
|
||||
NotificationCompat.Builder(this, ONGOING_CHANNEL)
|
||||
.setContentTitle("Watching for sessions that need you")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Starts the service if there is a server to connect to, and stops it otherwise.
|
||||
*
|
||||
* Called on every launch rather than once: a service Android killed does not restart itself
|
||||
* if the process was replaced, and asking for one that is already running is free.
|
||||
*/
|
||||
fun sync(context: Context) {
|
||||
val intent = Intent(context, NotificationService::class.java)
|
||||
if (loadServerSettings(context) == null) {
|
||||
context.stopService(intent)
|
||||
return
|
||||
}
|
||||
createChannels(context)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two channels, because they are two different things to be told.
|
||||
*
|
||||
* The alerts are what somebody turned this on for, so they get the default importance and
|
||||
* whatever sound and heads-up display the person has chosen for the app. The ongoing one is
|
||||
* the platform's tax for staying connected, so it takes the lowest importance that exists.
|
||||
* Both are created before the service starts, since posting to a channel that does not
|
||||
* exist is silently dropped.
|
||||
*/
|
||||
private fun createChannels(context: Context) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
ALERT_CHANNEL,
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
.setName("Sessions needing attention")
|
||||
.build()
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
ONGOING_CHANNEL,
|
||||
NotificationManagerCompat.IMPORTANCE_MIN,
|
||||
)
|
||||
.setName("Staying connected")
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The session somebody is looking at, or null when no screen is showing one.
|
||||
*
|
||||
* Process-wide state, which the rest of this app does without: Android constructs the
|
||||
* service and the composition draws the screen, so the two have no common owner a value
|
||||
* could be passed through. [showing] and [stoppedShowing] are the pair, both called from
|
||||
* the one composable that shows a session. Clearing names the session rather than setting
|
||||
* null outright, because moving from one session to another composes the new screen before
|
||||
* the old one's coroutine is cancelled -- an unconditional clear would then throw away the
|
||||
* new screen's claim and start notifying about what is on it.
|
||||
*/
|
||||
@Volatile private var onScreen: String? = null
|
||||
|
||||
private fun isOnScreen(sessionId: String) = onScreen == sessionId
|
||||
|
||||
/**
|
||||
* The way a notification reaches the app instead of Android's drawer.
|
||||
*
|
||||
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
|
||||
* [SessionAlerts] collects this exactly while it is on screen, so there is nothing that
|
||||
* could be left saying the app is up after it has gone. `tryEmit` neither suspends nor
|
||||
* blocks the thread reading the stream, and the buffer is there so a handful of sessions
|
||||
* finishing together all land rather than the last one winning.
|
||||
*/
|
||||
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
|
||||
|
||||
/** Everything meant for the screen rather than the drawer; see [toApp]. */
|
||||
val forTheScreen: SharedFlow<SessionNotification> = toApp.asSharedFlow()
|
||||
|
||||
private fun handOver(notification: SessionNotification) =
|
||||
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
|
||||
|
||||
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
|
||||
fun showing(context: Context, sessionId: String) {
|
||||
onScreen = sessionId
|
||||
// Whatever was posted about it before is about to be read, so it has nothing left
|
||||
// to say -- and a row in the drawer for the conversation on screen is the same
|
||||
// duplication this whole rule is about.
|
||||
NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID)
|
||||
}
|
||||
|
||||
/** They have stopped, unless another screen has claimed it since. */
|
||||
fun stoppedShowing(sessionId: String) {
|
||||
if (onScreen == sessionId) onScreen = null
|
||||
}
|
||||
|
||||
private const val ALERT_CHANNEL = "sessions"
|
||||
private const val ONGOING_CHANNEL = "connection"
|
||||
private const val ONGOING_ID = 1
|
||||
/** Shared by every alert; the session id is the tag that separates them. */
|
||||
private const val ALERT_ID = 2
|
||||
private const val RECONNECT_DELAY_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The intent that opens one session, and the id it carries back out.
|
||||
*
|
||||
* The two halves are written together so neither can be changed without the other, and the scheme
|
||||
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look at
|
||||
* when an intent arrives rather than two.
|
||||
*
|
||||
* The id rides in the intent's **data** rather than in an extra, which is not a style choice:
|
||||
* PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring
|
||||
* extras. Carried as an extra, every session's notification would update one shared PendingIntent
|
||||
* and every tap would open whichever session was notified last.
|
||||
*/
|
||||
fun sessionIntent(context: Context, sessionId: String): Intent =
|
||||
Intent(context, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.setData(
|
||||
// Built rather than concatenated so an id needing escaping survives the round trip;
|
||||
// lastPathSegment below decodes what appendPath encoded.
|
||||
Uri.Builder().scheme("aiapp").authority("session").appendPath(sessionId).build()
|
||||
)
|
||||
|
||||
/** The session [sessionIntent] named, or null for any other URI -- enrollment's included. */
|
||||
fun notifiedSessionId(uri: Uri): String? =
|
||||
if (uri.scheme == "aiapp" && uri.host == "session") uri.lastPathSegment else null
|
||||
|
||||
/** One frame of `GET /notifications`. */
|
||||
data class SessionNotification(
|
||||
val sessionId: String,
|
||||
val title: String,
|
||||
/** The wire's word: "awaitingInput" or "finished". */
|
||||
val kind: String,
|
||||
val at: Double,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a notification asks of the reader, in the words they see.
|
||||
*
|
||||
* What they have to do, not what the session did: "awaitingInput" is the wire's word and says
|
||||
* nothing to somebody reading a lock screen. One function because the same fact is now shown in two
|
||||
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The
|
||||
* banner colours the line as well, which is its own decision and stays with the drawing.
|
||||
*/
|
||||
fun attentionLine(kind: String): String =
|
||||
when (kind) {
|
||||
"awaitingInput" -> "Waiting for you"
|
||||
else -> "Finished"
|
||||
}
|
||||
|
||||
fun parseNotification(json: String): SessionNotification {
|
||||
val body = JSONObject(json)
|
||||
return SessionNotification(
|
||||
sessionId = body.getString("sessionId"),
|
||||
title = body.getString("title"),
|
||||
kind = body.getString("kind"),
|
||||
at = body.optDouble("at", 0.0),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user