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:
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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user