()
+ // Push-message listener readiness: UnifiedPushReceiver always posts the
+ // native notification itself; the JS "push-message" event is emitted only
+ // once a listener has attached.
+ private var hasPushMessageListener = false
+
// onNewIntent can fire before load() during a cold start triggered
// by a notification tap (Android delivers the launch intent via
// both onCreate's activity.intent AND onNewIntent in certain launch
@@ -749,6 +759,12 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
fun onUnifiedPushMessage(content: String, instance: String) {
if (instance != unifiedPushState.activeInstance || unifiedPushState.activeProvider != "unifiedpush") return
+ if (!hasPushMessageListener) {
+ // UnifiedPushReceiver already posted the native notification; without a
+ // JS push-message listener attached yet, the event would be lost, so
+ // drop it and let the native post stand.
+ return
+ }
val data = JSObject()
data.put("message", content)
data.put("transport", "unifiedpush")
@@ -930,4 +946,11 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
invoke.resolve()
}
+
+ @Command
+ fun setPushMessageListenerActive(invoke: Invoke) {
+ val args = invoke.parseArgs(SetPushMessageListenerActiveArgs::class.java)
+ hasPushMessageListener = args.active
+ invoke.resolve()
+ }
}
diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt
index c86a3d4..fbad220 100644
--- a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt
+++ b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt
@@ -9,8 +9,10 @@ import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
+import app.tauri.Logger
import com.fasterxml.jackson.databind.ObjectMapper
import org.json.JSONObject
+import kotlin.math.abs
object UnifiedPushNotifier {
private const val CHANNEL_ID = "messages"
@@ -49,7 +51,21 @@ object UnifiedPushNotifier {
.getIdentifier("notification_icon", "drawable", context.packageName)
.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info
- val notifId = sableNotifId(userId, roomId)
+ // Notification identity must match the warm path so the JS side can
+ // enrich or clear this entry: untagged Android key (null, id) with
+ // id = Math.abs(hashCode(userId + '\u0000' + roomId)). Without a user
+ // id in the payload the warm identity cannot be reproduced; fall
+ // back to the room/event key (stable same-room updates, but no warm
+ // clear/enrich match).
+ val notifId = if (userId.isNotEmpty() && roomId.isNotEmpty()) {
+ roomNotificationId(userId, roomId)
+ } else {
+ Logger.warn(
+ Logger.tags(TAG),
+ "Push payload has no user_id; cold notification will not match warm identity"
+ )
+ fallbackNotificationId(roomId.ifEmpty { eventId })
+ }
val intent = buildPushIntent(context, notifId, roomId, eventId, userId)
@@ -65,6 +81,7 @@ object UnifiedPushNotifier {
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
+ .setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(GROUP_KEY)
.setContentIntent(
@@ -105,15 +122,38 @@ object UnifiedPushNotifier {
}
}
- private fun sableNotifId(userId: String, roomId: String): Int {
- val key = "$userId\u0000$roomId"
- var hash = 0
- for (element in key) {
- hash = 31 * hash + element.code
- }
- return Math.abs(hash)
+ /**
+ * Stable, nonnegative notification id matching the warm-path (deployed
+ * Sable JS) identity for a room:
+ * `Math.abs(hashCode(userId + '\u0000' + roomId))`. The JS hash is a 32-bit
+ * wrap-around hash over UTF-16 code units, exactly what
+ * [String.hashCode] computes, and JS `Math.abs` corresponds to
+ * [kotlin.math.abs] here. [Int.MIN_VALUE] has no positive counterpart
+ * (the JS result 2^31 cannot cross the bridge as an Int), so it is
+ * mapped safely to 0.
+ */
+ internal fun roomNotificationId(userId: String, roomId: String): Int {
+ val hash = userId + '\u0000' + roomId
+ return hash.hashCode().let { if (it == Int.MIN_VALUE) 0 else abs(it) }
}
+ /**
+ * Stable, nonnegative id for a room-or-event key. Used only when the
+ * push payload carries no user id; this identity deliberately differs
+ * from the warm-path one.
+ */
+ internal fun fallbackNotificationId(roomOrEventKey: String): Int =
+ roomOrEventKey.hashCode() and Int.MAX_VALUE
+
+ /**
+ * Builds an intent carrying the push payload so that
+ * [NotificationPlugin.onIntent] can extract it via
+ * [TauriNotificationManager.handleNotificationActionPerformed] and
+ * [NotificationPlugin.extractLocalNotificationData].
+ *
+ * Mirrors the structure set by [TauriNotificationManager.buildIntent] in the
+ * warm path (JS-triggered sendNotification).
+ */
private fun buildPushIntent(
context: Context,
notifId: Int,
diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt b/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt
index fa7bdcf..cbc02a4 100644
--- a/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt
+++ b/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt
@@ -1,20 +1,21 @@
package app.tauri.notification
-import android.content.Context
import org.unifiedpush.android.connector.FailedReason
-import org.unifiedpush.android.connector.MessagingReceiver
-import org.unifiedpush.android.connector.UnifiedPush
+import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
-import org.unifiedpush.android.connector.keys.KeyManager
-class UnifiedPushReceiver : MessagingReceiver() {
-
- override fun getKeyManager(context: Context): KeyManager {
- return CachedKeyManager.getInstance(context)
- }
-
- override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) {
+/**
+ * UnifiedPush entry point. Declared in the manifest as a non-exported
+ * [PushService] with an intent-filter for [PushService.ACTION_PUSH_EVENT];
+ * the connector library's own MessagingReceiverImpl receives the distributor
+ * broadcasts and forwards them to this service over a bound connection.
+ * Do NOT declare a BroadcastReceiver for the connector actions
+ * (NEW_ENDPOINT/MESSAGE/UNREGISTERED/REGISTRATION_FAILED/TEMP_UNAVAILABLE):
+ * it would shadow the library's MessagingReceiverImpl.
+ */
+class UnifiedPushReceiver : PushService() {
+ override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
NotificationPlugin.instance?.onUnifiedPushNewEndpoint(
endpoint.url,
endpoint.pubKeySet?.pubKey,
@@ -23,21 +24,21 @@ class UnifiedPushReceiver : MessagingReceiver() {
)
}
- override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) {
+ override fun onRegistrationFailed(reason: FailedReason, instance: String) {
NotificationPlugin.instance?.onUnifiedPushRegistrationFailed(reason.name, instance)
}
- override fun onUnregistered(context: Context, instance: String) {
+ override fun onUnregistered(instance: String) {
NotificationPlugin.instance?.onUnifiedPushUnregistered(instance)
}
- override fun onTempUnavailable(context: Context, instance: String) {
+ override fun onTempUnavailable(instance: String) {
NotificationPlugin.instance?.onUnifiedPushTemporaryUnavailable(instance)
}
- override fun onMessage(context: Context, message: PushMessage, instance: String) {
+ override fun onMessage(message: PushMessage, instance: String) {
val content = String(message.content, Charsets.UTF_8)
- val state = UnifiedPushStateStore(context)
+ val state = UnifiedPushStateStore(this)
if (instance != state.activeInstance || state.activeProvider != "unifiedpush") return
// Always show the native notification immediately from the push payload.
// This eliminates the JS round-trip delay on the warm path (app alive in
@@ -45,8 +46,10 @@ class UnifiedPushReceiver : MessagingReceiver() {
// updates and notification enrichment (inbox grouping, fetched content
// for event_id_only payloads). When JS calls sendNotification() with the
// same notification ID, Android UPDATES the existing notification rather
- // than showing a duplicate.
- UnifiedPushNotifier.showFromPush(context, content)
+ // than showing a duplicate. If no JS push-message listener is attached,
+ // NotificationPlugin.onUnifiedPushMessage drops the event and the native
+ // post stands alone.
+ UnifiedPushNotifier.showFromPush(this, content)
NotificationPlugin.instance?.onUnifiedPushMessage(content, instance)
}
}
diff --git a/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt b/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt
new file mode 100644
index 0000000..c27c3b0
--- /dev/null
+++ b/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt
@@ -0,0 +1,160 @@
+package app.tauri.notification
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.content.pm.ResolveInfo
+import org.json.JSONObject
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class UnifiedPushNotifierTest {
+
+ private lateinit var context: Context
+ private lateinit var notificationManager: NotificationManager
+
+ @Before
+ fun setup() {
+ context = RuntimeEnvironment.getApplication()
+ notificationManager = context.getSystemService(NotificationManager::class.java)
+
+ // UnifiedPushNotifier builds its tap intent via
+ // packageManager.getLaunchIntentForPackage(); register a fake
+ // launcher activity so the lookup resolves in tests.
+ val launchIntent = Intent(Intent.ACTION_MAIN)
+ .addCategory(Intent.CATEGORY_LAUNCHER)
+ .setPackage(context.packageName)
+ shadowOf(context.packageManager).addResolveInfoForIntent(
+ launchIntent,
+ ResolveInfo().apply {
+ activityInfo = ActivityInfo().apply {
+ packageName = context.packageName
+ name = "TestLauncherActivity"
+ }
+ }
+ )
+ }
+
+ private fun shadowNotificationManager() = shadowOf(notificationManager)
+
+ private fun pushPayload(
+ roomId: String,
+ eventId: String,
+ body: String = "hello",
+ userId: String? = "@alice:example.org"
+ ): String {
+ val notification = JSONObject()
+ .put("room_id", roomId)
+ .put("event_id", eventId)
+ .put("room_name", "Room 1")
+ .put("sender_display_name", "Alice")
+ .put("type", "m.room.message")
+ .put("content", JSONObject().put("body", body))
+ return JSONObject()
+ .put("notification", notification)
+ .apply { if (userId != null) put("user_id", userId) }
+ .toString()
+ }
+
+ private fun canonicalId(roomId: String, userId: String = "@alice:example.org") =
+ UnifiedPushNotifier.roomNotificationId(userId, roomId)
+
+ @Test
+ fun roomNotificationId_matchesDeployedJsAbsHashSemantics() {
+ // Fixed vectors with expectations computed from Sable's JS
+ // Math.abs(hashCode(userId + NUL + roomId)); they pin the key order,
+ // the NUL separator, and the 32-bit wrap-around hash exactly.
+ assertEquals(238601196, canonicalId("!r1:example.org")) // positive hash
+ assertEquals(1475650254, canonicalId("!room-7:example.org")) // hash -1475650254
+ }
+
+ @Test
+ fun roomNotificationId_mapsIntMinValueHashToZero() {
+ // roomId = UTF-16 units 00D9 001B 000C 0009 001E: the key hashes to
+ // exactly Int.MIN_VALUE under 32-bit wrap-around, where deployed JS
+ // Math.abs would yield 2^31 — a value that cannot cross the Tauri
+ // bridge as an Int, so the id must be mapped safely to 0.
+ val roomId = String(charArrayOf(0x00D9.toChar(), 0x001B.toChar(), 0x000C.toChar(), 0x0009.toChar(), 0x001E.toChar()))
+ assertEquals(Int.MIN_VALUE, "AAA${0.toChar()}$roomId".hashCode())
+ assertEquals(0, UnifiedPushNotifier.roomNotificationId("AAA", roomId))
+ }
+
+ @Test
+ fun fallbackNotificationId_fixedVectors() {
+ assertEquals(461444550, UnifiedPushNotifier.fallbackNotificationId("!r1:example.org"))
+ assertEquals(708055431, UnifiedPushNotifier.fallbackNotificationId("!room-2:example.org"))
+ assertEquals(0, UnifiedPushNotifier.fallbackNotificationId(""))
+ }
+
+ @Test
+ fun showFromPush_postsUntaggedNotificationWithCanonicalIdWithExpectedFlags() {
+ UnifiedPushNotifier.showFromPush(context, pushPayload("!r1:example.org", "\$e1"))
+
+ val id = canonicalId("!r1:example.org")
+ val posted = shadowNotificationManager().getNotification(null, id)
+ assertNotNull(posted)
+ // A tagged lookup for the same id must find nothing: warm
+ // enrichment/clear uses the untagged key (null, id).
+ assertNull(shadowNotificationManager().getNotification("!r1:example.org", id))
+ assertTrue(posted!!.flags and Notification.FLAG_ONLY_ALERT_ONCE != 0)
+ assertTrue(posted.flags and Notification.FLAG_AUTO_CANCEL != 0)
+ }
+
+ @Test
+ fun showFromPush_sameRoomUpdatesInPlace_andTapCarriesLatestEvent() {
+ UnifiedPushNotifier.showFromPush(context, pushPayload("!r1:example.org", "\$e1"))
+ UnifiedPushNotifier.showFromPush(context, pushPayload("!r1:example.org", "\$e2", "second"))
+
+ assertEquals(1, shadowNotificationManager().allNotifications.size)
+
+ val posted = shadowNotificationManager().getNotification(null, canonicalId("!r1:example.org"))!!
+ val savedIntent = shadowOf(posted.contentIntent).savedIntent
+ val sourceJson = savedIntent.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY)!!
+ assertTrue(sourceJson.contains("\$e2"))
+ assertFalse(sourceJson.contains("\$e1"))
+ assertTrue(sourceJson.contains("!r1:example.org"))
+ }
+
+ @Test
+ fun showFromPush_differentRoomsPostSeparatelyUntagged() {
+ UnifiedPushNotifier.showFromPush(context, pushPayload("!r1:example.org", "\$e1"))
+ UnifiedPushNotifier.showFromPush(context, pushPayload("!r2:example.org", "\$e2"))
+
+ val shadow = shadowNotificationManager()
+ assertNotNull(shadow.getNotification(null, canonicalId("!r1:example.org")))
+ assertNotNull(shadow.getNotification(null, canonicalId("!r2:example.org")))
+ assertEquals(2, shadow.allNotifications.size)
+ }
+
+ @Test
+ fun showFromPush_withoutUserId_fallsBackToRoomKeyIdentity() {
+ UnifiedPushNotifier.showFromPush(
+ context,
+ pushPayload("!r3:example.org", "\$e9", userId = null)
+ )
+
+ val shadow = shadowNotificationManager()
+ assertNotNull(shadow.getNotification(null, UnifiedPushNotifier.fallbackNotificationId("!r3:example.org")))
+ // The fallback deliberately does NOT match the warm-path identity.
+ assertNull(shadow.getNotification(null, canonicalId("!r3:example.org")))
+ assertEquals(1, shadow.allNotifications.size)
+ }
+
+ @Test
+ fun showFromPush_ignoresMalformedPayloads() {
+ UnifiedPushNotifier.showFromPush(context, "not json at all")
+ UnifiedPushNotifier.showFromPush(context, """{"foo": "bar"}""")
+
+ assertTrue(shadowNotificationManager().allNotifications.isEmpty())
+ }
+}
diff --git a/build.rs b/build.rs
index 92471d8..9776931 100644
--- a/build.rs
+++ b/build.rs
@@ -25,6 +25,7 @@ const COMMANDS: &[&str] = &[
"permission_state",
"set_click_listener_active",
"set_action_listener_active",
+ "set_push_message_listener_active",
"list_distributors",
"set_distributor",
"set_token",
diff --git a/ios/Sources/NotificationPlugin.swift b/ios/Sources/NotificationPlugin.swift
index dce94f2..4f494a3 100644
--- a/ios/Sources/NotificationPlugin.swift
+++ b/ios/Sources/NotificationPlugin.swift
@@ -157,6 +157,10 @@ struct SetActionListenerActiveArgs: Decodable {
let active: Bool
}
+struct SetPushMessageListenerActiveArgs: Decodable {
+ let active: Bool
+}
+
struct PluginConfig: Decodable {
let actionTypes: [ActionType]?
}
@@ -414,6 +418,19 @@ class NotificationPlugin: Plugin {
invoke.reject(error.localizedDescription)
}
}
+
+ // No-op on iOS: remote notifications are delivered through APNs /
+ // UNUserNotificationCenter regardless of JS listener readiness, so
+ // push-message delivery is not gated on listener state. Exists for
+ // command parity with Android, where the gating prevents drops.
+ @objc func setPushMessageListenerActive(_ invoke: Invoke) {
+ do {
+ _ = try invoke.parseArgs(SetPushMessageListenerActiveArgs.self)
+ invoke.resolve()
+ } catch {
+ invoke.reject(error.localizedDescription)
+ }
+ }
}
@_cdecl("init_plugin_notification")
diff --git a/ios/Tests/PluginTests/PluginTests.swift b/ios/Tests/PluginTests/PluginTests.swift
index 85dc2e1..7db31cc 100644
--- a/ios/Tests/PluginTests/PluginTests.swift
+++ b/ios/Tests/PluginTests/PluginTests.swift
@@ -15,6 +15,20 @@ final class NotificationTests: XCTestCase {
XCTAssertEqual(config.actionTypes?.first?.actions.first?.input, true)
}
+ func testSetPushMessageListenerActiveArgsDecodes() throws {
+ let args = try JSONDecoder().decode(
+ SetPushMessageListenerActiveArgs.self, from: Data("{\"active\":true}".utf8))
+ XCTAssertTrue(args.active)
+
+ let inactive = try JSONDecoder().decode(
+ SetPushMessageListenerActiveArgs.self, from: Data("{\"active\":false}".utf8))
+ XCTAssertFalse(inactive.active)
+
+ XCTAssertThrowsError(
+ try JSONDecoder().decode(
+ SetPushMessageListenerActiveArgs.self, from: Data("{}".utf8)))
+ }
+
// MARK: - Notification Content Tests
func testMakeNotificationContentWithBasicNotification() throws {
diff --git a/permissions/autogenerated/commands/set_push_message_listener_active.toml b/permissions/autogenerated/commands/set_push_message_listener_active.toml
new file mode 100644
index 0000000..0975876
--- /dev/null
+++ b/permissions/autogenerated/commands/set_push_message_listener_active.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-set-push-message-listener-active"
+description = "Enables the set_push_message_listener_active command without any pre-configured scope."
+commands.allow = ["set_push_message_listener_active"]
+
+[[permission]]
+identifier = "deny-set-push-message-listener-active"
+description = "Denies the set_push_message_listener_active command without any pre-configured scope."
+commands.deny = ["set_push_message_listener_active"]
diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md
index fae0f95..9a86544 100644
--- a/permissions/autogenerated/reference.md
+++ b/permissions/autogenerated/reference.md
@@ -32,6 +32,7 @@ It allows all notification related features.
- `allow-permission-state`
- `allow-set-click-listener-active`
- `allow-set-action-listener-active`
+- `allow-set-push-message-listener-active`
- `allow-list-distributors`
- `allow-set-distributor`
- `allow-set-token`
@@ -646,6 +647,32 @@ Denies the set_distributor command without any pre-configured scope.
|
+`notifications:allow-set-push-message-listener-active`
+
+ |
+
+
+Enables the set_push_message_listener_active command without any pre-configured scope.
+
+ |
+
+
+
+|
+
+`notifications:deny-set-push-message-listener-active`
+
+ |
+
+
+Denies the set_push_message_listener_active command without any pre-configured scope.
+
+ |
+
+
+
+|
+
`notifications:allow-set-token`
|
diff --git a/permissions/default.toml b/permissions/default.toml
index 1067215..22485c1 100644
--- a/permissions/default.toml
+++ b/permissions/default.toml
@@ -34,6 +34,7 @@ permissions = [
"allow-permission-state",
"allow-set-click-listener-active",
"allow-set-action-listener-active",
+ "allow-set-push-message-listener-active",
"allow-list-distributors",
"allow-set-distributor",
"allow-set-token",
diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json
index 33e79d1..5ec4421 100644
--- a/permissions/schemas/schema.json
+++ b/permissions/schemas/schema.json
@@ -570,6 +570,18 @@
"const": "deny-set-distributor",
"markdownDescription": "Denies the set_distributor command without any pre-configured scope."
},
+ {
+ "description": "Enables the set_push_message_listener_active command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-set-push-message-listener-active",
+ "markdownDescription": "Enables the set_push_message_listener_active command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the set_push_message_listener_active command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-set-push-message-listener-active",
+ "markdownDescription": "Denies the set_push_message_listener_active command without any pre-configured scope."
+ },
{
"description": "Enables the set_token command without any pre-configured scope.",
"type": "string",
@@ -607,10 +619,10 @@
"markdownDescription": "Denies the unregister_for_push_notifications command without any pre-configured scope."
},
{
- "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-cancel-all`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-remove-all`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`\n- `allow-set-action-listener-active`\n- `allow-list-distributors`\n- `allow-set-distributor`\n- `allow-set-token`",
+ "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-cancel-all`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-remove-all`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`\n- `allow-set-action-listener-active`\n- `allow-set-push-message-listener-active`\n- `allow-list-distributors`\n- `allow-set-distributor`\n- `allow-set-token`",
"type": "string",
"const": "default",
- "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-cancel-all`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-remove-all`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`\n- `allow-set-action-listener-active`\n- `allow-list-distributors`\n- `allow-set-distributor`\n- `allow-set-token`"
+ "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-cancel-all`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-remove-all`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`\n- `allow-set-action-listener-active`\n- `allow-set-push-message-listener-active`\n- `allow-list-distributors`\n- `allow-set-distributor`\n- `allow-set-token`"
}
]
}
diff --git a/src/commands.rs b/src/commands.rs
index 1f7fb68..e73af70 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -161,6 +161,15 @@ pub fn set_action_listener_active(
notification.set_action_listener_active(active)
}
+#[command]
+pub fn set_push_message_listener_active(
+ _app: AppHandle,
+ notification: State<'_, Notifications>,
+ active: bool,
+) -> Result<()> {
+ notification.set_push_message_listener_active(active)
+}
+
#[command]
pub fn remove_active(
_app: AppHandle,
diff --git a/src/desktop.rs b/src/desktop.rs
index 01c159c..ad9e309 100644
--- a/src/desktop.rs
+++ b/src/desktop.rs
@@ -392,6 +392,10 @@ impl Notifications {
Ok(())
}
+ pub const fn set_push_message_listener_active(&self, _active: bool) -> crate::Result<()> {
+ Ok(())
+ }
+
/// Linux: closes every tracked notification whose caller-supplied id
/// appears in `ids` and removes it from the active map.
/// macOS / Windows: unsupported.
diff --git a/src/lib.rs b/src/lib.rs
index c1eefc2..d08e8dc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -311,6 +311,7 @@ pub fn init() -> TauriPlugin> {
commands::get_active,
commands::set_click_listener_active,
commands::set_action_listener_active,
+ commands::set_push_message_listener_active,
commands::remove_active,
commands::remove_all,
commands::cancel,
diff --git a/src/macos.rs b/src/macos.rs
index 06b283f..aa86a90 100644
--- a/src/macos.rs
+++ b/src/macos.rs
@@ -327,6 +327,10 @@ impl Notifications {
Ok(())
}
+ pub const fn set_push_message_listener_active(&self, _active: bool) -> crate::Result<()> {
+ Ok(())
+ }
+
/// Create a notification channel (not supported on macOS).
pub fn create_channel(&self, _channel: crate::Channel) -> crate::Result<()> {
Err(crate::Error::Io(std::io::Error::other(
diff --git a/src/mobile.rs b/src/mobile.rs
index 20a0d41..795fea5 100644
--- a/src/mobile.rs
+++ b/src/mobile.rs
@@ -263,4 +263,15 @@ impl Notifications {
.run_mobile_plugin("setActionListenerActive", args)
.map_err(Into::into)
}
+
+ /// Set push-message listener active state.
+ /// Until JS attaches, incoming push messages fall back to a native
+ /// notification instead of being emitted into the void.
+ pub fn set_push_message_listener_active(&self, active: bool) -> crate::Result<()> {
+ let mut args = HashMap::new();
+ args.insert("active", active);
+ self.0
+ .run_mobile_plugin("setPushMessageListenerActive", args)
+ .map_err(Into::into)
+ }
}
diff --git a/src/windows.rs b/src/windows.rs
index 1cb3753..0643124 100644
--- a/src/windows.rs
+++ b/src/windows.rs
@@ -1061,6 +1061,10 @@ impl Notifications {
self.plugin.set_action_listener(active)
}
+ pub const fn set_push_message_listener_active(&self, _active: bool) -> crate::Result<()> {
+ Ok(())
+ }
+
/// Create a notification channel (not supported on Windows).
pub fn create_channel(&self, _channel: crate::Channel) -> crate::Result<()> {
Err(crate::Error::Io(std::io::Error::other(