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