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(extraBufferCapacity = 8) /** Everything meant for the screen rather than the drawer; see [toApp]. */ val forTheScreen: SharedFlow = 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), ) }