From acd5fb4ad04a2adcc96b701c07cf6eed2e43c6a2 Mon Sep 17 00:00:00 2001 From: ARIA Date: Fri, 21 Aug 2026 17:21:47 +0200 Subject: [PATCH] =?UTF-8?q?Ntfy=20listener:=20only=20run=20when=20the=20pa?= =?UTF-8?q?ired=20gateway=20pushes=20via=20ntfy=20=E2=80=94=20the=20foregr?= =?UTF-8?q?ound=20service=20(and=20its=20permanent=20'Listening=20for=20me?= =?UTF-8?q?ssages'=20notification)=20is=20now=20started/stopped=20based=20?= =?UTF-8?q?on=20the=20gateway's=20push=20backend=20from=20hello.ack=20serv?= =?UTF-8?q?er=5Fcaps=20(persisted=20as=20pushBackend);=20FCM=20gateways=20?= =?UTF-8?q?no=20longer=20keep=20a=20persistent=20listener=20alive,=20and?= =?UTF-8?q?=20switching=20gateways=20toggles=20the=20service=20on=20the=20?= =?UTF-8?q?next=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/kotlin/dev/iris/app/MainActivity.kt | 23 ++++--- .../kotlin/iris/platform/AndroidPush.kt | 16 ++++- .../iris/platform/AndroidSecureStore.kt | 5 ++ .../kotlin/iris/data/SecureStore.kt | 13 +++- .../kotlin/iris/platform/PlatformPush.kt | 11 +++- .../kotlin/iris/state/IrisController.kt | 6 ++ .../kotlin/iris/platform/DesktopPush.kt | 63 ++++++++++++------- .../iris/platform/DesktopSecureStore.kt | 8 +++ 8 files changed, 105 insertions(+), 40 deletions(-) diff --git a/app/androidApp/src/main/kotlin/dev/iris/app/MainActivity.kt b/app/androidApp/src/main/kotlin/dev/iris/app/MainActivity.kt index 463c680..5a594a1 100644 --- a/app/androidApp/src/main/kotlin/dev/iris/app/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/dev/iris/app/MainActivity.kt @@ -15,7 +15,7 @@ import iris.IrisApp import iris.platform.AndroidEnv import iris.platform.AndroidSecureStore import iris.platform.AppBridge -import iris.platform.NtfyListenerService +import iris.platform.syncNtfyListener class MainActivity : ComponentActivity() { private val deepLinkChatId = mutableStateOf(null) @@ -31,7 +31,9 @@ class MainActivity : ComponentActivity() { val store = AndroidSecureStore(applicationContext) handleDeepLink(intent) requestNotificationPermission() - startNtfyListener(store) + // M5: the ntfy listener (and its permanent notification) only runs + // when the paired gateway pushes via ntfy; unknown ("") = not yet. + syncNtfyListener(store.pushBackend) setContent { val chatId by deepLinkChatId val threadId by deepLinkThreadId @@ -58,10 +60,12 @@ class MainActivity : ComponentActivity() { * extras or an iris://chat/?thread= 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") + 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 @@ -77,9 +81,4 @@ class MainActivity : ComponentActivity() { } } } - - private fun startNtfyListener(store: AndroidSecureStore) { - if (store.ntfyTopic.isBlank()) return - ContextCompat.startForegroundService(this, Intent(this, NtfyListenerService::class.java)) - } -} \ No newline at end of file +} diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt index dee9225..13ef16f 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt @@ -1,6 +1,7 @@ package iris.platform import android.Manifest +import android.content.Intent import android.content.pm.PackageManager import androidx.core.content.ContextCompat import iris.state.IrisController @@ -11,6 +12,19 @@ actual fun setActiveController(controller: Any?) { AppBridge.controller = controller as? IrisController } +actual fun syncNtfyListener(backend: String) { + val context = AndroidEnv.context + val intent = Intent(context, NtfyListenerService::class.java) + val store = AndroidSecureStore(context) + if (backend == "ntfy" && store.ntfyTopic.isNotBlank()) { + ContextCompat.startForegroundService(context, intent) + } else { + // FCM (or unknown) backend: no persistent listener, no permanent + // notification. stopService is a no-op when it isn't running. + context.stopService(intent) + } +} + actual fun postSystemNotification( chatId: String?, chatName: String?, @@ -27,4 +41,4 @@ actual fun postSystemNotification( return } IrisNotifications.post(context, id, chatName, title, body, threadId) -} \ No newline at end of file +} diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt index 32b4582..9d75102 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt @@ -110,6 +110,10 @@ class AndroidSecureStore( get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty() set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply() + override var pushBackend: String + get() = prefs.getString(KEY_PUSH_BACKEND, "").orEmpty() + set(value) = prefs.edit().putString(KEY_PUSH_BACKEND, value).apply() + override var threadsEnabled: Boolean get() = prefs.getBoolean(KEY_THREADS_ENABLED, false) set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply() @@ -184,6 +188,7 @@ class AndroidSecureStore( const val KEY_FCM_TOKEN = "fcm_token" const val KEY_NTFY_TOPIC = "ntfy_topic" const val KEY_NTFY_SERVER = "ntfy_server" + const val KEY_PUSH_BACKEND = "push_backend" const val KEY_THREADS_ENABLED = "threads_enabled" const val KEY_TOOL_DETAIL = "tool_detail" const val KEY_STREAMING_ENABLED = "streaming_enabled" diff --git a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt index 5a0d482..6d154d5 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt @@ -30,6 +30,11 @@ interface SecureStore { /** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */ var ntfyServer: String + /** Push backend of the paired gateway ("fcm"/"ntfy"; from hello.ack + * server_caps; empty when unknown — decides whether the ntfy listener + * foreground service runs at all). */ + var pushBackend: String + /** UI setting: show threads (topics) in the chat view. */ var threadsEnabled: Boolean @@ -61,6 +66,10 @@ interface SecureStore { * system font scale). */ var fontSizeScale: Float - fun savePairing(url: String, token: String) + fun savePairing( + url: String, + token: String, + ) + fun clear() -} \ No newline at end of file +} diff --git a/app/shared/src/commonMain/kotlin/iris/platform/PlatformPush.kt b/app/shared/src/commonMain/kotlin/iris/platform/PlatformPush.kt index f6d6e23..eced244 100644 --- a/app/shared/src/commonMain/kotlin/iris/platform/PlatformPush.kt +++ b/app/shared/src/commonMain/kotlin/iris/platform/PlatformPush.kt @@ -28,4 +28,13 @@ expect fun postSystemNotification( * 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?) \ No newline at end of file +expect fun setActiveController(controller: Any?) + +/** + * Align the ntfy listener with the paired gateway's push backend (Android: + * start/stop the listener foreground service — and with it the permanent + * "Listening for messages" notification; no-op on desktop). The listener + * only runs when the gateway actually pushes via ntfy; FCM gateways need + * no persistent listener. + */ +expect fun syncNtfyListener(backend: String) diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index c552cc9..6bf5baf 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -13,6 +13,7 @@ import iris.platform.PickedFile import iris.platform.isAppForeground import iris.platform.mediaCacheBaseDir import iris.platform.postSystemNotification +import iris.platform.syncNtfyListener import iris.protocol.ChannelDeletedPayload import iris.protocol.ChannelInfo import iris.protocol.CommandsCatalogPayload @@ -554,6 +555,11 @@ class IrisController( if (s.caps.pushNtfyServer.isNotBlank()) { store.ntfyServer = s.caps.pushNtfyServer } + // M5: align the ntfy listener (and its permanent + // "Listening for messages" notification) with the + // gateway's push backend — only ntfy gateways need it. + store.pushBackend = s.caps.push + syncNtfyListener(s.caps.push) // M5: a deep link tapped before we were connected. applyDeepLink() // Slash-command catalog for the composer's "/" drawer diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopPush.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopPush.kt index 0b6a5a6..9a2cd15 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopPush.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopPush.kt @@ -19,6 +19,10 @@ actual fun setActiveController(controller: Any?) { DesktopBridge.controller = controller as? IrisController } +actual fun syncNtfyListener(backend: String) { + // Desktop has no ntfy listener service. +} + /** * M6: OS notifications (docs/11 §11.2). Desktop has no FCM; the tray + the * platform notifier cover the "backgrounded" leg. Shells out to @@ -26,34 +30,45 @@ actual fun setActiveController(controller: Any?) { * Best effort — failures are ignored (the in-app banner is the primary path). */ object DesktopNotifier { - fun post(title: String, body: String) { + fun post( + title: String, + body: String, + ) { try { val os = System.getProperty("os.name").lowercase() - val cmd = when { - os.contains("linux") -> - listOf("notify-send", "-a", "Iris", "-c", "iris", title, body) - os.contains("mac") -> - listOf( - "osascript", "-e", - "display notification \"${esc(body)}\" with title \"${esc(title)}\"", - ) - else -> - listOf( - "powershell", "-NoProfile", "-Command", - "Add-Type -AssemblyName System.Windows.Forms; " + - "\$n = New-Object System.Windows.Forms.NotifyIcon; " + - "\$n.Icon = [System.Drawing.SystemIcons]::Information; " + - "\$n.Visible = \$true; " + - "\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " + - "[System.Windows.Forms.ToolTipIcon]::Info); " + - "Start-Sleep -Milliseconds 4500; \$n.Dispose()", - ) - } + val cmd = + when { + os.contains("linux") -> { + listOf("notify-send", "-a", "Iris", "-c", "iris", title, body) + } + + os.contains("mac") -> { + listOf( + "osascript", + "-e", + "display notification \"${esc(body)}\" with title \"${esc(title)}\"", + ) + } + + else -> { + listOf( + "powershell", + "-NoProfile", + "-Command", + "Add-Type -AssemblyName System.Windows.Forms; " + + "\$n = New-Object System.Windows.Forms.NotifyIcon; " + + "\$n.Icon = [System.Drawing.SystemIcons]::Information; " + + "\$n.Visible = \$true; " + + "\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " + + "[System.Windows.Forms.ToolTipIcon]::Info); " + + "Start-Sleep -Milliseconds 4500; \$n.Dispose()", + ) + } + } ProcessBuilder(cmd).redirectErrorStream(true).start() } catch (_: Exception) { } } - private fun esc(s: String): String = - s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ") -} \ No newline at end of file + private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ") +} diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt index 7ef3b67..a42f82e 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt @@ -37,6 +37,7 @@ class DesktopSecureStore : SecureStore { val fcmToken: String = "", val ntfyTopic: String = "", val ntfyServer: String = "", + val pushBackend: String = "", val threadsEnabled: Boolean = false, val toolDetail: String = "truncated", val streamingEnabled: Boolean = true, @@ -153,6 +154,13 @@ class DesktopSecureStore : SecureStore { save(d.copy(ntfyServer = value)) } + override var pushBackend: String + get() = load().pushBackend + set(value) { + val d = load() + save(d.copy(pushBackend = value)) + } + override var threadsEnabled: Boolean get() = load().threadsEnabled set(value) {