M5: push (FCM + ntfy) + offline catch-up + background notifications

Server (gateway-plugin):
- push.py: FCM (HTTP v1 service-account / legacy key) + ntfy backends; NtfyBackend.server_url for app discovery
- adapter.py: push on offline broadcast + high-priority push when live; fcm.register; server_caps.push_ntfy_server; outbox now ALWAYS appends so a reconnecting app catches up on live-delivered frames (fixes empty chat after notification tap / activity recreation)
- protocol.py / ws_server.py / outbox.py / plugin.yaml: M5 frames + env vars

App (Kotlin CMP):
- Protocol.kt: notification / fcm.register / sync frames + push caps
- GatewayClient.kt: hello carries push creds; auto-sync on reconnect
- IrisController.kt: banners, deep-link, notifyMessageIfBackgrounded (system notification on a regular reply when backgrounded)
- Android: PlatformPush, AppBridge, IrisNotifications, NtfyListenerService, IrisFirebaseMessagingService, AndroidPush, AndroidSecureStore
- Desktop: DesktopPush, DesktopSecureStore
- build files + manifest (permissions, services, deep-links)

Tests: 35-test tests/gateway/test_android.py suite passes (incl. new regression test_live_delivered_frame_still_parked_for_sync). E2E verified on device: push fire, reconnect sync, background notification, and message replay after ChatStore reset.
This commit is contained in:
ARIA committed 2026-08-19 19:20:55 +02:00
1 parent 913ee91024
commit 2ecfe1c05c
28 files changed
+1739 -51

No files matched your search

