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

@@ -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()
}