diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 71b493d..eb7f5ea 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -29,17 +29,17 @@ - - + + android:exported="false"> - - - - + - + diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 900b614..33a8cfc 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -81,6 +81,11 @@ class SetActionListenerActiveArgs { var active: Boolean = false } +@InvokeArg +class SetPushMessageListenerActiveArgs { + var active: Boolean = false +} + @InvokeArg class DistributorArgs { var distributor: String? = null @@ -135,6 +140,11 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private var hasActionListener = false private val pendingNotificationActions = ArrayDeque() + // 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(