Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ class RegisterActionTypesArgs {
lateinit var types: List<ActionType>
}

@InvokeArg
class RegisterPushArgs {
var vapid: String? = null
}

@InvokeArg
class SetClickListenerActiveArgs {
var active: Boolean = false
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ allprojects {
google()
mavenCentral()
}
configurations.all {
resolutionStrategy {
force("org.jetbrains.kotlin:kotlin-stdlib:2.1.21")
}
}
}

tasks.register("clean").configure {
Expand Down
11 changes: 6 additions & 5 deletions guest-js/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
40 changes: 17 additions & 23 deletions guest-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,34 +441,27 @@ async function requestPermission(): Promise<NotificationPermission> {
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<string> {
return await invoke("plugin:notifications|register_for_push_notifications");
async function registerForPushNotifications(
vapid?: string,
): Promise<PushRegistration> {
return await invoke("plugin:notifications|register_for_push_notifications", {
vapid,
});
}

/**
Expand Down Expand Up @@ -892,6 +885,7 @@ export type {
Channel,
ScheduleInterval,
NotificationClickedData,
PushRegistration,
};

export {
Expand Down
5 changes: 3 additions & 2 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ pub async fn request_permission<R: Runtime>(
pub async fn register_for_push_notifications<R: Runtime>(
_app: AppHandle<R>,
notification: State<'_, Notifications<R>>,
) -> Result<String> {
notification.register_for_push_notifications().await
vapid: Option<String>,
) -> Result<crate::models::PushNotificationResponse> {
notification.register_for_push_notifications(vapid).await
}

#[command]
Expand Down
11 changes: 9 additions & 2 deletions src/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,18 @@ impl<R: Runtime> Notifications<R> {
/// 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<String> {
pub async fn register_for_push_notifications(
&self,
vapid: Option<String>,
) -> crate::Result<crate::models::PushNotificationResponse> {
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")))]
{
Expand Down
7 changes: 5 additions & 2 deletions src/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,14 +191,17 @@ impl<R: Runtime> Notifications<R> {
Ok(response.permission_state)
}

pub async fn register_for_push_notifications(&self) -> crate::Result<String> {
pub async fn register_for_push_notifications(
&self,
_vapid: Option<String>,
) -> crate::Result<crate::PushNotificationResponse> {
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"))]
{
Expand Down
18 changes: 13 additions & 5 deletions src/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,20 +59,29 @@ impl<R: Runtime> Notifications<R> {
.map_err(Into::into)
}

pub async fn register_for_push_notifications(&self) -> crate::Result<String> {
pub async fn register_for_push_notifications(
&self,
vapid: Option<String>,
) -> crate::Result<PushNotificationResponse> {
#[cfg(feature = "push-notifications")]
{
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct RegisterArgs {
#[serde(skip_serializing_if = "Option::is_none")]
vapid: Option<String>,
}
self.0
.run_mobile_plugin_async::<PushNotificationResponse>(
"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",
)))
Expand Down
18 changes: 16 additions & 2 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<String>,
}

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"))]
Expand Down
9 changes: 7 additions & 2 deletions src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -825,8 +825,13 @@ impl<R: Runtime> Notifications<R> {
self.permission_state().await
}

pub async fn register_for_push_notifications(&self) -> crate::Result<String> {
self.plugin.open_push_channel()
pub async fn register_for_push_notifications(
&self,
_vapid: Option<String>,
) -> crate::Result<PushNotificationResponse> {
self.plugin
.open_push_channel()
.map(PushNotificationResponse::from_token)
}

pub fn unregister_for_push_notifications(&self) -> crate::Result<()> {
Expand Down