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:
1 parent
75d491fd30
commit
acd5fb4ad0
8 files changed
+100
-35
No files matched your search
@@ -15,7 +15,7 @@ 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.AppBridge
|
||||||
import iris.platform.NtfyListenerService
|
import iris.platform.syncNtfyListener
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val deepLinkChatId = mutableStateOf<String?>(null)
|
private val deepLinkChatId = mutableStateOf<String?>(null)
|
||||||
@@ -31,7 +31,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
val store = AndroidSecureStore(applicationContext)
|
val store = AndroidSecureStore(applicationContext)
|
||||||
handleDeepLink(intent)
|
handleDeepLink(intent)
|
||||||
requestNotificationPermission()
|
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 {
|
setContent {
|
||||||
val chatId by deepLinkChatId
|
val chatId by deepLinkChatId
|
||||||
val threadId by deepLinkThreadId
|
val threadId by deepLinkThreadId
|
||||||
@@ -58,10 +60,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
* extras or an iris://chat/<id>?thread=<tid> URI). */
|
* extras or an iris://chat/<id>?thread=<tid> URI). */
|
||||||
private fun handleDeepLink(intent: Intent?) {
|
private fun handleDeepLink(intent: Intent?) {
|
||||||
val data = intent?.data
|
val data = intent?.data
|
||||||
val chatId = intent?.getStringExtra("chat_id")
|
val chatId =
|
||||||
?: data?.pathSegments?.firstOrNull()
|
intent?.getStringExtra("chat_id")
|
||||||
val threadId = intent?.getStringExtra("thread_id")
|
?: data?.pathSegments?.firstOrNull()
|
||||||
?: data?.getQueryParameter("thread")
|
val threadId =
|
||||||
|
intent?.getStringExtra("thread_id")
|
||||||
|
?: data?.getQueryParameter("thread")
|
||||||
if (!chatId.isNullOrBlank()) {
|
if (!chatId.isNullOrBlank()) {
|
||||||
deepLinkChatId.value = chatId
|
deepLinkChatId.value = chatId
|
||||||
deepLinkThreadId.value = threadId
|
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package iris.platform
|
package iris.platform
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
@@ -11,6 +12,19 @@ actual fun setActiveController(controller: Any?) {
|
|||||||
AppBridge.controller = controller as? IrisController
|
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(
|
actual fun postSystemNotification(
|
||||||
chatId: String?,
|
chatId: String?,
|
||||||
chatName: String?,
|
chatName: String?,
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ class AndroidSecureStore(
|
|||||||
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
|
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
|
||||||
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
|
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
|
override var threadsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
|
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
|
||||||
set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply()
|
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_FCM_TOKEN = "fcm_token"
|
||||||
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||||
const val KEY_NTFY_SERVER = "ntfy_server"
|
const val KEY_NTFY_SERVER = "ntfy_server"
|
||||||
|
const val KEY_PUSH_BACKEND = "push_backend"
|
||||||
const val KEY_THREADS_ENABLED = "threads_enabled"
|
const val KEY_THREADS_ENABLED = "threads_enabled"
|
||||||
const val KEY_TOOL_DETAIL = "tool_detail"
|
const val KEY_TOOL_DETAIL = "tool_detail"
|
||||||
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
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). */
|
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
|
||||||
var ntfyServer: String
|
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. */
|
/** UI setting: show threads (topics) in the chat view. */
|
||||||
var threadsEnabled: Boolean
|
var threadsEnabled: Boolean
|
||||||
|
|
||||||
@@ -61,6 +66,10 @@ interface SecureStore {
|
|||||||
* system font scale). */
|
* system font scale). */
|
||||||
var fontSizeScale: Float
|
var fontSizeScale: Float
|
||||||
|
|
||||||
fun savePairing(url: String, token: String)
|
fun savePairing(
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
)
|
||||||
|
|
||||||
fun clear()
|
fun clear()
|
||||||
}
|
}
|
||||||
@@ -29,3 +29,12 @@ expect fun postSystemNotification(
|
|||||||
* FCM / ntfy services can reach it). No-op on desktop.
|
* FCM / ntfy services can reach it). No-op on desktop.
|
||||||
*/
|
*/
|
||||||
expect fun setActiveController(controller: Any?)
|
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.isAppForeground
|
||||||
import iris.platform.mediaCacheBaseDir
|
import iris.platform.mediaCacheBaseDir
|
||||||
import iris.platform.postSystemNotification
|
import iris.platform.postSystemNotification
|
||||||
|
import iris.platform.syncNtfyListener
|
||||||
import iris.protocol.ChannelDeletedPayload
|
import iris.protocol.ChannelDeletedPayload
|
||||||
import iris.protocol.ChannelInfo
|
import iris.protocol.ChannelInfo
|
||||||
import iris.protocol.CommandsCatalogPayload
|
import iris.protocol.CommandsCatalogPayload
|
||||||
@@ -554,6 +555,11 @@ class IrisController(
|
|||||||
if (s.caps.pushNtfyServer.isNotBlank()) {
|
if (s.caps.pushNtfyServer.isNotBlank()) {
|
||||||
store.ntfyServer = s.caps.pushNtfyServer
|
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.
|
// M5: a deep link tapped before we were connected.
|
||||||
applyDeepLink()
|
applyDeepLink()
|
||||||
// Slash-command catalog for the composer's "/" drawer
|
// Slash-command catalog for the composer's "/" drawer
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ actual fun setActiveController(controller: Any?) {
|
|||||||
DesktopBridge.controller = controller as? IrisController
|
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
|
* M6: OS notifications (docs/11 §11.2). Desktop has no FCM; the tray + the
|
||||||
* platform notifier cover the "backgrounded" leg. Shells out to
|
* 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).
|
* Best effort — failures are ignored (the in-app banner is the primary path).
|
||||||
*/
|
*/
|
||||||
object DesktopNotifier {
|
object DesktopNotifier {
|
||||||
fun post(title: String, body: String) {
|
fun post(
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
val os = System.getProperty("os.name").lowercase()
|
val os = System.getProperty("os.name").lowercase()
|
||||||
val cmd = when {
|
val cmd =
|
||||||
os.contains("linux") ->
|
when {
|
||||||
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
|
os.contains("linux") -> {
|
||||||
os.contains("mac") ->
|
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
|
||||||
listOf(
|
}
|
||||||
"osascript", "-e",
|
|
||||||
"display notification \"${esc(body)}\" with title \"${esc(title)}\"",
|
os.contains("mac") -> {
|
||||||
)
|
listOf(
|
||||||
else ->
|
"osascript",
|
||||||
listOf(
|
"-e",
|
||||||
"powershell", "-NoProfile", "-Command",
|
"display notification \"${esc(body)}\" with title \"${esc(title)}\"",
|
||||||
"Add-Type -AssemblyName System.Windows.Forms; " +
|
)
|
||||||
"\$n = New-Object System.Windows.Forms.NotifyIcon; " +
|
}
|
||||||
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
|
|
||||||
"\$n.Visible = \$true; " +
|
else -> {
|
||||||
"\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " +
|
listOf(
|
||||||
"[System.Windows.Forms.ToolTipIcon]::Info); " +
|
"powershell",
|
||||||
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
|
"-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()
|
ProcessBuilder(cmd).redirectErrorStream(true).start()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun esc(s: String): String =
|
private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
|
||||||
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
|
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,7 @@ class DesktopSecureStore : SecureStore {
|
|||||||
val fcmToken: String = "",
|
val fcmToken: String = "",
|
||||||
val ntfyTopic: String = "",
|
val ntfyTopic: String = "",
|
||||||
val ntfyServer: String = "",
|
val ntfyServer: String = "",
|
||||||
|
val pushBackend: String = "",
|
||||||
val threadsEnabled: Boolean = false,
|
val threadsEnabled: Boolean = false,
|
||||||
val toolDetail: String = "truncated",
|
val toolDetail: String = "truncated",
|
||||||
val streamingEnabled: Boolean = true,
|
val streamingEnabled: Boolean = true,
|
||||||
@@ -153,6 +154,13 @@ class DesktopSecureStore : SecureStore {
|
|||||||
save(d.copy(ntfyServer = value))
|
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
|
override var threadsEnabled: Boolean
|
||||||
get() = load().threadsEnabled
|
get() = load().threadsEnabled
|
||||||
set(value) {
|
set(value) {
|
||||||
|
|||||||
Reference in new issue
Block a user