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
+1738
-50
No files matched your search
@@ -40,3 +40,10 @@ dependencies {
|
||||
implementation("androidx.activity:activity-compose:1.9.2")
|
||||
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.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
|
||||
android:label="Iris"
|
||||
@@ -18,6 +23,19 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
|
||||
@@ -30,6 +48,22 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/iris_file_paths" />
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
@@ -1,19 +1,85 @@
|
||||
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 androidx.activity.ComponentActivity
|
||||
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.platform.AndroidEnv
|
||||
import iris.platform.AndroidSecureStore
|
||||
import iris.platform.AppBridge
|
||||
import iris.platform.NtfyListenerService
|
||||
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
AndroidEnv.context = applicationContext
|
||||
AppBridge.controller = null
|
||||
val store = AndroidSecureStore(applicationContext)
|
||||
handleDeepLink(intent)
|
||||
requestNotificationPermission()
|
||||
startNtfyListener(store)
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
kotlin("multiplatform") version "2.1.0" apply false
|
||||
kotlin("android") version "2.1.0" apply false
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -479,3 +525,14 @@ fun mediaPullFrame(id: Int, mediaId: String): Frame =
|
||||
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()))
|
||||
|
||||
+23
-12
@@ -118,18 +118,29 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
|
||||
## M5 — Push + offline (FCM + ntfy)
|
||||
**Goal:** reach the phone when backgrounded; catch up on reconnect.
|
||||
- [ ] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune.
|
||||
- [ ] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx) +
|
||||
`NtfyBackend`; selected by `ANDROID_PUSH_BACKEND`.
|
||||
- [ ] Fire push on no-live-subscriber; data payload for silent sync.
|
||||
- [ ] App: FCM service (foreground banner + background foreground-service sync),
|
||||
`onNewToken` → `fcm.register`; per-chat notification channels; deep-link.
|
||||
ntfy listener fallback.
|
||||
- [ ] In-app `notification` banners (channel_renamed, approval, cron, …).
|
||||
- **Demo (on-device):** background the app → trigger a message → notification
|
||||
appears → tap → syncs + opens the chat. Repeat with ntfy backend.
|
||||
- **Accept:** push arrives when backgrounded (both backends); reconnect syncs
|
||||
with no loss/dup; banners show for foreground events.
|
||||
- [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune
|
||||
(row cap 5000 + prune banner, throttled 1/h).
|
||||
- [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via
|
||||
PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by
|
||||
`ANDROID_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`.
|
||||
- [x] Fire push on no-live-subscriber; data payload for silent sync.
|
||||
High-priority kinds (approval/clarify/cron) push even when live.
|
||||
- [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a
|
||||
Firebase project), per-chat notification channels, deep-link
|
||||
(custom action + `iris://`), ntfy listener foreground-service fallback.
|
||||
App generates a device-specific ntfy topic; auto-sync on reconnect.
|
||||
- [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
|
||||
**Goal:** the same app on a big screen.
|
||||
|
||||
+418
-10
@@ -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
|
||||
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::
|
||||
|
||||
gateway:
|
||||
@@ -106,6 +114,7 @@ from . import protocol # noqa: E402
|
||||
from . import search as search_bridge # noqa: E402
|
||||
from .channels import get_directory # noqa: E402
|
||||
from .outbox import Outbox # noqa: E402
|
||||
from .push import NtfyBackend, PushBackend, build_push_backend # noqa: E402
|
||||
from .pairing import ( # noqa: E402
|
||||
DeviceRegistry,
|
||||
generate_token,
|
||||
@@ -280,6 +289,42 @@ def _strip_streaming_cursor(text: str) -> str:
|
||||
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]:
|
||||
"""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).
|
||||
self._media = media_bridge.MediaStore(get_hermes_home())
|
||||
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:
|
||||
st = self._turns.get(chat_id)
|
||||
@@ -747,6 +803,16 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
except Exception:
|
||||
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._mark_connected()
|
||||
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)
|
||||
|
||||
# 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:
|
||||
reasoning, body = _split_reasoning(content)
|
||||
# Non-streaming: reasoning is prepended to content (split above).
|
||||
@@ -863,11 +967,11 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
state.active = False
|
||||
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):
|
||||
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()
|
||||
state.active = True
|
||||
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:
|
||||
delivered = await self._ws_server.broadcast(frame)
|
||||
# M3/M5: always append to the outbox so a reconnecting app can catch
|
||||
# up on *all* recent frames, not just the ones that were parked. This
|
||||
# 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:
|
||||
cursor = self._outbox.append(chat_id, frame.to_json())
|
||||
except Exception:
|
||||
logger.warning("android: outbox append failed", exc_info=True)
|
||||
return
|
||||
if delivered == 0:
|
||||
# M3: no live device -- park the frame in the outbox so a
|
||||
# reconnecting app can `sync` it (M5 adds push to wake the device).
|
||||
logger.info(
|
||||
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
||||
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:
|
||||
cursor = self._outbox.append(chat_id, frame.to_json())
|
||||
logger.info(
|
||||
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
||||
chat_id, frame.type, cursor,
|
||||
ok = await backend.send(
|
||||
device_id=device_id,
|
||||
chat_id=chat_id,
|
||||
title=title,
|
||||
body=body,
|
||||
data=data,
|
||||
token=token,
|
||||
priority=priority,
|
||||
)
|
||||
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:
|
||||
"""Send a typing indicator (``typing`` frame, on=true)."""
|
||||
@@ -1464,6 +1702,16 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
resp = protocol.channel_created(entry)
|
||||
resp.id = frame.id
|
||||
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:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
@@ -1496,6 +1744,16 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
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:
|
||||
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.id = frame.id
|
||||
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:
|
||||
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)
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
def _channel_name(self, chat_id: str) -> str:
|
||||
@@ -1630,6 +2032,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
"media": True, # M4: media.upload/offer/pull
|
||||
"search": True, # M3: search frame
|
||||
"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+
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_RETENTION_HOURS = 72
|
||||
_REPLAY_LIMIT = 1000
|
||||
_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:
|
||||
@@ -38,12 +42,19 @@ class Outbox:
|
||||
``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.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._retention_hours = max(1, int(retention_hours))
|
||||
self._max_rows = max(1, int(max_rows))
|
||||
self._lock = threading.Lock()
|
||||
self._last_prune = 0.0
|
||||
self._overflow_pruned = 0
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
with self._lock:
|
||||
@@ -90,10 +101,37 @@ class Outbox:
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(cursor, chat_id, frame_json, now),
|
||||
)
|
||||
self._enforce_row_cap()
|
||||
self._conn.commit()
|
||||
self._maybe_prune()
|
||||
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:
|
||||
"""The high-water cursor (0 when nothing has been appended)."""
|
||||
with self._lock:
|
||||
|
||||
@@ -57,6 +57,10 @@ optional_env:
|
||||
description: "ntfy server URL (default https://ntfy.sh)"
|
||||
prompt: "ntfy server URL"
|
||||
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
|
||||
description: "TLS cert path for WSS (optional)"
|
||||
prompt: "WSS cert"
|
||||
|
||||
@@ -76,6 +76,10 @@ TYPE_MEDIA_OFFER = "media.offer"
|
||||
TYPE_MEDIA_PULL = "media.pull"
|
||||
TYPE_MEDIA_PULL_END = "media.pull.end"
|
||||
|
||||
# Push / notifications (M5)
|
||||
TYPE_NOTIFICATION = "notification"
|
||||
TYPE_FCM_REGISTER = "fcm.register"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error codes (``error`` frame payload.code)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -96,6 +100,22 @@ ROLE_ASSISTANT = "assistant"
|
||||
ROLE_SYSTEM = "system"
|
||||
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
|
||||
@@ -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})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+321
-5
@@ -4,12 +4,328 @@
|
||||
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
|
||||
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key
|
||||
(``ANDROID_FCM_SERVER_KEY``).
|
||||
- ``NtfyBackend``: reuses hermes ntfy publish (``NTFY_TOPIC`` /
|
||||
``NTFY_SERVER_URL``).
|
||||
- ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``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).
|
||||
Fired when a frame has no live subscriber; data payload drives a silent sync
|
||||
on the device.
|
||||
Fired when a frame has no live subscriber; the data payload drives a silent
|
||||
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)
|
||||
@@ -18,8 +18,12 @@ Options:
|
||||
--send TEXT send this message after pairing (default: "hello")
|
||||
--upload F M4: upload F (chunked media.upload) and attach it to the
|
||||
message.send via media_refs
|
||||
--pull-offer M4: when a media.offer arrives during the turn, pull the
|
||||
media (chunked) and verify the byte count
|
||||
--pull-offer M4: when a media.offer arrives during the turn, pull the
|
||||
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)
|
||||
--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')}"
|
||||
elif ftype == "media.pull.end":
|
||||
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 ""
|
||||
idpart = f" id={fid}" if fid is not None else ""
|
||||
print(f" <- {ftype}{idpart}{scope}{extra}")
|
||||
@@ -200,8 +211,10 @@ async def run(args) -> int:
|
||||
"caps": {"min_protocol": 1},
|
||||
},
|
||||
}
|
||||
if args.fcm_token:
|
||||
hello["payload"]["fcm_token"] = args.fcm_token
|
||||
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).
|
||||
try:
|
||||
@@ -224,6 +237,35 @@ async def run(args) -> int:
|
||||
await ws.close()
|
||||
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:
|
||||
print("== paired OK (no --send/--upload; exiting)")
|
||||
await ws.close()
|
||||
@@ -311,6 +353,12 @@ def main() -> int:
|
||||
help="M4: file to upload (chunked) and attach via media_refs")
|
||||
p.add_argument("--pull-offer", action="store_true",
|
||||
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("--authfail", action="store_true",
|
||||
help="expect an auth rejection (wrong token)")
|
||||
|
||||
@@ -316,18 +316,8 @@ class WsServer:
|
||||
await self._adapter.on_media_upload_end(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_MEDIA_PULL:
|
||||
await self._adapter.on_media_pull(frame, device_id)
|
||||
elif frame.type == "fcm.register":
|
||||
fcm_token = frame.payload.get("fcm_token")
|
||||
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)
|
||||
elif frame.type == protocol.TYPE_FCM_REGISTER:
|
||||
await self._adapter.on_fcm_register(frame, device_id)
|
||||
# Unknown types are ignored (forward-compat).
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user