diff --git a/README.md b/README.md index d8c0ed0b..e00317f2 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,10 @@ await sendNotification({ #### Interactive Notifications with Actions +For provider-rendered iOS notifications, configure action types in the Tauri +`plugins.notifications.actionTypes` config so categories are registered before +APNs delivers a notification. The provider's `aps.category` must match `id`. + ```typescript import { sendNotification, @@ -284,8 +288,9 @@ await sendNotification({ }); // Listen for action events -const unlisten = await onAction((notification) => { - console.log('Action performed on notification:', notification); +const unlisten = await onAction(({ actionId, inputValue, notification }) => { + console.log('Action:', actionId, 'reply:', inputValue); + console.log('Routing metadata:', notification.extra); }); // Stop listening diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 8d0f1284..a34ac8e8 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -7,6 +7,8 @@ import android.app.NotificationManager import android.content.Context import android.content.Intent import android.os.Build +import android.os.Handler +import android.os.Looper import android.webkit.WebView import app.tauri.PermissionState import app.tauri.annotation.Command @@ -21,14 +23,19 @@ import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin import com.google.firebase.messaging.FirebaseMessaging import org.unifiedpush.android.connector.UnifiedPush +import java.util.ArrayDeque +import java.util.UUID const val LOCAL_NOTIFICATIONS = "permissionState" +private const val MAX_PENDING_ACTIONS = 32 +private const val PUSH_REGISTRATION_TIMEOUT_MS = 30_000L @InvokeArg class PluginConfig { var icon: String? = null var sound: String? = null var iconColor: String? = null + var actionTypes: List? = null } @InvokeArg @@ -62,6 +69,7 @@ class RegisterActionTypesArgs { @InvokeArg class RegisterPushArgs { var vapid: String? = null + var provider: String? = null } @InvokeArg @@ -69,6 +77,11 @@ class SetClickListenerActiveArgs { var active: Boolean = false } +@InvokeArg +class SetActionListenerActiveArgs { + var active: Boolean = false +} + @InvokeArg class DistributorArgs { var distributor: String? = null @@ -97,13 +110,31 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private lateinit var notificationStorage: NotificationStorage private var channelManager = ChannelManager(activity) - private var pendingTokenInvoke: Invoke? = null - private var cachedToken: String? = null - private var vapid: String? = null + private data class PushRegistration( + val vapid: String?, + val provider: String, + val instance: String?, + var distributor: String?, + var phase: PushRegistrationPhase, + val invoke: Invoke, + val generation: Long, + var timeout: Runnable? = null + ) + + private enum class PushRegistrationPhase { PERMISSION, DISTRIBUTOR, UNIFIED_PUSH, FCM } + + private var pendingPushRegistration: PushRegistration? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var fcmToken: String? = null + private val unifiedPushState = UnifiedPushStateStore(activity) + private var unifiedPushGeneration = 0L + // Click listener tracking for cold-start support private var hasClickedListener = false private var pendingNotificationClick: JSObject? = null + private var hasActionListener = false + private val pendingNotificationActions = ArrayDeque() // onNewIntent can fire before load() during a cold start triggered // by a notification tap (Android delivers the launch intent via @@ -165,6 +196,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { manager.createNotificationChannel() this.manager = manager + getConfig(PluginConfig::class.java)?.actionTypes?.let { notificationStorage.writeActionGroup(it) } notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -196,6 +228,16 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { onIntent(intent) } + override fun onDestroy() { + pendingPushRegistration?.let { registration -> + if (registration.phase == PushRegistrationPhase.UNIFIED_PUSH || + registration.phase == PushRegistrationPhase.DISTRIBUTOR) retireUnifiedPush(registration.instance) + } + finishPushRegistrationError("Notification plugin destroyed during push registration") + if (instance === this) instance = null + super.onDestroy() + } + fun onIntent(intent: Intent) { Logger.debug(Logger.tags(TAG), "onIntent called - action: ${intent.action}, extras: ${intent.extras?.keySet()}") @@ -203,11 +245,17 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { if (Intent.ACTION_MAIN == intent.action) { val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage) if (dataJson != null) { - trigger("actionPerformed", dataJson) - triggerNotificationClicked( - intent.getIntExtra(NOTIFICATION_INTENT_KEY, -1), - extractLocalNotificationData(intent) - ) + when (dataJson.getString("actionId")) { + DEFAULT_PRESS_ACTION -> { + triggerActionPerformed(dataJson) + triggerNotificationClicked( + intent.getIntExtra(NOTIFICATION_INTENT_KEY, -1), + extractLocalNotificationData(intent) + ) + } + "dismiss" -> triggerActionPerformed(dataJson) + else -> triggerActionPerformed(dataJson) + } return } } @@ -268,6 +316,15 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } } + private fun triggerActionPerformed(data: JSObject) { + if (hasActionListener) { + trigger("actionPerformed", data) + } else { + if (pendingNotificationActions.size >= MAX_PENDING_ACTIONS) pendingNotificationActions.removeFirst() + pendingNotificationActions.add(data) + } + } + @Command fun show(invoke: Invoke) { val notification = invoke.parseArgs(Notification::class.java) @@ -397,62 +454,119 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } - invoke.parseArgs(RegisterPushArgs::class.java).vapid?.let { vapid = it } + val args = invoke.parseArgs(RegisterPushArgs::class.java) + val requestedVapid = args.vapid + val provider = args.provider ?: "auto" + if (provider !in setOf("auto", "fcm", "unifiedpush")) { + invoke.reject("Unknown push provider: $provider") + return + } + pendingPushRegistration?.let { registration -> + invoke.reject("Push registration already in progress") + return + } + if (provider == "fcm" && unifiedPushState.activeProvider == "unifiedpush") { + invoke.reject("Active UnifiedPush registration must be unregistered first") + return + } + if (provider == "unifiedpush" && unifiedPushState.activeProvider == "fcm") { + invoke.reject("Active FCM registration must be unregistered first") + return + } + val distributor = if (provider == "fcm") null else UnifiedPush.getSavedDistributor(activity) + pendingPushRegistration = PushRegistration( + requestedVapid, + provider, + if (provider == "fcm") null else "sable-registration-${UUID.randomUUID()}", + distributor, + PushRegistrationPhase.PERMISSION, + invoke, + ++unifiedPushGeneration + ) + schedulePushRegistrationTimeout() // First check if notifications are enabled if (!manager.areNotificationsEnabled()) { // Request permissions first if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (getPermissionState(LOCAL_NOTIFICATIONS) !== PermissionState.GRANTED) { - // Request permissions and then get token - pendingTokenInvoke = invoke - requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "pushPermissionsCallback") + try { + requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "pushPermissionsCallback") + } catch (error: Exception) { + finishPushRegistrationError(error.message ?: "Failed to request notification permissions") + } return } } else { - invoke.reject("Notification permissions not granted") + finishPushRegistrationError("Notification permissions not granted") return } } - proceedPushRegistration(invoke) + proceedPushRegistration() } - private fun proceedPushRegistration(invoke: Invoke) { - val webPushVapid = vapid - if (webPushVapid != null) { - pendingTokenInvoke = invoke - UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success -> - if (!success) { - pendingTokenInvoke?.reject("No UnifiedPush distributor available") - pendingTokenInvoke = null - return@tryUseCurrentOrDefaultDistributor - } - try { - UnifiedPush.register(activity, "default", vapid = webPushVapid) - } catch (error: Exception) { - pendingTokenInvoke?.reject(error.message ?: "UnifiedPush registration failed") - pendingTokenInvoke = null + private fun proceedPushRegistration() { + val registration = pendingPushRegistration ?: return + val webPushVapid = registration.vapid + if (registration.provider != "fcm" && registration.distributor != null) { + registration.phase = PushRegistrationPhase.UNIFIED_PUSH + try { + UnifiedPush.register(activity, registration.instance!!, vapid = webPushVapid) + } catch (error: Exception) { + finishPushRegistrationError(error.message ?: "UnifiedPush registration failed") + } + return + } + + if (webPushVapid != null && (registration.provider == "unifiedpush" || registration.provider == "auto")) { + registration.phase = PushRegistrationPhase.DISTRIBUTOR + try { + UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success -> + if (pendingPushRegistration !== registration) return@tryUseCurrentOrDefaultDistributor + if (!success) { + finishPushRegistrationError("No UnifiedPush distributor available") + return@tryUseCurrentOrDefaultDistributor + } + try { + registration.distributor = UnifiedPush.getSavedDistributor(activity) + registration.phase = PushRegistrationPhase.UNIFIED_PUSH + UnifiedPush.register(activity, registration.instance!!, vapid = webPushVapid) + } catch (error: Exception) { + finishPushRegistrationError(error.message ?: "UnifiedPush registration failed") + } } + } catch (error: Exception) { + finishPushRegistrationError(error.message ?: "UnifiedPush registration failed") + } + return + } + + if (registration.provider != "fcm" && UnifiedPush.getSavedDistributor(activity) != null) { + registration.phase = PushRegistrationPhase.UNIFIED_PUSH + try { + UnifiedPush.register(activity, registration.instance!!) + } catch (error: Exception) { + finishPushRegistrationError(error.message ?: "UnifiedPush registration failed") } return } - if (UnifiedPush.getSavedDistributor(activity) != null) { - pendingTokenInvoke = invoke - UnifiedPush.register(activity, "default") + if (registration.provider == "unifiedpush") { + finishPushRegistrationError("No UnifiedPush distributor available") return } - cachedToken?.let { + fcmToken?.let { + unifiedPushState.activeProvider = "fcm" val result = JSObject() result.put("deviceToken", it) - invoke.resolve(result) + finishPushRegistrationSuccess(result) return } - pendingTokenInvoke = invoke - getFirebaseToken() + registration.phase = PushRegistrationPhase.FCM + getFirebaseToken(registration) } @Command @@ -462,9 +576,20 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } - if (UnifiedPush.getSavedDistributor(activity) != null) { - UnifiedPush.unregister(activity, "default") - cachedToken = null + val pendingUnifiedPush = pendingPushRegistration?.takeIf { + it.phase == PushRegistrationPhase.UNIFIED_PUSH || it.phase == PushRegistrationPhase.DISTRIBUTOR + } + val instanceToUnregister = pendingUnifiedPush?.instance ?: unifiedPushState.activeInstance + finishPushRegistrationError("Push registration cancelled by unregister") + + if (pendingUnifiedPush != null || unifiedPushState.activeProvider == "unifiedpush") { + try { + retireUnifiedPush(instanceToUnregister) + } catch (error: Exception) { + invoke.reject(error.message ?: "Failed to unregister UnifiedPush") + return + } + unifiedPushState.activeProvider = null invoke.resolve() return } @@ -474,20 +599,24 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { invoke.reject("Failed to delete FCM token: ${task.exception?.message}") return@addOnCompleteListener } - cachedToken = null + fcmToken = null + if (unifiedPushState.activeProvider == "fcm") unifiedPushState.activeProvider = null invoke.resolve() } } @PermissionCallback private fun pushPermissionsCallback(invoke: Invoke) { + val registration = pendingPushRegistration + if (registration?.phase != PushRegistrationPhase.PERMISSION) { + return + } if (!manager.areNotificationsEnabled()) { - invoke.reject("Notification permissions denied") - pendingTokenInvoke = null + finishPushRegistrationError("Notification permissions denied") return } - proceedPushRegistration(invoke) + proceedPushRegistration() } @Command @@ -508,6 +637,17 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { invoke.reject("Distributor parameter is required") return } + if (pendingPushRegistration != null) { + invoke.reject("Cannot change distributor while push registration is in progress") + return + } + if (UnifiedPush.getSavedDistributor(activity) != distributor) { + try { retireUnifiedPush(unifiedPushState.activeInstance) } catch (error: Exception) { + invoke.reject(error.message ?: "Failed to retire UnifiedPush registration") + return + } + if (unifiedPushState.activeProvider == "unifiedpush") unifiedPushState.activeProvider = null + } UnifiedPush.saveDistributor(activity, distributor) invoke.resolve() } @@ -517,63 +657,130 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { invoke.resolve() } - fun onUnifiedPushNewEndpoint(endpoint: String, p256dh: String?, auth: String?) { - cachedToken = endpoint + fun onUnifiedPushNewEndpoint(endpoint: String, p256dh: String?, auth: String?, instance: String) { + val registration = pendingPushRegistration + if (registration?.phase != PushRegistrationPhase.UNIFIED_PUSH || + registration.generation != unifiedPushGeneration || instance != registration.instance) { + return + } + unifiedPushState.setUnifiedPushActive() + unifiedPushState.activeInstance = registration.instance val result = JSObject() result.put("deviceToken", endpoint) + result.put("instance", UnifiedPushStateStore.INSTANCE) p256dh?.let { result.put("p256dh", it) } auth?.let { result.put("auth", it) } - pendingTokenInvoke?.resolve(result) - pendingTokenInvoke = null + finishPushRegistrationSuccess(result) - val data = JSObject() - data.put("token", endpoint) - p256dh?.let { data.put("p256dh", it) } - auth?.let { data.put("auth", it) } - trigger("push-token", data) + triggerUnifiedPushToken(endpoint, p256dh, auth, if (registration.vapid == null) "direct" else "webpush") } - fun onUnifiedPushRegistrationFailed(reason: String?) { - pendingTokenInvoke?.reject(reason ?: "UnifiedPush registration failed") - pendingTokenInvoke = null + fun onUnifiedPushRegistrationFailed(reason: String?, instance: String) { + val registration = pendingPushRegistration + if (registration?.phase != PushRegistrationPhase.UNIFIED_PUSH || + registration.generation != unifiedPushGeneration || instance != registration.instance) return + finishPushRegistrationError(reason ?: "UnifiedPush registration failed") } - fun onUnifiedPushUnregistered() { - pendingTokenInvoke?.resolve(JSObject()) - pendingTokenInvoke = null - cachedToken = null + fun onUnifiedPushUnregistered(instance: String) { } - fun onUnifiedPushMessage(content: String) { + fun onUnifiedPushTemporaryUnavailable(instance: String) { + // Temporary distributor loss is nonterminal; endpoint/failure/timeout settles registration. + } + + fun onNotificationDismissed(id: Int) { + val notification = JSObject() + notification.put("id", id) + val action = JSObject() + action.put("actionId", "dismiss") + action.put("notification", notification) + triggerActionPerformed(action) + } + + fun onUnifiedPushMessage(content: String, instance: String) { + if (instance != unifiedPushState.activeInstance || unifiedPushState.activeProvider != "unifiedpush") return val data = JSObject() data.put("message", content) + data.put("transport", "unifiedpush") + data.put("instance", "default") trigger("push-message", data) } - private fun getFirebaseToken() { + private fun triggerUnifiedPushToken(endpoint: String, p256dh: String?, auth: String?, mode: String) { + val data = JSObject() + data.put("token", endpoint) + data.put("provider", "unifiedpush") + data.put("instance", UnifiedPushStateStore.INSTANCE) + data.put("mode", mode) + p256dh?.let { data.put("p256dh", it) } + auth?.let { data.put("auth", it) } + trigger("push-token", data) + } + + private fun getFirebaseToken(registration: PushRegistration) { if (!BuildConfig.ENABLE_PUSH_NOTIFICATIONS) { - pendingTokenInvoke?.reject("Push notifications are disabled in this build") - pendingTokenInvoke = null + finishPushRegistrationError("Push notifications are disabled in this build") return } - FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (!task.isSuccessful) { - val errorMessage = "Failed to get FCM token: ${task.exception?.message}" - val errorData = JSObject() - errorData.put("message", errorMessage) - trigger("push-error", errorData) - pendingTokenInvoke?.reject(errorMessage) - pendingTokenInvoke = null - return@addOnCompleteListener + try { + FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + if (pendingPushRegistration !== registration || registration.phase != PushRegistrationPhase.FCM) { + return@addOnCompleteListener + } + if (!task.isSuccessful) { + val errorMessage = "Failed to get FCM token: ${task.exception?.message}" + val errorData = JSObject() + errorData.put("message", errorMessage) + trigger("push-error", errorData) + finishPushRegistrationError(errorMessage) + return@addOnCompleteListener + } + + val token = task.result + fcmToken = token + unifiedPushState.activeProvider = "fcm" + val result = JSObject() + result.put("deviceToken", token) + finishPushRegistrationSuccess(result) } + } catch (error: Exception) { + finishPushRegistrationError(error.message ?: "Failed to get FCM token") + } + } - val token = task.result - cachedToken = token - val result = JSObject() - result.put("deviceToken", token) - pendingTokenInvoke?.resolve(result) - pendingTokenInvoke = null + private fun finishPushRegistrationSuccess(result: JSObject) { + val registration = pendingPushRegistration ?: return + pendingPushRegistration = null + registration.timeout?.let { mainHandler.removeCallbacks(it) } + registration.invoke.resolve(result) + } + + private fun finishPushRegistrationError(message: String) { + val registration = pendingPushRegistration ?: return + pendingPushRegistration = null + registration.timeout?.let { mainHandler.removeCallbacks(it) } + registration.invoke.reject(message) + } + + private fun schedulePushRegistrationTimeout() { + val registration = pendingPushRegistration ?: return + val timeout = Runnable { + if (pendingPushRegistration === registration) { + if (registration.phase == PushRegistrationPhase.UNIFIED_PUSH || registration.phase == PushRegistrationPhase.DISTRIBUTOR) { + retireUnifiedPush(registration.instance) + } + finishPushRegistrationError("Timed out registering for push notifications") + } + } + registration.timeout = timeout + mainHandler.postDelayed(timeout, PUSH_REGISTRATION_TIMEOUT_MS) + } + + private fun retireUnifiedPush(instance: String?) { + unifiedPushGeneration++ + try { UnifiedPush.unregister(activity, instance ?: UnifiedPushStateStore.INSTANCE) } catch (_: Exception) { } } @@ -581,7 +788,8 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { fun handleNewToken(token: String) { if (!BuildConfig.ENABLE_PUSH_NOTIFICATIONS) return - cachedToken = token + if (unifiedPushState.activeProvider != "fcm") return + fcmToken = token // Trigger push-token event to notify the frontend about the token val data = JSObject() data.put("token", token) @@ -591,6 +799,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { // Called by TauriFirebaseMessagingService when a push message is received fun triggerPushMessage(pushData: Map) { if (!BuildConfig.ENABLE_PUSH_NOTIFICATIONS) return + if (unifiedPushState.activeProvider != "fcm") return val data = JSObject() for ((key, value) in pushData) { @@ -650,4 +859,18 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { invoke.resolve() } + + @Command + fun setActionListenerActive(invoke: Invoke) { + val args = invoke.parseArgs(SetActionListenerActiveArgs::class.java) + hasActionListener = args.active + + if (args.active) { + while (pendingNotificationActions.isNotEmpty()) { + trigger("actionPerformed", pendingNotificationActions.removeFirst()) + } + } + + invoke.resolve() + } } diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index 7c66790b..541bf379 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -466,6 +466,7 @@ class NotificationDismissReceiver : BroadcastReceiver() { val notificationStorage = NotificationStorage(context, ObjectMapper()) notificationStorage.deleteNotification(intExtra.toString()) } + NotificationPlugin.instance?.onNotificationDismissed(intExtra) } } diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt index 3093d17f..65ae645f 100644 --- a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt +++ b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt @@ -99,6 +99,7 @@ object UnifiedPushNotifier { put("room_id", roomId) put("event_id", eventId) if (userId.isNotEmpty()) put("user_id", userId) + put("instance", UnifiedPushStateStore.INSTANCE) } val sourceJson = JSONObject().apply { put("id", notifId) diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt b/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt index 044fd34d..175d033f 100644 --- a/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt +++ b/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt @@ -3,6 +3,7 @@ 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.data.PushEndpoint import org.unifiedpush.android.connector.data.PushMessage @@ -12,22 +13,29 @@ class UnifiedPushReceiver : MessagingReceiver() { endpoint.url, endpoint.pubKeySet?.pubKey, endpoint.pubKeySet?.auth, + instance, ) } override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { - NotificationPlugin.instance?.onUnifiedPushRegistrationFailed(reason.name) + NotificationPlugin.instance?.onUnifiedPushRegistrationFailed(reason.name, instance) } override fun onUnregistered(context: Context, instance: String) { - NotificationPlugin.instance?.onUnifiedPushUnregistered() + NotificationPlugin.instance?.onUnifiedPushUnregistered(instance) + } + + override fun onTempUnavailable(context: Context, instance: String) { + NotificationPlugin.instance?.onUnifiedPushTemporaryUnavailable(instance) } override fun onMessage(context: Context, message: PushMessage, instance: String) { val content = String(message.content, Charsets.UTF_8) + val state = UnifiedPushStateStore(context) + if (instance != state.activeInstance || state.activeProvider != "unifiedpush") return val plugin = NotificationPlugin.instance if (plugin != null) { - plugin.onUnifiedPushMessage(content) + plugin.onUnifiedPushMessage(content, instance) } else { UnifiedPushNotifier.showFromPush(context, content) } diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushStateStore.kt b/android/src/main/java/app/tauri/notification/UnifiedPushStateStore.kt new file mode 100644 index 00000000..28e7b7c9 --- /dev/null +++ b/android/src/main/java/app/tauri/notification/UnifiedPushStateStore.kt @@ -0,0 +1,21 @@ +package app.tauri.notification + +import android.content.Context +import org.unifiedpush.android.connector.UnifiedPush + +internal class UnifiedPushStateStore(private val context: Context) { + private val prefs = context.getSharedPreferences("tauri-notifications", Context.MODE_PRIVATE) + + var activeProvider: String? + get() = prefs.getString("push-provider", null)?.takeUnless { it == "none" } + ?: if (!prefs.contains("push-provider") && UnifiedPush.getSavedDistributor(context) != null) "unifiedpush" else null + set(value) = prefs.edit().putString("push-provider", value ?: "none").apply() + var activeInstance: String? + get() = prefs.getString("push-instance", null) ?: INSTANCE + set(value) = prefs.edit().putString("push-instance", value ?: INSTANCE).apply() + fun setUnifiedPushActive() { activeProvider = "unifiedpush" } + + companion object { + const val INSTANCE = "default" + } +} diff --git a/build.rs b/build.rs index 1b782809..92471d8d 100644 --- a/build.rs +++ b/build.rs @@ -24,6 +24,7 @@ const COMMANDS: &[&str] = &[ "create_channel", "permission_state", "set_click_listener_active", + "set_action_listener_active", "list_distributors", "set_distributor", "set_token", diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index 540bfbb1..650122d4 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -482,10 +482,21 @@ describe("Notification Functions", () => { expect(mockInvoke).toHaveBeenCalledWith( "plugin:notifications|register_for_push_notifications", - { vapid: "vapid-key" }, + { vapid: "vapid-key", provider: "auto" }, ); expect(result).toBe(registration); }); + + it("should forward an explicit push provider", async () => { + mockInvoke.mockResolvedValue({ deviceToken: "token" }); + + await registerForPushNotifications("vapid-key", "unifiedpush"); + + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|register_for_push_notifications", + { vapid: "vapid-key", provider: "unifiedpush" }, + ); + }); }); describe("unregisterForPushNotifications", () => { @@ -910,7 +921,9 @@ describe("Notification Functions", () => { mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; - return Promise.resolve(vi.fn()); + return Promise.resolve({ + unregister: vi.fn().mockResolvedValue(undefined), + }); }); const callback = vi.fn(); @@ -924,8 +937,9 @@ describe("Notification Functions", () => { describe("onAction", () => { it("should register action performed listener", async () => { - const mockUnlisten = vi.fn(); - mockAddPluginListener.mockResolvedValue(mockUnlisten); + const mockUnregister = vi.fn().mockResolvedValue(undefined); + mockAddPluginListener.mockResolvedValue({ unregister: mockUnregister }); + mockInvoke.mockResolvedValue(undefined); const callback = vi.fn(); const unlisten = await onAction(callback); @@ -935,24 +949,99 @@ describe("Notification Functions", () => { "actionPerformed", callback, ); - expect(unlisten).toBe(mockUnlisten); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|set_action_listener_active", + { active: true }, + ); + expect(unlisten).toHaveProperty("unregister"); + await unlisten.unregister(); }); it("should call callback when action performed", async () => { - const mockNotification = { title: "Test", actionTypeId: "action-1" }; - let capturedCallback: ((notification: any) => void) | undefined; + const mockAction = { + actionId: "reply", + inputValue: "Hello", + notification: { + id: 1, + actionTypeId: "message-actions", + extra: { room_id: "!room:example.org" }, + }, + }; + let capturedCallback: ((action: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; - return Promise.resolve(vi.fn()); + return Promise.resolve({ + unregister: vi.fn().mockResolvedValue(undefined), + }); }); const callback = vi.fn(); - await onAction(callback); + const listener = await onAction(callback); - capturedCallback?.(mockNotification); + capturedCallback?.(mockAction); - expect(callback).toHaveBeenCalledWith(mockNotification); + expect(callback).toHaveBeenCalledWith(mockAction); + await listener.unregister(); + }); + + it("should notify native side before unregistering", async () => { + const mockUnregister = vi.fn().mockResolvedValue(undefined); + mockAddPluginListener.mockResolvedValue({ unregister: mockUnregister }); + mockInvoke.mockResolvedValue(undefined); + + const listener = await onAction(vi.fn()); + mockInvoke.mockClear(); + await listener.unregister(); + + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|set_action_listener_active", + { active: false }, + ); + expect(mockUnregister).toHaveBeenCalled(); + }); + + it("activates once for multiple listeners and deactivates after the last one", async () => { + const firstUnregister = vi.fn().mockResolvedValue(undefined); + const secondUnregister = vi.fn().mockResolvedValue(undefined); + mockAddPluginListener + .mockResolvedValueOnce({ unregister: firstUnregister }) + .mockResolvedValueOnce({ unregister: secondUnregister }); + mockInvoke.mockResolvedValue(undefined); + + const first = await onAction(vi.fn()); + const second = await onAction(vi.fn()); + expect(mockInvoke).toHaveBeenCalledTimes(1); + await first.unregister(); + expect(mockInvoke).toHaveBeenCalledTimes(1); + await second.unregister(); + expect(mockInvoke).toHaveBeenCalledTimes(2); + }); + + it("rolls back the JS listener when activation fails", async () => { + const unregister = vi.fn().mockResolvedValue(undefined); + mockAddPluginListener.mockResolvedValue({ unregister }); + mockInvoke.mockRejectedValueOnce(new Error("activation failed")); + + await expect(onAction(vi.fn())).rejects.toThrow("activation failed"); + expect(unregister).toHaveBeenCalled(); + }); + + it("keeps the final JS listener and allows cleanup retry when deactivation fails", async () => { + const unregister = vi.fn().mockResolvedValue(undefined); + mockAddPluginListener.mockResolvedValue({ unregister }); + mockInvoke + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("deactivation failed")) + .mockResolvedValueOnce(undefined); + + const listener = await onAction(vi.fn()); + await expect(listener.unregister()).rejects.toThrow( + "deactivation failed", + ); + expect(unregister).not.toHaveBeenCalled(); + await listener.unregister(); + expect(unregister).toHaveBeenCalledTimes(1); }); }); diff --git a/guest-js/index.ts b/guest-js/index.ts index 80ca1339..4f0113a7 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -359,6 +359,27 @@ interface ActiveNotification { sound?: string; } +/** Data received when an action is performed on a notification. */ +interface ActionPerformedData { + /** Identifier of the selected action, or `tap`/`dismiss` for system actions. */ + actionId: string; + /** Text entered for an action with `input: true`. */ + inputValue?: string; + /** The notification, including its action type and extra metadata. */ + notification: ActionNotification; +} + +/** Notification data included with an action result. Platform-specific fields are optional. */ +interface ActionNotification { + /** Numeric local notification ID, or -1 when a provider notification has no numeric ID. */ + id: number; + title?: string; + body?: string; + actionTypeId?: string; + extra?: Record; + source?: "local" | "push"; +} + /** * The importance level of a notification channel (Android). */ @@ -445,8 +466,11 @@ interface PushRegistration { deviceToken: string; p256dh?: string; auth?: string; + instance?: string; } +type PushProvider = "auto" | "fcm" | "unifiedpush"; + /** * Registers the app for push notifications. * @@ -458,9 +482,11 @@ interface PushRegistration { */ async function registerForPushNotifications( vapid?: string, + provider: PushProvider = "auto", ): Promise { return await invoke("plugin:notifications|register_for_push_notifications", { vapid, + provider, }); } @@ -811,13 +837,62 @@ async function onNotificationReceived( * // unlisten(); * ``` * - * @param cb - Callback function to handle notification actions. + * @param cb - Callback function to handle notification action results. * @returns A promise resolving to a function that removes the listener. */ async function onAction( - cb: (notification: Options) => void, + cb: (action: ActionPerformedData) => void, ): Promise { - return await addPluginListener("notifications", "actionPerformed", cb); + const listener = await addPluginListener( + "notifications", + "actionPerformed", + cb, + ); + try { + await withActionListenerLock(async () => { + if (actionListenerCount === 0) { + await invoke("plugin:notifications|set_action_listener_active", { + active: true, + }); + } + actionListenerCount += 1; + }); + } catch (error) { + await listener.unregister(); + throw error; + } + + let unregistered = false; + return { + unregister: async () => { + if (unregistered) return; + await withActionListenerLock(async () => { + if (actionListenerCount > 1) { + actionListenerCount -= 1; + return; + } + // Keep the final listener until native deactivation succeeds. + await invoke("plugin:notifications|set_action_listener_active", { + active: false, + }); + actionListenerCount = 0; + }); + unregistered = true; + await listener.unregister(); + }, + } as PluginListener; +} + +let actionListenerCount = 0; +let actionListenerLock = Promise.resolve(); + +function withActionListenerLock(operation: () => Promise): Promise { + const result = actionListenerLock.then(operation, operation); + actionListenerLock = result.then( + () => undefined, + () => undefined, + ); + return result; } /** @@ -885,7 +960,10 @@ export type { Channel, ScheduleInterval, NotificationClickedData, + ActionPerformedData, + ActionNotification, PushRegistration, + PushProvider, }; export { diff --git a/ios/Sources/NotificationHandler.swift b/ios/Sources/NotificationHandler.swift index 020e1478..66ec22b9 100644 --- a/ios/Sources/NotificationHandler.swift +++ b/ios/Sources/NotificationHandler.swift @@ -1,3 +1,4 @@ +import Foundation import Tauri import UserNotifications @@ -8,20 +9,50 @@ public class NotificationHandler: NSObject, NotificationHandlerProtocol { private var notificationsMap = [String: Notification]() private var hasClickedListener = false private var pendingNotificationClick: NotificationClickedData? = nil + private var hasActionListener = false + private var pendingNotificationActions = [ReceivedNotification]() + private let maxPendingActions = 32 + // Serializes delegate callbacks and plugin commands. + private let stateLock = NSRecursiveLock() internal func saveNotification(_ key: String, _ notification: Notification) { + stateLock.lock() + defer { stateLock.unlock() } notificationsMap.updateValue(notification, forKey: key) } func setClickListenerActive(_ active: Bool) { + stateLock.lock() hasClickedListener = active + let pending = active ? pendingNotificationClick : nil + if pending != nil { pendingNotificationClick = nil } + stateLock.unlock() + if let pending { try? self.plugin?.trigger("notificationClicked", data: pending) } + } - if active, let pending = pendingNotificationClick { - pendingNotificationClick = nil - try? self.plugin?.trigger("notificationClicked", data: pending) + func setActionListenerActive(_ active: Bool) { + stateLock.lock() + hasActionListener = active + let pendingActions = active ? pendingNotificationActions : [] + if active { pendingNotificationActions.removeAll() } + stateLock.unlock() + guard active else { return } + for action in pendingActions { + try? self.plugin?.trigger("actionPerformed", data: action) } } + private func triggerActionPerformed(_ action: ReceivedNotification) { + stateLock.lock() + let shouldTrigger = hasActionListener + if !shouldTrigger && pendingNotificationActions.count >= maxPendingActions { + pendingNotificationActions.removeFirst() + } + if !shouldTrigger { pendingNotificationActions.append(action) } + stateLock.unlock() + if shouldTrigger { try? self.plugin?.trigger("actionPerformed", data: action) } + } + public func requestPermissions(with completion: ((Bool, Error?) -> Void)? = nil) { let center = UNUserNotificationCenter.current() center.requestAuthorization(options: [.badge, .alert, .sound]) { (granted, error) in @@ -37,63 +68,48 @@ public class NotificationHandler: NSObject, NotificationHandlerProtocol { } public func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions { + stateLock.lock() + var event: (String, Encodable)? = nil // Trigger notification event for both local and push notifications if var notificationData = toActiveNotification(notification.request) { notificationData.source = "local" - try? self.plugin?.trigger("notification", data: notificationData) + event = ("notification", notificationData) } else { var notificationData = toReceivedNotification(notification.request) notificationData.source = "push" - try? self.plugin?.trigger("notification", data: notificationData) + event = ("notification", notificationData) } // For push notifications in foreground, don't show system notification // (only trigger event so developer can handle it) let isPushNotification = notification.request.trigger?.isKind(of: UNPushNotificationTrigger.self) == true + let options: UNNotificationPresentationOptions if isPushNotification { - return UNNotificationPresentationOptions.init(rawValue: 0) - } - - // For local notifications, check if silent - if let options = notificationsMap[notification.request.identifier] { - if options.silent ?? false { - return UNNotificationPresentationOptions.init(rawValue: 0) - } + options = UNNotificationPresentationOptions(rawValue: 0) + } else if let local = notificationsMap[notification.request.identifier], local.silent ?? false { + options = UNNotificationPresentationOptions(rawValue: 0) + } else { + options = [.badge, .sound, .alert] } - - return [ - .badge, - .sound, - .alert, - ] + stateLock.unlock() + if let event { try? self.plugin?.trigger(event.0, data: event.1) } + return options } /// Convert notification request to ReceivedNotification (for push notifications not in map) private func toReceivedNotification(_ request: UNNotificationRequest) -> ReceivedNotificationData { let content = request.content - var extra: [String: String]? = nil - - if !content.userInfo.isEmpty { - extra = [:] - for (key, value) in content.userInfo { - if let keyStr = key as? String, let valStr = value as? String { - extra?[keyStr] = valStr - } - } - if extra?.isEmpty == true { - extra = nil - } - } return ReceivedNotificationData( id: Int(request.identifier) ?? -1, title: content.title, body: content.body, - extra: extra + extra: notificationExtra(content.userInfo) ) } public func didReceive(response: UNNotificationResponse) { + stateLock.lock() let originalNotificationRequest = response.notification.request let actionId = response.actionIdentifier @@ -113,45 +129,50 @@ public class NotificationHandler: NSObject, NotificationHandlerProtocol { inputValue = inputType.userText } - // Only trigger actionPerformed for local notifications (those in our map) - if let activeNotification = toActiveNotification(originalNotificationRequest) { - try? self.plugin?.trigger( - "actionPerformed", - data: ReceivedNotification( - actionId: actionIdValue, - inputValue: inputValue, - notification: activeNotification - )) + let isSystemAction = actionId == UNNotificationDefaultActionIdentifier + || actionId == UNNotificationDismissActionIdentifier + + let actionNotification = toActiveNotification(originalNotificationRequest) + ?? toRemoteActionNotification(originalNotificationRequest) + let action = ReceivedNotification( + actionId: actionIdValue, + inputValue: inputValue, + notification: actionNotification + ) + let shouldTriggerAction = hasActionListener + if !shouldTriggerAction { + if pendingNotificationActions.count >= maxPendingActions { pendingNotificationActions.removeFirst() } + pendingNotificationActions.append(action) } - // Handle notificationClicked for both local and push notifications - let id = Int(originalNotificationRequest.identifier) ?? -1 - let userInfo = originalNotificationRequest.content.userInfo - var dataDict: [String: String]? = nil - if !userInfo.isEmpty { - dataDict = [:] - for (key, value) in userInfo { - if let keyStr = key as? String, let valStr = value as? String { - dataDict?[keyStr] = valStr - } - } - if dataDict?.isEmpty == true { - dataDict = nil - } + if !isSystemAction { + stateLock.unlock() + if shouldTriggerAction { try? self.plugin?.trigger("actionPerformed", data: action) } + return } - let clickedData = NotificationClickedData(id: id, data: dataDict) + // Handle notificationClicked for both local and push notifications + let id = Int(originalNotificationRequest.identifier) ?? -1 + let clickedData = NotificationClickedData( + id: id, + data: notificationExtra(originalNotificationRequest.content.userInfo) + ) - if hasClickedListener { - // Listener exists, trigger directly - try? self.plugin?.trigger("notificationClicked", data: clickedData) + let shouldTriggerClick = hasClickedListener + if shouldTriggerClick { + // Listener exists, trigger directly after releasing the lock. } else { // No listener (cold-start), store for later pendingNotificationClick = clickedData } + stateLock.unlock() + if shouldTriggerAction { try? self.plugin?.trigger("actionPerformed", data: action) } + if shouldTriggerClick { try? self.plugin?.trigger("notificationClicked", data: clickedData) } } func toActiveNotification(_ request: UNNotificationRequest) -> ActiveNotification? { + stateLock.lock() + defer { stateLock.unlock() } guard let notificationRequest = notificationsMap[request.identifier] else { return nil } @@ -161,11 +182,41 @@ public class NotificationHandler: NSObject, NotificationHandlerProtocol { body: request.content.body, sound: notificationRequest.sound ?? "", actionTypeId: request.content.categoryIdentifier, - attachments: notificationRequest.attachments + attachments: notificationRequest.attachments, + extra: notificationExtra(request.content.userInfo) + ) + } + + func toRemoteActionNotification(_ request: UNNotificationRequest) -> ActiveNotification { + ActiveNotification( + id: Int(request.identifier) ?? -1, + title: request.content.title, + body: request.content.body, + sound: "", + actionTypeId: request.content.categoryIdentifier, + attachments: nil, + extra: notificationExtra(request.content.userInfo), + source: "push" ) } + private func notificationExtra(_ userInfo: [AnyHashable: Any]) -> [String: String]? { + var extra = [String: String]() + for (key, value) in userInfo { + guard let key = key as? String else { continue } + guard key != "aps" else { continue } + if let value = value as? String { + extra[key] = value + } else if let value = value as? NSNumber { + extra[key] = value.stringValue + } + } + return extra.isEmpty ? nil : extra + } + func toPendingNotification(_ request: UNNotificationRequest) -> PendingNotification? { + stateLock.lock() + defer { stateLock.unlock() } guard let notification = notificationsMap[request.identifier], let schedule = notification.schedule else { return nil @@ -193,7 +244,28 @@ struct ActiveNotification: Encodable { let sound: String let actionTypeId: String let attachments: [NotificationAttachment]? - var source: String = "local" + let extra: [String: String]? + var source: String + + init( + id: Int, + title: String, + body: String, + sound: String, + actionTypeId: String, + attachments: [NotificationAttachment]?, + extra: [String: String]? = nil, + source: String = "local" + ) { + self.id = id + self.title = title + self.body = body + self.sound = sound + self.actionTypeId = actionTypeId + self.attachments = attachments + self.extra = extra + self.source = source + } } struct ReceivedNotification: Encodable { @@ -205,6 +277,11 @@ struct ReceivedNotification: Encodable { struct NotificationClickedData: Encodable { let id: Int let data: [String: String]? + + init(id: Int, data: [String: String]?) { + self.id = id + self.data = data + } } struct ReceivedNotificationData: Encodable { diff --git a/ios/Sources/NotificationPlugin.swift b/ios/Sources/NotificationPlugin.swift index 224eaf41..15d042a9 100644 --- a/ios/Sources/NotificationPlugin.swift +++ b/ios/Sources/NotificationPlugin.swift @@ -153,6 +153,14 @@ struct SetClickListenerActiveArgs: Decodable { let active: Bool } +struct SetActionListenerActiveArgs: Decodable { + let active: Bool +} + +struct PluginConfig: Decodable { + let actionTypes: [ActionType]? +} + class NotificationPlugin: Plugin { let notificationHandler = NotificationHandler() let notificationManager = NotificationManager() @@ -173,6 +181,11 @@ class NotificationPlugin: Plugin { public override func load(webview: WKWebView) { super.load(webview: webview) + if let config = try? parseConfig(PluginConfig.self), + let actionTypes = config.actionTypes { + makeCategories(actionTypes) + } + #if ENABLE_PUSH_NOTIFICATIONS // Store reference to this plugin for event triggering AppDelegateSwizzler.plugin = self @@ -390,6 +403,16 @@ class NotificationPlugin: Plugin { invoke.reject(error.localizedDescription) } } + + @objc func setActionListenerActive(_ invoke: Invoke) { + do { + let args = try invoke.parseArgs(SetActionListenerActiveArgs.self) + notificationHandler.setActionListenerActive(args.active) + 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 be368cb1..419adb4d 100644 --- a/ios/Tests/PluginTests/PluginTests.swift +++ b/ios/Tests/PluginTests/PluginTests.swift @@ -4,6 +4,17 @@ import UserNotifications final class NotificationTests: XCTestCase { + func testPluginConfigDecodesLaunchTimeActionTypes() throws { + let json = """ + {"actionTypes":[{"id":"sable-message","actions":[{"id":"sable-reply","title":"Reply","input":true,"inputButtonTitle":"Send","inputPlaceholder":"Type a reply"}]}]} + """ + let config = try JSONDecoder().decode(PluginConfig.self, from: Data(json.utf8)) + + XCTAssertEqual(config.actionTypes?.first?.id, "sable-message") + XCTAssertEqual(config.actionTypes?.first?.actions.first?.id, "sable-reply") + XCTAssertEqual(config.actionTypes?.first?.actions.first?.input, true) + } + // MARK: - Notification Content Tests func testMakeNotificationContentWithBasicNotification() throws { @@ -540,6 +551,64 @@ final class NotificationTests: XCTestCase { XCTAssertEqual(json?["source"] as? String, "local") } + func testActiveNotificationIncludesExtraMetadata() throws { + let handler = NotificationHandler() + let notification = Notification( + id: 42, + title: "Message", + body: "Reply", + extra: ["room_id": "!room:example.org", "event_id": "$event"], + schedule: nil, + attachments: nil, + sound: nil, + group: nil, + actionTypeId: "message-actions", + summary: nil, + silent: nil + ) + handler.saveNotification("42", notification) + + let content = try makeNotificationContent(notification) + let request = UNNotificationRequest(identifier: "42", content: content, trigger: nil) + let active = handler.toActiveNotification(request) + + XCTAssertEqual(active?.actionTypeId, "message-actions") + XCTAssertEqual(active?.extra?["room_id"], "!room:example.org") + XCTAssertEqual(active?.extra?["event_id"], "$event") + } + + func testRemoteActionNotificationIncludesRoutingMetadata() throws { + let handler = NotificationHandler() + let content = UNMutableNotificationContent() + content.title = "New message" + content.body = "Reply inline" + content.categoryIdentifier = "sable-message" + content.userInfo = [ + "room_id": "!room:example.org", + "event_id": "$event", + "user_id": "@user:example.org", + ] + let request = UNNotificationRequest(identifier: "remote-message", content: content, trigger: nil) + + let active = handler.toRemoteActionNotification(request) + let result = ReceivedNotification( + actionId: "reply", + inputValue: "Hello", + notification: active + ) + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let notification = json?["notification"] as? [String: Any] + let extra = notification?["extra"] as? [String: String] + + XCTAssertEqual(active.id, -1) + XCTAssertEqual(active.source, "push") + XCTAssertEqual(active.actionTypeId, "sable-message") + XCTAssertEqual(extra?["room_id"], "!room:example.org") + XCTAssertEqual(extra?["event_id"], "$event") + XCTAssertEqual(extra?["user_id"], "@user:example.org") + } + func testReceivedNotificationEncoding() throws { let active = ActiveNotification( id: 1, @@ -547,7 +616,8 @@ final class NotificationTests: XCTestCase { body: "Body", sound: "default", actionTypeId: "CATEGORY", - attachments: nil + attachments: nil, + extra: ["room_id": "!room:example.org"] ) let received = ReceivedNotification( @@ -570,6 +640,10 @@ final class NotificationTests: XCTestCase { let notification = json?["notification"] as? [String: Any] XCTAssertNotNil(notification) XCTAssertEqual(notification?["id"] as? Int, 1) + XCTAssertEqual( + (notification?["extra"] as? [String: String])?["room_id"], + "!room:example.org" + ) } // MARK: - NotificationHandler Tests diff --git a/permissions/autogenerated/commands/set_action_listener_active.toml b/permissions/autogenerated/commands/set_action_listener_active.toml new file mode 100644 index 00000000..8c02114d --- /dev/null +++ b/permissions/autogenerated/commands/set_action_listener_active.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-set-action-listener-active" +description = "Enables the set_action_listener_active command without any pre-configured scope." +commands.allow = ["set_action_listener_active"] + +[[permission]] +identifier = "deny-set-action-listener-active" +description = "Denies the set_action_listener_active command without any pre-configured scope." +commands.deny = ["set_action_listener_active"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index 47cb871e..fae0f95e 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -31,6 +31,7 @@ It allows all notification related features. - `allow-create-channel` - `allow-permission-state` - `allow-set-click-listener-active` +- `allow-set-action-listener-active` - `allow-list-distributors` - `allow-set-distributor` - `allow-set-token` @@ -567,6 +568,32 @@ Denies the request_permission command without any pre-configured scope. +`notifications:allow-set-action-listener-active` + + + + +Enables the set_action_listener_active command without any pre-configured scope. + + + + + + + +`notifications:deny-set-action-listener-active` + + + + +Denies the set_action_listener_active command without any pre-configured scope. + + + + + + + `notifications:allow-set-click-listener-active` diff --git a/permissions/default.toml b/permissions/default.toml index cffc2fa0..10672153 100644 --- a/permissions/default.toml +++ b/permissions/default.toml @@ -33,6 +33,7 @@ permissions = [ "allow-create-channel", "allow-permission-state", "allow-set-click-listener-active", + "allow-set-action-listener-active", "allow-list-distributors", "allow-set-distributor", "allow-set-token", diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index 22e0055b..33e79d12 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -534,6 +534,18 @@ "const": "deny-request-permission", "markdownDescription": "Denies the request_permission command without any pre-configured scope." }, + { + "description": "Enables the set_action_listener_active command without any pre-configured scope.", + "type": "string", + "const": "allow-set-action-listener-active", + "markdownDescription": "Enables the set_action_listener_active command without any pre-configured scope." + }, + { + "description": "Denies the set_action_listener_active command without any pre-configured scope.", + "type": "string", + "const": "deny-set-action-listener-active", + "markdownDescription": "Denies the set_action_listener_active command without any pre-configured scope." + }, { "description": "Enables the set_click_listener_active command without any pre-configured scope.", "type": "string", @@ -595,10 +607,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-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-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-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-list-distributors`\n- `allow-set-distributor`\n- `allow-set-token`" } ] } diff --git a/src/commands.rs b/src/commands.rs index 6bd0e3c2..1f7fb681 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -41,8 +41,17 @@ pub async fn register_for_push_notifications( _app: AppHandle, notification: State<'_, Notifications>, vapid: Option, + provider: Option, ) -> Result { - notification.register_for_push_notifications(vapid).await + #[cfg(mobile)] + return notification + .register_for_push_notifications(vapid, provider) + .await; + #[cfg(desktop)] + { + let _ = provider; + notification.register_for_push_notifications(vapid).await + } } #[command] @@ -143,6 +152,15 @@ pub fn set_click_listener_active( notification.set_click_listener_active(active) } +#[command] +pub fn set_action_listener_active( + _app: AppHandle, + notification: State<'_, Notifications>, + active: bool, +) -> Result<()> { + notification.set_action_listener_active(active) +} + #[command] pub fn remove_active( _app: AppHandle, diff --git a/src/desktop.rs b/src/desktop.rs index 77614ed0..01c159cb 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -388,6 +388,10 @@ impl Notifications { ))) } + pub const fn set_action_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 14d4ff55..c1eefc2f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,11 @@ use tauri::{ #[derive(Debug, Default, Clone, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PluginConfig { + /// Notification action categories registered during native plugin startup. + /// This is required for provider-rendered iOS notifications whose + /// `aps.category` is received before JavaScript initializes. + #[serde(default)] + pub action_types: Vec, #[cfg(target_os = "windows")] pub windows: WindowsConfig, } @@ -305,6 +310,7 @@ pub fn init() -> TauriPlugin> { commands::get_pending, commands::get_active, commands::set_click_listener_active, + commands::set_action_listener_active, commands::remove_active, commands::remove_all, commands::cancel, diff --git a/src/macos.rs b/src/macos.rs index 174e9923..06b283f7 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -323,6 +323,10 @@ impl Notifications { .parse_void() } + pub const fn set_action_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 512c7b81..20a0d414 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -62,6 +62,7 @@ impl Notifications { pub async fn register_for_push_notifications( &self, vapid: Option, + provider: Option, ) -> crate::Result { #[cfg(feature = "push-notifications")] { @@ -70,18 +71,20 @@ impl Notifications { struct RegisterArgs { #[serde(skip_serializing_if = "Option::is_none")] vapid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + provider: Option, } self.0 .run_mobile_plugin_async::( "registerForPushNotifications", - RegisterArgs { vapid }, + RegisterArgs { vapid, provider }, ) .await .map_err(Into::into) } #[cfg(not(feature = "push-notifications"))] { - let _ = vapid; + let _ = (vapid, provider); Err(crate::Error::Io(std::io::Error::other( "Push notifications feature is not enabled", ))) @@ -250,4 +253,14 @@ impl Notifications { .run_mobile_plugin("setClickListenerActive", args) .map_err(Into::into) } + + /// Set action listener active state. + /// Used internally to queue action results until JS is ready. + pub fn set_action_listener_active(&self, active: bool) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("active", active); + self.0 + .run_mobile_plugin("setActionListenerActive", args) + .map_err(Into::into) + } } diff --git a/src/models.rs b/src/models.rs index 8096bfac..629e4474 100644 --- a/src/models.rs +++ b/src/models.rs @@ -19,6 +19,8 @@ pub struct PushNotificationResponse { pub p256dh: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instance: Option, } impl PushNotificationResponse { @@ -28,6 +30,7 @@ impl PushNotificationResponse { device_token, p256dh: None, auth: None, + instance: None, } } } diff --git a/src/windows.rs b/src/windows.rs index 024412b2..973bc131 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -1022,6 +1022,10 @@ impl Notifications { self.plugin.set_click_listener(active) } + pub const fn set_action_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(