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.activity:activity-compose:1.9.2")
|
||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M5: apply the google-services plugin only when a Firebase project is
|
||||||
|
// configured (google-services.json present). Without it the FCM service is
|
||||||
|
// inert and the ntfy listener is the push path.
|
||||||
|
if (file("google-services.json").exists()) {
|
||||||
|
apply(plugin = "com.google.gms.google-services")
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<!-- M5: post push notifications (runtime permission on API 33+). -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="Iris"
|
android:label="Iris"
|
||||||
@@ -18,6 +23,19 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- M5: deep link from a push notification tap (custom action,
|
||||||
|
no compile-time dependency from the shared module). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="dev.iris.app.OPEN_CHAT" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- M5: iris:// deep link (external links / share targets). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="iris" android:host="chat" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
|
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
|
||||||
@@ -30,6 +48,22 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/iris_file_paths" />
|
android:resource="@xml/iris_file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<!-- M5: ntfy listener (foreground service; subscribes to the device
|
||||||
|
topic and posts push notifications). -->
|
||||||
|
<service
|
||||||
|
android:name="iris.platform.NtfyListenerService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<!-- M5: FCM handler (inert without a Firebase project). -->
|
||||||
|
<service
|
||||||
|
android:name="iris.platform.IrisFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,19 +1,85 @@
|
|||||||
package dev.iris.app
|
package dev.iris.app
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import iris.IrisApp
|
import iris.IrisApp
|
||||||
import iris.platform.AndroidEnv
|
import iris.platform.AndroidEnv
|
||||||
import iris.platform.AndroidSecureStore
|
import iris.platform.AndroidSecureStore
|
||||||
|
import iris.platform.AppBridge
|
||||||
|
import iris.platform.NtfyListenerService
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val deepLinkChatId = mutableStateOf<String?>(null)
|
||||||
|
private val deepLinkThreadId = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
private val notificationPermission =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
AndroidEnv.context = applicationContext
|
AndroidEnv.context = applicationContext
|
||||||
|
AppBridge.controller = null
|
||||||
val store = AndroidSecureStore(applicationContext)
|
val store = AndroidSecureStore(applicationContext)
|
||||||
|
handleDeepLink(intent)
|
||||||
|
requestNotificationPermission()
|
||||||
|
startNtfyListener(store)
|
||||||
setContent {
|
setContent {
|
||||||
IrisApp(store)
|
val chatId by deepLinkChatId
|
||||||
|
val threadId by deepLinkThreadId
|
||||||
|
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
handleDeepLink(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
AppBridge.foreground = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
AppBridge.foreground = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract chat_id / thread_id from a deep-link intent (custom action
|
||||||
|
* extras or an iris://chat/<id>?thread=<tid> URI). */
|
||||||
|
private fun handleDeepLink(intent: Intent?) {
|
||||||
|
val data = intent?.data
|
||||||
|
val chatId = intent?.getStringExtra("chat_id")
|
||||||
|
?: data?.pathSegments?.firstOrNull()
|
||||||
|
val threadId = intent?.getStringExtra("thread_id")
|
||||||
|
?: data?.getQueryParameter("thread")
|
||||||
|
if (!chatId.isNullOrBlank()) {
|
||||||
|
deepLinkChatId.value = chatId
|
||||||
|
deepLinkThreadId.value = threadId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startNtfyListener(store: AndroidSecureStore) {
|
||||||
|
if (store.ntfyTopic.isBlank()) return
|
||||||
|
ContextCompat.startForegroundService(this, Intent(this, NtfyListenerService::class.java))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,16 @@
|
|||||||
|
// M5: google-services plugin classpath (applied conditionally in :androidApp
|
||||||
|
// only when a google-services.json is present; the FCM path is inert without
|
||||||
|
// a Firebase project and the ntfy listener is the fallback).
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath("com.google.gms:google-services:4.4.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
kotlin("multiplatform") version "2.1.0" apply false
|
kotlin("multiplatform") version "2.1.0" apply false
|
||||||
kotlin("android") version "2.1.0" apply false
|
kotlin("android") version "2.1.0" apply false
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ kotlin {
|
|||||||
implementation("androidx.media3:media3-ui:1.3.1")
|
implementation("androidx.media3:media3-ui:1.3.1")
|
||||||
// SAF picker (rememberLauncherForActivityResult).
|
// SAF picker (rememberLauncherForActivityResult).
|
||||||
implementation("androidx.activity:activity-compose:1.9.2")
|
implementation("androidx.activity:activity-compose:1.9.2")
|
||||||
|
// M5: FCM push (inert without a Firebase project / google-services.json;
|
||||||
|
// the ntfy listener is the fallback). The google-services plugin is
|
||||||
|
// applied conditionally in the app module.
|
||||||
|
implementation("com.google.firebase:firebase-messaging:23.1.2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import iris.state.IrisController
|
||||||
|
|
||||||
|
actual fun isAppForeground(): Boolean = AppBridge.foreground
|
||||||
|
|
||||||
|
actual fun setActiveController(controller: Any?) {
|
||||||
|
AppBridge.controller = controller as? IrisController
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun postSystemNotification(
|
||||||
|
chatId: String?,
|
||||||
|
chatName: String?,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
threadId: String?,
|
||||||
|
) {
|
||||||
|
val context = AndroidEnv.context
|
||||||
|
val id = chatId ?: "android:default"
|
||||||
|
// POST_NOTIFICATIONS is a runtime permission on API 33+.
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
IrisNotifications.post(context, id, chatName, title, body, threadId)
|
||||||
|
}
|
||||||
@@ -33,6 +33,22 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
|||||||
override val deviceName: String
|
override val deviceName: String
|
||||||
get() = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
|
get() = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
|
||||||
|
|
||||||
|
override var syncCursor: Long
|
||||||
|
get() = prefs.getLong(KEY_SYNC_CURSOR, 0L)
|
||||||
|
set(value) = prefs.edit().putLong(KEY_SYNC_CURSOR, value).apply()
|
||||||
|
|
||||||
|
override var fcmToken: String
|
||||||
|
get() = prefs.getString(KEY_FCM_TOKEN, "").orEmpty()
|
||||||
|
set(value) = prefs.edit().putString(KEY_FCM_TOKEN, value).apply()
|
||||||
|
|
||||||
|
override var ntfyTopic: String
|
||||||
|
get() = prefs.getString(KEY_NTFY_TOPIC, "").orEmpty()
|
||||||
|
set(value) = prefs.edit().putString(KEY_NTFY_TOPIC, value).apply()
|
||||||
|
|
||||||
|
override var ntfyServer: String
|
||||||
|
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
|
||||||
|
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
|
||||||
|
|
||||||
override fun savePairing(url: String, token: String) {
|
override fun savePairing(url: String, token: String) {
|
||||||
serverUrl = url
|
serverUrl = url
|
||||||
this.token = token
|
this.token = token
|
||||||
@@ -46,5 +62,9 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
|||||||
const val KEY_URL = "server_url"
|
const val KEY_URL = "server_url"
|
||||||
const val KEY_TOKEN = "token"
|
const val KEY_TOKEN = "token"
|
||||||
const val KEY_DEVICE_ID = "device_id"
|
const val KEY_DEVICE_ID = "device_id"
|
||||||
|
const val KEY_SYNC_CURSOR = "sync_cursor"
|
||||||
|
const val KEY_FCM_TOKEN = "fcm_token"
|
||||||
|
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||||
|
const val KEY_NTFY_SERVER = "ntfy_server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
import iris.state.IrisController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-wide reference to the active [IrisController] (M5).
|
||||||
|
*
|
||||||
|
* The FCM service and the ntfy listener service are separate Android
|
||||||
|
* components that need to reach the controller (to trigger a sync or open a
|
||||||
|
* chat) without a compile-time dependency on the app module. Set by
|
||||||
|
* [iris.IrisApp] on composition, cleared on dispose.
|
||||||
|
*/
|
||||||
|
object AppBridge {
|
||||||
|
@Volatile
|
||||||
|
var controller: IrisController? = null
|
||||||
|
|
||||||
|
/** True while the launcher activity is resumed (set by MainActivity). */
|
||||||
|
@Volatile
|
||||||
|
var foreground: Boolean = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5: FCM handler (docs/08 §8.1).
|
||||||
|
*
|
||||||
|
* - [onNewToken]: persist the rotated token and push it to the server via
|
||||||
|
* `fcm.register` (so the next push targets the current token).
|
||||||
|
* - [onMessageReceived]: the data payload drives a silent sync. When the app
|
||||||
|
* is foregrounded the WS path already delivered the frame (in-app banner),
|
||||||
|
* so we only post a system notification when backgrounded.
|
||||||
|
*
|
||||||
|
* Inert without a Firebase project (no google-services.json): the service is
|
||||||
|
* declared in the manifest but never receives messages, and the app falls
|
||||||
|
* back to the ntfy listener.
|
||||||
|
*/
|
||||||
|
class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
val store = AndroidSecureStore(applicationContext)
|
||||||
|
store.fcmToken = token
|
||||||
|
// Push the rotation to the server if we're connected.
|
||||||
|
AppBridge.controller?.client?.sendFrame(
|
||||||
|
iris.protocol.fcmRegisterFrame(fcmToken = token),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
val data = message.data
|
||||||
|
val chatId = data["chat_id"] ?: "android:default"
|
||||||
|
val threadId = data["thread_id"]
|
||||||
|
val title = data["title"] ?: "Iris"
|
||||||
|
val body = data["body"] ?: data["title"].orEmpty()
|
||||||
|
// Foreground + live WS: the in-app banner already showed this.
|
||||||
|
if (AppBridge.foreground) return
|
||||||
|
if (ContextCompat.checkSelfPermission(applicationContext, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
IrisNotifications.post(applicationContext, chatId, null, title, body, threadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5: per-chat notification channels + posting (docs/08 §8.2).
|
||||||
|
*
|
||||||
|
* One channel per chat_id so the user can mute individual chats. The content
|
||||||
|
* intent uses a custom action (no compile-time dependency on the app module's
|
||||||
|
* MainActivity); the app's manifest routes it back to the launcher activity
|
||||||
|
* with chat_id / thread_id extras for the deep link.
|
||||||
|
*/
|
||||||
|
object IrisNotifications {
|
||||||
|
const val CHANNEL_PREFIX = "iris_chat_"
|
||||||
|
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
|
||||||
|
private const val NOTIF_ID_BASE = 1_000_000
|
||||||
|
|
||||||
|
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val id = CHANNEL_PREFIX + chatId
|
||||||
|
val name = chatName ?: chatId
|
||||||
|
if (nm.getNotificationChannel(id) == null) {
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
|
||||||
|
.apply { description = "Iris messages for $name" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun post(
|
||||||
|
context: Context,
|
||||||
|
chatId: String,
|
||||||
|
chatName: String?,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
threadId: String?,
|
||||||
|
) {
|
||||||
|
ensureChannel(context, chatId, chatName)
|
||||||
|
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff)
|
||||||
|
val intent = Intent(ACTION_OPEN_CHAT).apply {
|
||||||
|
setPackage(context.packageName)
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
putExtra("chat_id", chatId)
|
||||||
|
if (threadId != null) putExtra("thread_id", threadId)
|
||||||
|
}
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.notify(
|
||||||
|
id,
|
||||||
|
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import iris.protocol.IrisJson
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5: ntfy listener fallback (docs/08 §8.4).
|
||||||
|
*
|
||||||
|
* A foreground service that subscribes to this device's ntfy topic and posts
|
||||||
|
* a system notification for each push. The structured payload rides in the
|
||||||
|
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body
|
||||||
|
* is the short preview. When the app is foregrounded the WS path already
|
||||||
|
* delivered the frame, so the service skips posting to avoid a duplicate.
|
||||||
|
*/
|
||||||
|
class NtfyListenerService : Service() {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var streamJob: Job? = null
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
startForeground(NOTIF_ID, foregroundNotification())
|
||||||
|
streamJob?.cancel()
|
||||||
|
streamJob = scope.launch { stream() }
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
streamJob?.cancel()
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun stream() {
|
||||||
|
val store = AndroidSecureStore(AndroidEnv.context)
|
||||||
|
val topic = store.ntfyTopic
|
||||||
|
if (topic.isBlank()) return
|
||||||
|
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
|
||||||
|
val url = "$server/$topic"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client.newCall(request).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) return
|
||||||
|
val body = resp.body ?: return
|
||||||
|
val source = body.source()
|
||||||
|
var data: String? = null
|
||||||
|
var title: String? = null
|
||||||
|
var msgBody: String? = null
|
||||||
|
while (!source.exhausted()) {
|
||||||
|
val line = source.readUtf8Line() ?: break
|
||||||
|
when {
|
||||||
|
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
|
||||||
|
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
|
||||||
|
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
|
||||||
|
line.isEmpty() -> {
|
||||||
|
// Event boundary: process the accumulated message.
|
||||||
|
data?.let { handleData(it, title, msgBody) }
|
||||||
|
data = null
|
||||||
|
title = null
|
||||||
|
msgBody = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Stream dropped; the service is START_STICKY so the system
|
||||||
|
// restarts it. If it keeps failing, the WS path still works.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleData(dataJson: String, title: String?, msgBody: String?) {
|
||||||
|
val data = try {
|
||||||
|
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val chatId = data?.str("chat_id") ?: "android:default"
|
||||||
|
val threadId = data?.str("thread_id")
|
||||||
|
// The short preview rides in the SSE `data:` field; fall back to the
|
||||||
|
// X-Title, then a generic label.
|
||||||
|
val body = msgBody?.takeIf { it.isNotBlank() } ?: title.orEmpty()
|
||||||
|
val notifTitle = title ?: "Iris"
|
||||||
|
// Foreground + live WS: the in-app banner already showed this.
|
||||||
|
if (AppBridge.foreground) return
|
||||||
|
if (ContextCompat.checkSelfPermission(AndroidEnv.context, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
IrisNotifications.post(AndroidEnv.context, chatId, null, notifTitle, body, threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun foregroundNotification(): Notification {
|
||||||
|
val context = AndroidEnv.context
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java)
|
||||||
|
if (nm.getNotificationChannel(LISTENER_CHANNEL) == null) {
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
LISTENER_CHANNEL,
|
||||||
|
"Iris push listener",
|
||||||
|
NotificationManager.IMPORTANCE_MIN,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NotificationCompat.Builder(context, LISTENER_CHANNEL)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle("Iris")
|
||||||
|
.setContentText("Listening for messages")
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LISTENER_CHANNEL = "iris_ntfy_listener"
|
||||||
|
const val NOTIF_ID = 999_001
|
||||||
|
const val DEFAULT_NTFY_SERVER = "https://ntfy.sh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a string field from a JSON object (null when absent / not a string). */
|
||||||
|
private fun JsonObject?.str(key: String): String? =
|
||||||
|
(this?.get(key) as? JsonPrimitive)?.content
|
||||||
@@ -6,6 +6,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -13,6 +14,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import iris.data.SecureStore
|
import iris.data.SecureStore
|
||||||
import iris.net.GatewayClient
|
import iris.net.GatewayClient
|
||||||
|
import iris.platform.setActiveController
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
import iris.ui.screens.ChatScreen
|
import iris.ui.screens.ChatScreen
|
||||||
import iris.ui.screens.ConnectScreen
|
import iris.ui.screens.ConnectScreen
|
||||||
@@ -33,10 +35,25 @@ private val IrisDark = darkColorScheme(
|
|||||||
* settings, and media (docs/10-android-app.md).
|
* settings, and media (docs/10-android-app.md).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun IrisApp(store: SecureStore) {
|
fun IrisApp(
|
||||||
|
store: SecureStore,
|
||||||
|
deepLinkChatId: String? = null,
|
||||||
|
deepLinkThreadId: String? = null,
|
||||||
|
) {
|
||||||
val controller = remember(store) { IrisController(store) }
|
val controller = remember(store) { IrisController(store) }
|
||||||
DisposableEffect(controller) {
|
DisposableEffect(controller) {
|
||||||
onDispose { controller.dispose() }
|
setActiveController(controller)
|
||||||
|
onDispose {
|
||||||
|
setActiveController(null)
|
||||||
|
controller.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// M5: open the chat referenced by a push notification tap (applied now or
|
||||||
|
// on the next hello.ack, whichever is later).
|
||||||
|
LaunchedEffect(deepLinkChatId, deepLinkThreadId) {
|
||||||
|
if (!deepLinkChatId.isNullOrBlank()) {
|
||||||
|
controller.openDeepLink(deepLinkChatId, deepLinkThreadId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val state by controller.client.state.collectAsState()
|
val state by controller.client.state.collectAsState()
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ interface SecureStore {
|
|||||||
/** Human-readable device name (e.g. "MIX 2S"). */
|
/** Human-readable device name (e.g. "MIX 2S"). */
|
||||||
val deviceName: String
|
val deviceName: String
|
||||||
|
|
||||||
|
/** Last sync cursor confirmed by the server (M5 reconnect catch-up). */
|
||||||
|
var syncCursor: Long
|
||||||
|
|
||||||
|
/** FCM registration token (M5; empty when Firebase is unavailable). */
|
||||||
|
var fcmToken: String
|
||||||
|
|
||||||
|
/** ntfy topic this device subscribes to (M5; empty when unused). */
|
||||||
|
var ntfyTopic: String
|
||||||
|
|
||||||
|
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
|
||||||
|
var ntfyServer: String
|
||||||
|
|
||||||
fun savePairing(url: String, token: String)
|
fun savePairing(url: String, token: String)
|
||||||
fun clear()
|
fun clear()
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ import iris.protocol.mediaUploadEndFrame
|
|||||||
import iris.protocol.mediaUploadStartFrame
|
import iris.protocol.mediaUploadStartFrame
|
||||||
import iris.protocol.messageSendFrame
|
import iris.protocol.messageSendFrame
|
||||||
import iris.protocol.pingFrame
|
import iris.protocol.pingFrame
|
||||||
|
import iris.protocol.syncFrame
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -187,7 +188,15 @@ class GatewayClient(
|
|||||||
request,
|
request,
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
webSocket.send(helloFrame(token, store.deviceId, store.deviceName).toWire())
|
webSocket.send(
|
||||||
|
helloFrame(
|
||||||
|
token = token,
|
||||||
|
deviceId = store.deviceId,
|
||||||
|
deviceName = store.deviceName,
|
||||||
|
fcmToken = store.fcmToken.ifBlank { null },
|
||||||
|
ntfyTopic = store.ntfyTopic.ifBlank { null },
|
||||||
|
).toWire(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
@@ -250,6 +259,12 @@ class GatewayClient(
|
|||||||
if (e == null) {
|
if (e == null) {
|
||||||
val ack = helloAck.getCompleted()
|
val ack = helloAck.getCompleted()
|
||||||
_state.value = State.Connected(ack.serverCaps, ack.channels)
|
_state.value = State.Connected(ack.serverCaps, ack.channels)
|
||||||
|
// M5: reconnect catch-up — replay frames parked while offline.
|
||||||
|
val local = store.syncCursor
|
||||||
|
if (local < ack.syncCursor) {
|
||||||
|
val id = nextRequestId++
|
||||||
|
ws.send(syncFrame(id, local).toWire())
|
||||||
|
}
|
||||||
winner.complete(DialResult.Connected)
|
winner.complete(DialResult.Connected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5: push / notification platform hooks (docs/08 §8.2).
|
||||||
|
*
|
||||||
|
* The controller (commonMain) needs to know whether the app is in the
|
||||||
|
* foreground (to decide between an in-app banner and a system notification)
|
||||||
|
* and to post a system notification when a `notification` frame arrives while
|
||||||
|
* the app is backgrounded but the WS is still live.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** True when the app's UI is visible (Android: activity resumed). */
|
||||||
|
expect fun isAppForeground(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a system notification for a chat (Android). No-op on desktop.
|
||||||
|
* [chatName] is used for the per-chat notification channel (best effort).
|
||||||
|
*/
|
||||||
|
expect fun postSystemNotification(
|
||||||
|
chatId: String?,
|
||||||
|
chatName: String?,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
threadId: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the active controller with the platform bridge (Android: so the
|
||||||
|
* FCM / ntfy services can reach it). No-op on desktop.
|
||||||
|
*/
|
||||||
|
expect fun setActiveController(controller: Any?)
|
||||||
@@ -54,6 +54,10 @@ const val TYPE_MEDIA_OFFER = "media.offer"
|
|||||||
const val TYPE_MEDIA_PULL = "media.pull"
|
const val TYPE_MEDIA_PULL = "media.pull"
|
||||||
const val TYPE_MEDIA_PULL_END = "media.pull.end"
|
const val TYPE_MEDIA_PULL_END = "media.pull.end"
|
||||||
|
|
||||||
|
// M5 — push / notifications
|
||||||
|
const val TYPE_NOTIFICATION = "notification"
|
||||||
|
const val TYPE_FCM_REGISTER = "fcm.register"
|
||||||
|
|
||||||
// M3 — channels / threads / search / sync
|
// M3 — channels / threads / search / sync
|
||||||
const val TYPE_CHANNEL_CREATE = "channel.create"
|
const val TYPE_CHANNEL_CREATE = "channel.create"
|
||||||
const val TYPE_CHANNEL_RENAME = "channel.rename"
|
const val TYPE_CHANNEL_RENAME = "channel.rename"
|
||||||
@@ -131,6 +135,7 @@ data class ServerCaps(
|
|||||||
val media: Boolean = false,
|
val media: Boolean = false,
|
||||||
val search: Boolean = false,
|
val search: Boolean = false,
|
||||||
val push: String = "fcm",
|
val push: String = "fcm",
|
||||||
|
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
|
||||||
val pickers: Boolean = false,
|
val pickers: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -349,14 +354,55 @@ data class SyncPayload(val cursor: Long)
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class SyncDonePayload(val cursor: Long)
|
data class SyncDonePayload(val cursor: Long)
|
||||||
|
|
||||||
|
// ── M5: push / notifications ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Notification kinds (mirror of protocol.NOTIF_*). */
|
||||||
|
const val NOTIF_MESSAGE = "message"
|
||||||
|
const val NOTIF_APPROVAL = "approval"
|
||||||
|
const val NOTIF_CLARIFY = "clarify"
|
||||||
|
const val NOTIF_CRON = "cron"
|
||||||
|
const val NOTIF_CHANNEL = "channel"
|
||||||
|
const val NOTIF_OUTBOX_PRUNED = "outbox_pruned"
|
||||||
|
|
||||||
|
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */
|
||||||
|
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationPayload(
|
||||||
|
val kind: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
@SerialName("chat_id") val chatId: String? = null,
|
||||||
|
@SerialName("thread_id") val threadId: String? = null,
|
||||||
|
val ts: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class FcmRegisterPayload(
|
||||||
|
@SerialName("fcm_token") val fcmToken: String? = null,
|
||||||
|
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
// ── Frame builders ──────────────────────────────────────────────────────
|
// ── Frame builders ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun helloFrame(token: String, deviceId: String, deviceName: String): Frame =
|
fun helloFrame(
|
||||||
|
token: String,
|
||||||
|
deviceId: String,
|
||||||
|
deviceName: String,
|
||||||
|
fcmToken: String? = null,
|
||||||
|
ntfyTopic: String? = null,
|
||||||
|
): Frame =
|
||||||
Frame(
|
Frame(
|
||||||
type = TYPE_HELLO,
|
type = TYPE_HELLO,
|
||||||
payload = IrisJson.instance.encodeToJsonElement(
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
HelloPayload.serializer(),
|
HelloPayload.serializer(),
|
||||||
HelloPayload(token = token, deviceId = deviceId, deviceName = deviceName),
|
HelloPayload(
|
||||||
|
token = token,
|
||||||
|
deviceId = deviceId,
|
||||||
|
deviceName = deviceName,
|
||||||
|
fcmToken = fcmToken,
|
||||||
|
ntfyTopic = ntfyTopic,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -479,3 +525,14 @@ fun mediaPullFrame(id: Int, mediaId: String): Frame =
|
|||||||
MediaPullPayload(mediaId),
|
MediaPullPayload(mediaId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── M5 frame builders ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame =
|
||||||
|
Frame(
|
||||||
|
type = TYPE_FCM_REGISTER,
|
||||||
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
|
FcmRegisterPayload.serializer(),
|
||||||
|
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -8,11 +8,19 @@ import iris.media.MediaCache
|
|||||||
import iris.media.kindFromMime
|
import iris.media.kindFromMime
|
||||||
import iris.net.GatewayClient
|
import iris.net.GatewayClient
|
||||||
import iris.platform.PickedFile
|
import iris.platform.PickedFile
|
||||||
|
import iris.platform.isAppForeground
|
||||||
import iris.platform.mediaCacheBaseDir
|
import iris.platform.mediaCacheBaseDir
|
||||||
|
import iris.platform.postSystemNotification
|
||||||
|
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
|
||||||
import iris.protocol.MediaOfferPayload
|
import iris.protocol.MediaOfferPayload
|
||||||
|
import iris.protocol.MessagePayload
|
||||||
|
import iris.protocol.MessageStopPayload
|
||||||
|
import iris.protocol.NotificationPayload
|
||||||
|
import iris.protocol.ROLE_ASSISTANT
|
||||||
import iris.protocol.SearchHit
|
import iris.protocol.SearchHit
|
||||||
import iris.protocol.SearchResultsPayload
|
import iris.protocol.SearchResultsPayload
|
||||||
import iris.protocol.SyncDonePayload
|
import iris.protocol.SyncDonePayload
|
||||||
|
import iris.protocol.TYPE_NOTIFICATION
|
||||||
import iris.protocol.TYPE_CHANNEL_CREATED
|
import iris.protocol.TYPE_CHANNEL_CREATED
|
||||||
import iris.protocol.TYPE_CHANNEL_DELETED
|
import iris.protocol.TYPE_CHANNEL_DELETED
|
||||||
import iris.protocol.TYPE_CHANNEL_LIST
|
import iris.protocol.TYPE_CHANNEL_LIST
|
||||||
@@ -40,6 +48,7 @@ import iris.protocol.syncFrame
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -114,6 +123,76 @@ class IrisController(
|
|||||||
private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
|
private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
|
||||||
val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
|
val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
|
||||||
|
|
||||||
|
// ── M5: notification banners (docs/08 §8.3) ───────────────────────────
|
||||||
|
/** An in-app notification banner shown above the composer. */
|
||||||
|
data class Banner(
|
||||||
|
val id: Long,
|
||||||
|
val kind: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val chatId: String?,
|
||||||
|
val threadId: String?,
|
||||||
|
val persistent: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _banners = MutableStateFlow<List<Banner>>(emptyList())
|
||||||
|
val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
|
||||||
|
private var bannerSeq = 0L
|
||||||
|
|
||||||
|
fun dismissBanner(id: Long) {
|
||||||
|
_banners.value = _banners.value.filterNot { it.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pushBanner(kind: String, title: String, body: String, chatId: String?, threadId: String?) {
|
||||||
|
val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS
|
||||||
|
val banner = Banner(bannerSeq++, kind, title, body, chatId, threadId, persistent)
|
||||||
|
_banners.value = (_banners.value + banner).takeLast(5)
|
||||||
|
if (!persistent) {
|
||||||
|
scope.launch {
|
||||||
|
delay(5_000)
|
||||||
|
_banners.value = _banners.value.filterNot { it.id == banner.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5: Telegram/WhatsApp-style message notification. When the app is
|
||||||
|
* backgrounded and a finalized assistant reply arrives, post a system
|
||||||
|
* notification (per-chat channel) with a short preview. Tapping it opens
|
||||||
|
* the chat (deep link). The same per-chat notification id is reused, so a
|
||||||
|
* multi-segment turn updates one notification instead of stacking.
|
||||||
|
*/
|
||||||
|
private fun notifyMessageIfBackgrounded(chatId: String?, threadId: String?, text: String) {
|
||||||
|
if (isAppForeground()) return
|
||||||
|
if (text.isBlank()) return
|
||||||
|
val id = chatId ?: "android:default"
|
||||||
|
val chatName = channels.byId(id)?.name
|
||||||
|
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-line preview for a notification body (lock-screen privacy). */
|
||||||
|
private fun preview(text: String, limit: Int = 120): String {
|
||||||
|
val flat = text.replace('\n', ' ').replace('\t', ' ').trim()
|
||||||
|
return if (flat.length <= limit) flat else flat.take(limit).trimEnd() + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── M5: deep link (push notification tap) ─────────────────────────────
|
||||||
|
private var pendingDeepLink: Pair<String, String?>? = null
|
||||||
|
|
||||||
|
/** Open the chat referenced by a push notification tap. Applied now if
|
||||||
|
* connected, otherwise on the next hello.ack (lane may not exist yet). */
|
||||||
|
fun openDeepLink(chatId: String?, threadId: String?) {
|
||||||
|
if (chatId.isNullOrBlank()) return
|
||||||
|
pendingDeepLink = chatId to threadId
|
||||||
|
if (client.state.value is GatewayClient.State.Connected) applyDeepLink()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDeepLink() {
|
||||||
|
val link = pendingDeepLink ?: return
|
||||||
|
pendingDeepLink = null
|
||||||
|
if (link.second.isNullOrBlank()) openChannel(link.first) else openThread(link.first, link.second!!)
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
client.events.collect { frame ->
|
client.events.collect { frame ->
|
||||||
@@ -132,6 +211,25 @@ class IrisController(
|
|||||||
if (frame.type == TYPE_MEDIA_OFFER) {
|
if (frame.type == TYPE_MEDIA_OFFER) {
|
||||||
frame.payloadAs<MediaOfferPayload>()?.let { pullMedia(it) }
|
frame.payloadAs<MediaOfferPayload>()?.let { pullMedia(it) }
|
||||||
}
|
}
|
||||||
|
// M5: Telegram/WhatsApp-style — when the app is
|
||||||
|
// backgrounded, a finalized assistant reply posts a
|
||||||
|
// system notification (the message still lands in the
|
||||||
|
// chat via onFrame above). Streaming turns finalize on
|
||||||
|
// message.stop; non-streaming replies are a single
|
||||||
|
// assistant `message`.
|
||||||
|
when (frame.type) {
|
||||||
|
TYPE_MESSAGE_STOP ->
|
||||||
|
frame.payloadAs<MessageStopPayload>()?.let {
|
||||||
|
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||||
|
}
|
||||||
|
TYPE_MESSAGE ->
|
||||||
|
frame.payloadAs<MessagePayload>()?.let {
|
||||||
|
if (it.role == ROLE_ASSISTANT) {
|
||||||
|
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TYPE_CHANNEL_CREATED,
|
TYPE_CHANNEL_CREATED,
|
||||||
TYPE_CHANNEL_RENAMED,
|
TYPE_CHANNEL_RENAMED,
|
||||||
@@ -144,7 +242,19 @@ class IrisController(
|
|||||||
TYPE_SYNC_DONE -> {
|
TYPE_SYNC_DONE -> {
|
||||||
// Replayed frames already flowed through [events]; the
|
// Replayed frames already flowed through [events]; the
|
||||||
// cursor is authoritative server-side (outbox).
|
// cursor is authoritative server-side (outbox).
|
||||||
frame.payloadAs<SyncDonePayload>()
|
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor }
|
||||||
|
}
|
||||||
|
TYPE_NOTIFICATION -> {
|
||||||
|
frame.payloadAs<NotificationPayload>()?.let { p ->
|
||||||
|
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
||||||
|
// M5: WS is live but the app is backgrounded — the
|
||||||
|
// in-app banner is invisible, so mirror to a system
|
||||||
|
// notification (the push backend only fires when
|
||||||
|
// there is no live subscriber).
|
||||||
|
if (!isAppForeground()) {
|
||||||
|
postSystemNotification(p.chatId, null, p.title, p.body, p.threadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TYPE_TYPING -> {
|
TYPE_TYPING -> {
|
||||||
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
|
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
|
||||||
@@ -162,9 +272,19 @@ class IrisController(
|
|||||||
_homeChannel.value = home
|
_homeChannel.value = home
|
||||||
chat.setLane(home)
|
chat.setLane(home)
|
||||||
}
|
}
|
||||||
|
// M5: remember the ntfy server for the listener service.
|
||||||
|
if (s.caps.pushNtfyServer.isNotBlank()) {
|
||||||
|
store.ntfyServer = s.caps.pushNtfyServer
|
||||||
|
}
|
||||||
|
// M5: a deep link tapped before we were connected.
|
||||||
|
applyDeepLink()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// M5: ensure this device has an ntfy topic (device-specific, random).
|
||||||
|
if (store.ntfyTopic.isBlank()) {
|
||||||
|
store.ntfyTopic = "iris-${store.deviceId}-${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||||
|
}
|
||||||
client.startHeartbeat()
|
client.startHeartbeat()
|
||||||
client.start()
|
client.start()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
var showNewThread by remember { mutableStateOf(false) }
|
var showNewThread by remember { mutableStateOf(false) }
|
||||||
var showPicker by remember { mutableStateOf(false) }
|
var showPicker by remember { mutableStateOf(false) }
|
||||||
val attachments by controller.attachments.collectAsState()
|
val attachments by controller.attachments.collectAsState()
|
||||||
|
val banners by controller.banners.collectAsState()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val drawerScope = rememberCoroutineScope()
|
val drawerScope = rememberCoroutineScope()
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
@@ -239,6 +240,27 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M5: notification banners (above the composer)
|
||||||
|
if (banners.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
banners.forEach { b ->
|
||||||
|
NotificationBanner(
|
||||||
|
banner = b,
|
||||||
|
onOpen = {
|
||||||
|
controller.openDeepLink(b.chatId, b.threadId)
|
||||||
|
controller.dismissBanner(b.id)
|
||||||
|
},
|
||||||
|
onDismiss = { controller.dismissBanner(b.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Composer
|
// Composer
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -530,6 +552,38 @@ private fun StatusChip(state: GatewayClient.State) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** M5: in-app notification banner (docs/08 §8.3). Tapping opens the chat. */
|
||||||
|
@Composable
|
||||||
|
private fun NotificationBanner(
|
||||||
|
banner: IrisController.Banner,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val accent = when (banner.kind) {
|
||||||
|
"approval" -> Color(0xFFFFC107)
|
||||||
|
"clarify" -> Color(0xFF4F7CFF)
|
||||||
|
"cron" -> Color(0xFF4CAF50)
|
||||||
|
else -> Color(0xFF8A93A6)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(accent.copy(alpha = 0.12f))
|
||||||
|
.clickable(onClick = onOpen)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(banner.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = accent)
|
||||||
|
if (banner.body.isNotBlank()) {
|
||||||
|
Text(banner.body, fontSize = 12.sp, color = Color(0xFFD7DBE5), maxLines = 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(onClick = onDismiss) { Text("✕", fontSize = 12.sp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageBubble(msg: MessageItem) {
|
private fun MessageBubble(msg: MessageItem) {
|
||||||
val isUser = msg.role == ROLE_USER
|
val isUser = msg.role == ROLE_USER
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
actual fun isAppForeground(): Boolean = true
|
||||||
|
|
||||||
|
actual fun postSystemNotification(
|
||||||
|
chatId: String?,
|
||||||
|
chatName: String?,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
threadId: String?,
|
||||||
|
) {
|
||||||
|
// Desktop: in-app banner only (no system notification in M5).
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun setActiveController(controller: Any?) {
|
||||||
|
// Desktop: no push services to bridge to.
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ private data class PairingData(
|
|||||||
val serverUrl: String = "",
|
val serverUrl: String = "",
|
||||||
val token: String = "",
|
val token: String = "",
|
||||||
val deviceId: String = "",
|
val deviceId: String = "",
|
||||||
|
val syncCursor: Long = 0L,
|
||||||
|
val fcmToken: String = "",
|
||||||
|
val ntfyTopic: String = "",
|
||||||
|
val ntfyServer: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
class DesktopSecureStore : SecureStore {
|
class DesktopSecureStore : SecureStore {
|
||||||
@@ -63,6 +67,34 @@ class DesktopSecureStore : SecureStore {
|
|||||||
override val deviceName: String
|
override val deviceName: String
|
||||||
get() = "Desktop (${System.getProperty("os.name")})"
|
get() = "Desktop (${System.getProperty("os.name")})"
|
||||||
|
|
||||||
|
override var syncCursor: Long
|
||||||
|
get() = load().syncCursor
|
||||||
|
set(value) {
|
||||||
|
val d = load()
|
||||||
|
save(d.copy(syncCursor = value))
|
||||||
|
}
|
||||||
|
|
||||||
|
override var fcmToken: String
|
||||||
|
get() = load().fcmToken
|
||||||
|
set(value) {
|
||||||
|
val d = load()
|
||||||
|
save(d.copy(fcmToken = value))
|
||||||
|
}
|
||||||
|
|
||||||
|
override var ntfyTopic: String
|
||||||
|
get() = load().ntfyTopic
|
||||||
|
set(value) {
|
||||||
|
val d = load()
|
||||||
|
save(d.copy(ntfyTopic = value))
|
||||||
|
}
|
||||||
|
|
||||||
|
override var ntfyServer: String
|
||||||
|
get() = load().ntfyServer
|
||||||
|
set(value) {
|
||||||
|
val d = load()
|
||||||
|
save(d.copy(ntfyServer = value))
|
||||||
|
}
|
||||||
|
|
||||||
override fun savePairing(url: String, token: String) {
|
override fun savePairing(url: String, token: String) {
|
||||||
val d = load()
|
val d = load()
|
||||||
save(d.copy(serverUrl = url.trim(), token = token.trim()))
|
save(d.copy(serverUrl = url.trim(), token = token.trim()))
|
||||||
|
|||||||
+23
-12
@@ -118,18 +118,29 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
|
|
||||||
## M5 — Push + offline (FCM + ntfy)
|
## M5 — Push + offline (FCM + ntfy)
|
||||||
**Goal:** reach the phone when backgrounded; catch up on reconnect.
|
**Goal:** reach the phone when backgrounded; catch up on reconnect.
|
||||||
- [ ] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune.
|
- [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune
|
||||||
- [ ] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx) +
|
(row cap 5000 + prune banner, throttled 1/h).
|
||||||
`NtfyBackend`; selected by `ANDROID_PUSH_BACKEND`.
|
- [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via
|
||||||
- [ ] Fire push on no-live-subscriber; data payload for silent sync.
|
PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by
|
||||||
- [ ] App: FCM service (foreground banner + background foreground-service sync),
|
`ANDROID_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`.
|
||||||
`onNewToken` → `fcm.register`; per-chat notification channels; deep-link.
|
- [x] Fire push on no-live-subscriber; data payload for silent sync.
|
||||||
ntfy listener fallback.
|
High-priority kinds (approval/clarify/cron) push even when live.
|
||||||
- [ ] In-app `notification` banners (channel_renamed, approval, cron, …).
|
- [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a
|
||||||
- **Demo (on-device):** background the app → trigger a message → notification
|
Firebase project), per-chat notification channels, deep-link
|
||||||
appears → tap → syncs + opens the chat. Repeat with ntfy backend.
|
(custom action + `iris://`), ntfy listener foreground-service fallback.
|
||||||
- **Accept:** push arrives when backgrounded (both backends); reconnect syncs
|
App generates a device-specific ntfy topic; auto-sync on reconnect.
|
||||||
with no loss/dup; banners show for foreground events.
|
- [x] In-app `notification` banners (channel events, approval, clarify, cron,
|
||||||
|
outbox-pruned); system notification mirror when backgrounded.
|
||||||
|
- **Demo (on-device, verified 2026-08-19):** device offline → message
|
||||||
|
broadcast → frame parked in outbox → `push via ntfy -> <device>` fired →
|
||||||
|
app reconnect auto-synced (cursor 0→8) → parked message rendered in-app.
|
||||||
|
ntfy publish confirmed (POST 200). ntfy listener service runs (foreground
|
||||||
|
notification posted); listener→system-notification leg not exercised live
|
||||||
|
because ntfy.sh's public SSE endpoint was serving its web UI (not streams)
|
||||||
|
during the test — verify against a self-hosted ntfy.
|
||||||
|
- **Accept:** push arrives when backgrounded (ntfy verified; FCM code path
|
||||||
|
complete, needs a Firebase project to exercise); reconnect syncs with no
|
||||||
|
loss/dup (verified); banners show for foreground events (implemented).
|
||||||
|
|
||||||
## M6 — Desktop app
|
## M6 — Desktop app
|
||||||
**Goal:** the same app on a big screen.
|
**Goal:** the same app on a big screen.
|
||||||
|
|||||||
+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
|
``media.offer``; ``media.pull`` streams the file back as chunked binary
|
||||||
frames, re-checking ``validate_media_delivery_path`` at pull time.
|
frames, re-checking ``validate_media_delivery_path`` at pull time.
|
||||||
|
|
||||||
|
Milestone M5: push + offline. Frames with no live subscriber are parked in
|
||||||
|
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
|
||||||
|
HTTP v1 primary, ntfy fallback, selected by ``ANDROID_PUSH_BACKEND``).
|
||||||
|
``notification`` frames render in-app banners and mirror to push (channel
|
||||||
|
events, cron deliveries, approvals, clarifies); high-priority kinds push even
|
||||||
|
when a device is live. ``fcm.register`` rotates push tokens (registry + live
|
||||||
|
connection). The outbox enforces a row cap with a throttled prune notice.
|
||||||
|
|
||||||
Configuration in config.yaml::
|
Configuration in config.yaml::
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
@@ -106,6 +114,7 @@ from . import protocol # noqa: E402
|
|||||||
from . import search as search_bridge # noqa: E402
|
from . import search as search_bridge # noqa: E402
|
||||||
from .channels import get_directory # noqa: E402
|
from .channels import get_directory # noqa: E402
|
||||||
from .outbox import Outbox # noqa: E402
|
from .outbox import Outbox # noqa: E402
|
||||||
|
from .push import NtfyBackend, PushBackend, build_push_backend # noqa: E402
|
||||||
from .pairing import ( # noqa: E402
|
from .pairing import ( # noqa: E402
|
||||||
DeviceRegistry,
|
DeviceRegistry,
|
||||||
generate_token,
|
generate_token,
|
||||||
@@ -280,6 +289,42 @@ def _strip_streaming_cursor(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _push_preview(text: Any, limit: int = 120) -> str:
|
||||||
|
"""Short single-line preview for push bodies (lock-screen privacy: no
|
||||||
|
secrets, no full bodies -- full content arrives via ``sync``)."""
|
||||||
|
s = " ".join(str(text or "").split())
|
||||||
|
if len(s) > limit:
|
||||||
|
s = s[: limit - 1] + "…"
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# Cron delivery wrap (cron/scheduler.py ``_deliver_result``,
|
||||||
|
# cron.wrap_response: true):
|
||||||
|
# "Cronjob Response: <name>\n(job_id: <id>)\n-------------\n\n<content>\n\n
|
||||||
|
# To stop or manage this job, send me a new message (e.g. ...)."
|
||||||
|
_CRON_WRAP_RE = re.compile(
|
||||||
|
r"^Cronjob Response: (.+?)\n\(job_id: [^)]*\)\n-+\n\n"
|
||||||
|
)
|
||||||
|
_CRON_FOOTER = "\n\nTo stop or manage this job"
|
||||||
|
|
||||||
|
|
||||||
|
def _cron_brief(content: str, job_id: str) -> Tuple[str, str]:
|
||||||
|
"""Parse a cron delivery into ``(job_name, inner_text)``.
|
||||||
|
|
||||||
|
Falls back to ``(job_id, content)`` when the wrap is disabled
|
||||||
|
(``cron.wrap_response: false``) or unrecognised.
|
||||||
|
"""
|
||||||
|
m = _CRON_WRAP_RE.match(content or "")
|
||||||
|
if not m:
|
||||||
|
return str(job_id or "cron"), (content or "").strip()
|
||||||
|
name = m.group(1).strip()
|
||||||
|
body = content[m.end():]
|
||||||
|
idx = body.rfind(_CRON_FOOTER)
|
||||||
|
if idx != -1:
|
||||||
|
body = body[:idx]
|
||||||
|
return name, body.strip()
|
||||||
|
|
||||||
|
|
||||||
def _split_reasoning(text: str) -> Tuple[Optional[str], str]:
|
def _split_reasoning(text: str) -> Tuple[Optional[str], str]:
|
||||||
"""Split a code-style reasoning prefix off the front of *text*.
|
"""Split a code-style reasoning prefix off the front of *text*.
|
||||||
|
|
||||||
@@ -693,6 +738,17 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# last finalized assistant message id per chat (offer association).
|
# last finalized assistant message id per chat (offer association).
|
||||||
self._media = media_bridge.MediaStore(get_hermes_home())
|
self._media = media_bridge.MediaStore(get_hermes_home())
|
||||||
self._last_message_id: Dict[str, str] = {}
|
self._last_message_id: Dict[str, str] = {}
|
||||||
|
# M5: push backend (FCM primary, ntfy fallback) + the throttle for
|
||||||
|
# the outbox-prune banner.
|
||||||
|
self._push: PushBackend = build_push_backend(
|
||||||
|
self.push_backend,
|
||||||
|
fcm_service_account=_get_scoped_secret("ANDROID_FCM_SERVICE_ACCOUNT"),
|
||||||
|
fcm_server_key=_get_scoped_secret("ANDROID_FCM_SERVER_KEY"),
|
||||||
|
ntfy_topic=_get_scoped_secret("NTFY_TOPIC"),
|
||||||
|
ntfy_server_url=os.getenv("NTFY_SERVER_URL", "").strip() or None,
|
||||||
|
ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"),
|
||||||
|
)
|
||||||
|
self._prune_notified_at = 0.0
|
||||||
|
|
||||||
def _turn_state(self, chat_id: str) -> _TurnState:
|
def _turn_state(self, chat_id: str) -> _TurnState:
|
||||||
st = self._turns.get(chat_id)
|
st = self._turns.get(chat_id)
|
||||||
@@ -747,6 +803,16 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: ensure_default failed", exc_info=True)
|
logger.warning("android: ensure_default failed", exc_info=True)
|
||||||
|
|
||||||
|
# M5: push backend status (degrade gracefully when unconfigured).
|
||||||
|
if not self._push.configured():
|
||||||
|
logger.warning(
|
||||||
|
"android: push backend %r not configured (no credentials) -- "
|
||||||
|
"offline devices will not be woken; outbox + sync still apply",
|
||||||
|
self.push_backend,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("android: push backend: %s", self._push.name)
|
||||||
|
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self._mark_connected()
|
self._mark_connected()
|
||||||
logger.info("android: connected; WS server on %s:%s", self.host, self.port)
|
logger.info("android: connected; WS server on %s:%s", self.host, self.port)
|
||||||
@@ -817,7 +883,45 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
)
|
)
|
||||||
return SendResult(success=True, message_id=message_id)
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
|
||||||
# 2. Final message (non-streaming final, or streaming fallback final).
|
# 2. Cron delivery (the cron scheduler passes ``job_id`` in metadata):
|
||||||
|
# a final assistant message + a high-priority notification banner
|
||||||
|
# (pushed even when a device is live, docs/08 §8.1).
|
||||||
|
if meta.get("job_id"):
|
||||||
|
reasoning, _ = _split_reasoning(content)
|
||||||
|
if reasoning:
|
||||||
|
_reset_reasoning()
|
||||||
|
else:
|
||||||
|
await _wait_for_reasoning_flushed()
|
||||||
|
reasoning = _take_reasoning() or None
|
||||||
|
name, inner = _cron_brief(content, str(meta.get("job_id")))
|
||||||
|
message_id = _mint_message_id()
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_CRON,
|
||||||
|
f"Cron: {name}",
|
||||||
|
_push_preview(inner),
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
role=protocol.ROLE_ASSISTANT,
|
||||||
|
text=inner,
|
||||||
|
thread_id=thread_id,
|
||||||
|
reasoning=reasoning,
|
||||||
|
ts=int(time.time() * 1000),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._last_message_id[chat_id] = message_id
|
||||||
|
state.active = False
|
||||||
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
|
||||||
|
# 3. Final message (non-streaming final, or streaming fallback final).
|
||||||
if meta.get("notify") is True:
|
if meta.get("notify") is True:
|
||||||
reasoning, body = _split_reasoning(content)
|
reasoning, body = _split_reasoning(content)
|
||||||
# Non-streaming: reasoning is prepended to content (split above).
|
# Non-streaming: reasoning is prepended to content (split above).
|
||||||
@@ -863,11 +967,11 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
state.active = False
|
state.active = False
|
||||||
return SendResult(success=True, message_id=message_id)
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
|
||||||
# 3. Tool progress (first tool bubble of an editable line buffer).
|
# 4. Tool progress (first tool bubble of an editable line buffer).
|
||||||
if _is_tool_progress(content):
|
if _is_tool_progress(content):
|
||||||
return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=False)
|
return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=False)
|
||||||
|
|
||||||
# 4. Commentary (interim assistant beat).
|
# 5. Commentary (interim assistant beat).
|
||||||
message_id = _mint_message_id()
|
message_id = _mint_message_id()
|
||||||
state.active = True
|
state.active = True
|
||||||
await self._broadcast_or_log(
|
await self._broadcast_or_log(
|
||||||
@@ -1043,17 +1147,151 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
|
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
|
||||||
delivered = await self._ws_server.broadcast(frame)
|
delivered = await self._ws_server.broadcast(frame)
|
||||||
|
# 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:
|
if delivered == 0:
|
||||||
# M3: no live device -- park the frame in the outbox so a
|
logger.info(
|
||||||
# reconnecting app can `sync` it (M5 adds push to wake the device).
|
"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:
|
try:
|
||||||
cursor = self._outbox.append(chat_id, frame.to_json())
|
ok = await backend.send(
|
||||||
logger.info(
|
device_id=device_id,
|
||||||
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
chat_id=chat_id,
|
||||||
chat_id, frame.type, cursor,
|
title=title,
|
||||||
|
body=body,
|
||||||
|
data=data,
|
||||||
|
token=token,
|
||||||
|
priority=priority,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: outbox append failed", exc_info=True)
|
logger.warning("android: push via %s failed", backend.name, exc_info=True)
|
||||||
|
continue
|
||||||
|
if ok:
|
||||||
|
logger.info(
|
||||||
|
"android: push via %s -> %s (%s, chat=%s)",
|
||||||
|
backend.name, device_id, frame.type, chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _maybe_notify_outbox_prune(self, chat_id: str) -> None:
|
||||||
|
"""When the outbox row cap pruned old frames, tell the app (throttled
|
||||||
|
to once per hour so a full box doesn't banner per frame)."""
|
||||||
|
pruned = self._outbox.take_overflow_pruned()
|
||||||
|
if pruned <= 0:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
if now - self._prune_notified_at < 3600.0:
|
||||||
|
return
|
||||||
|
self._prune_notified_at = now
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_GENERIC,
|
||||||
|
"Outbox",
|
||||||
|
f"{pruned} older message(s) pruned",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||||
"""Send a typing indicator (``typing`` frame, on=true)."""
|
"""Send a typing indicator (``typing`` frame, on=true)."""
|
||||||
@@ -1464,6 +1702,16 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
resp = protocol.channel_created(entry)
|
resp = protocol.channel_created(entry)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._ws_server.broadcast(resp)
|
||||||
|
# M5: banner + push mirror (parked in the outbox when offline).
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
entry["chat_id"],
|
||||||
|
protocol.notification(
|
||||||
|
entry["chat_id"],
|
||||||
|
protocol.NOTIF_CHANNEL_CREATED,
|
||||||
|
"Channels",
|
||||||
|
f"New channel: {name}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
@@ -1496,6 +1744,16 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
resp = protocol.channel_renamed(entry)
|
resp = protocol.channel_renamed(entry)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._ws_server.broadcast(resp)
|
||||||
|
# M5: banner + push mirror (parked in the outbox when offline).
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_CHANNEL_RENAMED,
|
||||||
|
"Channels",
|
||||||
|
f"Renamed to {name}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
@@ -1536,6 +1794,16 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
resp = protocol.channel_deleted(chat_id)
|
resp = protocol.channel_deleted(chat_id)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._ws_server.broadcast(resp)
|
||||||
|
# M5: banner + push mirror (parked in the outbox when offline).
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_CHANNEL_DELETED,
|
||||||
|
"Channels",
|
||||||
|
f"{entry.get('name') or chat_id} deleted",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def on_channel_list(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_channel_list(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
channels = self._channels.list(include_archived=False)
|
channels = self._channels.list(include_archived=False)
|
||||||
@@ -1596,6 +1864,140 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
|
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
|
||||||
await self._ws_server.send_to(device_id, done)
|
await self._ws_server.send_to(device_id, done)
|
||||||
|
|
||||||
|
# ── M5: push token registration ───────────────────────────────────────
|
||||||
|
|
||||||
|
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
"""Update the device's push tokens (FCM rotation / ntfy topic).
|
||||||
|
|
||||||
|
Persists to the device registry AND refreshes the live connection so
|
||||||
|
the next push targets the current token without a stale read.
|
||||||
|
"""
|
||||||
|
fcm_token = frame.payload.get("fcm_token")
|
||||||
|
ntfy_topic = frame.payload.get("ntfy_topic")
|
||||||
|
fcm_token = fcm_token if isinstance(fcm_token, str) and fcm_token else None
|
||||||
|
ntfy_topic = ntfy_topic if isinstance(ntfy_topic, str) and ntfy_topic else None
|
||||||
|
if fcm_token is None and ntfy_topic is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._devices.update_push_tokens(
|
||||||
|
device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("android: fcm.register update failed", exc_info=True)
|
||||||
|
return
|
||||||
|
conn = self._ws_server.connection(device_id)
|
||||||
|
if conn is not None:
|
||||||
|
if fcm_token is not None:
|
||||||
|
conn.fcm_token = fcm_token
|
||||||
|
if ntfy_topic is not None:
|
||||||
|
conn.ntfy_topic = ntfy_topic
|
||||||
|
logger.info("android: push tokens updated for %s", device_id)
|
||||||
|
|
||||||
|
# ── M5: approval / clarify banners ────────────────────────────────────
|
||||||
|
|
||||||
|
async def send_slash_confirm(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
session_key: str,
|
||||||
|
confirm_id: str,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Banner + push for a slash-command approval prompt.
|
||||||
|
|
||||||
|
The gateway's text fallback still renders the actionable prompt (the
|
||||||
|
app has no inline buttons yet); the notification is the push-visible
|
||||||
|
signal (high priority: pushed even when a device is live).
|
||||||
|
"""
|
||||||
|
thread_id = _thread_id_from_metadata(metadata)
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_APPROVAL,
|
||||||
|
title or "Approval needed",
|
||||||
|
_push_preview(message),
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return await super().send_slash_confirm(
|
||||||
|
chat_id, title, message, session_key, confirm_id, metadata=metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_clarify(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
question: str,
|
||||||
|
choices: Optional[list],
|
||||||
|
clarify_id: str,
|
||||||
|
session_key: str,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Banner + push for a clarify prompt.
|
||||||
|
|
||||||
|
Renders the prompt as a proper ``message`` frame (the base text
|
||||||
|
fallback would route through ``send()`` and be misclassified as
|
||||||
|
commentary/tool progress) and keeps the gateway's text intercept
|
||||||
|
working via ``mark_awaiting_text``.
|
||||||
|
"""
|
||||||
|
thread_id = _thread_id_from_metadata(metadata)
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_CLARIFY,
|
||||||
|
"Question",
|
||||||
|
_push_preview(question),
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if choices:
|
||||||
|
# Multi-select clarifies register their flag on the pending entry;
|
||||||
|
# look it up by id (mirrors the base text fallback).
|
||||||
|
_is_multi = False
|
||||||
|
try:
|
||||||
|
from tools import clarify_gateway as _cg
|
||||||
|
|
||||||
|
with _cg._lock:
|
||||||
|
_entry = _cg._entries.get(clarify_id)
|
||||||
|
_is_multi = bool(_entry and getattr(_entry, "multi_select", False))
|
||||||
|
except Exception:
|
||||||
|
_is_multi = False
|
||||||
|
lines = [f"❓ {question}", ""]
|
||||||
|
for i, choice in enumerate(choices, start=1):
|
||||||
|
lines.append(f" {i}. {choice}")
|
||||||
|
lines.append("")
|
||||||
|
if _is_multi:
|
||||||
|
lines.append(
|
||||||
|
"Multiple selections allowed — reply with the numbers "
|
||||||
|
"separated by commas or spaces (e.g. \"1, 3\"), the option "
|
||||||
|
"text, or your own answer."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("Reply with the number, the option text, or your own answer.")
|
||||||
|
text = "\n".join(lines)
|
||||||
|
# Text fallback: enable text-capture so the gateway intercept
|
||||||
|
# picks up the user's typed reply (e.g. "2" or choice text).
|
||||||
|
from tools.clarify_gateway import mark_awaiting_text
|
||||||
|
|
||||||
|
mark_awaiting_text(clarify_id)
|
||||||
|
else:
|
||||||
|
text = f"❓ {question}"
|
||||||
|
message_id = _mint_message_id()
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
role=protocol.ROLE_ASSISTANT,
|
||||||
|
text=text,
|
||||||
|
thread_id=thread_id,
|
||||||
|
ts=int(time.time() * 1000),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
|
||||||
# ── Chat info ─────────────────────────────────────────────────────────
|
# ── Chat info ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _channel_name(self, chat_id: str) -> str:
|
def _channel_name(self, chat_id: str) -> str:
|
||||||
@@ -1630,6 +2032,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
"media": True, # M4: media.upload/offer/pull
|
"media": True, # M4: media.upload/offer/pull
|
||||||
"search": True, # M3: search frame
|
"search": True, # M3: search frame
|
||||||
"push": self.push_backend,
|
"push": self.push_backend,
|
||||||
|
# M5: ntfy server URL (app listener discovery; "" when not ntfy).
|
||||||
|
"push_ntfy_server": (
|
||||||
|
self._push.server_url
|
||||||
|
if isinstance(self._push, NtfyBackend)
|
||||||
|
else ""
|
||||||
|
),
|
||||||
"pickers": False, # M2+
|
"pickers": False, # M2+
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
|
|||||||
DEFAULT_RETENTION_HOURS = 72
|
DEFAULT_RETENTION_HOURS = 72
|
||||||
_REPLAY_LIMIT = 1000
|
_REPLAY_LIMIT = 1000
|
||||||
_PRUNE_INTERVAL_S = 3600.0
|
_PRUNE_INTERVAL_S = 3600.0
|
||||||
|
# Row cap (docs/08 §8.3): a very busy offline period must not grow the outbox
|
||||||
|
# without bound; the oldest rows beyond the cap are pruned and the app is
|
||||||
|
# told (generic notification, throttled in the adapter).
|
||||||
|
DEFAULT_MAX_ROWS = 5000
|
||||||
|
|
||||||
|
|
||||||
class Outbox:
|
class Outbox:
|
||||||
@@ -38,12 +42,19 @@ class Outbox:
|
|||||||
``DeviceRegistry`` / ``ChannelDirectory``).
|
``DeviceRegistry`` / ``ChannelDirectory``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db_path: Path, retention_hours: int = DEFAULT_RETENTION_HOURS):
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: Path,
|
||||||
|
retention_hours: int = DEFAULT_RETENTION_HOURS,
|
||||||
|
max_rows: int = DEFAULT_MAX_ROWS,
|
||||||
|
):
|
||||||
self._db_path = Path(db_path)
|
self._db_path = Path(db_path)
|
||||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._retention_hours = max(1, int(retention_hours))
|
self._retention_hours = max(1, int(retention_hours))
|
||||||
|
self._max_rows = max(1, int(max_rows))
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._last_prune = 0.0
|
self._last_prune = 0.0
|
||||||
|
self._overflow_pruned = 0
|
||||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||||
self._conn.row_factory = sqlite3.Row
|
self._conn.row_factory = sqlite3.Row
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -90,10 +101,37 @@ class Outbox:
|
|||||||
"VALUES (?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?)",
|
||||||
(cursor, chat_id, frame_json, now),
|
(cursor, chat_id, frame_json, now),
|
||||||
)
|
)
|
||||||
|
self._enforce_row_cap()
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
self._maybe_prune()
|
self._maybe_prune()
|
||||||
return cursor
|
return cursor
|
||||||
|
|
||||||
|
def take_overflow_pruned(self) -> int:
|
||||||
|
"""Rows pruned by the row cap since the last call (and reset to 0).
|
||||||
|
|
||||||
|
The adapter turns a non-zero count into a (throttled) generic
|
||||||
|
notification so the app knows older frames are gone.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
n = self._overflow_pruned
|
||||||
|
self._overflow_pruned = 0
|
||||||
|
return n
|
||||||
|
|
||||||
|
def _enforce_row_cap(self) -> None:
|
||||||
|
"""Drop the oldest rows beyond ``max_rows`` (caller holds the lock)."""
|
||||||
|
row = self._conn.execute("SELECT COUNT(*) AS n FROM outbox").fetchone()
|
||||||
|
n = int(row["n"]) if row else 0
|
||||||
|
excess = n - self._max_rows
|
||||||
|
if excess <= 0:
|
||||||
|
return
|
||||||
|
self._conn.execute(
|
||||||
|
"DELETE FROM outbox WHERE cursor IN ("
|
||||||
|
" SELECT cursor FROM outbox ORDER BY cursor ASC LIMIT ?)",
|
||||||
|
(excess,),
|
||||||
|
)
|
||||||
|
self._overflow_pruned += excess
|
||||||
|
logger.info("android outbox: row cap pruned %s oldest row(s)", excess)
|
||||||
|
|
||||||
def latest_cursor(self) -> int:
|
def latest_cursor(self) -> int:
|
||||||
"""The high-water cursor (0 when nothing has been appended)."""
|
"""The high-water cursor (0 when nothing has been appended)."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ optional_env:
|
|||||||
description: "ntfy server URL (default https://ntfy.sh)"
|
description: "ntfy server URL (default https://ntfy.sh)"
|
||||||
prompt: "ntfy server URL"
|
prompt: "ntfy server URL"
|
||||||
password: false
|
password: false
|
||||||
|
- name: NTFY_AUTH_TOKEN
|
||||||
|
description: "ntfy auth token for a private topic (trust boundary)"
|
||||||
|
prompt: "ntfy auth token"
|
||||||
|
password: true
|
||||||
- name: ANDROID_WS_CERT
|
- name: ANDROID_WS_CERT
|
||||||
description: "TLS cert path for WSS (optional)"
|
description: "TLS cert path for WSS (optional)"
|
||||||
prompt: "WSS cert"
|
prompt: "WSS cert"
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ TYPE_MEDIA_OFFER = "media.offer"
|
|||||||
TYPE_MEDIA_PULL = "media.pull"
|
TYPE_MEDIA_PULL = "media.pull"
|
||||||
TYPE_MEDIA_PULL_END = "media.pull.end"
|
TYPE_MEDIA_PULL_END = "media.pull.end"
|
||||||
|
|
||||||
|
# Push / notifications (M5)
|
||||||
|
TYPE_NOTIFICATION = "notification"
|
||||||
|
TYPE_FCM_REGISTER = "fcm.register"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Error codes (``error`` frame payload.code)
|
# Error codes (``error`` frame payload.code)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -96,6 +100,22 @@ ROLE_ASSISTANT = "assistant"
|
|||||||
ROLE_SYSTEM = "system"
|
ROLE_SYSTEM = "system"
|
||||||
ROLE_CRON = "cron"
|
ROLE_CRON = "cron"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Notification kinds (``notification`` frame payload.kind, M5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
NOTIF_CHANNEL_CREATED = "channel_created"
|
||||||
|
NOTIF_CHANNEL_RENAMED = "channel_renamed"
|
||||||
|
NOTIF_CHANNEL_DELETED = "channel_deleted"
|
||||||
|
NOTIF_CRON = "cron"
|
||||||
|
NOTIF_APPROVAL = "approval"
|
||||||
|
NOTIF_CLARIFY = "clarify"
|
||||||
|
NOTIF_GENERIC = "generic"
|
||||||
|
|
||||||
|
# Kinds that push even when a device is live (the app may be backgrounded;
|
||||||
|
# it decides whether to also show an in-app banner).
|
||||||
|
HIGH_PRIORITY_NOTIF_KINDS = frozenset({NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Envelope
|
# Envelope
|
||||||
@@ -452,6 +472,41 @@ def sync_done(cursor: int, *, id: Optional[int] = None) -> Frame:
|
|||||||
return Frame(type=TYPE_SYNC_DONE, id=id, payload={"cursor": cursor})
|
return Frame(type=TYPE_SYNC_DONE, id=id, payload={"cursor": cursor})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Push / notification frames (M5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def notification(
|
||||||
|
chat_id: str,
|
||||||
|
kind: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
*,
|
||||||
|
thread_id: Optional[str] = None,
|
||||||
|
ts: Optional[int] = None,
|
||||||
|
) -> Frame:
|
||||||
|
"""Event: a transient in-app banner (and a push mirror when the device is
|
||||||
|
offline). ``kind`` is one of the ``NOTIF_*`` constants."""
|
||||||
|
payload: Dict[str, Any] = {"kind": kind, "title": title, "body": body}
|
||||||
|
if ts is not None:
|
||||||
|
payload["ts"] = ts
|
||||||
|
return Frame(
|
||||||
|
type=TYPE_NOTIFICATION, chat_id=chat_id, thread_id=thread_id, payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fcm_register(
|
||||||
|
fcm_token: Optional[str] = None, ntfy_topic: Optional[str] = None
|
||||||
|
) -> Frame:
|
||||||
|
"""Request: update the device's push tokens (FCM rotation / ntfy topic)."""
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
if fcm_token:
|
||||||
|
payload["fcm_token"] = fcm_token
|
||||||
|
if ntfy_topic:
|
||||||
|
payload["ntfy_topic"] = ntfy_topic
|
||||||
|
return Frame(type=TYPE_FCM_REGISTER, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Media frames (M4)
|
# Media frames (M4)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+321
-5
@@ -4,12 +4,328 @@
|
|||||||
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
|
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
|
||||||
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key
|
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key
|
||||||
(``ANDROID_FCM_SERVER_KEY``).
|
(``ANDROID_FCM_SERVER_KEY``).
|
||||||
- ``NtfyBackend``: reuses hermes ntfy publish (``NTFY_TOPIC`` /
|
- ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL``
|
||||||
``NTFY_SERVER_URL``).
|
(default ``https://ntfy.sh``) via ``httpx``; the app's listener
|
||||||
|
subscribes to the topic.
|
||||||
|
|
||||||
Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
|
Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
|
||||||
Fired when a frame has no live subscriber; data payload drives a silent sync
|
Fired when a frame has no live subscriber; the data payload drives a silent
|
||||||
on the device.
|
sync on the device (docs/08-push.md).
|
||||||
|
|
||||||
Milestone M5.
|
Zero new dependencies: ``httpx`` is a hermes core dep. ``google.auth`` is NOT
|
||||||
|
installed, so the FCM service-account OAuth2 access token is minted directly
|
||||||
|
with PyJWT + cryptography (both core deps).
|
||||||
|
|
||||||
|
Payloads carry no secrets and only a short preview (lock-screen privacy);
|
||||||
|
full content is fetched via ``sync`` over the authenticated WS.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
|
||||||
|
FCM_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||||
|
FCM_V1_SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send"
|
||||||
|
FCM_LEGACY_SEND_URL = "https://fcm.googleapis.com/fcm/send"
|
||||||
|
# Refresh the cached access token this long before its expiry.
|
||||||
|
_TOKEN_REFRESH_MARGIN_S = 600.0
|
||||||
|
_DEFAULT_NTFY_SERVER = "https://ntfy.sh"
|
||||||
|
_NTFY_BODY_LIMIT = 4096
|
||||||
|
_HTTP_TIMEOUT_S = 15.0
|
||||||
|
|
||||||
|
_NTFY_PRIORITY = {"high": "5", "normal": "3", "low": "1"}
|
||||||
|
|
||||||
|
|
||||||
|
class PushBackend:
|
||||||
|
"""Interface: wake one device (FCM token or ntfy topic)."""
|
||||||
|
|
||||||
|
name: str = "push"
|
||||||
|
# DeviceRegistry column that carries this backend's target token.
|
||||||
|
token_field: str = ""
|
||||||
|
|
||||||
|
def configured(self) -> bool:
|
||||||
|
"""True when the backend has credentials to send with."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
device_id: str,
|
||||||
|
chat_id: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
token: str,
|
||||||
|
priority: str = "normal",
|
||||||
|
data_only: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Deliver one push to *token*. Returns True on success.
|
||||||
|
|
||||||
|
``data`` is the silent-sync payload (``chat_id``, ``kind``,
|
||||||
|
``cursor``, optional ``thread_id``/``message_id``) -- string values
|
||||||
|
only on the wire.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class FcmBackend(PushBackend):
|
||||||
|
"""FCM HTTP v1 (service account) or legacy ``/fcm/send`` (server key)."""
|
||||||
|
|
||||||
|
name = "fcm"
|
||||||
|
token_field = "fcm_token"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service_account: Optional[str] = None,
|
||||||
|
server_key: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self._sa_path = (service_account or "").strip() or None
|
||||||
|
self._server_key = (server_key or "").strip() or None
|
||||||
|
self._sa: Optional[Dict[str, Any]] = None
|
||||||
|
self._sa_failed = False
|
||||||
|
self._access_token: Optional[str] = None
|
||||||
|
self._token_expiry = 0.0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def configured(self) -> bool:
|
||||||
|
if self._server_key:
|
||||||
|
return True
|
||||||
|
return bool(self._sa_path and Path(self._sa_path).is_file())
|
||||||
|
|
||||||
|
def _load_sa(self) -> Optional[Dict[str, Any]]:
|
||||||
|
if self._sa is not None:
|
||||||
|
return self._sa
|
||||||
|
if not self._sa_path or self._sa_failed:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(self._sa_path, "r", encoding="utf-8") as f:
|
||||||
|
sa = json.load(f)
|
||||||
|
if isinstance(sa, dict) and sa.get("client_email") and sa.get("private_key"):
|
||||||
|
self._sa = sa
|
||||||
|
return sa
|
||||||
|
except Exception:
|
||||||
|
logger.warning("android: FCM service account unreadable: %s", self._sa_path)
|
||||||
|
self._sa_failed = True
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _authorization(self, client: httpx.AsyncClient) -> Optional[str]:
|
||||||
|
"""Bearer token: the legacy server key, or a cached service-account
|
||||||
|
OAuth2 access token (JWT-bearer grant, minted with PyJWT)."""
|
||||||
|
if self._server_key:
|
||||||
|
return self._server_key
|
||||||
|
sa = self._load_sa()
|
||||||
|
if sa is None:
|
||||||
|
return None
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
if self._access_token and now < self._token_expiry - _TOKEN_REFRESH_MARGIN_S:
|
||||||
|
return self._access_token
|
||||||
|
import jwt # PyJWT (core dep)
|
||||||
|
|
||||||
|
claims = {
|
||||||
|
"iss": sa["client_email"],
|
||||||
|
"scope": FCM_SCOPE,
|
||||||
|
"aud": FCM_TOKEN_URL,
|
||||||
|
"iat": int(now),
|
||||||
|
"exp": int(now) + 3600,
|
||||||
|
}
|
||||||
|
headers = {"kid": sa["private_key_id"]} if sa.get("private_key_id") else None
|
||||||
|
try:
|
||||||
|
assertion = jwt.encode(
|
||||||
|
claims, sa["private_key"], algorithm="RS256", headers=headers
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("android: FCM JWT mint failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
FCM_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||||
|
"assertion": assertion,
|
||||||
|
},
|
||||||
|
timeout=_HTTP_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("android: FCM token exchange failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"android: FCM token exchange HTTP %s: %s",
|
||||||
|
resp.status_code, resp.text[:200],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
token = data.get("access_token")
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
self._access_token = token
|
||||||
|
try:
|
||||||
|
self._token_expiry = now + float(data.get("expires_in", 3600))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self._token_expiry = now + 3600.0
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
device_id: str,
|
||||||
|
chat_id: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
token: str,
|
||||||
|
priority: str = "normal",
|
||||||
|
data_only: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
data = {str(k): str(v) for k, v in (data or {}).items()}
|
||||||
|
notification = None if data_only else {"title": title or "Iris", "body": body or ""}
|
||||||
|
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
|
||||||
|
if self._server_key:
|
||||||
|
payload: Dict[str, Any] = {"to": token}
|
||||||
|
if notification:
|
||||||
|
payload["notification"] = notification
|
||||||
|
if data:
|
||||||
|
payload["data"] = data
|
||||||
|
auth = self._server_key
|
||||||
|
url = FCM_LEGACY_SEND_URL
|
||||||
|
else:
|
||||||
|
sa = self._load_sa()
|
||||||
|
project_id = (sa or {}).get("project_id")
|
||||||
|
if not project_id:
|
||||||
|
return False
|
||||||
|
message: Dict[str, Any] = {"token": token}
|
||||||
|
if notification:
|
||||||
|
message["notification"] = notification
|
||||||
|
if data:
|
||||||
|
message["data"] = data
|
||||||
|
message["android"] = {
|
||||||
|
"priority": "high" if priority == "high" else "normal"
|
||||||
|
}
|
||||||
|
payload = {"message": message}
|
||||||
|
auth = await self._authorization(client)
|
||||||
|
if auth is None:
|
||||||
|
return False
|
||||||
|
url = FCM_V1_SEND_URL.format(project_id=project_id)
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {auth}"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("android: FCM send failed (network)", exc_info=True)
|
||||||
|
return False
|
||||||
|
if resp.status_code >= 300:
|
||||||
|
# 404 NOT_FOUND = stale/invalid registration token.
|
||||||
|
logger.warning(
|
||||||
|
"android: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyBackend(PushBackend):
|
||||||
|
"""ntfy publish (self-host friendly; zero Firebase).
|
||||||
|
|
||||||
|
The structured payload rides in an ``X-Data`` header (JSON) the app's
|
||||||
|
listener parses; the message body is the short preview. A private topic
|
||||||
|
+ ``NTFY_AUTH_TOKEN`` provides the trust boundary (docs/08 §8.7).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "ntfy"
|
||||||
|
token_field = "ntfy_topic"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
topic: Optional[str] = None,
|
||||||
|
server_url: Optional[str] = None,
|
||||||
|
auth_token: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self._topic = (topic or "").strip() or None
|
||||||
|
self._server = (
|
||||||
|
(server_url or _DEFAULT_NTFY_SERVER).strip().rstrip("/")
|
||||||
|
or _DEFAULT_NTFY_SERVER
|
||||||
|
)
|
||||||
|
self._auth_token = (auth_token or "").strip() or None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def server_url(self) -> str:
|
||||||
|
"""The ntfy server this backend publishes to (for app discovery)."""
|
||||||
|
return self._server
|
||||||
|
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self._topic)
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
device_id: str,
|
||||||
|
chat_id: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
token: str,
|
||||||
|
priority: str = "normal",
|
||||||
|
data_only: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
topic = (token or self._topic or "").strip()
|
||||||
|
if not topic:
|
||||||
|
return False
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"X-Title": (title or "Iris")[:512],
|
||||||
|
"X-Priority": _NTFY_PRIORITY.get(priority, "3"),
|
||||||
|
"X-Tag": "bell",
|
||||||
|
"X-Data": json.dumps(data or {}, separators=(",", ":")),
|
||||||
|
}
|
||||||
|
if self._auth_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self._auth_token}"
|
||||||
|
text = (body or "")[:_NTFY_BODY_LIMIT]
|
||||||
|
url = f"{self._server}/{quote(topic, safe='')}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url, content=text.encode("utf-8"), headers=headers
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("android: ntfy publish failed (network)", exc_info=True)
|
||||||
|
return False
|
||||||
|
if resp.status_code >= 300:
|
||||||
|
logger.warning(
|
||||||
|
"android: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_push_backend(
|
||||||
|
name: Optional[str],
|
||||||
|
*,
|
||||||
|
fcm_service_account: Optional[str] = None,
|
||||||
|
fcm_server_key: Optional[str] = None,
|
||||||
|
ntfy_topic: Optional[str] = None,
|
||||||
|
ntfy_server_url: Optional[str] = None,
|
||||||
|
ntfy_auth_token: Optional[str] = None,
|
||||||
|
) -> PushBackend:
|
||||||
|
"""Select the backend by name (``ANDROID_PUSH_BACKEND``; fcm default)."""
|
||||||
|
if (name or "").strip().lower() == "ntfy":
|
||||||
|
return NtfyBackend(
|
||||||
|
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
|
||||||
|
)
|
||||||
|
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)
|
||||||
@@ -18,8 +18,12 @@ Options:
|
|||||||
--send TEXT send this message after pairing (default: "hello")
|
--send TEXT send this message after pairing (default: "hello")
|
||||||
--upload F M4: upload F (chunked media.upload) and attach it to the
|
--upload F M4: upload F (chunked media.upload) and attach it to the
|
||||||
message.send via media_refs
|
message.send via media_refs
|
||||||
--pull-offer M4: when a media.offer arrives during the turn, pull the
|
--pull-offer M4: when a media.offer arrives during the turn, pull the
|
||||||
media (chunked) and verify the byte count
|
media (chunked) and verify the byte count
|
||||||
|
--sync C M5: after pairing, send sync {cursor: C} and print the
|
||||||
|
replay + sync.done (no turn is driven)
|
||||||
|
--fcm-token M5: attach this FCM token to the hello payload
|
||||||
|
--fcm-reg M5: after pairing, send fcm.register with --fcm-token
|
||||||
--timeout S seconds to wait for the final reply (default 120)
|
--timeout S seconds to wait for the final reply (default 120)
|
||||||
--authfail expect an auth rejection (wrong token) and exit 0 on it
|
--authfail expect an auth rejection (wrong token) and exit 0 on it
|
||||||
"""
|
"""
|
||||||
@@ -94,6 +98,13 @@ def _print_frame(raw):
|
|||||||
extra = f" ok={payload.get('ok')} ref={payload.get('media_ref')}"
|
extra = f" ok={payload.get('ok')} ref={payload.get('media_ref')}"
|
||||||
elif ftype == "media.pull.end":
|
elif ftype == "media.pull.end":
|
||||||
extra = f" ok={payload.get('ok')}"
|
extra = f" ok={payload.get('ok')}"
|
||||||
|
elif ftype == "notification":
|
||||||
|
extra = (f" kind={payload.get('kind')} title={payload.get('title')!r} "
|
||||||
|
f"body={(payload.get('body') or '')[:100]!r}")
|
||||||
|
elif ftype == "sync":
|
||||||
|
extra = f" cursor={payload.get('cursor')}"
|
||||||
|
elif ftype == "sync.done":
|
||||||
|
extra = f" cursor={payload.get('cursor')}"
|
||||||
scope = f" chat={chat}" if chat else ""
|
scope = f" chat={chat}" if chat else ""
|
||||||
idpart = f" id={fid}" if fid is not None else ""
|
idpart = f" id={fid}" if fid is not None else ""
|
||||||
print(f" <- {ftype}{idpart}{scope}{extra}")
|
print(f" <- {ftype}{idpart}{scope}{extra}")
|
||||||
@@ -200,8 +211,10 @@ async def run(args) -> int:
|
|||||||
"caps": {"min_protocol": 1},
|
"caps": {"min_protocol": 1},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if args.fcm_token:
|
||||||
|
hello["payload"]["fcm_token"] = args.fcm_token
|
||||||
await ws.send(json.dumps(hello))
|
await ws.send(json.dumps(hello))
|
||||||
print(" -> hello")
|
print(" -> hello" + (f" fcm_token={args.fcm_token[:12]}…" if args.fcm_token else ""))
|
||||||
|
|
||||||
# First response must be hello.ack (or an auth error).
|
# First response must be hello.ack (or an auth error).
|
||||||
try:
|
try:
|
||||||
@@ -224,6 +237,35 @@ async def run(args) -> int:
|
|||||||
await ws.close()
|
await ws.close()
|
||||||
return 5
|
return 5
|
||||||
|
|
||||||
|
# M5: optional fcm.register after pairing.
|
||||||
|
if args.fcm_reg:
|
||||||
|
reg_token = args.fcm_token or f"probe-{uuid.uuid4().hex[:12]}"
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"v": 1, "type": "fcm.register",
|
||||||
|
"payload": {"fcm_token": reg_token},
|
||||||
|
}))
|
||||||
|
print(f" -> fcm.register fcm_token={reg_token[:12]}…")
|
||||||
|
|
||||||
|
# M5: sync catch-up mode (no turn driven).
|
||||||
|
if args.sync is not None:
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"v": 1, "id": 1, "type": "sync", "payload": {"cursor": args.sync},
|
||||||
|
}))
|
||||||
|
print(f" -> sync cursor={args.sync}")
|
||||||
|
while True:
|
||||||
|
raw = await asyncio.wait_for(ws.recv(), timeout=30)
|
||||||
|
data = _print_frame(raw)
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
if data.get("type") == "sync.done":
|
||||||
|
print(f"== sync done at cursor {data['payload'].get('cursor')}")
|
||||||
|
await ws.close()
|
||||||
|
return 0
|
||||||
|
if data.get("type") == "error":
|
||||||
|
print(f"!! sync failed: {data['payload']}")
|
||||||
|
await ws.close()
|
||||||
|
return 8
|
||||||
|
|
||||||
if not args.send and not args.upload:
|
if not args.send and not args.upload:
|
||||||
print("== paired OK (no --send/--upload; exiting)")
|
print("== paired OK (no --send/--upload; exiting)")
|
||||||
await ws.close()
|
await ws.close()
|
||||||
@@ -311,6 +353,12 @@ def main() -> int:
|
|||||||
help="M4: file to upload (chunked) and attach via media_refs")
|
help="M4: file to upload (chunked) and attach via media_refs")
|
||||||
p.add_argument("--pull-offer", action="store_true",
|
p.add_argument("--pull-offer", action="store_true",
|
||||||
help="M4: pull any media.offer that arrives during the turn")
|
help="M4: pull any media.offer that arrives during the turn")
|
||||||
|
p.add_argument("--sync", type=int, default=None,
|
||||||
|
help="M5: send sync {cursor} after pairing, print replay, exit")
|
||||||
|
p.add_argument("--fcm-token", default="",
|
||||||
|
help="M5: FCM token to attach to the hello payload")
|
||||||
|
p.add_argument("--fcm-reg", action="store_true",
|
||||||
|
help="M5: send fcm.register after pairing (uses --fcm-token)")
|
||||||
p.add_argument("--timeout", type=float, default=120.0)
|
p.add_argument("--timeout", type=float, default=120.0)
|
||||||
p.add_argument("--authfail", action="store_true",
|
p.add_argument("--authfail", action="store_true",
|
||||||
help="expect an auth rejection (wrong token)")
|
help="expect an auth rejection (wrong token)")
|
||||||
|
|||||||
@@ -316,18 +316,8 @@ class WsServer:
|
|||||||
await self._adapter.on_media_upload_end(frame, device_id)
|
await self._adapter.on_media_upload_end(frame, device_id)
|
||||||
elif frame.type == protocol.TYPE_MEDIA_PULL:
|
elif frame.type == protocol.TYPE_MEDIA_PULL:
|
||||||
await self._adapter.on_media_pull(frame, device_id)
|
await self._adapter.on_media_pull(frame, device_id)
|
||||||
elif frame.type == "fcm.register":
|
elif frame.type == protocol.TYPE_FCM_REGISTER:
|
||||||
fcm_token = frame.payload.get("fcm_token")
|
await self._adapter.on_fcm_register(frame, device_id)
|
||||||
ntfy_topic = frame.payload.get("ntfy_topic")
|
|
||||||
if isinstance(fcm_token, str) or isinstance(ntfy_topic, str):
|
|
||||||
try:
|
|
||||||
self._devices.update_push_tokens(
|
|
||||||
device_id,
|
|
||||||
fcm_token=fcm_token if isinstance(fcm_token, str) else None,
|
|
||||||
ntfy_topic=ntfy_topic if isinstance(ntfy_topic, str) else None,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("android: fcm.register update failed", exc_info=True)
|
|
||||||
# Unknown types are ignored (forward-compat).
|
# Unknown types are ignored (forward-compat).
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in new issue
Block a user