The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
353 lines
15 KiB
Kotlin
353 lines
15 KiB
Kotlin
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 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. 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 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, so it retries quietly
|
|
* and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell
|
|
* you whether anything happened" is noise about a condition nobody can 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 is how a 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.
|
|
if (isOnScreen(notification.sessionId)) return
|
|
// The app is up: it says this itself, as a banner over whatever screen they are on. 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.
|
|
//
|
|
// 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.
|
|
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 what
|
|
* lint's InlinedApi exists to catch.
|
|
*/
|
|
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. 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. 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 throw away the new screen's claim.
|
|
*/
|
|
@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. `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.
|
|
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.
|
|
*
|
|
* 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 shown in two
|
|
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift.
|
|
*/
|
|
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),
|
|
)
|
|
}
|