@@ -6,6 +6,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -13,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import iris.data.SecureStore
import iris.net.GatewayClient
import iris.platform.setActiveController
import iris.state.IrisController
import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen
@@ -33,10 +35,25 @@ private val IrisDark = darkColorScheme(
* settings, and media (docs/10-android-app.md).
*/
@Composable
fun IrisApp(store: SecureStore) {
fun IrisApp(
store: SecureStore,
deepLinkChatId: String? = null,
deepLinkThreadId: String? = null,
) {
val controller = remember(store) { IrisController(store) }
DisposableEffect(controller) {
onDispose { controller.dispose() }
setActiveController(controller)
onDispose {
setActiveController(null)
controller.dispose()
}
}
// M5: open the chat referenced by a push notification tap (applied now or
// on the next hello.ack, whichever is later).
LaunchedEffect(deepLinkChatId, deepLinkThreadId) {
if (!deepLinkChatId.isNullOrBlank()) {
controller.openDeepLink(deepLinkChatId, deepLinkThreadId)
}
}
val state by controller.client.state.collectAsState()
@@ -18,6 +18,18 @@ interface SecureStore {
/** Human-readable device name (e.g. "MIX 2S"). */
val deviceName: String
/** Last sync cursor confirmed by the server (M5 reconnect catch-up). */
var syncCursor: Long
/** FCM registration token (M5; empty when Firebase is unavailable). */
var fcmToken: String
/** ntfy topic this device subscribes to (M5; empty when unused). */
var ntfyTopic: String
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
var ntfyServer: String
fun savePairing(url: String, token: String)
fun clear()
}
@@ -22,6 +22,7 @@ import iris.protocol.mediaUploadEndFrame
import iris.protocol.mediaUploadStartFrame
import iris.protocol.messageSendFrame
import iris.protocol.pingFrame
import iris.protocol.syncFrame
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -187,7 +188,15 @@ class GatewayClient(
request,
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(helloFrame(token, store.deviceId, store.deviceName).toWire())
webSocket.send(
helloFrame(
token = token,
deviceId = store.deviceId,
deviceName = store.deviceName,
fcmToken = store.fcmToken.ifBlank { null },
ntfyTopic = store.ntfyTopic.ifBlank { null },
).toWire(),
)
}
override fun onMessage(webSocket: WebSocket, text: String) {
@@ -250,6 +259,12 @@ class GatewayClient(
if (e == null) {
val ack = helloAck.getCompleted()
_state.value = State.Connected(ack.serverCaps, ack.channels)
// M5: reconnect catch-up — replay frames parked while offline.
val local = store.syncCursor
if (local < ack.syncCursor) {
val id = nextRequestId++
ws.send(syncFrame(id, local).toWire())
}
winner.complete(DialResult.Connected)
}
}
@@ -0,0 +1,31 @@
package iris.platform
/**
* M5: push / notification platform hooks (docs/08 §8.2).
*
* The controller (commonMain) needs to know whether the app is in the
* foreground (to decide between an in-app banner and a system notification)
* and to post a system notification when a `notification` frame arrives while
* the app is backgrounded but the WS is still live.
*/
/** True when the app's UI is visible (Android: activity resumed). */
expect fun isAppForeground(): Boolean
/**
* Post a system notification for a chat (Android). No-op on desktop.
* [chatName] is used for the per-chat notification channel (best effort).
*/
expect fun postSystemNotification(
chatId: String?,
chatName: String?,
title: String,
body: String,
threadId: String?,
)
/**
* Register the active controller with the platform bridge (Android: so the
* FCM / ntfy services can reach it). No-op on desktop.
*/
expect fun setActiveController(controller: Any?)
@@ -54,6 +54,10 @@ const val TYPE_MEDIA_OFFER = "media.offer"
const val TYPE_MEDIA_PULL = "media.pull"
const val TYPE_MEDIA_PULL_END = "media.pull.end"
// M5 — push / notifications
const val TYPE_NOTIFICATION = "notification"
const val TYPE_FCM_REGISTER = "fcm.register"
// M3 — channels / threads / search / sync
const val TYPE_CHANNEL_CREATE = "channel.create"
const val TYPE_CHANNEL_RENAME = "channel.rename"
@@ -131,6 +135,7 @@ data class ServerCaps(
val media: Boolean = false,
val search: Boolean = false,
val push: String = "fcm",
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
val pickers: Boolean = false,
)
@@ -349,14 +354,55 @@ data class SyncPayload(val cursor: Long)
@Serializable
data class SyncDonePayload(val cursor: Long)
// ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */
const val NOTIF_MESSAGE = "message"
const val NOTIF_APPROVAL = "approval"
const val NOTIF_CLARIFY = "clarify"
const val NOTIF_CRON = "cron"
const val NOTIF_CHANNEL = "channel"
const val NOTIF_OUTBOX_PRUNED = "outbox_pruned"
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
@Serializable
data class NotificationPayload(
val kind: String,
val title: String,
val body: String,
@SerialName("chat_id") val chatId: String? = null,
@SerialName("thread_id") val threadId: String? = null,
val ts: Long? = null,
)
@Serializable
data class FcmRegisterPayload(
@SerialName("fcm_token") val fcmToken: String? = null,
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
)
// ── Frame builders ──────────────────────────────────────────────────────
fun helloFrame(token: String, deviceId: String, deviceName: String): Frame =
fun helloFrame(
token: String,
deviceId: String,
deviceName: String,
fcmToken: String? = null,
ntfyTopic: String? = null,
): Frame =
Frame(
type = TYPE_HELLO,
payload = IrisJson.instance.encodeToJsonElement(
HelloPayload.serializer(),
HelloPayload(token = token, deviceId = deviceId, deviceName = deviceName),
HelloPayload(
token = token,
deviceId = deviceId,
deviceName = deviceName,
fcmToken = fcmToken,
ntfyTopic = ntfyTopic,
),
),
)
@@ -478,4 +524,15 @@ fun mediaPullFrame(id: Int, mediaId: String): Frame =
MediaPullPayload.serializer(),
MediaPullPayload(mediaId),
),
)
// ── M5 frame builders ───────────────────────────────────────────────────
fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame =
Frame(
type = TYPE_FCM_REGISTER,
payload = IrisJson.instance.encodeToJsonElement(
FcmRegisterPayload.serializer(),
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
),
)
@@ -8,11 +8,19 @@ import iris.media.MediaCache
import iris.media.kindFromMime
import iris.net.GatewayClient
import iris.platform.PickedFile
import iris.platform.isAppForeground
import iris.platform.mediaCacheBaseDir
import iris.platform.postSystemNotification
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
import iris.protocol.MediaOfferPayload
import iris.protocol.MessagePayload
import iris.protocol.MessageStopPayload
import iris.protocol.NotificationPayload
import iris.protocol.ROLE_ASSISTANT
import iris.protocol.SearchHit
import iris.protocol.SearchResultsPayload
import iris.protocol.SyncDonePayload
import iris.protocol.TYPE_NOTIFICATION
import iris.protocol.TYPE_CHANNEL_CREATED
import iris.protocol.TYPE_CHANNEL_DELETED
import iris.protocol.TYPE_CHANNEL_LIST
@@ -40,6 +48,7 @@ import iris.protocol.syncFrame
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -114,6 +123,76 @@ class IrisController(
private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
// ── M5: notification banners (docs/08 §8.3) ───────────────────────────
/** An in-app notification banner shown above the composer. */
data class Banner(
val id: Long,
val kind: String,
val title: String,
val body: String,
val chatId: String?,
val threadId: String?,
val persistent: Boolean,
)
private val _banners = MutableStateFlow<List<Banner>>(emptyList())
val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
private var bannerSeq = 0L
fun dismissBanner(id: Long) {
_banners.value = _banners.value.filterNot { it.id == id }
}
private fun pushBanner(kind: String, title: String, body: String, chatId: String?, threadId: String?) {
val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS
val banner = Banner(bannerSeq++, kind, title, body, chatId, threadId, persistent)
_banners.value = (_banners.value + banner).takeLast(5)
if (!persistent) {
scope.launch {
delay(5_000)
_banners.value = _banners.value.filterNot { it.id == banner.id }
}
}
}
/**
* M5: Telegram/WhatsApp-style message notification. When the app is
* backgrounded and a finalized assistant reply arrives, post a system
* notification (per-chat channel) with a short preview. Tapping it opens
* the chat (deep link). The same per-chat notification id is reused, so a
* multi-segment turn updates one notification instead of stacking.
*/
private fun notifyMessageIfBackgrounded(chatId: String?, threadId: String?, text: String) {
if (isAppForeground()) return
if (text.isBlank()) return
val id = chatId ?: "android:default"
val chatName = channels.byId(id)?.name
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
}
/** Single-line preview for a notification body (lock-screen privacy). */
private fun preview(text: String, limit: Int = 120): String {
val flat = text.replace('\n', ' ').replace('\t', ' ').trim()
return if (flat.length <= limit) flat else flat.take(limit).trimEnd() + "…"
}
// ── M5: deep link (push notification tap) ─────────────────────────────
private var pendingDeepLink: Pair<String, String?>? = null
/** Open the chat referenced by a push notification tap. Applied now if
* connected, otherwise on the next hello.ack (lane may not exist yet). */
fun openDeepLink(chatId: String?, threadId: String?) {
if (chatId.isNullOrBlank()) return
pendingDeepLink = chatId to threadId
if (client.state.value is GatewayClient.State.Connected) applyDeepLink()
}
private fun applyDeepLink() {
val link = pendingDeepLink ?: return
pendingDeepLink = null
if (link.second.isNullOrBlank()) openChannel(link.first) else openThread(link.first, link.second!!)
}
init {
scope.launch {
client.events.collect { frame ->
@@ -132,6 +211,25 @@ class IrisController(
if (frame.type == TYPE_MEDIA_OFFER) {
frame.payloadAs<MediaOfferPayload>()?.let { pullMedia(it) }
}
// M5: Telegram/WhatsApp-style — when the app is
// backgrounded, a finalized assistant reply posts a
// system notification (the message still lands in the
// chat via onFrame above). Streaming turns finalize on
// message.stop; non-streaming replies are a single
// assistant `message`.
when (frame.type) {
TYPE_MESSAGE_STOP ->
frame.payloadAs<MessageStopPayload>()?.let {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
}
TYPE_MESSAGE ->
frame.payloadAs<MessagePayload>()?.let {
if (it.role == ROLE_ASSISTANT) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
}
}
else -> Unit
}
}
TYPE_CHANNEL_CREATED,
TYPE_CHANNEL_RENAMED,
@@ -144,7 +242,19 @@ class IrisController(
TYPE_SYNC_DONE -> {
// Replayed frames already flowed through [events]; the
// cursor is authoritative server-side (outbox).
frame.payloadAs<SyncDonePayload>()
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor }
}
TYPE_NOTIFICATION -> {
frame.payloadAs<NotificationPayload>()?.let { p ->
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
// M5: WS is live but the app is backgrounded — the
// in-app banner is invisible, so mirror to a system
// notification (the push backend only fires when
// there is no live subscriber).
if (!isAppForeground()) {
postSystemNotification(p.chatId, null, p.title, p.body, p.threadId)
}
}
}
TYPE_TYPING -> {
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
@@ -162,9 +272,19 @@ class IrisController(
_homeChannel.value = home
chat.setLane(home)
}
// M5: remember the ntfy server for the listener service.
if (s.caps.pushNtfyServer.isNotBlank()) {
store.ntfyServer = s.caps.pushNtfyServer
}
// M5: a deep link tapped before we were connected.
applyDeepLink()
}
}
}
// M5: ensure this device has an ntfy topic (device-specific, random).
if (store.ntfyTopic.isBlank()) {
store.ntfyTopic = "iris-${store.deviceId}-${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
}
client.startHeartbeat()
client.start()
}
@@ -105,6 +105,7 @@ fun ChatScreen(controller: IrisController) {
var showNewThread by remember { mutableStateOf(false) }
var showPicker by remember { mutableStateOf(false) }
val attachments by controller.attachments.collectAsState()
val banners by controller.banners.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val drawerScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
@@ -239,6 +240,27 @@ fun ChatScreen(controller: IrisController) {
}
}
// M5: notification banners (above the composer)
if (banners.isNotEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
banners.forEach { b ->
NotificationBanner(
banner = b,
onOpen = {
controller.openDeepLink(b.chatId, b.threadId)
controller.dismissBanner(b.id)
},
onDismiss = { controller.dismissBanner(b.id) },
)
}
}
}
// Composer
Row(
modifier = Modifier
@@ -530,6 +552,38 @@ private fun StatusChip(state: GatewayClient.State) {
}
}
/** M5: in-app notification banner (docs/08 §8.3). Tapping opens the chat. */
@Composable
private fun NotificationBanner(
banner: IrisController.Banner,
onOpen: () -> Unit,
onDismiss: () -> Unit,
) {
val accent = when (banner.kind) {
"approval" -> Color(0xFFFFC107)
"clarify" -> Color(0xFF4F7CFF)
"cron" -> Color(0xFF4CAF50)
else -> Color(0xFF8A93A6)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(accent.copy(alpha = 0.12f))
.clickable(onClick = onOpen)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(banner.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = accent)
if (banner.body.isNotBlank()) {
Text(banner.body, fontSize = 12.sp, color = Color(0xFFD7DBE5), maxLines = 2)
}
}
TextButton(onClick = onDismiss) { Text("✕", fontSize = 12.sp) }
}
}
@Composable
private fun MessageBubble(msg: MessageItem) {
val isUser = msg.role == ROLE_USER