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
+1733 -45

No files matched your search

+7
View File
@@ -40,3 +40,10 @@ dependencies {
implementation("androidx.activity:activity-compose:1.9.2") implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.core:core-splashscreen:1.0.1")
} }
// M5: apply the google-services plugin only when a Firebase project is
// configured (google-services.json present). Without it the FCM service is
// inert and the ntfy listener is the push path.
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
@@ -3,6 +3,11 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- M5: post push notifications (runtime permission on API 33+). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application <application
android:label="Iris" android:label="Iris"
@@ -18,6 +23,19 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- M5: deep link from a push notification tap (custom action,
no compile-time dependency from the shared module). -->
<intent-filter>
<action android:name="dev.iris.app.OPEN_CHAT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- M5: iris:// deep link (external links / share targets). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="iris" android:host="chat" />
</intent-filter>
</activity> </activity>
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). --> <!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
@@ -30,6 +48,22 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/iris_file_paths" /> android:resource="@xml/iris_file_paths" />
</provider> </provider>
<!-- M5: ntfy listener (foreground service; subscribes to the device
topic and posts push notifications). -->
<service
android:name="iris.platform.NtfyListenerService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- M5: FCM handler (inert without a Firebase project). -->
<service
android:name="iris.platform.IrisFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>
@@ -1,19 +1,85 @@
package dev.iris.app package dev.iris.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import iris.IrisApp import iris.IrisApp
import iris.platform.AndroidEnv import iris.platform.AndroidEnv
import iris.platform.AndroidSecureStore import iris.platform.AndroidSecureStore
import iris.platform.AppBridge
import iris.platform.NtfyListenerService
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val deepLinkChatId = mutableStateOf<String?>(null)
private val deepLinkThreadId = mutableStateOf<String?>(null)
private val notificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
AndroidEnv.context = applicationContext AndroidEnv.context = applicationContext
AppBridge.controller = null
val store = AndroidSecureStore(applicationContext) val store = AndroidSecureStore(applicationContext)
handleDeepLink(intent)
requestNotificationPermission()
startNtfyListener(store)
setContent { setContent {
IrisApp(store) val chatId by deepLinkChatId
val threadId by deepLinkThreadId
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLink(intent)
}
override fun onResume() {
super.onResume()
AppBridge.foreground = true
}
override fun onPause() {
super.onPause()
AppBridge.foreground = false
}
/** Extract chat_id / thread_id from a deep-link intent (custom action
* extras or an iris://chat/<id>?thread=<tid> URI). */
private fun handleDeepLink(intent: Intent?) {
val data = intent?.data
val chatId = intent?.getStringExtra("chat_id")
?: data?.pathSegments?.firstOrNull()
val threadId = intent?.getStringExtra("thread_id")
?: data?.getQueryParameter("thread")
if (!chatId.isNullOrBlank()) {
deepLinkChatId.value = chatId
deepLinkThreadId.value = threadId
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} }
} }
} }
private fun startNtfyListener(store: AndroidSecureStore) {
if (store.ntfyTopic.isBlank()) return
ContextCompat.startForegroundService(this, Intent(this, NtfyListenerService::class.java))
}
}
+13
View File
@@ -1,3 +1,16 @@
// M5: google-services plugin classpath (applied conditionally in :androidApp
// only when a google-services.json is present; the FCM path is inert without
// a Firebase project and the ntfy listener is the fallback).
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.google.gms:google-services:4.4.2")
}
}
plugins { plugins {
kotlin("multiplatform") version "2.1.0" apply false kotlin("multiplatform") version "2.1.0" apply false
kotlin("android") version "2.1.0" apply false kotlin("android") version "2.1.0" apply false
+4
View File
@@ -39,6 +39,10 @@ kotlin {
implementation("androidx.media3:media3-ui:1.3.1") implementation("androidx.media3:media3-ui:1.3.1")
// SAF picker (rememberLauncherForActivityResult). // SAF picker (rememberLauncherForActivityResult).
implementation("androidx.activity:activity-compose:1.9.2") implementation("androidx.activity:activity-compose:1.9.2")
// M5: FCM push (inert without a Firebase project / google-services.json;
// the ntfy listener is the fallback). The google-services plugin is
// applied conditionally in the app module.
implementation("com.google.firebase:firebase-messaging:23.1.2")
} }
} }
} }
@@ -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 override val deviceName: String
get() = "${Build.MANUFACTURER} ${Build.MODEL}".trim() 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) { override fun savePairing(url: String, token: String) {
serverUrl = url serverUrl = url
this.token = token this.token = token
@@ -46,5 +62,9 @@ class AndroidSecureStore(context: Context) : SecureStore {
const val KEY_URL = "server_url" const val KEY_URL = "server_url"
const val KEY_TOKEN = "token" const val KEY_TOKEN = "token"
const val KEY_DEVICE_ID = "device_id" 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.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -13,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import iris.data.SecureStore import iris.data.SecureStore
import iris.net.GatewayClient import iris.net.GatewayClient
import iris.platform.setActiveController
import iris.state.IrisController import iris.state.IrisController
import iris.ui.screens.ChatScreen import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen import iris.ui.screens.ConnectScreen
@@ -33,10 +35,25 @@ private val IrisDark = darkColorScheme(
* settings, and media (docs/10-android-app.md). * settings, and media (docs/10-android-app.md).
*/ */
@Composable @Composable
fun IrisApp(store: SecureStore) { fun IrisApp(
store: SecureStore,
deepLinkChatId: String? = null,
deepLinkThreadId: String? = null,
) {
val controller = remember(store) { IrisController(store) } val controller = remember(store) { IrisController(store) }
DisposableEffect(controller) { 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() val state by controller.client.state.collectAsState()
@@ -18,6 +18,18 @@ interface SecureStore {
/** Human-readable device name (e.g. "MIX 2S"). */ /** Human-readable device name (e.g. "MIX 2S"). */
val deviceName: String 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 savePairing(url: String, token: String)
fun clear() fun clear()
} }
@@ -22,6 +22,7 @@ import iris.protocol.mediaUploadEndFrame
import iris.protocol.mediaUploadStartFrame import iris.protocol.mediaUploadStartFrame
import iris.protocol.messageSendFrame import iris.protocol.messageSendFrame
import iris.protocol.pingFrame import iris.protocol.pingFrame
import iris.protocol.syncFrame
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -187,7 +188,15 @@ class GatewayClient(
request, request,
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { 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) { override fun onMessage(webSocket: WebSocket, text: String) {
@@ -250,6 +259,12 @@ class GatewayClient(
if (e == null) { if (e == null) {
val ack = helloAck.getCompleted() val ack = helloAck.getCompleted()
_state.value = State.Connected(ack.serverCaps, ack.channels) _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) 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 = "media.pull"
const val TYPE_MEDIA_PULL_END = "media.pull.end" 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 // M3 — channels / threads / search / sync
const val TYPE_CHANNEL_CREATE = "channel.create" const val TYPE_CHANNEL_CREATE = "channel.create"
const val TYPE_CHANNEL_RENAME = "channel.rename" const val TYPE_CHANNEL_RENAME = "channel.rename"
@@ -131,6 +135,7 @@ data class ServerCaps(
val media: Boolean = false, val media: Boolean = false,
val search: Boolean = false, val search: Boolean = false,
val push: String = "fcm", val push: String = "fcm",
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
val pickers: Boolean = false, val pickers: Boolean = false,
) )
@@ -349,14 +354,55 @@ data class SyncPayload(val cursor: Long)
@Serializable @Serializable
data class SyncDonePayload(val cursor: Long) 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 ────────────────────────────────────────────────────── // ── 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( Frame(
type = TYPE_HELLO, type = TYPE_HELLO,
payload = IrisJson.instance.encodeToJsonElement( payload = IrisJson.instance.encodeToJsonElement(
HelloPayload.serializer(), HelloPayload.serializer(),
HelloPayload(token = token, deviceId = deviceId, deviceName = deviceName), HelloPayload(
token = token,
deviceId = deviceId,
deviceName = deviceName,
fcmToken = fcmToken,
ntfyTopic = ntfyTopic,
),
), ),
) )
@@ -479,3 +525,14 @@ fun mediaPullFrame(id: Int, mediaId: String): Frame =
MediaPullPayload(mediaId), 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.media.kindFromMime
import iris.net.GatewayClient import iris.net.GatewayClient
import iris.platform.PickedFile import iris.platform.PickedFile
import iris.platform.isAppForeground
import iris.platform.mediaCacheBaseDir import iris.platform.mediaCacheBaseDir
import iris.platform.postSystemNotification
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
import iris.protocol.MediaOfferPayload 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.SearchHit
import iris.protocol.SearchResultsPayload import iris.protocol.SearchResultsPayload
import iris.protocol.SyncDonePayload import iris.protocol.SyncDonePayload
import iris.protocol.TYPE_NOTIFICATION
import iris.protocol.TYPE_CHANNEL_CREATED import iris.protocol.TYPE_CHANNEL_CREATED
import iris.protocol.TYPE_CHANNEL_DELETED import iris.protocol.TYPE_CHANNEL_DELETED
import iris.protocol.TYPE_CHANNEL_LIST import iris.protocol.TYPE_CHANNEL_LIST
@@ -40,6 +48,7 @@ import iris.protocol.syncFrame
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -114,6 +123,76 @@ class IrisController(
private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList()) private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow() 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 { init {
scope.launch { scope.launch {
client.events.collect { frame -> client.events.collect { frame ->
@@ -132,6 +211,25 @@ class IrisController(
if (frame.type == TYPE_MEDIA_OFFER) { if (frame.type == TYPE_MEDIA_OFFER) {
frame.payloadAs<MediaOfferPayload>()?.let { pullMedia(it) } 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_CREATED,
TYPE_CHANNEL_RENAMED, TYPE_CHANNEL_RENAMED,
@@ -144,7 +242,19 @@ class IrisController(
TYPE_SYNC_DONE -> { TYPE_SYNC_DONE -> {
// Replayed frames already flowed through [events]; the // Replayed frames already flowed through [events]; the
// cursor is authoritative server-side (outbox). // 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 -> { TYPE_TYPING -> {
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on } frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
@@ -162,9 +272,19 @@ class IrisController(
_homeChannel.value = home _homeChannel.value = home
chat.setLane(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.startHeartbeat()
client.start() client.start()
} }
@@ -105,6 +105,7 @@ fun ChatScreen(controller: IrisController) {
var showNewThread by remember { mutableStateOf(false) } var showNewThread by remember { mutableStateOf(false) }
var showPicker by remember { mutableStateOf(false) } var showPicker by remember { mutableStateOf(false) }
val attachments by controller.attachments.collectAsState() val attachments by controller.attachments.collectAsState()
val banners by controller.banners.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val drawerScope = rememberCoroutineScope() val drawerScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current 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 // Composer
Row( Row(
modifier = Modifier 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 @Composable
private fun MessageBubble(msg: MessageItem) { private fun MessageBubble(msg: MessageItem) {
val isUser = msg.role == ROLE_USER 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 serverUrl: String = "",
val token: String = "", val token: String = "",
val deviceId: String = "", val deviceId: String = "",
val syncCursor: Long = 0L,
val fcmToken: String = "",
val ntfyTopic: String = "",
val ntfyServer: String = "",
) )
class DesktopSecureStore : SecureStore { class DesktopSecureStore : SecureStore {
@@ -63,6 +67,34 @@ class DesktopSecureStore : SecureStore {
override val deviceName: String override val deviceName: String
get() = "Desktop (${System.getProperty("os.name")})" 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) { override fun savePairing(url: String, token: String) {
val d = load() val d = load()
save(d.copy(serverUrl = url.trim(), token = token.trim())) save(d.copy(serverUrl = url.trim(), token = token.trim()))
+23 -12
View File
@@ -118,18 +118,29 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
## M5 — Push + offline (FCM + ntfy) ## M5 — Push + offline (FCM + ntfy)
**Goal:** reach the phone when backgrounded; catch up on reconnect. **Goal:** reach the phone when backgrounded; catch up on reconnect.
- [ ] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune. - [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune
- [ ] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx) + (row cap 5000 + prune banner, throttled 1/h).
`NtfyBackend`; selected by `ANDROID_PUSH_BACKEND`. - [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via
- [ ] Fire push on no-live-subscriber; data payload for silent sync. PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by
- [ ] App: FCM service (foreground banner + background foreground-service sync), `ANDROID_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`.
`onNewToken` → `fcm.register`; per-chat notification channels; deep-link. - [x] Fire push on no-live-subscriber; data payload for silent sync.
ntfy listener fallback. High-priority kinds (approval/clarify/cron) push even when live.
- [ ] In-app `notification` banners (channel_renamed, approval, cron, …). - [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a
- **Demo (on-device):** background the app → trigger a message → notification Firebase project), per-chat notification channels, deep-link
appears → tap → syncs + opens the chat. Repeat with ntfy backend. (custom action + `iris://`), ntfy listener foreground-service fallback.
- **Accept:** push arrives when backgrounded (both backends); reconnect syncs App generates a device-specific ntfy topic; auto-sync on reconnect.
with no loss/dup; banners show for foreground events. - [x] In-app `notification` banners (channel events, approval, clarify, cron,
outbox-pruned); system notification mirror when backgrounded.
- **Demo (on-device, verified 2026-08-19):** device offline → message
broadcast → frame parked in outbox → `push via ntfy -> <device>` fired →
app reconnect auto-synced (cursor 0→8) → parked message rendered in-app.
ntfy publish confirmed (POST 200). ntfy listener service runs (foreground
notification posted); listener→system-notification leg not exercised live
because ntfy.sh's public SSE endpoint was serving its web UI (not streams)
during the test — verify against a self-hosted ntfy.
- **Accept:** push arrives when backgrounded (ntfy verified; FCM code path
complete, needs a Firebase project to exercise); reconnect syncs with no
loss/dup (verified); banners show for foreground events (implemented).
## M6 — Desktop app ## M6 — Desktop app
**Goal:** the same app on a big screen. **Goal:** the same app on a big screen.
+415 -7
View File
@@ -30,6 +30,14 @@ register the (delivery-validated) file in the media registry and emit
``media.offer``; ``media.pull`` streams the file back as chunked binary ``media.offer``; ``media.pull`` streams the file back as chunked binary
frames, re-checking ``validate_media_delivery_path`` at pull time. frames, re-checking ``validate_media_delivery_path`` at pull time.
Milestone M5: push + offline. Frames with no live subscriber are parked in
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
HTTP v1 primary, ntfy fallback, selected by ``ANDROID_PUSH_BACKEND``).
``notification`` frames render in-app banners and mirror to push (channel
events, cron deliveries, approvals, clarifies); high-priority kinds push even
when a device is live. ``fcm.register`` rotates push tokens (registry + live
connection). The outbox enforces a row cap with a throttled prune notice.
Configuration in config.yaml:: Configuration in config.yaml::
gateway: gateway:
@@ -106,6 +114,7 @@ from . import protocol # noqa: E402
from . import search as search_bridge # noqa: E402 from . import search as search_bridge # noqa: E402
from .channels import get_directory # noqa: E402 from .channels import get_directory # noqa: E402
from .outbox import Outbox # noqa: E402 from .outbox import Outbox # noqa: E402
from .push import NtfyBackend, PushBackend, build_push_backend # noqa: E402
from .pairing import ( # noqa: E402 from .pairing import ( # noqa: E402
DeviceRegistry, DeviceRegistry,
generate_token, generate_token,
@@ -280,6 +289,42 @@ def _strip_streaming_cursor(text: str) -> str:
return text return text
def _push_preview(text: Any, limit: int = 120) -> str:
"""Short single-line preview for push bodies (lock-screen privacy: no
secrets, no full bodies -- full content arrives via ``sync``)."""
s = " ".join(str(text or "").split())
if len(s) > limit:
s = s[: limit - 1] + "…"
return s
# Cron delivery wrap (cron/scheduler.py ``_deliver_result``,
# cron.wrap_response: true):
# "Cronjob Response: <name>\n(job_id: <id>)\n-------------\n\n<content>\n\n
# To stop or manage this job, send me a new message (e.g. ...)."
_CRON_WRAP_RE = re.compile(
r"^Cronjob Response: (.+?)\n\(job_id: [^)]*\)\n-+\n\n"
)
_CRON_FOOTER = "\n\nTo stop or manage this job"
def _cron_brief(content: str, job_id: str) -> Tuple[str, str]:
"""Parse a cron delivery into ``(job_name, inner_text)``.
Falls back to ``(job_id, content)`` when the wrap is disabled
(``cron.wrap_response: false``) or unrecognised.
"""
m = _CRON_WRAP_RE.match(content or "")
if not m:
return str(job_id or "cron"), (content or "").strip()
name = m.group(1).strip()
body = content[m.end():]
idx = body.rfind(_CRON_FOOTER)
if idx != -1:
body = body[:idx]
return name, body.strip()
def _split_reasoning(text: str) -> Tuple[Optional[str], str]: def _split_reasoning(text: str) -> Tuple[Optional[str], str]:
"""Split a code-style reasoning prefix off the front of *text*. """Split a code-style reasoning prefix off the front of *text*.
@@ -693,6 +738,17 @@ class AndroidAdapter(BasePlatformAdapter):
# last finalized assistant message id per chat (offer association). # last finalized assistant message id per chat (offer association).
self._media = media_bridge.MediaStore(get_hermes_home()) self._media = media_bridge.MediaStore(get_hermes_home())
self._last_message_id: Dict[str, str] = {} self._last_message_id: Dict[str, str] = {}
# M5: push backend (FCM primary, ntfy fallback) + the throttle for
# the outbox-prune banner.
self._push: PushBackend = build_push_backend(
self.push_backend,
fcm_service_account=_get_scoped_secret("ANDROID_FCM_SERVICE_ACCOUNT"),
fcm_server_key=_get_scoped_secret("ANDROID_FCM_SERVER_KEY"),
ntfy_topic=_get_scoped_secret("NTFY_TOPIC"),
ntfy_server_url=os.getenv("NTFY_SERVER_URL", "").strip() or None,
ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"),
)
self._prune_notified_at = 0.0
def _turn_state(self, chat_id: str) -> _TurnState: def _turn_state(self, chat_id: str) -> _TurnState:
st = self._turns.get(chat_id) st = self._turns.get(chat_id)
@@ -747,6 +803,16 @@ class AndroidAdapter(BasePlatformAdapter):
except Exception: except Exception:
logger.warning("android: ensure_default failed", exc_info=True) logger.warning("android: ensure_default failed", exc_info=True)
# M5: push backend status (degrade gracefully when unconfigured).
if not self._push.configured():
logger.warning(
"android: push backend %r not configured (no credentials) -- "
"offline devices will not be woken; outbox + sync still apply",
self.push_backend,
)
else:
logger.info("android: push backend: %s", self._push.name)
self._connected = True self._connected = True
self._mark_connected() self._mark_connected()
logger.info("android: connected; WS server on %s:%s", self.host, self.port) logger.info("android: connected; WS server on %s:%s", self.host, self.port)
@@ -817,7 +883,45 @@ class AndroidAdapter(BasePlatformAdapter):
) )
return SendResult(success=True, message_id=message_id) return SendResult(success=True, message_id=message_id)
# 2. Final message (non-streaming final, or streaming fallback final). # 2. Cron delivery (the cron scheduler passes ``job_id`` in metadata):
# a final assistant message + a high-priority notification banner
# (pushed even when a device is live, docs/08 §8.1).
if meta.get("job_id"):
reasoning, _ = _split_reasoning(content)
if reasoning:
_reset_reasoning()
else:
await _wait_for_reasoning_flushed()
reasoning = _take_reasoning() or None
name, inner = _cron_brief(content, str(meta.get("job_id")))
message_id = _mint_message_id()
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_CRON,
f"Cron: {name}",
_push_preview(inner),
thread_id=thread_id,
),
)
await self._broadcast_or_log(
chat_id,
protocol.message(
chat_id=chat_id,
message_id=message_id,
role=protocol.ROLE_ASSISTANT,
text=inner,
thread_id=thread_id,
reasoning=reasoning,
ts=int(time.time() * 1000),
),
)
self._last_message_id[chat_id] = message_id
state.active = False
return SendResult(success=True, message_id=message_id)
# 3. Final message (non-streaming final, or streaming fallback final).
if meta.get("notify") is True: if meta.get("notify") is True:
reasoning, body = _split_reasoning(content) reasoning, body = _split_reasoning(content)
# Non-streaming: reasoning is prepended to content (split above). # Non-streaming: reasoning is prepended to content (split above).
@@ -863,11 +967,11 @@ class AndroidAdapter(BasePlatformAdapter):
state.active = False state.active = False
return SendResult(success=True, message_id=message_id) return SendResult(success=True, message_id=message_id)
# 3. Tool progress (first tool bubble of an editable line buffer). # 4. Tool progress (first tool bubble of an editable line buffer).
if _is_tool_progress(content): if _is_tool_progress(content):
return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=False) return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=False)
# 4. Commentary (interim assistant beat). # 5. Commentary (interim assistant beat).
message_id = _mint_message_id() message_id = _mint_message_id()
state.active = True state.active = True
await self._broadcast_or_log( await self._broadcast_or_log(
@@ -1043,17 +1147,151 @@ class AndroidAdapter(BasePlatformAdapter):
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None: async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
delivered = await self._ws_server.broadcast(frame) delivered = await self._ws_server.broadcast(frame)
if delivered == 0: # M3/M5: always append to the outbox so a reconnecting app can catch
# M3: no live device -- park the frame in the outbox so a # up on *all* recent frames, not just the ones that were parked. This
# reconnecting app can `sync` it (M5 adds push to wake the device). # covers the case where the app's in-memory ChatStore is reset (e.g.
# activity recreation / composition recompose) while the process stays
# alive: the recreated controller re-syncs from its stale cursor and
# replays the frames it missed. The push below still only fires when
# there is no live subscriber (or for high-priority events).
try: try:
cursor = self._outbox.append(chat_id, frame.to_json()) cursor = self._outbox.append(chat_id, frame.to_json())
except Exception:
logger.warning("android: outbox append failed", exc_info=True)
return
if delivered == 0:
logger.info( logger.info(
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)", "android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
chat_id, frame.type, cursor, chat_id, frame.type, cursor,
) )
# M5: wake the offline device(s) via the push backend.
await self._maybe_push(chat_id, frame, cursor)
await self._maybe_notify_outbox_prune(chat_id)
elif (
frame.type == protocol.TYPE_NOTIFICATION
and frame.payload.get("kind") in protocol.HIGH_PRIORITY_NOTIF_KINDS
):
# M5: high-priority events (approval/clarify/cron) push even when
# a device is live -- the app may be backgrounded and decides
# whether to also show an in-app banner (docs/08 §8.1).
await self._maybe_push(chat_id, frame, cursor)
# ── M5: push ───────────────────────────────────────────────────────────
def _push_summary(
self, frame: "protocol.Frame"
) -> Optional[Tuple[str, str, str, str]]:
"""``(title, body, kind, priority)`` for a pushable frame, else None.
Only terminal/interesting frames wake a device: intermediate
streaming and tool frames are replayed by ``sync`` without a push
(no notification spam per turn).
"""
t = frame.type
p = frame.payload
if t == protocol.TYPE_MESSAGE:
return (
self._channel_name(frame.chat_id or ""),
_push_preview(p.get("text")),
"message",
"normal",
)
if t == protocol.TYPE_MESSAGE_STOP:
return (
self._channel_name(frame.chat_id or ""),
_push_preview(p.get("final_text")),
"message",
"normal",
)
if t == protocol.TYPE_NOTIFICATION:
kind = str(p.get("kind") or protocol.NOTIF_GENERIC)
priority = "high" if kind in protocol.HIGH_PRIORITY_NOTIF_KINDS else "normal"
return (
str(p.get("title") or "Iris"),
str(p.get("body") or ""),
kind,
priority,
)
if t == protocol.TYPE_MEDIA_OFFER:
return (
self._channel_name(frame.chat_id or ""),
f"New {p.get('kind') or 'media'}: {p.get('filename') or ''}".strip(),
"media",
"normal",
)
return None
async def _maybe_push(self, chat_id: str, frame: "protocol.Frame", cursor: int) -> None:
"""Fire the configured push backend for a parked (or high-priority)
frame. Best-effort: failures are logged, never raised."""
summary = self._push_summary(frame)
if summary is None:
return
title, body, kind, priority = summary
backend = self._push
if backend is None or not backend.token_field:
return
devices = self._devices.list()
if not backend.configured() and not any(
d.get(backend.token_field) for d in devices
):
return
data: Dict[str, Any] = {"chat_id": chat_id, "kind": kind, "cursor": str(cursor)}
if frame.thread_id:
data["thread_id"] = frame.thread_id
message_id = frame.payload.get("message_id")
if isinstance(message_id, str) and message_id:
data["message_id"] = message_id
for device in devices:
device_id = device.get("device_id")
if not device_id:
continue
# Prefer the live connection's token (fcm.register refreshes it
# in memory) over the possibly-stale registry row.
conn = self._ws_server.connection(device_id)
token = getattr(conn, backend.token_field, None) if conn is not None else None
if not token:
token = device.get(backend.token_field)
if not token:
continue
try:
ok = await backend.send(
device_id=device_id,
chat_id=chat_id,
title=title,
body=body,
data=data,
token=token,
priority=priority,
)
except Exception: except Exception:
logger.warning("android: outbox append failed", exc_info=True) logger.warning("android: push via %s failed", backend.name, exc_info=True)
continue
if ok:
logger.info(
"android: push via %s -> %s (%s, chat=%s)",
backend.name, device_id, frame.type, chat_id,
)
async def _maybe_notify_outbox_prune(self, chat_id: str) -> None:
"""When the outbox row cap pruned old frames, tell the app (throttled
to once per hour so a full box doesn't banner per frame)."""
pruned = self._outbox.take_overflow_pruned()
if pruned <= 0:
return
now = time.time()
if now - self._prune_notified_at < 3600.0:
return
self._prune_notified_at = now
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_GENERIC,
"Outbox",
f"{pruned} older message(s) pruned",
),
)
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
"""Send a typing indicator (``typing`` frame, on=true).""" """Send a typing indicator (``typing`` frame, on=true)."""
@@ -1464,6 +1702,16 @@ class AndroidAdapter(BasePlatformAdapter):
resp = protocol.channel_created(entry) resp = protocol.channel_created(entry)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._ws_server.broadcast(resp)
# M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log(
entry["chat_id"],
protocol.notification(
entry["chat_id"],
protocol.NOTIF_CHANNEL_CREATED,
"Channels",
f"New channel: {name}",
),
)
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None: async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id") chat_id = frame.chat_id or frame.payload.get("chat_id")
@@ -1496,6 +1744,16 @@ class AndroidAdapter(BasePlatformAdapter):
resp = protocol.channel_renamed(entry) resp = protocol.channel_renamed(entry)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._ws_server.broadcast(resp)
# M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_CHANNEL_RENAMED,
"Channels",
f"Renamed to {name}",
),
)
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None: async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id") chat_id = frame.chat_id or frame.payload.get("chat_id")
@@ -1536,6 +1794,16 @@ class AndroidAdapter(BasePlatformAdapter):
resp = protocol.channel_deleted(chat_id) resp = protocol.channel_deleted(chat_id)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._ws_server.broadcast(resp)
# M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_CHANNEL_DELETED,
"Channels",
f"{entry.get('name') or chat_id} deleted",
),
)
async def on_channel_list(self, frame: protocol.Frame, device_id: str) -> None: async def on_channel_list(self, frame: protocol.Frame, device_id: str) -> None:
channels = self._channels.list(include_archived=False) channels = self._channels.list(include_archived=False)
@@ -1596,6 +1864,140 @@ class AndroidAdapter(BasePlatformAdapter):
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id) done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
await self._ws_server.send_to(device_id, done) await self._ws_server.send_to(device_id, done)
# ── M5: push token registration ───────────────────────────────────────
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
"""Update the device's push tokens (FCM rotation / ntfy topic).
Persists to the device registry AND refreshes the live connection so
the next push targets the current token without a stale read.
"""
fcm_token = frame.payload.get("fcm_token")
ntfy_topic = frame.payload.get("ntfy_topic")
fcm_token = fcm_token if isinstance(fcm_token, str) and fcm_token else None
ntfy_topic = ntfy_topic if isinstance(ntfy_topic, str) and ntfy_topic else None
if fcm_token is None and ntfy_topic is None:
return
try:
self._devices.update_push_tokens(
device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic
)
except Exception:
logger.warning("android: fcm.register update failed", exc_info=True)
return
conn = self._ws_server.connection(device_id)
if conn is not None:
if fcm_token is not None:
conn.fcm_token = fcm_token
if ntfy_topic is not None:
conn.ntfy_topic = ntfy_topic
logger.info("android: push tokens updated for %s", device_id)
# ── M5: approval / clarify banners ────────────────────────────────────
async def send_slash_confirm(
self,
chat_id: str,
title: str,
message: str,
session_key: str,
confirm_id: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Banner + push for a slash-command approval prompt.
The gateway's text fallback still renders the actionable prompt (the
app has no inline buttons yet); the notification is the push-visible
signal (high priority: pushed even when a device is live).
"""
thread_id = _thread_id_from_metadata(metadata)
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_APPROVAL,
title or "Approval needed",
_push_preview(message),
thread_id=thread_id,
),
)
return await super().send_slash_confirm(
chat_id, title, message, session_key, confirm_id, metadata=metadata
)
async def send_clarify(
self,
chat_id: str,
question: str,
choices: Optional[list],
clarify_id: str,
session_key: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Banner + push for a clarify prompt.
Renders the prompt as a proper ``message`` frame (the base text
fallback would route through ``send()`` and be misclassified as
commentary/tool progress) and keeps the gateway's text intercept
working via ``mark_awaiting_text``.
"""
thread_id = _thread_id_from_metadata(metadata)
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_CLARIFY,
"Question",
_push_preview(question),
thread_id=thread_id,
),
)
if choices:
# Multi-select clarifies register their flag on the pending entry;
# look it up by id (mirrors the base text fallback).
_is_multi = False
try:
from tools import clarify_gateway as _cg
with _cg._lock:
_entry = _cg._entries.get(clarify_id)
_is_multi = bool(_entry and getattr(_entry, "multi_select", False))
except Exception:
_is_multi = False
lines = [f"❓ {question}", ""]
for i, choice in enumerate(choices, start=1):
lines.append(f" {i}. {choice}")
lines.append("")
if _is_multi:
lines.append(
"Multiple selections allowed — reply with the numbers "
"separated by commas or spaces (e.g. \"1, 3\"), the option "
"text, or your own answer."
)
else:
lines.append("Reply with the number, the option text, or your own answer.")
text = "\n".join(lines)
# Text fallback: enable text-capture so the gateway intercept
# picks up the user's typed reply (e.g. "2" or choice text).
from tools.clarify_gateway import mark_awaiting_text
mark_awaiting_text(clarify_id)
else:
text = f"❓ {question}"
message_id = _mint_message_id()
await self._broadcast_or_log(
chat_id,
protocol.message(
chat_id=chat_id,
message_id=message_id,
role=protocol.ROLE_ASSISTANT,
text=text,
thread_id=thread_id,
ts=int(time.time() * 1000),
),
)
return SendResult(success=True, message_id=message_id)
# ── Chat info ───────────────────────────────────────────────────────── # ── Chat info ─────────────────────────────────────────────────────────
def _channel_name(self, chat_id: str) -> str: def _channel_name(self, chat_id: str) -> str:
@@ -1630,6 +2032,12 @@ class AndroidAdapter(BasePlatformAdapter):
"media": True, # M4: media.upload/offer/pull "media": True, # M4: media.upload/offer/pull
"search": True, # M3: search frame "search": True, # M3: search frame
"push": self.push_backend, "push": self.push_backend,
# M5: ntfy server URL (app listener discovery; "" when not ntfy).
"push_ntfy_server": (
self._push.server_url
if isinstance(self._push, NtfyBackend)
else ""
),
"pickers": False, # M2+ "pickers": False, # M2+
} }
+39 -1
View File
@@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
DEFAULT_RETENTION_HOURS = 72 DEFAULT_RETENTION_HOURS = 72
_REPLAY_LIMIT = 1000 _REPLAY_LIMIT = 1000
_PRUNE_INTERVAL_S = 3600.0 _PRUNE_INTERVAL_S = 3600.0
# Row cap (docs/08 §8.3): a very busy offline period must not grow the outbox
# without bound; the oldest rows beyond the cap are pruned and the app is
# told (generic notification, throttled in the adapter).
DEFAULT_MAX_ROWS = 5000
class Outbox: class Outbox:
@@ -38,12 +42,19 @@ class Outbox:
``DeviceRegistry`` / ``ChannelDirectory``). ``DeviceRegistry`` / ``ChannelDirectory``).
""" """
def __init__(self, db_path: Path, retention_hours: int = DEFAULT_RETENTION_HOURS): def __init__(
self,
db_path: Path,
retention_hours: int = DEFAULT_RETENTION_HOURS,
max_rows: int = DEFAULT_MAX_ROWS,
):
self._db_path = Path(db_path) self._db_path = Path(db_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True) self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._retention_hours = max(1, int(retention_hours)) self._retention_hours = max(1, int(retention_hours))
self._max_rows = max(1, int(max_rows))
self._lock = threading.Lock() self._lock = threading.Lock()
self._last_prune = 0.0 self._last_prune = 0.0
self._overflow_pruned = 0
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False) self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row self._conn.row_factory = sqlite3.Row
with self._lock: with self._lock:
@@ -90,10 +101,37 @@ class Outbox:
"VALUES (?, ?, ?, ?)", "VALUES (?, ?, ?, ?)",
(cursor, chat_id, frame_json, now), (cursor, chat_id, frame_json, now),
) )
self._enforce_row_cap()
self._conn.commit() self._conn.commit()
self._maybe_prune() self._maybe_prune()
return cursor return cursor
def take_overflow_pruned(self) -> int:
"""Rows pruned by the row cap since the last call (and reset to 0).
The adapter turns a non-zero count into a (throttled) generic
notification so the app knows older frames are gone.
"""
with self._lock:
n = self._overflow_pruned
self._overflow_pruned = 0
return n
def _enforce_row_cap(self) -> None:
"""Drop the oldest rows beyond ``max_rows`` (caller holds the lock)."""
row = self._conn.execute("SELECT COUNT(*) AS n FROM outbox").fetchone()
n = int(row["n"]) if row else 0
excess = n - self._max_rows
if excess <= 0:
return
self._conn.execute(
"DELETE FROM outbox WHERE cursor IN ("
" SELECT cursor FROM outbox ORDER BY cursor ASC LIMIT ?)",
(excess,),
)
self._overflow_pruned += excess
logger.info("android outbox: row cap pruned %s oldest row(s)", excess)
def latest_cursor(self) -> int: def latest_cursor(self) -> int:
"""The high-water cursor (0 when nothing has been appended).""" """The high-water cursor (0 when nothing has been appended)."""
with self._lock: with self._lock:
+4
View File
@@ -57,6 +57,10 @@ optional_env:
description: "ntfy server URL (default https://ntfy.sh)" description: "ntfy server URL (default https://ntfy.sh)"
prompt: "ntfy server URL" prompt: "ntfy server URL"
password: false password: false
- name: NTFY_AUTH_TOKEN
description: "ntfy auth token for a private topic (trust boundary)"
prompt: "ntfy auth token"
password: true
- name: ANDROID_WS_CERT - name: ANDROID_WS_CERT
description: "TLS cert path for WSS (optional)" description: "TLS cert path for WSS (optional)"
prompt: "WSS cert" prompt: "WSS cert"
+55
View File
@@ -76,6 +76,10 @@ TYPE_MEDIA_OFFER = "media.offer"
TYPE_MEDIA_PULL = "media.pull" TYPE_MEDIA_PULL = "media.pull"
TYPE_MEDIA_PULL_END = "media.pull.end" TYPE_MEDIA_PULL_END = "media.pull.end"
# Push / notifications (M5)
TYPE_NOTIFICATION = "notification"
TYPE_FCM_REGISTER = "fcm.register"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Error codes (``error`` frame payload.code) # Error codes (``error`` frame payload.code)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -96,6 +100,22 @@ ROLE_ASSISTANT = "assistant"
ROLE_SYSTEM = "system" ROLE_SYSTEM = "system"
ROLE_CRON = "cron" ROLE_CRON = "cron"
# ---------------------------------------------------------------------------
# Notification kinds (``notification`` frame payload.kind, M5)
# ---------------------------------------------------------------------------
NOTIF_CHANNEL_CREATED = "channel_created"
NOTIF_CHANNEL_RENAMED = "channel_renamed"
NOTIF_CHANNEL_DELETED = "channel_deleted"
NOTIF_CRON = "cron"
NOTIF_APPROVAL = "approval"
NOTIF_CLARIFY = "clarify"
NOTIF_GENERIC = "generic"
# Kinds that push even when a device is live (the app may be backgrounded;
# it decides whether to also show an in-app banner).
HIGH_PRIORITY_NOTIF_KINDS = frozenset({NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Envelope # Envelope
@@ -452,6 +472,41 @@ def sync_done(cursor: int, *, id: Optional[int] = None) -> Frame:
return Frame(type=TYPE_SYNC_DONE, id=id, payload={"cursor": cursor}) return Frame(type=TYPE_SYNC_DONE, id=id, payload={"cursor": cursor})
# ---------------------------------------------------------------------------
# Push / notification frames (M5)
# ---------------------------------------------------------------------------
def notification(
chat_id: str,
kind: str,
title: str,
body: str,
*,
thread_id: Optional[str] = None,
ts: Optional[int] = None,
) -> Frame:
"""Event: a transient in-app banner (and a push mirror when the device is
offline). ``kind`` is one of the ``NOTIF_*`` constants."""
payload: Dict[str, Any] = {"kind": kind, "title": title, "body": body}
if ts is not None:
payload["ts"] = ts
return Frame(
type=TYPE_NOTIFICATION, chat_id=chat_id, thread_id=thread_id, payload=payload
)
def fcm_register(
fcm_token: Optional[str] = None, ntfy_topic: Optional[str] = None
) -> Frame:
"""Request: update the device's push tokens (FCM rotation / ntfy topic)."""
payload: Dict[str, Any] = {}
if fcm_token:
payload["fcm_token"] = fcm_token
if ntfy_topic:
payload["ntfy_topic"] = ntfy_topic
return Frame(type=TYPE_FCM_REGISTER, payload=payload)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Media frames (M4) # Media frames (M4)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+321 -5
View File
@@ -4,12 +4,328 @@
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account - ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key (``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key
(``ANDROID_FCM_SERVER_KEY``). (``ANDROID_FCM_SERVER_KEY``).
- ``NtfyBackend``: reuses hermes ntfy publish (``NTFY_TOPIC`` / - ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL``
``NTFY_SERVER_URL``). (default ``https://ntfy.sh``) via ``httpx``; the app's listener
subscribes to the topic.
Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback). Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
Fired when a frame has no live subscriber; data payload drives a silent sync Fired when a frame has no live subscriber; the data payload drives a silent
on the device. sync on the device (docs/08-push.md).
Milestone M5. Zero new dependencies: ``httpx`` is a hermes core dep. ``google.auth`` is NOT
installed, so the FCM service-account OAuth2 access token is minted directly
with PyJWT + cryptography (both core deps).
Payloads carry no secrets and only a short preview (lock-screen privacy);
full content is fetched via ``sync`` over the authenticated WS.
""" """
import json
import logging
import threading
import time
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import quote
import httpx
logger = logging.getLogger(__name__)
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
FCM_TOKEN_URL = "https://oauth2.googleapis.com/token"
FCM_V1_SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send"
FCM_LEGACY_SEND_URL = "https://fcm.googleapis.com/fcm/send"
# Refresh the cached access token this long before its expiry.
_TOKEN_REFRESH_MARGIN_S = 600.0
_DEFAULT_NTFY_SERVER = "https://ntfy.sh"
_NTFY_BODY_LIMIT = 4096
_HTTP_TIMEOUT_S = 15.0
_NTFY_PRIORITY = {"high": "5", "normal": "3", "low": "1"}
class PushBackend:
"""Interface: wake one device (FCM token or ntfy topic)."""
name: str = "push"
# DeviceRegistry column that carries this backend's target token.
token_field: str = ""
def configured(self) -> bool:
"""True when the backend has credentials to send with."""
raise NotImplementedError
async def send(
self,
*,
device_id: str,
chat_id: str,
title: str,
body: str,
data: Dict[str, Any],
token: str,
priority: str = "normal",
data_only: bool = False,
) -> bool:
"""Deliver one push to *token*. Returns True on success.
``data`` is the silent-sync payload (``chat_id``, ``kind``,
``cursor``, optional ``thread_id``/``message_id``) -- string values
only on the wire.
"""
raise NotImplementedError
class FcmBackend(PushBackend):
"""FCM HTTP v1 (service account) or legacy ``/fcm/send`` (server key)."""
name = "fcm"
token_field = "fcm_token"
def __init__(
self,
service_account: Optional[str] = None,
server_key: Optional[str] = None,
):
self._sa_path = (service_account or "").strip() or None
self._server_key = (server_key or "").strip() or None
self._sa: Optional[Dict[str, Any]] = None
self._sa_failed = False
self._access_token: Optional[str] = None
self._token_expiry = 0.0
self._lock = threading.Lock()
def configured(self) -> bool:
if self._server_key:
return True
return bool(self._sa_path and Path(self._sa_path).is_file())
def _load_sa(self) -> Optional[Dict[str, Any]]:
if self._sa is not None:
return self._sa
if not self._sa_path or self._sa_failed:
return None
try:
with open(self._sa_path, "r", encoding="utf-8") as f:
sa = json.load(f)
if isinstance(sa, dict) and sa.get("client_email") and sa.get("private_key"):
self._sa = sa
return sa
except Exception:
logger.warning("android: FCM service account unreadable: %s", self._sa_path)
self._sa_failed = True
return None
async def _authorization(self, client: httpx.AsyncClient) -> Optional[str]:
"""Bearer token: the legacy server key, or a cached service-account
OAuth2 access token (JWT-bearer grant, minted with PyJWT)."""
if self._server_key:
return self._server_key
sa = self._load_sa()
if sa is None:
return None
now = time.time()
with self._lock:
if self._access_token and now < self._token_expiry - _TOKEN_REFRESH_MARGIN_S:
return self._access_token
import jwt # PyJWT (core dep)
claims = {
"iss": sa["client_email"],
"scope": FCM_SCOPE,
"aud": FCM_TOKEN_URL,
"iat": int(now),
"exp": int(now) + 3600,
}
headers = {"kid": sa["private_key_id"]} if sa.get("private_key_id") else None
try:
assertion = jwt.encode(
claims, sa["private_key"], algorithm="RS256", headers=headers
)
except Exception:
logger.warning("android: FCM JWT mint failed", exc_info=True)
return None
try:
resp = await client.post(
FCM_TOKEN_URL,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
},
timeout=_HTTP_TIMEOUT_S,
)
except Exception:
logger.warning("android: FCM token exchange failed", exc_info=True)
return None
if resp.status_code != 200:
logger.warning(
"android: FCM token exchange HTTP %s: %s",
resp.status_code, resp.text[:200],
)
return None
try:
data = resp.json()
except (json.JSONDecodeError, ValueError):
return None
token = data.get("access_token")
if not isinstance(token, str) or not token:
return None
with self._lock:
self._access_token = token
try:
self._token_expiry = now + float(data.get("expires_in", 3600))
except (TypeError, ValueError):
self._token_expiry = now + 3600.0
return token
async def send(
self,
*,
device_id: str,
chat_id: str,
title: str,
body: str,
data: Dict[str, Any],
token: str,
priority: str = "normal",
data_only: bool = False,
) -> bool:
if not token:
return False
data = {str(k): str(v) for k, v in (data or {}).items()}
notification = None if data_only else {"title": title or "Iris", "body": body or ""}
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
if self._server_key:
payload: Dict[str, Any] = {"to": token}
if notification:
payload["notification"] = notification
if data:
payload["data"] = data
auth = self._server_key
url = FCM_LEGACY_SEND_URL
else:
sa = self._load_sa()
project_id = (sa or {}).get("project_id")
if not project_id:
return False
message: Dict[str, Any] = {"token": token}
if notification:
message["notification"] = notification
if data:
message["data"] = data
message["android"] = {
"priority": "high" if priority == "high" else "normal"
}
payload = {"message": message}
auth = await self._authorization(client)
if auth is None:
return False
url = FCM_V1_SEND_URL.format(project_id=project_id)
try:
resp = await client.post(
url,
json=payload,
headers={"Authorization": f"Bearer {auth}"},
)
except Exception:
logger.warning("android: FCM send failed (network)", exc_info=True)
return False
if resp.status_code >= 300:
# 404 NOT_FOUND = stale/invalid registration token.
logger.warning(
"android: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
)
return False
return True
class NtfyBackend(PushBackend):
"""ntfy publish (self-host friendly; zero Firebase).
The structured payload rides in an ``X-Data`` header (JSON) the app's
listener parses; the message body is the short preview. A private topic
+ ``NTFY_AUTH_TOKEN`` provides the trust boundary (docs/08 §8.7).
"""
name = "ntfy"
token_field = "ntfy_topic"
def __init__(
self,
topic: Optional[str] = None,
server_url: Optional[str] = None,
auth_token: Optional[str] = None,
):
self._topic = (topic or "").strip() or None
self._server = (
(server_url or _DEFAULT_NTFY_SERVER).strip().rstrip("/")
or _DEFAULT_NTFY_SERVER
)
self._auth_token = (auth_token or "").strip() or None
@property
def server_url(self) -> str:
"""The ntfy server this backend publishes to (for app discovery)."""
return self._server
def configured(self) -> bool:
return bool(self._topic)
async def send(
self,
*,
device_id: str,
chat_id: str,
title: str,
body: str,
data: Dict[str, Any],
token: str,
priority: str = "normal",
data_only: bool = False,
) -> bool:
topic = (token or self._topic or "").strip()
if not topic:
return False
headers = {
"Content-Type": "text/plain; charset=utf-8",
"X-Title": (title or "Iris")[:512],
"X-Priority": _NTFY_PRIORITY.get(priority, "3"),
"X-Tag": "bell",
"X-Data": json.dumps(data or {}, separators=(",", ":")),
}
if self._auth_token:
headers["Authorization"] = f"Bearer {self._auth_token}"
text = (body or "")[:_NTFY_BODY_LIMIT]
url = f"{self._server}/{quote(topic, safe='')}"
try:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
resp = await client.post(
url, content=text.encode("utf-8"), headers=headers
)
except Exception:
logger.warning("android: ntfy publish failed (network)", exc_info=True)
return False
if resp.status_code >= 300:
logger.warning(
"android: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
)
return False
return True
def build_push_backend(
name: Optional[str],
*,
fcm_service_account: Optional[str] = None,
fcm_server_key: Optional[str] = None,
ntfy_topic: Optional[str] = None,
ntfy_server_url: Optional[str] = None,
ntfy_auth_token: Optional[str] = None,
) -> PushBackend:
"""Select the backend by name (``ANDROID_PUSH_BACKEND``; fcm default)."""
if (name or "").strip().lower() == "ntfy":
return NtfyBackend(
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
)
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)
+49 -1
View File
@@ -20,6 +20,10 @@ Options:
message.send via media_refs message.send via media_refs
--pull-offer M4: when a media.offer arrives during the turn, pull the --pull-offer M4: when a media.offer arrives during the turn, pull the
media (chunked) and verify the byte count media (chunked) and verify the byte count
--sync C M5: after pairing, send sync {cursor: C} and print the
replay + sync.done (no turn is driven)
--fcm-token M5: attach this FCM token to the hello payload
--fcm-reg M5: after pairing, send fcm.register with --fcm-token
--timeout S seconds to wait for the final reply (default 120) --timeout S seconds to wait for the final reply (default 120)
--authfail expect an auth rejection (wrong token) and exit 0 on it --authfail expect an auth rejection (wrong token) and exit 0 on it
""" """
@@ -94,6 +98,13 @@ def _print_frame(raw):
extra = f" ok={payload.get('ok')} ref={payload.get('media_ref')}" extra = f" ok={payload.get('ok')} ref={payload.get('media_ref')}"
elif ftype == "media.pull.end": elif ftype == "media.pull.end":
extra = f" ok={payload.get('ok')}" extra = f" ok={payload.get('ok')}"
elif ftype == "notification":
extra = (f" kind={payload.get('kind')} title={payload.get('title')!r} "
f"body={(payload.get('body') or '')[:100]!r}")
elif ftype == "sync":
extra = f" cursor={payload.get('cursor')}"
elif ftype == "sync.done":
extra = f" cursor={payload.get('cursor')}"
scope = f" chat={chat}" if chat else "" scope = f" chat={chat}" if chat else ""
idpart = f" id={fid}" if fid is not None else "" idpart = f" id={fid}" if fid is not None else ""
print(f" <- {ftype}{idpart}{scope}{extra}") print(f" <- {ftype}{idpart}{scope}{extra}")
@@ -200,8 +211,10 @@ async def run(args) -> int:
"caps": {"min_protocol": 1}, "caps": {"min_protocol": 1},
}, },
} }
if args.fcm_token:
hello["payload"]["fcm_token"] = args.fcm_token
await ws.send(json.dumps(hello)) await ws.send(json.dumps(hello))
print(" -> hello") print(" -> hello" + (f" fcm_token={args.fcm_token[:12]}…" if args.fcm_token else ""))
# First response must be hello.ack (or an auth error). # First response must be hello.ack (or an auth error).
try: try:
@@ -224,6 +237,35 @@ async def run(args) -> int:
await ws.close() await ws.close()
return 5 return 5
# M5: optional fcm.register after pairing.
if args.fcm_reg:
reg_token = args.fcm_token or f"probe-{uuid.uuid4().hex[:12]}"
await ws.send(json.dumps({
"v": 1, "type": "fcm.register",
"payload": {"fcm_token": reg_token},
}))
print(f" -> fcm.register fcm_token={reg_token[:12]}…")
# M5: sync catch-up mode (no turn driven).
if args.sync is not None:
await ws.send(json.dumps({
"v": 1, "id": 1, "type": "sync", "payload": {"cursor": args.sync},
}))
print(f" -> sync cursor={args.sync}")
while True:
raw = await asyncio.wait_for(ws.recv(), timeout=30)
data = _print_frame(raw)
if data is None:
continue
if data.get("type") == "sync.done":
print(f"== sync done at cursor {data['payload'].get('cursor')}")
await ws.close()
return 0
if data.get("type") == "error":
print(f"!! sync failed: {data['payload']}")
await ws.close()
return 8
if not args.send and not args.upload: if not args.send and not args.upload:
print("== paired OK (no --send/--upload; exiting)") print("== paired OK (no --send/--upload; exiting)")
await ws.close() await ws.close()
@@ -311,6 +353,12 @@ def main() -> int:
help="M4: file to upload (chunked) and attach via media_refs") help="M4: file to upload (chunked) and attach via media_refs")
p.add_argument("--pull-offer", action="store_true", p.add_argument("--pull-offer", action="store_true",
help="M4: pull any media.offer that arrives during the turn") help="M4: pull any media.offer that arrives during the turn")
p.add_argument("--sync", type=int, default=None,
help="M5: send sync {cursor} after pairing, print replay, exit")
p.add_argument("--fcm-token", default="",
help="M5: FCM token to attach to the hello payload")
p.add_argument("--fcm-reg", action="store_true",
help="M5: send fcm.register after pairing (uses --fcm-token)")
p.add_argument("--timeout", type=float, default=120.0) p.add_argument("--timeout", type=float, default=120.0)
p.add_argument("--authfail", action="store_true", p.add_argument("--authfail", action="store_true",
help="expect an auth rejection (wrong token)") help="expect an auth rejection (wrong token)")
+2 -12
View File
@@ -316,18 +316,8 @@ class WsServer:
await self._adapter.on_media_upload_end(frame, device_id) await self._adapter.on_media_upload_end(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_PULL: elif frame.type == protocol.TYPE_MEDIA_PULL:
await self._adapter.on_media_pull(frame, device_id) await self._adapter.on_media_pull(frame, device_id)
elif frame.type == "fcm.register": elif frame.type == protocol.TYPE_FCM_REGISTER:
fcm_token = frame.payload.get("fcm_token") await self._adapter.on_fcm_register(frame, device_id)
ntfy_topic = frame.payload.get("ntfy_topic")
if isinstance(fcm_token, str) or isinstance(ntfy_topic, str):
try:
self._devices.update_push_tokens(
device_id,
fcm_token=fcm_token if isinstance(fcm_token, str) else None,
ntfy_topic=ntfy_topic if isinstance(ntfy_topic, str) else None,
)
except Exception:
logger.warning("android: fcm.register update failed", exc_info=True)
# Unknown types are ignored (forward-compat). # Unknown types are ignored (forward-compat).
# ── Helpers ─────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────