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
@@ -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
|
||||
Reference in new issue
Block a user