M5: push (FCM + ntfy) + offline catch-up + background notifications

Server (gateway-plugin):
- push.py: FCM (HTTP v1 service-account / legacy key) + ntfy backends; NtfyBackend.server_url for app discovery
- adapter.py: push on offline broadcast + high-priority push when live; fcm.register; server_caps.push_ntfy_server; outbox now ALWAYS appends so a reconnecting app catches up on live-delivered frames (fixes empty chat after notification tap / activity recreation)
- protocol.py / ws_server.py / outbox.py / plugin.yaml: M5 frames + env vars

App (Kotlin CMP):
- Protocol.kt: notification / fcm.register / sync frames + push caps
- GatewayClient.kt: hello carries push creds; auto-sync on reconnect
- IrisController.kt: banners, deep-link, notifyMessageIfBackgrounded (system notification on a regular reply when backgrounded)
- Android: PlatformPush, AppBridge, IrisNotifications, NtfyListenerService, IrisFirebaseMessagingService, AndroidPush, AndroidSecureStore
- Desktop: DesktopPush, DesktopSecureStore
- build files + manifest (permissions, services, deep-links)

Tests: 35-test tests/gateway/test_android.py suite passes (incl. new regression test_live_delivered_frame_still_parked_for_sync). E2E verified on device: push fire, reconnect sync, background notification, and message replay after ChatStore reset.
This commit is contained in:
ARIA committed 2026-08-19 19:20:55 +02:00
1 parent 913ee91024
commit 2ecfe1c05c
28 files changed
+1739 -51

No files matched your search

+7
View File
@@ -39,4 +39,11 @@ dependencies {
implementation("androidx.compose.ui:ui")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.core:core-splashscreen:1.0.1")
}
// M5: apply the google-services plugin only when a Firebase project is
// configured (google-services.json present). Without it the FCM service is
// inert and the ntfy listener is the push path.
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
@@ -3,6 +3,11 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- M5: post push notifications (runtime permission on API 33+). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:label="Iris"
@@ -18,6 +23,19 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- M5: deep link from a push notification tap (custom action,
no compile-time dependency from the shared module). -->
<intent-filter>
<action android:name="dev.iris.app.OPEN_CHAT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- M5: iris:// deep link (external links / share targets). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="iris" android:host="chat" />
</intent-filter>
</activity>
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
@@ -30,6 +48,22 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/iris_file_paths" />
</provider>
<!-- M5: ntfy listener (foreground service; subscribes to the device
topic and posts push notifications). -->
<service
android:name="iris.platform.NtfyListenerService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- M5: FCM handler (inert without a Firebase project). -->
<service
android:name="iris.platform.IrisFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -1,19 +1,85 @@
package dev.iris.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import iris.IrisApp
import iris.platform.AndroidEnv
import iris.platform.AndroidSecureStore
import iris.platform.AppBridge
import iris.platform.NtfyListenerService
class MainActivity : ComponentActivity() {
private val deepLinkChatId = mutableStateOf<String?>(null)
private val deepLinkThreadId = mutableStateOf<String?>(null)
private val notificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AndroidEnv.context = applicationContext
AppBridge.controller = null
val store = AndroidSecureStore(applicationContext)
handleDeepLink(intent)
requestNotificationPermission()
startNtfyListener(store)
setContent {
IrisApp(store)
val chatId by deepLinkChatId
val threadId by deepLinkThreadId
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLink(intent)
}
override fun onResume() {
super.onResume()
AppBridge.foreground = true
}
override fun onPause() {
super.onPause()
AppBridge.foreground = false
}
/** Extract chat_id / thread_id from a deep-link intent (custom action
* extras or an iris://chat/<id>?thread=<tid> URI). */
private fun handleDeepLink(intent: Intent?) {
val data = intent?.data
val chatId = intent?.getStringExtra("chat_id")
?: data?.pathSegments?.firstOrNull()
val threadId = intent?.getStringExtra("thread_id")
?: data?.getQueryParameter("thread")
if (!chatId.isNullOrBlank()) {
deepLinkChatId.value = chatId
deepLinkThreadId.value = threadId
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
private fun startNtfyListener(store: AndroidSecureStore) {
if (store.ntfyTopic.isBlank()) return
ContextCompat.startForegroundService(this, Intent(this, NtfyListenerService::class.java))
}
}