Ntfy listener: only run when the paired gateway pushes via ntfy — the foreground service (and its permanent 'Listening for messages' notification) is now started/stopped based on the gateway's push backend from hello.ack server_caps (persisted as pushBackend); FCM gateways no longer keep a persistent listener alive, and switching gateways toggles the service on the next connect

This commit is contained in:
ARIA committed 2026-08-21 17:21:47 +02:00
1 parent 75d491fd30
commit acd5fb4ad0
8 files changed
+84 -19

No files matched your search

@@ -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<String?>(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,9 +60,11 @@ class MainActivity : ComponentActivity() {
* extras or an iris://chat/<id>?thread=<tid> URI). */
private fun handleDeepLink(intent: Intent?) {
val data = intent?.data
val chatId = intent?.getStringExtra("chat_id")
val chatId =
intent?.getStringExtra("chat_id")
?: data?.pathSegments?.firstOrNull()
val threadId = intent?.getStringExtra("thread_id")
val threadId =
intent?.getStringExtra("thread_id")
?: data?.getQueryParameter("thread")
if (!chatId.isNullOrBlank()) {
deepLinkChatId.value = chatId
@@ -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))
}
}
@@ -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?,
@@ -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"
@@ -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()
}
@@ -29,3 +29,12 @@ expect fun postSystemNotification(
* FCM / ntfy services can reach it). No-op on desktop.
*/
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)
@@ -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
@@ -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,20 +30,31 @@ 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") ->
val cmd =
when {
os.contains("linux") -> {
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
os.contains("mac") ->
}
os.contains("mac") -> {
listOf(
"osascript", "-e",
"osascript",
"-e",
"display notification \"${esc(body)}\" with title \"${esc(title)}\"",
)
else ->
}
else -> {
listOf(
"powershell", "-NoProfile", "-Command",
"powershell",
"-NoProfile",
"-Command",
"Add-Type -AssemblyName System.Windows.Forms; " +
"\$n = New-Object System.Windows.Forms.NotifyIcon; " +
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
@@ -49,11 +64,11 @@ object DesktopNotifier {
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
)
}
}
ProcessBuilder(cmd).redirectErrorStream(true).start()
} catch (_: Exception) {
}
}
private fun esc(s: String): String =
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
}
@@ -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) {