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

@@ -0,0 +1,30 @@
package iris.platform
import android.Manifest
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import iris.state.IrisController
actual fun isAppForeground(): Boolean = AppBridge.foreground
actual fun setActiveController(controller: Any?) {
AppBridge.controller = controller as? IrisController
}
actual fun postSystemNotification(
chatId: String?,
chatName: String?,
title: String,
body: String,
threadId: String?,
) {
val context = AndroidEnv.context
val id = chatId ?: "android:default"
// POST_NOTIFICATIONS is a runtime permission on API 33+.
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
return
}
IrisNotifications.post(context, id, chatName, title, body, threadId)
}
@@ -33,6 +33,22 @@ class AndroidSecureStore(context: Context) : SecureStore {
override val deviceName: String
get() = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
override var syncCursor: Long
get() = prefs.getLong(KEY_SYNC_CURSOR, 0L)
set(value) = prefs.edit().putLong(KEY_SYNC_CURSOR, value).apply()
override var fcmToken: String
get() = prefs.getString(KEY_FCM_TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(KEY_FCM_TOKEN, value).apply()
override var ntfyTopic: String
get() = prefs.getString(KEY_NTFY_TOPIC, "").orEmpty()
set(value) = prefs.edit().putString(KEY_NTFY_TOPIC, value).apply()
override var ntfyServer: String
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
override fun savePairing(url: String, token: String) {
serverUrl = url
this.token = token
@@ -46,5 +62,9 @@ class AndroidSecureStore(context: Context) : SecureStore {
const val KEY_URL = "server_url"
const val KEY_TOKEN = "token"
const val KEY_DEVICE_ID = "device_id"
const val KEY_SYNC_CURSOR = "sync_cursor"
const val KEY_FCM_TOKEN = "fcm_token"
const val KEY_NTFY_TOPIC = "ntfy_topic"
const val KEY_NTFY_SERVER = "ntfy_server"
}
}
@@ -0,0 +1,20 @@
package iris.platform
import iris.state.IrisController
/**
* Process-wide reference to the active [IrisController] (M5).
*
* The FCM service and the ntfy listener service are separate Android
* components that need to reach the controller (to trigger a sync or open a
* chat) without a compile-time dependency on the app module. Set by
* [iris.IrisApp] on composition, cleared on dispose.
*/
object AppBridge {
@Volatile
var controller: IrisController? = null
/** True while the launcher activity is resumed (set by MainActivity). */
@Volatile
var foreground: Boolean = false
}
@@ -0,0 +1,47 @@
package iris.platform
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
/**
* M5: FCM handler (docs/08 §8.1).
*
* - [onNewToken]: persist the rotated token and push it to the server via
* `fcm.register` (so the next push targets the current token).
* - [onMessageReceived]: the data payload drives a silent sync. When the app
* is foregrounded the WS path already delivered the frame (in-app banner),
* so we only post a system notification when backgrounded.
*
* Inert without a Firebase project (no google-services.json): the service is
* declared in the manifest but never receives messages, and the app falls
* back to the ntfy listener.
*/
class IrisFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
val store = AndroidSecureStore(applicationContext)
store.fcmToken = token
// Push the rotation to the server if we're connected.
AppBridge.controller?.client?.sendFrame(
iris.protocol.fcmRegisterFrame(fcmToken = token),
)
}
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
val chatId = data["chat_id"] ?: "android:default"
val threadId = data["thread_id"]
val title = data["title"] ?: "Iris"
val body = data["body"] ?: data["title"].orEmpty()
// Foreground + live WS: the in-app banner already showed this.
if (AppBridge.foreground) return
if (ContextCompat.checkSelfPermission(applicationContext, android.Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
return
}
IrisNotifications.post(applicationContext, chatId, null, title, body, threadId)
}
}
@@ -0,0 +1,72 @@
package iris.platform
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
/**
* M5: per-chat notification channels + posting (docs/08 §8.2).
*
* One channel per chat_id so the user can mute individual chats. The content
* intent uses a custom action (no compile-time dependency on the app module's
* MainActivity); the app's manifest routes it back to the launcher activity
* with chat_id / thread_id extras for the deep link.
*/
object IrisNotifications {
const val CHANNEL_PREFIX = "iris_chat_"
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
private const val NOTIF_ID_BASE = 1_000_000
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val id = CHANNEL_PREFIX + chatId
val name = chatName ?: chatId
if (nm.getNotificationChannel(id) == null) {
nm.createNotificationChannel(
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = "Iris messages for $name" }
)
}
}
fun post(
context: Context,
chatId: String,
chatName: String?,
title: String,
body: String,
threadId: String?,
) {
ensureChannel(context, chatId, chatName)
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff)
val intent = Intent(ACTION_OPEN_CHAT).apply {
setPackage(context.packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("chat_id", chatId)
if (threadId != null) putExtra("thread_id", threadId)
}
val pi = PendingIntent.getActivity(
context,
id,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(
id,
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setContentIntent(pi)
.build(),
)
}
}
@@ -0,0 +1,150 @@
package iris.platform
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import iris.protocol.IrisJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
/**
* M5: ntfy listener fallback (docs/08 §8.4).
*
* A foreground service that subscribes to this device's ntfy topic and posts
* a system notification for each push. The structured payload rides in the
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body
* is the short preview. When the app is foregrounded the WS path already
* delivered the frame, so the service skips posting to avoid a duplicate.
*/
class NtfyListenerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var streamJob: Job? = null
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream
.build()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIF_ID, foregroundNotification())
streamJob?.cancel()
streamJob = scope.launch { stream() }
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
streamJob?.cancel()
scope.cancel()
super.onDestroy()
}
private suspend fun stream() {
val store = AndroidSecureStore(AndroidEnv.context)
val topic = store.ntfyTopic
if (topic.isBlank()) return
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
val url = "$server/$topic"
val request = Request.Builder()
.url(url)
.header("Accept", "text/event-stream")
.build()
try {
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return
val body = resp.body ?: return
val source = body.source()
var data: String? = null
var title: String? = null
var msgBody: String? = null
while (!source.exhausted()) {
val line = source.readUtf8Line() ?: break
when {
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
line.isEmpty() -> {
// Event boundary: process the accumulated message.
data?.let { handleData(it, title, msgBody) }
data = null
title = null
msgBody = null
}
}
}
}
} catch (_: Exception) {
// Stream dropped; the service is START_STICKY so the system
// restarts it. If it keeps failing, the WS path still works.
}
}
private fun handleData(dataJson: String, title: String?, msgBody: String?) {
val data = try {
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
} catch (_: Exception) {
null
}
val chatId = data?.str("chat_id") ?: "android:default"
val threadId = data?.str("thread_id")
// The short preview rides in the SSE `data:` field; fall back to the
// X-Title, then a generic label.
val body = msgBody?.takeIf { it.isNotBlank() } ?: title.orEmpty()
val notifTitle = title ?: "Iris"
// Foreground + live WS: the in-app banner already showed this.
if (AppBridge.foreground) return
if (ContextCompat.checkSelfPermission(AndroidEnv.context, android.Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
return
}
IrisNotifications.post(AndroidEnv.context, chatId, null, notifTitle, body, threadId)
}
private fun foregroundNotification(): Notification {
val context = AndroidEnv.context
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = context.getSystemService(NotificationManager::class.java)
if (nm.getNotificationChannel(LISTENER_CHANNEL) == null) {
nm.createNotificationChannel(
NotificationChannel(
LISTENER_CHANNEL,
"Iris push listener",
NotificationManager.IMPORTANCE_MIN,
)
)
}
}
return NotificationCompat.Builder(context, LISTENER_CHANNEL)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Iris")
.setContentText("Listening for messages")
.setOngoing(true)
.build()
}
private companion object {
const val LISTENER_CHANNEL = "iris_ntfy_listener"
const val NOTIF_ID = 999_001
const val DEFAULT_NTFY_SERVER = "https://ntfy.sh"
}
}
/** Read a string field from a JSON object (null when absent / not a string). */
private fun JsonObject?.str(key: String): String? =
(this?.get(key) as? JsonPrimitive)?.content
@@ -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
@@ -0,0 +1,17 @@
package iris.platform
actual fun isAppForeground(): Boolean = true
actual fun postSystemNotification(
chatId: String?,
chatName: String?,
title: String,
body: String,
threadId: String?,
) {
// Desktop: in-app banner only (no system notification in M5).
}
actual fun setActiveController(controller: Any?) {
// Desktop: no push services to bridge to.
}
@@ -16,6 +16,10 @@ private data class PairingData(
val serverUrl: String = "",
val token: String = "",
val deviceId: String = "",
val syncCursor: Long = 0L,
val fcmToken: String = "",
val ntfyTopic: String = "",
val ntfyServer: String = "",
)
class DesktopSecureStore : SecureStore {
@@ -63,6 +67,34 @@ class DesktopSecureStore : SecureStore {
override val deviceName: String
get() = "Desktop (${System.getProperty("os.name")})"
override var syncCursor: Long
get() = load().syncCursor
set(value) {
val d = load()
save(d.copy(syncCursor = value))
}
override var fcmToken: String
get() = load().fcmToken
set(value) {
val d = load()
save(d.copy(fcmToken = value))
}
override var ntfyTopic: String
get() = load().ntfyTopic
set(value) {
val d = load()
save(d.copy(ntfyTopic = value))
}
override var ntfyServer: String
get() = load().ntfyServer
set(value) {
val d = load()
save(d.copy(ntfyServer = value))
}
override fun savePairing(url: String, token: String) {
val d = load()
save(d.copy(serverUrl = url.trim(), token = token.trim()))