From 29d767ec98dabc378582b1d5522bda39f3b8d4a5 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 24 Jul 2026 22:12:12 +0200 Subject: [PATCH 1/6] fix(android): make UnifiedPush registration idempotent and share one KeyManager --- .../tauri/notification/CachedKeyManager.kt | 5 +- .../tauri/notification/NotificationPlugin.kt | 77 +++++++++++++++---- .../notification/UnifiedPushStateStore.kt | 23 +++++- 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/CachedKeyManager.kt b/android/src/main/java/app/tauri/notification/CachedKeyManager.kt index 7f9c03b..042e815 100644 --- a/android/src/main/java/app/tauri/notification/CachedKeyManager.kt +++ b/android/src/main/java/app/tauri/notification/CachedKeyManager.kt @@ -4,6 +4,7 @@ import android.content.Context import org.unifiedpush.android.connector.keys.DefaultKeyManager import org.unifiedpush.android.connector.keys.KeyManager import org.unifiedpush.android.connector.data.PublicKeySet +import java.util.concurrent.ConcurrentHashMap /** * Wraps DefaultKeyManager with in-memory caching to avoid repeated @@ -11,8 +12,8 @@ import org.unifiedpush.android.connector.data.PublicKeySet */ class CachedKeyManager private constructor(context: Context) : KeyManager { private val delegate = DefaultKeyManager(context) - private val pubkeyCache = mutableMapOf() - private val existsCache = mutableMapOf() + private val pubkeyCache = ConcurrentHashMap() + private val existsCache = ConcurrentHashMap() override fun decrypt(instance: String, sealed: ByteArray): ByteArray? { return delegate.decrypt(instance, sealed) diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 675a79e..900b614 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -473,6 +473,25 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } val distributor = if (provider == "fcm") null else UnifiedPush.getSavedDistributor(activity) + + // Reuse the current registration instead of re-registering. + if (provider != "fcm" && + pendingPushRegistration == null && + unifiedPushState.activeProvider == "unifiedpush" && + distributor != null && + distributor == unifiedPushState.distributor && + requestedVapid == unifiedPushState.vapid && + unifiedPushState.endpoint != null + ) { + val cached = JSObject() + cached.put("deviceToken", unifiedPushState.endpoint) + cached.put("instance", UnifiedPushStateStore.INSTANCE) + unifiedPushState.p256dh?.let { cached.put("p256dh", it) } + unifiedPushState.auth?.let { cached.put("auth", it) } + invoke.resolve(cached) + return + } + pendingPushRegistration = PushRegistration( requestedVapid, provider, @@ -508,10 +527,15 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private fun proceedPushRegistration() { val registration = pendingPushRegistration ?: return val webPushVapid = registration.vapid - if (registration.provider != "fcm" && registration.distributor != null) { + // Re-read: the saved distributor may be gone, in which case register() sends + // no broadcast and we'd wait out the timeout. Fall through to selection. + val savedDistributor = + if (registration.provider == "fcm") null else UnifiedPush.getSavedDistributor(activity) + registration.distributor = savedDistributor + if (registration.provider != "fcm" && savedDistributor != null) { registration.phase = PushRegistrationPhase.UNIFIED_PUSH try { - UnifiedPush.register(activity, registration.instance!!, vapid = webPushVapid) + UnifiedPush.register(activity, registration.instance!!, vapid = webPushVapid, keyManager = CachedKeyManager.getInstance(activity)) } catch (error: Exception) { finishPushRegistrationError(error.message ?: "UnifiedPush registration failed") } @@ -530,7 +554,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { try { registration.distributor = UnifiedPush.getSavedDistributor(activity) registration.phase = PushRegistrationPhase.UNIFIED_PUSH - UnifiedPush.register(activity, registration.instance!!, vapid = webPushVapid) + UnifiedPush.register(activity, registration.instance!!, vapid = webPushVapid, keyManager = CachedKeyManager.getInstance(activity)) } catch (error: Exception) { finishPushRegistrationError(error.message ?: "UnifiedPush registration failed") } @@ -544,7 +568,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { if (registration.provider != "fcm" && UnifiedPush.getSavedDistributor(activity) != null) { registration.phase = PushRegistrationPhase.UNIFIED_PUSH try { - UnifiedPush.register(activity, registration.instance!!) + UnifiedPush.register(activity, registration.instance!!, keyManager = CachedKeyManager.getInstance(activity)) } catch (error: Exception) { finishPushRegistrationError(error.message ?: "UnifiedPush registration failed") } @@ -593,11 +617,18 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } - FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task -> - if (!task.isSuccessful) { - invoke.reject("Failed to delete FCM token: ${task.exception?.message}") - return@addOnCompleteListener + try { + FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task -> + if (!task.isSuccessful) { + invoke.reject("Failed to delete FCM token: ${task.exception?.message}") + return@addOnCompleteListener + } + fcmToken = null + if (unifiedPushState.activeProvider == "fcm") unifiedPushState.activeProvider = null + invoke.resolve() } + } catch (error: Exception) { + // No default FirebaseApp (embedded-FCM/VAPID, no google-services.json): nothing to delete. fcmToken = null if (unifiedPushState.activeProvider == "fcm") unifiedPushState.activeProvider = null invoke.resolve() @@ -637,16 +668,17 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } if (pendingPushRegistration != null) { - invoke.reject("Cannot change distributor while push registration is in progress") - return + // Cancel the in-flight registration so the switch isn't blocked. + finishPushRegistrationError("Superseded by distributor change") } val distributorChanged = UnifiedPush.getSavedDistributor(activity) != distributor if (distributorChanged) { - try { retireUnifiedPush(unifiedPushState.activeInstance ?: UnifiedPushStateStore.INSTANCE) } catch (error: Exception) { - invoke.reject(error.message ?: "Failed to retire UnifiedPush registration") - return - } + // saveDistributor already replaces the previous primary. Unregistering + // here would also wipe it, since unregister() drops every distributor + // once the last instance is removed. + unifiedPushGeneration++ if (unifiedPushState.activeProvider == "unifiedpush") unifiedPushState.activeProvider = null + unifiedPushState.clearRegistration() } UnifiedPush.saveDistributor(activity, distributor) if (distributorChanged) unifiedPushState.ensureExplicitInstance() @@ -662,11 +694,26 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { val registration = pendingPushRegistration if (registration?.phase != PushRegistrationPhase.UNIFIED_PUSH || registration.generation != unifiedPushGeneration || instance != registration.instance) { + // Endpoint rotated outside a registration: keep the cache fresh. + if (pendingPushRegistration == null && + unifiedPushState.activeProvider == "unifiedpush" && + instance == (unifiedPushState.activeInstance ?: UnifiedPushStateStore.INSTANCE) + ) { + unifiedPushState.endpoint = endpoint + unifiedPushState.p256dh = p256dh + unifiedPushState.auth = auth + unifiedPushState.distributor = UnifiedPush.getSavedDistributor(activity) + triggerUnifiedPushToken(endpoint, p256dh, auth, if (p256dh == null) "direct" else "webpush") + } return } unifiedPushState.setUnifiedPushActive() unifiedPushState.endpoint = endpoint unifiedPushState.activeInstance = registration.instance + unifiedPushState.p256dh = p256dh + unifiedPushState.auth = auth + unifiedPushState.distributor = registration.distributor ?: UnifiedPush.getSavedDistributor(activity) + unifiedPushState.vapid = registration.vapid val result = JSObject() result.put("deviceToken", endpoint) result.put("instance", UnifiedPushStateStore.INSTANCE) @@ -782,7 +829,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private fun retireUnifiedPush(instance: String?) { unifiedPushGeneration++ - try { UnifiedPush.unregister(activity, instance ?: UnifiedPushStateStore.INSTANCE) } catch (_: Exception) { + try { UnifiedPush.unregister(activity, instance ?: UnifiedPushStateStore.INSTANCE, CachedKeyManager.getInstance(activity)) } catch (_: Exception) { } val activeInstance = unifiedPushState.activeInstance val retiresActiveInstance = activeInstance == instance || diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushStateStore.kt b/android/src/main/java/app/tauri/notification/UnifiedPushStateStore.kt index 56835e7..1b695ec 100644 --- a/android/src/main/java/app/tauri/notification/UnifiedPushStateStore.kt +++ b/android/src/main/java/app/tauri/notification/UnifiedPushStateStore.kt @@ -18,14 +18,33 @@ internal class UnifiedPushStateStore(private val context: Context) { var endpoint: String? get() = prefs.getString("up-endpoint", null) set(value) = prefs.edit().putString("up-endpoint", value).apply() + var p256dh: String? + get() = prefs.getString("up-p256dh", null) + set(value) = prefs.edit().putString("up-p256dh", value).apply() + var auth: String? + get() = prefs.getString("up-auth", null) + set(value) = prefs.edit().putString("up-auth", value).apply() + var distributor: String? + get() = prefs.getString("up-distributor", null) + set(value) = prefs.edit().putString("up-distributor", value).apply() + var vapid: String? + get() = prefs.getString("up-vapid", null) + set(value) = prefs.edit().putString("up-vapid", value).apply() fun clearRegistration() { - prefs.edit().remove("push-instance").remove("up-endpoint").apply() + prefs.edit() + .remove("push-instance") + .remove("up-endpoint") + .remove("up-p256dh") + .remove("up-auth") + .remove("up-distributor") + .remove("up-vapid") + .apply() } fun instanceForRegistration(): String { val current = activeInstance if (current != null && current != INSTANCE) { - try { UnifiedPush.unregister(context, current) } catch (_: Exception) {} + try { UnifiedPush.unregister(context, current, CachedKeyManager.getInstance(context)) } catch (_: Exception) {} activeInstance = INSTANCE } return INSTANCE From 7092440474fff990e90defa7eff5454e9f7ff4f6 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 24 Jul 2026 22:12:12 +0200 Subject: [PATCH 2/6] fix(android): guard FCM warm-path delivery and missing launch intents --- .../notification/TauriFirebaseMessagingService.kt | 2 +- .../tauri/notification/TauriNotificationManager.kt | 3 ++- .../app/tauri/notification/UnifiedPushNotifier.kt | 11 ++++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/TauriFirebaseMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriFirebaseMessagingService.kt index 51e9316..da02649 100644 --- a/android/src/main/java/app/tauri/notification/TauriFirebaseMessagingService.kt +++ b/android/src/main/java/app/tauri/notification/TauriFirebaseMessagingService.kt @@ -32,7 +32,7 @@ class TauriFirebaseMessagingService : FirebaseMessagingService() { message.from?.let { pushData["from"] = it } pushData["sentTime"] = message.sentTime - if (message.data.isNotEmpty()) { + if (message.data.isNotEmpty() && UnifiedPushStateStore(this).activeProvider == "fcm") { val dataJson = JSONObject(message.data as Map) UnifiedPushNotifier.showFromPush(this, dataJson.toString()) } diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index 541bf37..48bf994 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -291,7 +291,8 @@ class TauriNotificationManager( Intent(context, activity.javaClass) } else { val packageName = context.packageName - context.packageManager.getLaunchIntentForPackage(packageName)!! + context.packageManager.getLaunchIntentForPackage(packageName) + ?: Intent(Intent.ACTION_MAIN).setPackage(packageName) } intent.action = Intent.ACTION_MAIN intent.addCategory(Intent.CATEGORY_LAUNCHER) diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt index 53f35ee..c86a3d4 100644 --- a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt +++ b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt @@ -24,7 +24,12 @@ object UnifiedPushNotifier { null } ?: return - val notification = rootJson.optJSONObject("notification") ?: return + // Also accept the payload nested as a JSON string, not just an object. + val notification = rootJson.optJSONObject("notification") + ?: rootJson.optString("notification").takeIf { it.isNotEmpty() }?.let { + try { JSONObject(it) } catch (e: Exception) { null } + } + ?: return val roomId = notification.optString("room_id") val eventId = notification.optString("event_id") @@ -117,8 +122,8 @@ object UnifiedPushNotifier { userId: String, action: String = DEFAULT_PRESS_ACTION ): Intent { - val intent = context.packageManager - .getLaunchIntentForPackage(context.packageName)!! + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: Intent(Intent.ACTION_MAIN).setPackage(context.packageName) intent.action = Intent.ACTION_MAIN intent.addCategory(Intent.CATEGORY_LAUNCHER) intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP From e9a6f9c45b38457b06fc6085da010b98cc7980bc Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 24 Jul 2026 22:12:13 +0200 Subject: [PATCH 3/6] fix(ios): chain existing push delegate callbacks and fix registration timeout --- ios/Sources/AppDelegateSwizzler.swift | 127 +++++++++++++----------- ios/Sources/Notification.swift | 5 + ios/Sources/NotificationCategory.swift | 22 ++-- ios/Sources/NotificationPlugin.swift | 23 +++-- ios/Tests/PluginTests/PluginTests.swift | 11 +- 5 files changed, 103 insertions(+), 85 deletions(-) diff --git a/ios/Sources/AppDelegateSwizzler.swift b/ios/Sources/AppDelegateSwizzler.swift index 0bb1c96..9a2d59f 100644 --- a/ios/Sources/AppDelegateSwizzler.swift +++ b/ios/Sources/AppDelegateSwizzler.swift @@ -7,102 +7,111 @@ import ObjectiveC.runtime enum AppDelegateSwizzler { static weak var plugin: NotificationPlugin? + private static var originalIMPs: [Selector: IMP] = [:] + private static var swizzled = false + static func swizzlePushCallbacks() { - guard let app = UIApplication.shared as UIApplication?, - let delegate = app.delegate else { return } + guard !swizzled else { return } + guard let delegate = UIApplication.shared.delegate else { return } + swizzled = true + let cls: AnyClass = type(of: delegate) - // didRegisterForRemoteNotificationsWithDeviceToken - swizzle( - type(of: delegate), + install( + cls, #selector(UIApplicationDelegate.application(_:didRegisterForRemoteNotificationsWithDeviceToken:)), #selector(PushForwarder.ta_application(_:didRegisterForRemoteNotificationsWithDeviceToken:)) ) - - // didFailToRegisterForRemoteNotificationsWithError - swizzle( - type(of: delegate), + install( + cls, #selector(UIApplicationDelegate.application(_:didFailToRegisterForRemoteNotificationsWithError:)), #selector(PushForwarder.ta_application(_:didFailToRegisterForRemoteNotificationsWithError:)) ) - - // didReceiveRemoteNotification (silent/background) - swizzle( - type(of: delegate), + install( + cls, #selector(UIApplicationDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:)), #selector(PushForwarder.ta_application(_:didReceiveRemoteNotification:fetchCompletionHandler:)) ) } - private static func swizzle(_ cls: AnyClass, _ original: Selector, _ replacement: Selector) { - guard - let swizzledMethod = class_getInstanceMethod(PushForwarder.self, replacement) - else { return } - - if let originalMethod = class_getInstanceMethod(cls, original) { - // Original method exists - exchange implementations - method_exchangeImplementations(originalMethod, swizzledMethod) - } else { - // Original method doesn't exist - add our method - class_addMethod( - cls, - original, - method_getImplementation(swizzledMethod), - method_getTypeEncoding(swizzledMethod) - ) + /// Installs the forwarder, keeping the delegate's own implementation so it can still be chained. + private static func install(_ cls: AnyClass, _ selector: Selector, _ replacement: Selector) { + guard let replacementMethod = class_getInstanceMethod(PushForwarder.self, replacement) else { return } + let replacementIMP = method_getImplementation(replacementMethod) + let typeEncoding = method_getTypeEncoding(replacementMethod) + if let previousIMP = class_replaceMethod(cls, selector, replacementIMP, typeEncoding) { + originalIMPs[selector] = previousIMP } } + + fileprivate static func callOriginalDidRegister( + _ receiver: Any, _ selector: Selector, _ application: UIApplication, _ deviceToken: Data + ) { + guard let imp = originalIMPs[selector] else { return } + typealias Fn = @convention(c) (Any, Selector, UIApplication, Data) -> Void + unsafeBitCast(imp, to: Fn.self)(receiver, selector, application, deviceToken) + } + + fileprivate static func callOriginalDidFail( + _ receiver: Any, _ selector: Selector, _ application: UIApplication, _ error: Error + ) { + guard let imp = originalIMPs[selector] else { return } + typealias Fn = @convention(c) (Any, Selector, UIApplication, Error) -> Void + unsafeBitCast(imp, to: Fn.self)(receiver, selector, application, error) + } + + fileprivate static func callOriginalDidReceive( + _ receiver: Any, _ selector: Selector, _ application: UIApplication, + _ userInfo: [AnyHashable: Any], _ completion: @escaping (UIBackgroundFetchResult) -> Void + ) -> Bool { + guard let imp = originalIMPs[selector] else { return false } + typealias Fn = @convention(c) (Any, Selector, UIApplication, [AnyHashable: Any], @escaping (UIBackgroundFetchResult) -> Void) -> Void + unsafeBitCast(imp, to: Fn.self)(receiver, selector, application, userInfo, completion) + return true + } } -/// A helper that hosts the swizzled implementations. -/// NOTE: These method names must exactly match the selectors we swizzle. +/// Hosts the implementations installed onto the app delegate class. At call time +/// `self` is the app delegate, so these may only touch params and static state. final class PushForwarder: NSObject, UIApplicationDelegate { - // Token success @objc func ta_application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - // Convert token to hex string let hex = deviceToken.map { String(format: "%02x", $0) }.joined() - - // Notify plugin about token AppDelegateSwizzler.plugin?.handlePushTokenReceived(hex) - - // Also emit event for JS/Rust listeners try? AppDelegateSwizzler.plugin?.trigger("push-token", data: ["token": hex]) - - // Call original only if it was swapped (not added) - if responds(to: #selector(ta_application(_:didRegisterForRemoteNotificationsWithDeviceToken:))) { - self.ta_application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) - } + AppDelegateSwizzler.callOriginalDidRegister( + self, + #selector(UIApplicationDelegate.application(_:didRegisterForRemoteNotificationsWithDeviceToken:)), + application, + deviceToken + ) } - // Token failure @objc func ta_application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - // Notify plugin about error AppDelegateSwizzler.plugin?.handlePushTokenError(error) - - // Also emit event for JS/Rust listeners try? AppDelegateSwizzler.plugin?.trigger("push-error", data: ["message": error.localizedDescription]) - - // Call original only if it was swapped (not added) - if responds(to: #selector(ta_application(_:didFailToRegisterForRemoteNotificationsWithError:))) { - self.ta_application(application, didFailToRegisterForRemoteNotificationsWithError: error) - } + AppDelegateSwizzler.callOriginalDidFail( + self, + #selector(UIApplicationDelegate.application(_:didFailToRegisterForRemoteNotificationsWithError:)), + application, + error + ) } - // Background/remote (silent) payloads @objc func ta_application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completion: @escaping (UIBackgroundFetchResult) -> Void) { - // Emit event for push message if let jsData = JSTypes.coerceDictionaryToJSObject(userInfo) { try? AppDelegateSwizzler.plugin?.trigger("push-message", data: jsData) } - - // Call original only if it was swapped (not added) - if responds(to: #selector(ta_application(_:didReceiveRemoteNotification:fetchCompletionHandler:))) { - self.ta_application(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: completion) - } else { - // If no original implementation, we should still call the completion handler + let chained = AppDelegateSwizzler.callOriginalDidReceive( + self, + #selector(UIApplicationDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:)), + application, + userInfo, + completion + ) + if !chained { completion(.noData) } } diff --git a/ios/Sources/Notification.swift b/ios/Sources/Notification.swift index f7115ce..71d0355 100644 --- a/ios/Sources/Notification.swift +++ b/ios/Sources/Notification.swift @@ -94,6 +94,11 @@ func makeAttachments(_ attachments: [NotificationAttachment]) throws -> [UNNotif } func makeAttachmentUrl(_ path: String) -> URL? { + // UNNotificationAttachment needs a file URL, and URL(string:) leaves an + // absolute filesystem path schemeless. Relative/empty input stays nil. + if path.hasPrefix("/") { + return URL(fileURLWithPath: path) + } return URL(string: path) } diff --git a/ios/Sources/NotificationCategory.swift b/ios/Sources/NotificationCategory.swift index 7b97003..0b36951 100644 --- a/ios/Sources/NotificationCategory.swift +++ b/ios/Sources/NotificationCategory.swift @@ -62,32 +62,32 @@ func makeActions(_ actions: [Action]) -> [UNNotificationAction] { } func makeActionOptions(_ action: Action) -> UNNotificationActionOptions { + var options: UNNotificationActionOptions = [] if action.foreground ?? false { - return .foreground + options.insert(.foreground) } if action.destructive ?? false { - return .destructive + options.insert(.destructive) } if action.requiresAuthentication ?? false { - return .authenticationRequired + options.insert(.authenticationRequired) } - return UNNotificationActionOptions(rawValue: 0) + return options } func makeCategoryOptions(_ type: ActionType) -> UNNotificationCategoryOptions { + var options: UNNotificationCategoryOptions = [] if type.customDismissAction ?? false { - return .customDismissAction + options.insert(.customDismissAction) } if type.allowInCarPlay ?? false { - return .allowInCarPlay + options.insert(.allowInCarPlay) } - if type.hiddenPreviewsShowTitle ?? false { - return .hiddenPreviewsShowTitle + options.insert(.hiddenPreviewsShowTitle) } if type.hiddenPreviewsShowSubtitle ?? false { - return .hiddenPreviewsShowSubtitle + options.insert(.hiddenPreviewsShowSubtitle) } - - return UNNotificationCategoryOptions(rawValue: 0) + return options } diff --git a/ios/Sources/NotificationPlugin.swift b/ios/Sources/NotificationPlugin.swift index 15d042a..dce94f2 100644 --- a/ios/Sources/NotificationPlugin.swift +++ b/ios/Sources/NotificationPlugin.swift @@ -265,18 +265,19 @@ class NotificationPlugin: Plugin { #if ENABLE_PUSH_NOTIFICATIONS private func registerForPushNotifications(completion: @escaping (Result) -> Void) { - // Store completion for later - self.pushTokenCompletion = completion - - // Set up timeout - self.pushTokenTimer?.invalidate() - self.pushTokenTimer = Timer.scheduledTimer(withTimeInterval: pushTokenTimeout, repeats: false) - { [weak self] _ in - self?.handlePushTokenTimeout() - } + // Main queue: the caller runs on an arbitrary thread whose run loop never + // runs (so the Timer would never fire), and this serializes the token state. + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.pushTokenCompletion = completion + + self.pushTokenTimer?.invalidate() + self.pushTokenTimer = Timer.scheduledTimer( + withTimeInterval: self.pushTokenTimeout, repeats: false + ) { [weak self] _ in + self?.handlePushTokenTimeout() + } - // Register for remote notifications - DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } } diff --git a/ios/Tests/PluginTests/PluginTests.swift b/ios/Tests/PluginTests/PluginTests.swift index 419adb4..85dc2e1 100644 --- a/ios/Tests/PluginTests/PluginTests.swift +++ b/ios/Tests/PluginTests/PluginTests.swift @@ -795,8 +795,8 @@ final class NotificationTests: XCTestCase { let options = makeActionOptions(action) - // Should return foreground as it's checked first - XCTAssertEqual(options, .foreground) + // Options form an OptionSet, so every requested flag is combined + XCTAssertEqual(options, [.foreground, .destructive, .authenticationRequired]) } func testMakeCategoryOptionsWithMultipleFlags() { @@ -813,8 +813,11 @@ final class NotificationTests: XCTestCase { let options = makeCategoryOptions(actionType) - // Should return customDismissAction as it's checked first - XCTAssertEqual(options, .customDismissAction) + // Options form an OptionSet, so every requested flag is combined + XCTAssertEqual( + options, + [.customDismissAction, .allowInCarPlay, .hiddenPreviewsShowTitle, .hiddenPreviewsShowSubtitle] + ) } // MARK: - Silent Notification Tests From feab3dd8d4b7819eaaf1b087b9791a4a232965a2 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 24 Jul 2026 22:12:13 +0200 Subject: [PATCH 4/6] fix(desktop): return the existing endpoint on repeated UnifiedPush register --- src/unifiedpush.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/unifiedpush.rs b/src/unifiedpush.rs index e7a8a63..0ac4539 100644 --- a/src/unifiedpush.rs +++ b/src/unifiedpush.rs @@ -47,6 +47,7 @@ trait Distributor { struct ActiveRegistration { client_token: String, distributor: String, + endpoint: String, } /// Callback used to display an incoming push as a desktop toast. Provided @@ -206,14 +207,9 @@ impl UnifiedPushState { } pub async fn register(&self) -> crate::Result { - // Reject if there's already an active registration. Otherwise the - // previous token would stay registered at the distributor (and keep - // receiving pushes) while the plugin only tracks the latest one, - // making `unregister` impossible for the orphaned token. - if self.active.read().await.is_some() { - return Err(io_err( - "Already registered; call unregister first to re-register", - )); + // Return the existing endpoint rather than orphaning the current token. + if let Some(active) = self.active.read().await.as_ref() { + return Ok(active.endpoint.clone()); } let distributor = self.pick_distributor().await?; @@ -286,6 +282,7 @@ impl UnifiedPushState { *self.active.write().await = Some(ActiveRegistration { client_token, distributor, + endpoint: endpoint.clone(), }); Ok(endpoint) } From 4499751351cbcdba243a4b68461635daa61bd7a1 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 24 Jul 2026 22:12:13 +0200 Subject: [PATCH 5/6] fix(windows): support action-only listeners and remove grouped notifications --- src/windows.rs | 47 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/windows.rs b/src/windows.rs index 973bc13..1cb3753 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -149,6 +149,7 @@ pub struct WindowsPlugin { notifier: ToastNotifier, action_types: RwLock>, click_listener_active: RwLock, + action_listener_active: RwLock, /// Cold-start activation payloads queued before any JS listener has /// subscribed. Drained synchronously the first time a `notificationClicked` /// listener registers (see `crate::listeners::register_listener`). @@ -349,6 +350,25 @@ impl WindowsPlugin { Ok(()) } + fn is_action_listener_active(&self) -> crate::Result { + Ok(*self + .action_listener_active + .read() + .map_err(|_| crate::Error::Io(std::io::Error::other("Lock poisoned")))?) + } + + fn set_action_listener(&self, active: bool) -> crate::Result<()> { + *self + .action_listener_active + .write() + .map_err(|_| crate::Error::Io(std::io::Error::other("Lock poisoned")))? = active; + Ok(()) + } + + fn is_activation_listener_active(&self) -> crate::Result { + Ok(self.is_click_listener_active()? || self.is_action_listener_active()?) + } + /// Drain queued cold-start click payloads through the listener bus. Called /// when a `notificationClicked` listener subscribes (see /// `crate::listeners::register_listener`). Idempotent: subsequent calls @@ -431,6 +451,7 @@ pub fn init( notifier, action_types: RwLock::new(HashMap::new()), click_listener_active: RwLock::new(false), + action_listener_active: RwLock::new(false), pending_clicks: RwLock::new(Vec::new()), _com_cookie: RwLock::new(None), #[cfg(feature = "push-notifications")] @@ -650,7 +671,7 @@ impl crate::NotificationsBuilder { toast.SetGroup(g)?; } - if self.plugin.is_click_listener_active()? { + if self.plugin.is_activation_listener_active()? { let notification = ActiveNotification { id: self.data.id, tag: Some(self.data.id.to_string()), @@ -860,13 +881,27 @@ impl Notifications { pub fn remove_active(&self, notifications: Vec) -> crate::Result<()> { let history = ToastNotificationManager::History()?; let app_id = &self.plugin.app_id; + + // Removal needs the same (tag, group) pair used at show time. + let delivered = if self.plugin.packaged { + history.GetHistory()? + } else { + history.GetHistoryWithId(&HSTRING::from(app_id))? + }; + let mut groups: HashMap = HashMap::new(); + for i in 0..delivered.Size()? { + let notification = delivered.GetAt(i)?; + let tag = notification.Tag()?.to_string_lossy(); + groups.insert(tag, notification.Group().unwrap_or_default()); + } + for id in notifications { let tag = HSTRING::from(id.to_string()); - // Use app-scoped removal with empty group (consistent with GetHistoryWithId usage) + let group = groups.get(&id.to_string()).cloned().unwrap_or_default(); let res = if self.plugin.packaged { - history.RemoveGroupedTag(&tag, &HSTRING::new()) + history.RemoveGroupedTag(&tag, &group) } else { - history.RemoveGroupedTagWithId(&tag, &HSTRING::new(), &HSTRING::from(app_id)) + history.RemoveGroupedTagWithId(&tag, &group, &HSTRING::from(app_id)) }; if let Err(e) = res { log::error!("Failed to remove notification {id}: {e}"); @@ -1022,8 +1057,8 @@ impl Notifications { self.plugin.set_click_listener(active) } - pub const fn set_action_listener_active(&self, _active: bool) -> crate::Result<()> { - Ok(()) + pub fn set_action_listener_active(&self, active: bool) -> crate::Result<()> { + self.plugin.set_action_listener(active) } /// Create a notification channel (not supported on Windows). From 92a2442f4b284f199b5475cb2de16eba63be6808 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 24 Jul 2026 22:21:54 +0200 Subject: [PATCH 6/6] test(android): cover UnifiedPush registration state persistence --- .../notification/UnifiedPushStateStoreTest.kt | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 android/src/test/java/app/tauri/notification/UnifiedPushStateStoreTest.kt diff --git a/android/src/test/java/app/tauri/notification/UnifiedPushStateStoreTest.kt b/android/src/test/java/app/tauri/notification/UnifiedPushStateStoreTest.kt new file mode 100644 index 0000000..abedbea --- /dev/null +++ b/android/src/test/java/app/tauri/notification/UnifiedPushStateStoreTest.kt @@ -0,0 +1,58 @@ +package app.tauri.notification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class UnifiedPushStateStoreTest { + + private fun newStore() = UnifiedPushStateStore(RuntimeEnvironment.getApplication()) + + @Test + fun persistsRegistrationAcrossInstances() { + val store = newStore() + store.activeProvider = "unifiedpush" + store.endpoint = "https://ntfy.sh/upAbc?up=1" + store.p256dh = "pubkey" + store.auth = "authsecret" + store.distributor = "io.heckel.ntfy" + store.vapid = "vapidkey" + + val reopened = newStore() + assertEquals("unifiedpush", reopened.activeProvider) + assertEquals("https://ntfy.sh/upAbc?up=1", reopened.endpoint) + assertEquals("pubkey", reopened.p256dh) + assertEquals("authsecret", reopened.auth) + assertEquals("io.heckel.ntfy", reopened.distributor) + assertEquals("vapidkey", reopened.vapid) + } + + @Test + fun clearRegistrationDropsKeysButKeepsProvider() { + val store = newStore() + store.activeProvider = "unifiedpush" + store.endpoint = "https://ntfy.sh/upAbc?up=1" + store.p256dh = "pubkey" + store.auth = "authsecret" + store.distributor = "io.heckel.ntfy" + store.vapid = "vapidkey" + + store.clearRegistration() + + assertNull(store.endpoint) + assertNull(store.p256dh) + assertNull(store.auth) + assertNull(store.distributor) + assertNull(store.vapid) + assertEquals("unifiedpush", store.activeProvider) + } + + @Test + fun activeProviderIsNullWhenUnset() { + assertNull(newStore().activeProvider) + } +}