M5: push (FCM + ntfy) + offline catch-up + background notifications
Server (gateway-plugin): - push.py: FCM (HTTP v1 service-account / legacy key) + ntfy backends; NtfyBackend.server_url for app discovery - adapter.py: push on offline broadcast + high-priority push when live; fcm.register; server_caps.push_ntfy_server; outbox now ALWAYS appends so a reconnecting app catches up on live-delivered frames (fixes empty chat after notification tap / activity recreation) - protocol.py / ws_server.py / outbox.py / plugin.yaml: M5 frames + env vars App (Kotlin CMP): - Protocol.kt: notification / fcm.register / sync frames + push caps - GatewayClient.kt: hello carries push creds; auto-sync on reconnect - IrisController.kt: banners, deep-link, notifyMessageIfBackgrounded (system notification on a regular reply when backgrounded) - Android: PlatformPush, AppBridge, IrisNotifications, NtfyListenerService, IrisFirebaseMessagingService, AndroidPush, AndroidSecureStore - Desktop: DesktopPush, DesktopSecureStore - build files + manifest (permissions, services, deep-links) Tests: 35-test tests/gateway/test_android.py suite passes (incl. new regression test_live_delivered_frame_still_parked_for_sync). E2E verified on device: push fire, reconnect sync, background notification, and message replay after ChatStore reset.
This commit is contained in:
1 parent
913ee91024
commit
2ecfe1c05c
28 files changed
+1739
-51
No files matched your search
@@ -39,6 +39,10 @@ kotlin {
|
||||
implementation("androidx.media3:media3-ui:1.3.1")
|
||||
// SAF picker (rememberLauncherForActivityResult).
|
||||
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
|
||||
get() = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
|
||||
|
||||
override var syncCursor: Long
|
||||
get() = prefs.getLong(KEY_SYNC_CURSOR, 0L)
|
||||
set(value) = prefs.edit().putLong(KEY_SYNC_CURSOR, value).apply()
|
||||
|
||||
override var fcmToken: String
|
||||
get() = prefs.getString(KEY_FCM_TOKEN, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_FCM_TOKEN, value).apply()
|
||||
|
||||
override var ntfyTopic: String
|
||||
get() = prefs.getString(KEY_NTFY_TOPIC, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_NTFY_TOPIC, value).apply()
|
||||
|
||||
override var ntfyServer: String
|
||||
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
|
||||
|
||||
override fun savePairing(url: String, token: String) {
|
||||
serverUrl = url
|
||||
this.token = token
|
||||
@@ -46,5 +62,9 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
const val KEY_URL = "server_url"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
const val KEY_SYNC_CURSOR = "sync_cursor"
|
||||
const val KEY_FCM_TOKEN = "fcm_token"
|
||||
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||
const val KEY_NTFY_SERVER = "ntfy_server"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package iris.platform
|
||||
|
||||
import iris.state.IrisController
|
||||
|
||||
/**
|
||||
* Process-wide reference to the active [IrisController] (M5).
|
||||
*
|
||||
* The FCM service and the ntfy listener service are separate Android
|
||||
* components that need to reach the controller (to trigger a sync or open a
|
||||
* chat) without a compile-time dependency on the app module. Set by
|
||||
* [iris.IrisApp] on composition, cleared on dispose.
|
||||
*/
|
||||
object AppBridge {
|
||||
@Volatile
|
||||
var controller: IrisController? = null
|
||||
|
||||
/** True while the launcher activity is resumed (set by MainActivity). */
|
||||
@Volatile
|
||||
var foreground: Boolean = false
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package iris.platform
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
|
||||
/**
|
||||
* M5: FCM handler (docs/08 §8.1).
|
||||
*
|
||||
* - [onNewToken]: persist the rotated token and push it to the server via
|
||||
* `fcm.register` (so the next push targets the current token).
|
||||
* - [onMessageReceived]: the data payload drives a silent sync. When the app
|
||||
* is foregrounded the WS path already delivered the frame (in-app banner),
|
||||
* so we only post a system notification when backgrounded.
|
||||
*
|
||||
* Inert without a Firebase project (no google-services.json): the service is
|
||||
* declared in the manifest but never receives messages, and the app falls
|
||||
* back to the ntfy listener.
|
||||
*/
|
||||
class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
val store = AndroidSecureStore(applicationContext)
|
||||
store.fcmToken = token
|
||||
// Push the rotation to the server if we're connected.
|
||||
AppBridge.controller?.client?.sendFrame(
|
||||
iris.protocol.fcmRegisterFrame(fcmToken = token),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
val data = message.data
|
||||
val chatId = data["chat_id"] ?: "android:default"
|
||||
val threadId = data["thread_id"]
|
||||
val title = data["title"] ?: "Iris"
|
||||
val body = data["body"] ?: data["title"].orEmpty()
|
||||
// Foreground + live WS: the in-app banner already showed this.
|
||||
if (AppBridge.foreground) return
|
||||
if (ContextCompat.checkSelfPermission(applicationContext, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
IrisNotifications.post(applicationContext, chatId, null, title, body, threadId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package iris.platform
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* M5: per-chat notification channels + posting (docs/08 §8.2).
|
||||
*
|
||||
* One channel per chat_id so the user can mute individual chats. The content
|
||||
* intent uses a custom action (no compile-time dependency on the app module's
|
||||
* MainActivity); the app's manifest routes it back to the launcher activity
|
||||
* with chat_id / thread_id extras for the deep link.
|
||||
*/
|
||||
object IrisNotifications {
|
||||
const val CHANNEL_PREFIX = "iris_chat_"
|
||||
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
|
||||
private const val NOTIF_ID_BASE = 1_000_000
|
||||
|
||||
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val id = CHANNEL_PREFIX + chatId
|
||||
val name = chatName ?: chatId
|
||||
if (nm.getNotificationChannel(id) == null) {
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
|
||||
.apply { description = "Iris messages for $name" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun post(
|
||||
context: Context,
|
||||
chatId: String,
|
||||
chatName: String?,
|
||||
title: String,
|
||||
body: String,
|
||||
threadId: String?,
|
||||
) {
|
||||
ensureChannel(context, chatId, chatName)
|
||||
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff)
|
||||
val intent = Intent(ACTION_OPEN_CHAT).apply {
|
||||
setPackage(context.packageName)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra("chat_id", chatId)
|
||||
if (threadId != null) putExtra("thread_id", threadId)
|
||||
}
|
||||
val pi = PendingIntent.getActivity(
|
||||
context,
|
||||
id,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(
|
||||
id,
|
||||
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pi)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package iris.platform
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import iris.protocol.IrisJson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* M5: ntfy listener fallback (docs/08 §8.4).
|
||||
*
|
||||
* A foreground service that subscribes to this device's ntfy topic and posts
|
||||
* a system notification for each push. The structured payload rides in the
|
||||
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body
|
||||
* is the short preview. When the app is foregrounded the WS path already
|
||||
* delivered the frame, so the service skips posting to avoid a duplicate.
|
||||
*/
|
||||
class NtfyListenerService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var streamJob: Job? = null
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream
|
||||
.build()
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIF_ID, foregroundNotification())
|
||||
streamJob?.cancel()
|
||||
streamJob = scope.launch { stream() }
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
streamJob?.cancel()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private suspend fun stream() {
|
||||
val store = AndroidSecureStore(AndroidEnv.context)
|
||||
val topic = store.ntfyTopic
|
||||
if (topic.isBlank()) return
|
||||
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
|
||||
val url = "$server/$topic"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
try {
|
||||
client.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return
|
||||
val body = resp.body ?: return
|
||||
val source = body.source()
|
||||
var data: String? = null
|
||||
var title: String? = null
|
||||
var msgBody: String? = null
|
||||
while (!source.exhausted()) {
|
||||
val line = source.readUtf8Line() ?: break
|
||||
when {
|
||||
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
|
||||
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
|
||||
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
|
||||
line.isEmpty() -> {
|
||||
// Event boundary: process the accumulated message.
|
||||
data?.let { handleData(it, title, msgBody) }
|
||||
data = null
|
||||
title = null
|
||||
msgBody = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Stream dropped; the service is START_STICKY so the system
|
||||
// restarts it. If it keeps failing, the WS path still works.
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleData(dataJson: String, title: String?, msgBody: String?) {
|
||||
val data = try {
|
||||
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val chatId = data?.str("chat_id") ?: "android:default"
|
||||
val threadId = data?.str("thread_id")
|
||||
// The short preview rides in the SSE `data:` field; fall back to the
|
||||
// X-Title, then a generic label.
|
||||
val body = msgBody?.takeIf { it.isNotBlank() } ?: title.orEmpty()
|
||||
val notifTitle = title ?: "Iris"
|
||||
// Foreground + live WS: the in-app banner already showed this.
|
||||
if (AppBridge.foreground) return
|
||||
if (ContextCompat.checkSelfPermission(AndroidEnv.context, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
IrisNotifications.post(AndroidEnv.context, chatId, null, notifTitle, body, threadId)
|
||||
}
|
||||
|
||||
private fun foregroundNotification(): Notification {
|
||||
val context = AndroidEnv.context
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val nm = context.getSystemService(NotificationManager::class.java)
|
||||
if (nm.getNotificationChannel(LISTENER_CHANNEL) == null) {
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
LISTENER_CHANNEL,
|
||||
"Iris push listener",
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return NotificationCompat.Builder(context, LISTENER_CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Iris")
|
||||
.setContentText("Listening for messages")
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LISTENER_CHANNEL = "iris_ntfy_listener"
|
||||
const val NOTIF_ID = 999_001
|
||||
const val DEFAULT_NTFY_SERVER = "https://ntfy.sh"
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a string field from a JSON object (null when absent / not a string). */
|
||||
private fun JsonObject?.str(key: String): String? =
|
||||
(this?.get(key) as? JsonPrimitive)?.content
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -13,6 +14,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import iris.data.SecureStore
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.setActiveController
|
||||
import iris.state.IrisController
|
||||
import iris.ui.screens.ChatScreen
|
||||
import iris.ui.screens.ConnectScreen
|
||||
@@ -33,10 +35,25 @@ private val IrisDark = darkColorScheme(
|
||||
* settings, and media (docs/10-android-app.md).
|
||||
*/
|
||||
@Composable
|
||||
fun IrisApp(store: SecureStore) {
|
||||
fun IrisApp(
|
||||
store: SecureStore,
|
||||
deepLinkChatId: String? = null,
|
||||
deepLinkThreadId: String? = null,
|
||||
) {
|
||||
val controller = remember(store) { IrisController(store) }
|
||||
DisposableEffect(controller) {
|
||||
onDispose { controller.dispose() }
|
||||
setActiveController(controller)
|
||||
onDispose {
|
||||
setActiveController(null)
|
||||
controller.dispose()
|
||||
}
|
||||
}
|
||||
// M5: open the chat referenced by a push notification tap (applied now or
|
||||
// on the next hello.ack, whichever is later).
|
||||
LaunchedEffect(deepLinkChatId, deepLinkThreadId) {
|
||||
if (!deepLinkChatId.isNullOrBlank()) {
|
||||
controller.openDeepLink(deepLinkChatId, deepLinkThreadId)
|
||||
}
|
||||
}
|
||||
val state by controller.client.state.collectAsState()
|
||||
|
||||
|
||||
@@ -18,6 +18,18 @@ interface SecureStore {
|
||||
/** Human-readable device name (e.g. "MIX 2S"). */
|
||||
val deviceName: String
|
||||
|
||||
/** Last sync cursor confirmed by the server (M5 reconnect catch-up). */
|
||||
var syncCursor: Long
|
||||
|
||||
/** FCM registration token (M5; empty when Firebase is unavailable). */
|
||||
var fcmToken: String
|
||||
|
||||
/** ntfy topic this device subscribes to (M5; empty when unused). */
|
||||
var ntfyTopic: String
|
||||
|
||||
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
|
||||
var ntfyServer: String
|
||||
|
||||
fun savePairing(url: String, token: String)
|
||||
fun clear()
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import iris.protocol.mediaUploadEndFrame
|
||||
import iris.protocol.mediaUploadStartFrame
|
||||
import iris.protocol.messageSendFrame
|
||||
import iris.protocol.pingFrame
|
||||
import iris.protocol.syncFrame
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -187,7 +188,15 @@ class GatewayClient(
|
||||
request,
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(helloFrame(token, store.deviceId, store.deviceName).toWire())
|
||||
webSocket.send(
|
||||
helloFrame(
|
||||
token = token,
|
||||
deviceId = store.deviceId,
|
||||
deviceName = store.deviceName,
|
||||
fcmToken = store.fcmToken.ifBlank { null },
|
||||
ntfyTopic = store.ntfyTopic.ifBlank { null },
|
||||
).toWire(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
@@ -250,6 +259,12 @@ class GatewayClient(
|
||||
if (e == null) {
|
||||
val ack = helloAck.getCompleted()
|
||||
_state.value = State.Connected(ack.serverCaps, ack.channels)
|
||||
// M5: reconnect catch-up — replay frames parked while offline.
|
||||
val local = store.syncCursor
|
||||
if (local < ack.syncCursor) {
|
||||
val id = nextRequestId++
|
||||
ws.send(syncFrame(id, local).toWire())
|
||||
}
|
||||
winner.complete(DialResult.Connected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package iris.platform
|
||||
|
||||
/**
|
||||
* M5: push / notification platform hooks (docs/08 §8.2).
|
||||
*
|
||||
* The controller (commonMain) needs to know whether the app is in the
|
||||
* foreground (to decide between an in-app banner and a system notification)
|
||||
* and to post a system notification when a `notification` frame arrives while
|
||||
* the app is backgrounded but the WS is still live.
|
||||
*/
|
||||
|
||||
/** True when the app's UI is visible (Android: activity resumed). */
|
||||
expect fun isAppForeground(): Boolean
|
||||
|
||||
/**
|
||||
* Post a system notification for a chat (Android). No-op on desktop.
|
||||
* [chatName] is used for the per-chat notification channel (best effort).
|
||||
*/
|
||||
expect fun postSystemNotification(
|
||||
chatId: String?,
|
||||
chatName: String?,
|
||||
title: String,
|
||||
body: String,
|
||||
threadId: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Register the active controller with the platform bridge (Android: so the
|
||||
* FCM / ntfy services can reach it). No-op on desktop.
|
||||
*/
|
||||
expect fun setActiveController(controller: Any?)
|
||||
@@ -54,6 +54,10 @@ const val TYPE_MEDIA_OFFER = "media.offer"
|
||||
const val TYPE_MEDIA_PULL = "media.pull"
|
||||
const val TYPE_MEDIA_PULL_END = "media.pull.end"
|
||||
|
||||
// M5 — push / notifications
|
||||
const val TYPE_NOTIFICATION = "notification"
|
||||
const val TYPE_FCM_REGISTER = "fcm.register"
|
||||
|
||||
// M3 — channels / threads / search / sync
|
||||
const val TYPE_CHANNEL_CREATE = "channel.create"
|
||||
const val TYPE_CHANNEL_RENAME = "channel.rename"
|
||||
@@ -131,6 +135,7 @@ data class ServerCaps(
|
||||
val media: Boolean = false,
|
||||
val search: Boolean = false,
|
||||
val push: String = "fcm",
|
||||
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
|
||||
val pickers: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -349,14 +354,55 @@ data class SyncPayload(val cursor: Long)
|
||||
@Serializable
|
||||
data class SyncDonePayload(val cursor: Long)
|
||||
|
||||
// ── M5: push / notifications ────────────────────────────────────────────
|
||||
|
||||
/** Notification kinds (mirror of protocol.NOTIF_*). */
|
||||
const val NOTIF_MESSAGE = "message"
|
||||
const val NOTIF_APPROVAL = "approval"
|
||||
const val NOTIF_CLARIFY = "clarify"
|
||||
const val NOTIF_CRON = "cron"
|
||||
const val NOTIF_CHANNEL = "channel"
|
||||
const val NOTIF_OUTBOX_PRUNED = "outbox_pruned"
|
||||
|
||||
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */
|
||||
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
|
||||
|
||||
@Serializable
|
||||
data class NotificationPayload(
|
||||
val kind: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
@SerialName("chat_id") val chatId: String? = null,
|
||||
@SerialName("thread_id") val threadId: String? = null,
|
||||
val ts: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FcmRegisterPayload(
|
||||
@SerialName("fcm_token") val fcmToken: String? = null,
|
||||
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
|
||||
)
|
||||
|
||||
// ── Frame builders ──────────────────────────────────────────────────────
|
||||
|
||||
fun helloFrame(token: String, deviceId: String, deviceName: String): Frame =
|
||||
fun helloFrame(
|
||||
token: String,
|
||||
deviceId: String,
|
||||
deviceName: String,
|
||||
fcmToken: String? = null,
|
||||
ntfyTopic: String? = null,
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_HELLO,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
HelloPayload.serializer(),
|
||||
HelloPayload(token = token, deviceId = deviceId, deviceName = deviceName),
|
||||
HelloPayload(
|
||||
token = token,
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName,
|
||||
fcmToken = fcmToken,
|
||||
ntfyTopic = ntfyTopic,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -478,4 +524,15 @@ fun mediaPullFrame(id: Int, mediaId: String): Frame =
|
||||
MediaPullPayload.serializer(),
|
||||
MediaPullPayload(mediaId),
|
||||
),
|
||||
)
|
||||
|
||||
// ── M5 frame builders ───────────────────────────────────────────────────
|
||||
|
||||
fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame =
|
||||
Frame(
|
||||
type = TYPE_FCM_REGISTER,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
FcmRegisterPayload.serializer(),
|
||||
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
|
||||
),
|
||||
)
|
||||
@@ -8,11 +8,19 @@ import iris.media.MediaCache
|
||||
import iris.media.kindFromMime
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.PickedFile
|
||||
import iris.platform.isAppForeground
|
||||
import iris.platform.mediaCacheBaseDir
|
||||
import iris.platform.postSystemNotification
|
||||
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
|
||||
import iris.protocol.MediaOfferPayload
|
||||
import iris.protocol.MessagePayload
|
||||
import iris.protocol.MessageStopPayload
|
||||
import iris.protocol.NotificationPayload
|
||||
import iris.protocol.ROLE_ASSISTANT
|
||||
import iris.protocol.SearchHit
|
||||
import iris.protocol.SearchResultsPayload
|
||||
import iris.protocol.SyncDonePayload
|
||||
import iris.protocol.TYPE_NOTIFICATION
|
||||
import iris.protocol.TYPE_CHANNEL_CREATED
|
||||
import iris.protocol.TYPE_CHANNEL_DELETED
|
||||
import iris.protocol.TYPE_CHANNEL_LIST
|
||||
@@ -40,6 +48,7 @@ import iris.protocol.syncFrame
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -114,6 +123,76 @@ class IrisController(
|
||||
private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
|
||||
val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
|
||||
|
||||
// ── M5: notification banners (docs/08 §8.3) ───────────────────────────
|
||||
/** An in-app notification banner shown above the composer. */
|
||||
data class Banner(
|
||||
val id: Long,
|
||||
val kind: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val chatId: String?,
|
||||
val threadId: String?,
|
||||
val persistent: Boolean,
|
||||
)
|
||||
|
||||
private val _banners = MutableStateFlow<List<Banner>>(emptyList())
|
||||
val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
|
||||
private var bannerSeq = 0L
|
||||
|
||||
fun dismissBanner(id: Long) {
|
||||
_banners.value = _banners.value.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
private fun pushBanner(kind: String, title: String, body: String, chatId: String?, threadId: String?) {
|
||||
val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS
|
||||
val banner = Banner(bannerSeq++, kind, title, body, chatId, threadId, persistent)
|
||||
_banners.value = (_banners.value + banner).takeLast(5)
|
||||
if (!persistent) {
|
||||
scope.launch {
|
||||
delay(5_000)
|
||||
_banners.value = _banners.value.filterNot { it.id == banner.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M5: Telegram/WhatsApp-style message notification. When the app is
|
||||
* backgrounded and a finalized assistant reply arrives, post a system
|
||||
* notification (per-chat channel) with a short preview. Tapping it opens
|
||||
* the chat (deep link). The same per-chat notification id is reused, so a
|
||||
* multi-segment turn updates one notification instead of stacking.
|
||||
*/
|
||||
private fun notifyMessageIfBackgrounded(chatId: String?, threadId: String?, text: String) {
|
||||
if (isAppForeground()) return
|
||||
if (text.isBlank()) return
|
||||
val id = chatId ?: "android:default"
|
||||
val chatName = channels.byId(id)?.name
|
||||
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
||||
}
|
||||
|
||||
/** Single-line preview for a notification body (lock-screen privacy). */
|
||||
private fun preview(text: String, limit: Int = 120): String {
|
||||
val flat = text.replace('\n', ' ').replace('\t', ' ').trim()
|
||||
return if (flat.length <= limit) flat else flat.take(limit).trimEnd() + "…"
|
||||
}
|
||||
|
||||
// ── M5: deep link (push notification tap) ─────────────────────────────
|
||||
private var pendingDeepLink: Pair<String, String?>? = null
|
||||
|
||||
/** Open the chat referenced by a push notification tap. Applied now if
|
||||
* connected, otherwise on the next hello.ack (lane may not exist yet). */
|
||||
fun openDeepLink(chatId: String?, threadId: String?) {
|
||||
if (chatId.isNullOrBlank()) return
|
||||
pendingDeepLink = chatId to threadId
|
||||
if (client.state.value is GatewayClient.State.Connected) applyDeepLink()
|
||||
}
|
||||
|
||||
private fun applyDeepLink() {
|
||||
val link = pendingDeepLink ?: return
|
||||
pendingDeepLink = null
|
||||
if (link.second.isNullOrBlank()) openChannel(link.first) else openThread(link.first, link.second!!)
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
client.events.collect { frame ->
|
||||
@@ -132,6 +211,25 @@ class IrisController(
|
||||
if (frame.type == TYPE_MEDIA_OFFER) {
|
||||
frame.payloadAs<MediaOfferPayload>()?.let { pullMedia(it) }
|
||||
}
|
||||
// M5: Telegram/WhatsApp-style — when the app is
|
||||
// backgrounded, a finalized assistant reply posts a
|
||||
// system notification (the message still lands in the
|
||||
// chat via onFrame above). Streaming turns finalize on
|
||||
// message.stop; non-streaming replies are a single
|
||||
// assistant `message`.
|
||||
when (frame.type) {
|
||||
TYPE_MESSAGE_STOP ->
|
||||
frame.payloadAs<MessageStopPayload>()?.let {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||
}
|
||||
TYPE_MESSAGE ->
|
||||
frame.payloadAs<MessagePayload>()?.let {
|
||||
if (it.role == ROLE_ASSISTANT) {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
TYPE_CHANNEL_CREATED,
|
||||
TYPE_CHANNEL_RENAMED,
|
||||
@@ -144,7 +242,19 @@ class IrisController(
|
||||
TYPE_SYNC_DONE -> {
|
||||
// Replayed frames already flowed through [events]; the
|
||||
// cursor is authoritative server-side (outbox).
|
||||
frame.payloadAs<SyncDonePayload>()
|
||||
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor }
|
||||
}
|
||||
TYPE_NOTIFICATION -> {
|
||||
frame.payloadAs<NotificationPayload>()?.let { p ->
|
||||
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
||||
// M5: WS is live but the app is backgrounded — the
|
||||
// in-app banner is invisible, so mirror to a system
|
||||
// notification (the push backend only fires when
|
||||
// there is no live subscriber).
|
||||
if (!isAppForeground()) {
|
||||
postSystemNotification(p.chatId, null, p.title, p.body, p.threadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
TYPE_TYPING -> {
|
||||
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
|
||||
@@ -162,9 +272,19 @@ class IrisController(
|
||||
_homeChannel.value = home
|
||||
chat.setLane(home)
|
||||
}
|
||||
// M5: remember the ntfy server for the listener service.
|
||||
if (s.caps.pushNtfyServer.isNotBlank()) {
|
||||
store.ntfyServer = s.caps.pushNtfyServer
|
||||
}
|
||||
// M5: a deep link tapped before we were connected.
|
||||
applyDeepLink()
|
||||
}
|
||||
}
|
||||
}
|
||||
// M5: ensure this device has an ntfy topic (device-specific, random).
|
||||
if (store.ntfyTopic.isBlank()) {
|
||||
store.ntfyTopic = "iris-${store.deviceId}-${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||
}
|
||||
client.startHeartbeat()
|
||||
client.start()
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ fun ChatScreen(controller: IrisController) {
|
||||
var showNewThread by remember { mutableStateOf(false) }
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
val attachments by controller.attachments.collectAsState()
|
||||
val banners by controller.banners.collectAsState()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val drawerScope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -239,6 +240,27 @@ fun ChatScreen(controller: IrisController) {
|
||||
}
|
||||
}
|
||||
|
||||
// M5: notification banners (above the composer)
|
||||
if (banners.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
banners.forEach { b ->
|
||||
NotificationBanner(
|
||||
banner = b,
|
||||
onOpen = {
|
||||
controller.openDeepLink(b.chatId, b.threadId)
|
||||
controller.dismissBanner(b.id)
|
||||
},
|
||||
onDismiss = { controller.dismissBanner(b.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composer
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -530,6 +552,38 @@ private fun StatusChip(state: GatewayClient.State) {
|
||||
}
|
||||
}
|
||||
|
||||
/** M5: in-app notification banner (docs/08 §8.3). Tapping opens the chat. */
|
||||
@Composable
|
||||
private fun NotificationBanner(
|
||||
banner: IrisController.Banner,
|
||||
onOpen: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val accent = when (banner.kind) {
|
||||
"approval" -> Color(0xFFFFC107)
|
||||
"clarify" -> Color(0xFF4F7CFF)
|
||||
"cron" -> Color(0xFF4CAF50)
|
||||
else -> Color(0xFF8A93A6)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(accent.copy(alpha = 0.12f))
|
||||
.clickable(onClick = onOpen)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(banner.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = accent)
|
||||
if (banner.body.isNotBlank()) {
|
||||
Text(banner.body, fontSize = 12.sp, color = Color(0xFFD7DBE5), maxLines = 2)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onDismiss) { Text("✕", fontSize = 12.sp) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBubble(msg: MessageItem) {
|
||||
val isUser = msg.role == ROLE_USER
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package iris.platform
|
||||
|
||||
actual fun isAppForeground(): Boolean = true
|
||||
|
||||
actual fun postSystemNotification(
|
||||
chatId: String?,
|
||||
chatName: String?,
|
||||
title: String,
|
||||
body: String,
|
||||
threadId: String?,
|
||||
) {
|
||||
// Desktop: in-app banner only (no system notification in M5).
|
||||
}
|
||||
|
||||
actual fun setActiveController(controller: Any?) {
|
||||
// Desktop: no push services to bridge to.
|
||||
}
|
||||
@@ -16,6 +16,10 @@ private data class PairingData(
|
||||
val serverUrl: String = "",
|
||||
val token: String = "",
|
||||
val deviceId: String = "",
|
||||
val syncCursor: Long = 0L,
|
||||
val fcmToken: String = "",
|
||||
val ntfyTopic: String = "",
|
||||
val ntfyServer: String = "",
|
||||
)
|
||||
|
||||
class DesktopSecureStore : SecureStore {
|
||||
@@ -63,6 +67,34 @@ class DesktopSecureStore : SecureStore {
|
||||
override val deviceName: String
|
||||
get() = "Desktop (${System.getProperty("os.name")})"
|
||||
|
||||
override var syncCursor: Long
|
||||
get() = load().syncCursor
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(syncCursor = value))
|
||||
}
|
||||
|
||||
override var fcmToken: String
|
||||
get() = load().fcmToken
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(fcmToken = value))
|
||||
}
|
||||
|
||||
override var ntfyTopic: String
|
||||
get() = load().ntfyTopic
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(ntfyTopic = value))
|
||||
}
|
||||
|
||||
override var ntfyServer: String
|
||||
get() = load().ntfyServer
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(ntfyServer = value))
|
||||
}
|
||||
|
||||
override fun savePairing(url: String, token: String) {
|
||||
val d = load()
|
||||
save(d.copy(serverUrl = url.trim(), token = token.trim()))
|
||||
|
||||
Reference in new issue
Block a user