A notification arriving while the app was open was shown as a banner and nowhere else, so a moment that happened while the phone was face-up on a desk left nothing behind at all -- the banner is seconds long and reaches only somebody already looking. The two are not two versions of one thing: a banner interrupts and a row records. Both go up now, and the banner having done the interrupting is what makes the row a silent one (`setSilent`), so one moment is worth a noise once. What keeps the drawer from filling up is the other end rather than suppression, and already was: opening a session clears whatever is posted about it, whichever way the reader got there. Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug, and on the emulator against the sandbox, reading the posted record out of dumpsys: app on the session list gives a banner and flags=AUTO_CANCEL|SILENT; app backgrounded gives flags=AUTO_CANCEL; opening the session leaves nothing posted about it in either case.
362 lines
16 KiB
Kotlin
362 lines
16 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.
|
|
*
|
|
* Every moment it hears about goes to the drawer; [show] decides what else is done with it.
|
|
*
|
|
* 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, so it says this itself as a banner over whatever screen they are on --
|
|
// which interrupts, where the drawer's row records: a banner lasts seconds and reaches only
|
|
// somebody already looking. Both go up, and the banner having done the interrupting is what
|
|
// makes the row a silent one.
|
|
val banner = handOver(notification)
|
|
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)
|
|
.setSilent(banner)
|
|
.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. Reaching the app
|
|
* does not stop the drawer's row; it makes it a silent one.
|
|
*/
|
|
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, and
|
|
* whatever the drawer is already holding about it goes now rather than waiting to be swiped
|
|
* away. Opening the session *is* reading the notification, whichever way they got here.
|
|
*/
|
|
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),
|
|
)
|
|
}
|