From 00709a640ab4b3eef7d3c0b3cb2a10c52bfae222 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 19 Jul 2026 20:56:36 +0200 Subject: [PATCH] feat(android): add embedded FCM WebPush distributor path --- android/build.gradle.kts | 8 ++++ .../tauri/notification/NotificationPlugin.kt | 33 ++++++++++++++- .../tauri/notification/UnifiedPushReceiver.kt | 6 ++- .../src-tauri/gen/android/build.gradle.kts | 5 +++ guest-js/index.test.ts | 11 ++--- guest-js/index.ts | 40 ++++++++----------- src/commands.rs | 5 ++- src/desktop.rs | 11 ++++- src/macos.rs | 7 +++- src/mobile.rs | 18 ++++++--- src/models.rs | 18 ++++++++- src/windows.rs | 9 ++++- 12 files changed, 126 insertions(+), 45 deletions(-) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index ae244e9a..7e58a794 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,6 +1,7 @@ import java.util.Properties import java.io.FileInputStream import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion plugins { id("com.android.library") @@ -65,6 +66,12 @@ android { } } +configurations.all { + resolutionStrategy { + force("org.jetbrains.kotlin:kotlin-stdlib:${getKotlinPluginVersion()}") + } +} + dependencies { implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.appcompat:appcompat:1.7.1") @@ -74,6 +81,7 @@ dependencies { implementation(platform("com.google.firebase:firebase-bom:34.16.0")) implementation("com.google.firebase:firebase-messaging-ktx:24.1.2") implementation("org.unifiedpush.android:connector:3.3.3") + implementation("org.unifiedpush.android:embedded-fcm-distributor:3.0.0") testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk-android:1.14.11") testImplementation("io.mockk:mockk-agent:1.14.11") diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 36c1b13e..8d0f1284 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -59,6 +59,11 @@ class RegisterActionTypesArgs { lateinit var types: List } +@InvokeArg +class RegisterPushArgs { + var vapid: String? = null +} + @InvokeArg class SetClickListenerActiveArgs { var active: Boolean = false @@ -94,6 +99,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private var pendingTokenInvoke: Invoke? = null private var cachedToken: String? = null + private var vapid: String? = null // Click listener tracking for cold-start support private var hasClickedListener = false @@ -391,6 +397,8 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } + invoke.parseArgs(RegisterPushArgs::class.java).vapid?.let { vapid = it } + // First check if notifications are enabled if (!manager.areNotificationsEnabled()) { // Request permissions first @@ -411,6 +419,25 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } 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 + } + } + return + } + if (UnifiedPush.getSavedDistributor(activity) != null) { pendingTokenInvoke = invoke UnifiedPush.register(activity, "default") @@ -490,15 +517,19 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { invoke.resolve() } - fun onUnifiedPushNewEndpoint(endpoint: String) { + fun onUnifiedPushNewEndpoint(endpoint: String, p256dh: String?, auth: String?) { cachedToken = endpoint val result = JSObject() result.put("deviceToken", endpoint) + p256dh?.let { result.put("p256dh", it) } + auth?.let { result.put("auth", it) } pendingTokenInvoke?.resolve(result) pendingTokenInvoke = null val data = JSObject() data.put("token", endpoint) + p256dh?.let { data.put("p256dh", it) } + auth?.let { data.put("auth", it) } trigger("push-token", data) } diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt b/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt index 4ac37416..044fd34d 100644 --- a/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt +++ b/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt @@ -8,7 +8,11 @@ import org.unifiedpush.android.connector.data.PushMessage class UnifiedPushReceiver : MessagingReceiver() { override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { - NotificationPlugin.instance?.onUnifiedPushNewEndpoint(endpoint.url) + NotificationPlugin.instance?.onUnifiedPushNewEndpoint( + endpoint.url, + endpoint.pubKeySet?.pubKey, + endpoint.pubKeySet?.auth, + ) } override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { diff --git a/examples/notifications-demo/src-tauri/gen/android/build.gradle.kts b/examples/notifications-demo/src-tauri/gen/android/build.gradle.kts index b343ab32..002f7100 100644 --- a/examples/notifications-demo/src-tauri/gen/android/build.gradle.kts +++ b/examples/notifications-demo/src-tauri/gen/android/build.gradle.kts @@ -15,6 +15,11 @@ allprojects { google() mavenCentral() } + configurations.all { + resolutionStrategy { + force("org.jetbrains.kotlin:kotlin-stdlib:2.1.21") + } + } } tasks.register("clean").configure { diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index 3ed4451c..540bfbb1 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -474,16 +474,17 @@ describe("Notification Functions", () => { }); describe("registerForPushNotifications", () => { - it("should call invoke and return push token", async () => { - const mockToken = "abc123token"; - mockInvoke.mockResolvedValue(mockToken); + it("should call invoke and return the registration", async () => { + const registration = { deviceToken: "abc123token" }; + mockInvoke.mockResolvedValue(registration); - const result = await registerForPushNotifications(); + const result = await registerForPushNotifications("vapid-key"); expect(mockInvoke).toHaveBeenCalledWith( "plugin:notifications|register_for_push_notifications", + { vapid: "vapid-key" }, ); - expect(result).toBe(mockToken); + expect(result).toBe(registration); }); }); diff --git a/guest-js/index.ts b/guest-js/index.ts index c2468d9a..80ca1339 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -441,34 +441,27 @@ async function requestPermission(): Promise { return await invoke("plugin:notifications|request_permission"); } +interface PushRegistration { + deviceToken: string; + p256dh?: string; + auth?: string; +} + /** * Registers the app for push notifications. * - * Returns a platform-dependent string identifying this push registration: - * - **iOS**: APNs device token - * - **Android**: Firebase Cloud Messaging token - * - **Linux**: UnifiedPush endpoint URL (the URL your backend POSTs payloads to). - * The host app may persist this and treat it as the new endpoint on each - * launch (FCM/APNs style), or call {@link setToken} beforehand with a - * stored client token to receive the same endpoint URL across launches. - * - * On Linux this requires the `push-notifications` feature and at least one - * UnifiedPush distributor running on the system; see the README for setup. - * Any {@link setDistributor} or {@link setToken} calls must happen - * **before** this — they only affect the next register call, and once - * registered the endpoint URL is fixed until you unregister. - * - * @example - * ```typescript - * import { registerForPushNotifications } from '@choochmeque/tauri-plugin-notifications-api'; - * const token = await registerForPushNotifications(); - * console.log('Push token:', token); - * ``` + * `deviceToken` is the APNs token on iOS and the UnifiedPush endpoint URL on + * Android/Linux. Pass a base64url VAPID public key to register against a Web + * Push distributor; `p256dh` and `auth` are then set on the result. * - * @returns A promise resolving to the platform-specific push identifier. + * @returns A promise resolving to the {@link PushRegistration}. */ -async function registerForPushNotifications(): Promise { - return await invoke("plugin:notifications|register_for_push_notifications"); +async function registerForPushNotifications( + vapid?: string, +): Promise { + return await invoke("plugin:notifications|register_for_push_notifications", { + vapid, + }); } /** @@ -892,6 +885,7 @@ export type { Channel, ScheduleInterval, NotificationClickedData, + PushRegistration, }; export { diff --git a/src/commands.rs b/src/commands.rs index 8f082bd4..6bd0e3c2 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -40,8 +40,9 @@ pub async fn request_permission( pub async fn register_for_push_notifications( _app: AppHandle, notification: State<'_, Notifications>, -) -> Result { - notification.register_for_push_notifications().await + vapid: Option, +) -> Result { + notification.register_for_push_notifications(vapid).await } #[command] diff --git a/src/desktop.rs b/src/desktop.rs index 93d24c8d..77614ed0 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -269,11 +269,18 @@ impl Notifications { /// selected (or first available) `UnifiedPush` distributor and returns the /// endpoint URL. Apps that need endpoint stability across launches should /// call [`set_token`](Self::set_token) before this with a persisted token. - pub async fn register_for_push_notifications(&self) -> crate::Result { + pub async fn register_for_push_notifications( + &self, + vapid: Option, + ) -> crate::Result { + let _ = vapid; #[cfg(all(target_os = "linux", feature = "push-notifications"))] { let state = self.unifiedpush_state().await?; - state.register().await + let endpoint = state.register().await?; + Ok(crate::models::PushNotificationResponse::from_token( + endpoint, + )) } #[cfg(not(all(target_os = "linux", feature = "push-notifications")))] { diff --git a/src/macos.rs b/src/macos.rs index 957d194c..174e9923 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -191,14 +191,17 @@ impl Notifications { Ok(response.permission_state) } - pub async fn register_for_push_notifications(&self) -> crate::Result { + pub async fn register_for_push_notifications( + &self, + _vapid: Option, + ) -> crate::Result { validation::require_bundle()?; #[cfg(feature = "push-notifications")] { let response: crate::PushNotificationResponse = self.plugin.registerForPushNotifications().await.parse()?; - Ok(response.device_token) + Ok(response) } #[cfg(not(feature = "push-notifications"))] { diff --git a/src/mobile.rs b/src/mobile.rs index 04e46c99..512c7b81 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -4,10 +4,9 @@ use tauri::{ plugin::{PermissionState, PluginApi, PluginHandle}, }; -#[cfg(feature = "push-notifications")] -use crate::models::PushNotificationResponse; use crate::models::{ ActionType, ActiveNotification, Channel, PendingNotification, PermissionResponse, + PushNotificationResponse, }; use std::collections::HashMap; @@ -60,20 +59,29 @@ impl Notifications { .map_err(Into::into) } - pub async fn register_for_push_notifications(&self) -> crate::Result { + pub async fn register_for_push_notifications( + &self, + vapid: Option, + ) -> crate::Result { #[cfg(feature = "push-notifications")] { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct RegisterArgs { + #[serde(skip_serializing_if = "Option::is_none")] + vapid: Option, + } self.0 .run_mobile_plugin_async::( "registerForPushNotifications", - (), + RegisterArgs { vapid }, ) .await - .map(|r| r.device_token) .map_err(Into::into) } #[cfg(not(feature = "push-notifications"))] { + let _ = vapid; Err(crate::Error::Io(std::io::Error::other( "Push notifications feature is not enabled", ))) diff --git a/src/models.rs b/src/models.rs index 0d94ba27..8096bfac 100644 --- a/src/models.rs +++ b/src/models.rs @@ -11,11 +11,25 @@ pub struct PermissionResponse { pub permission_state: PermissionState, } -#[cfg(feature = "push-notifications")] -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PushNotificationResponse { pub device_token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub p256dh: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, +} + +impl PushNotificationResponse { + #[must_use] + pub const fn from_token(device_token: String) -> Self { + Self { + device_token, + p256dh: None, + auth: None, + } + } } #[cfg(all(target_os = "android", feature = "push-notifications"))] diff --git a/src/windows.rs b/src/windows.rs index 4af5463d..024412b2 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -825,8 +825,13 @@ impl Notifications { self.permission_state().await } - pub async fn register_for_push_notifications(&self) -> crate::Result { - self.plugin.open_push_channel() + pub async fn register_for_push_notifications( + &self, + _vapid: Option, + ) -> crate::Result { + self.plugin + .open_push_channel() + .map(PushNotificationResponse::from_token) } pub fn unregister_for_push_notifications(&self) -> crate::Result<()> {