Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
988ee33
Added UnifiedPush support
TastelessVoid Mar 8, 2026
f26e0fa
Move to UnifiedPush v3
TastelessVoid Mar 8, 2026
15d3536
Minor fixes
TastelessVoid Mar 8, 2026
3fdf3e7
Update src/mobile.rs
TastelessVoid Mar 8, 2026
7bd435d
Update android/src/main/java/app/tauri/notification/NotificationPlugi…
TastelessVoid Mar 8, 2026
aa3db73
Update android/src/main/java/app/tauri/notification/TauriUnifiedPushM…
TastelessVoid Mar 8, 2026
c9710f3
Update android/src/main/java/app/tauri/notification/TauriUnifiedPushM…
TastelessVoid Mar 8, 2026
237ea4e
Added tests and locked UnifiedPush to Android
TastelessVoid Mar 9, 2026
2753478
Added tests and locked UnifiedPush to Android
TastelessVoid Mar 9, 2026
4044ab7
Merge remote-tracking branch 'origin/unified-push-support' into unifi…
TastelessVoid Mar 9, 2026
21a1b0c
Fixed compiling errors
TastelessVoid Mar 9, 2026
61a4d83
Do not import UnifiedPush unconditionally or the CI/Unit test fail
TastelessVoid Mar 9, 2026
3d55514
Implement review suggestions
TastelessVoid Mar 9, 2026
b745963
Added UnifiedPush related terms to cspell.json
TastelessVoid Mar 9, 2026
17ecb04
Implement review suggestions from Copilot
TastelessVoid Mar 9, 2026
cd69721
Add support for UnifiedPush temporary unavailability and VAPID key ha…
TastelessVoid Mar 10, 2026
9c0c1e0
Added MessageStyling and other features that were TODO that I need fo…
TastelessVoid Mar 10, 2026
bcfe750
Added MessageStyling and other features that were TODO that I need fo…
TastelessVoid Mar 10, 2026
59fe15c
Add support for avatar images and authentication in messaging style n…
TastelessVoid Mar 10, 2026
150e5b6
Add avatar image downloading on a background thread and update notifi…
TastelessVoid Mar 10, 2026
e48ecc5
Fixed prettier issues
TastelessVoid Mar 10, 2026
ea3f3d8
Refactor unified push permission handling and improve avatar image do…
TastelessVoid Mar 10, 2026
daa413d
Adjust codestyle to follow prettier to make CI happy
TastelessVoid Mar 10, 2026
c77ca0e
Add logging for past scheduled notification time checks
TastelessVoid Mar 10, 2026
90e5d49
Refactor avatar image downloading to prefetch avatars in parallel and…
TastelessVoid Mar 10, 2026
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
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "tauri-plugin-notifications"
version = "0.4.3"
authors = ["You"]
description = "A Tauri v2 plugin for sending notifications on desktop and mobile platforms with support for system notifications and push delivery via FCM and APNs."
description = "A Tauri v2 plugin for sending notifications on desktop and mobile platforms with support for system notifications and push delivery via FCM, APNs, and UnifiedPush."
edition = "2021"
rust-version = "1.77.2"
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
Expand All @@ -13,6 +13,7 @@ repository = "https://github.com/Choochmeque/tauri-plugin-notifications"
[features]
default = ["notify-rust"]
push-notifications = []
unified-push = []
notify-rust = ["dep:notify-rust"]

[dependencies]
Expand Down
165 changes: 165 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,40 @@ Without this feature enabled:
- Push notification registration code is disabled
- The `registerForPushNotifications()` function will return an error if called

### UnifiedPush Support

The `unified-push` feature is **disabled by default**. UnifiedPush is a decentralized push notification protocol that allows apps to receive push notifications through various distributors (like NextPush, ntfy, etc.) instead of being locked into FCM or APNs.

To enable UnifiedPush support on Android:

```toml
[dependencies]
tauri-plugin-notifications = { version = "0.4", features = ["unified-push"] }
```

**Note:** UnifiedPush is currently only supported on Android. It can be used alongside FCM or as a standalone push notification solution.

To enable both FCM and UnifiedPush:

```toml
[dependencies]
tauri-plugin-notifications = { version = "0.4", features = ["push-notifications", "unified-push"] }
```

**What is UnifiedPush?**

UnifiedPush is an open standard for push notifications that gives users control over their notification delivery. Instead of relying solely on Google's FCM or Apple's APNs, apps can receive notifications through user-chosen distributors:

- **NextPush** - A UnifiedPush distributor with server-side support
- **ntfy** - Simple, self-hostable push notification service
- **Other distributors** - Any app implementing the UnifiedPush protocol

**Benefits:**
- User privacy - users choose their notification provider
- No Google Services dependency required
- Works with self-hosted solutions
- Open protocol that any distributor can implement

### Desktop Notification Backend (notify-rust)

The `notify-rust` feature is **enabled by default** and provides cross-platform desktop notifications using the [notify-rust](https://crates.io/crates/notify-rust) crate.
Expand Down Expand Up @@ -105,6 +139,17 @@ Configure the plugin permissions in your `capabilities/default.json`:
}
```

If you enabled the `unified-push` feature, also add the UnifiedPush permission set:

```json
{
"permissions": [
"notifications:default",
"notifications:allow-unified-push"
]
}
```

Register the plugin in your Tauri app:

```rust
Expand Down Expand Up @@ -390,6 +435,63 @@ try {
}
```

#### UnifiedPush (Android)

UnifiedPush provides a decentralized alternative to FCM, giving users control over their push notification delivery.

```typescript
import {
registerForUnifiedPush,
getUnifiedPushDistributors,
saveUnifiedPushDistributor,
onUnifiedPushMessage,
onUnifiedPushEndpoint
} from '@choochmeque/tauri-plugin-notifications-api';

// Check available UnifiedPush distributors
const { distributors } = await getUnifiedPushDistributors();
console.log('Available distributors:', distributors);

// If no distributor is selected, prompt user to choose one
if (distributors.length === 0) {
console.error('No UnifiedPush distributor installed. Please install NextPush or ntfy.');
} else {
// Save the selected distributor (e.g., NextPush)
await saveUnifiedPushDistributor(distributors[0]);

// Listen for new endpoints
const unlistenEndpoint = await onUnifiedPushEndpoint((data) => {
console.log('UnifiedPush endpoint:', data.endpoint);
// Send this endpoint to your server to send push notifications
});

// Listen for incoming messages
const unlistenMessage = await onUnifiedPushMessage((data) => {
console.log('UnifiedPush message received:', data);
});

// Register for UnifiedPush
const { endpoint, instance } = await registerForUnifiedPush();
console.log('Registered with UnifiedPush:', endpoint);
}
```

**Sending UnifiedPush notifications:**

Once you have the endpoint URL, your server can send notifications by making an HTTP POST request to the endpoint:

```bash
curl -X POST "https://your-distributor-url/endpoint" \
-H "Content-Type: application/json" \
-d '{
"title": "Hello",
"body": "This is a UnifiedPush notification",
"data": {
"custom": "data"
}
}'
```

### Rust

```rust
Expand Down Expand Up @@ -452,6 +554,69 @@ Registers the app for push notifications (mobile only). On Android, this retriev

**Returns:** `Promise<string>` - The device push token

### `unregisterForPushNotifications()`
Unregisters the app from push notifications (mobile only). Deletes the FCM token on Android.

**Returns:** `Promise<void>`

> **Breaking change:** `unregisterForPushNotifications()` previously returned `Promise<string>`. It now returns `Promise<void>` since the native side no longer resolves with a value.

### `registerForUnifiedPush()`
Registers the app for UnifiedPush notifications (Android only). UnifiedPush is a decentralized push notification protocol that allows receiving notifications through various distributors.

**Returns:** `Promise<UnifiedPushEndpoint>` - An object containing:
- `endpoint`: The URL where push messages should be sent
- `instance`: The instance identifier for this registration
- `pubKeySet` (optional): VAPID public-key set for encrypted push (contains `pubKey` and `auth` fields)

### `unregisterFromUnifiedPush()`
Unregisters the app from UnifiedPush notifications (Android only).

**Returns:** `Promise<void>`

### `getUnifiedPushDistributors()`
Gets the list of available UnifiedPush distributors installed on the device (Android only). Distributors are apps that handle push notification delivery (e.g., NextPush, ntfy).

**Returns:** `Promise<{ distributors: string[] }>`

### `saveUnifiedPushDistributor(distributor: string)`
Saves the selected UnifiedPush distributor (Android only). This sets which distributor app should be used for handling push notifications.

**Parameters:**
- `distributor`: The package name of the distributor to use

**Returns:** `Promise<void>`

### `getUnifiedPushDistributor()`
Gets the currently selected UnifiedPush distributor (Android only).

**Returns:** `Promise<{ distributor: string }>`

### `onUnifiedPushEndpoint(callback: (data: UnifiedPushEndpoint) => void)`
Listens for new UnifiedPush endpoint events. This event is triggered when a new UnifiedPush endpoint is registered or updated.

**Returns:** `Promise<PluginListener>` with `unlisten()` method

### `onUnifiedPushMessage(callback: (data: Record<string, unknown>) => void)`
Listens for UnifiedPush message events. This event is triggered when a push message is received through UnifiedPush.

**Returns:** `Promise<PluginListener>` with `unlisten()` method

### `onUnifiedPushUnregistered(callback: (data: { instance: string }) => void)`
Listens for UnifiedPush unregistration events. This event is triggered when the app is unregistered from UnifiedPush.

**Returns:** `Promise<PluginListener>` with `unlisten()` method

### `onUnifiedPushError(callback: (data: { message: string, instance?: string }) => void)`
Listens for UnifiedPush error events. This event is triggered when there's an error with UnifiedPush registration or delivery.

**Returns:** `Promise<PluginListener>` with `unlisten()` method
Comment thread
TastelessVoid marked this conversation as resolved.

### `onUnifiedPushTempUnavailable(callback: (data: { instance: string }) => void)`
Listens for UnifiedPush temporary-unavailability events. Fired when the distributor app is temporarily unavailable (e.g. being updated). The existing registration remains valid; wait for an `onUnifiedPushEndpoint` callback before sending push messages again.

**Returns:** `Promise<PluginListener>` with `unlisten()` method

### `sendNotification(options: Options | string)`
Sends a notification to the user. Can be called with a simple string for the title or with a detailed options object.

Expand Down
9 changes: 8 additions & 1 deletion android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")

// Enable push notifications based on Cargo feature flag
val enablePush = buildProperties.getProperty("enablePushNotifications", "false").toBoolean()
buildConfigField("boolean", "ENABLE_PUSH_NOTIFICATIONS", "$enablePush")

val enableUnifiedPush = buildProperties.getProperty("enableUnifiedPush", "false").toBoolean()
buildConfigField("boolean", "ENABLE_UNIFIED_PUSH", "$enableUnifiedPush")

manifestPlaceholders["unifiedPushReceiverEnabled"] = "$enableUnifiedPush"
}

buildTypes {
Expand Down Expand Up @@ -73,6 +77,9 @@ dependencies {

implementation(platform("com.google.firebase:firebase-bom:34.7.0"))
implementation("com.google.firebase:firebase-messaging-ktx:24.1.2")

implementation("org.unifiedpush.android:connector:3.3.2")

testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk-android:1.14.9")
testImplementation("io.mockk:mockk-agent:1.14.9")
Expand Down
1 change: 0 additions & 1 deletion android/settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ dependencyResolutionManagement {
repositories {
mavenCentral()
google()

}
}

Expand Down
14 changes: 13 additions & 1 deletion android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,26 @@
</intent-filter>
</receiver>

<!-- Firebase Cloud Messaging Service for handling push notifications -->
<service
android:name="app.tauri.notification.TauriFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

<receiver
android:name="app.tauri.notification.TauriUnifiedPushMessagingService"
android:exported="true"
android:enabled="${unifiedPushReceiverEnabled}">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
<action android:name="org.unifiedpush.android.connector.TEMP_UNAVAILABLE" />
</intent-filter>
</receiver>
Comment thread
TastelessVoid marked this conversation as resolved.
</application>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
Expand Down
96 changes: 96 additions & 0 deletions android/src/main/java/app/tauri/notification/JSObjectUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package app.tauri.notification

import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import org.json.JSONArray
import org.json.JSONObject

/**
* Shared helpers for recursively converting native Kotlin values (Maps, Lists, primitives)
* into [JSObject] / [JSArray] structures understood by the Tauri plugin bridge.
*
* Both [NotificationPlugin] and [TauriUnifiedPushMessagingService] need to convert
* the push-data maps they receive into JS-bridge types; keeping the logic in one
* place prevents the two copies from drifting apart.
*/
internal object JSObjectUtils {

/**
* Recursively put [value] into [target] under [key].
* Handles String, Int, Long, Double, Boolean, Map, and List types.
*/
fun putValueToJSObject(target: JSObject, key: String, value: Any) {
when (value) {
is String -> target.put(key, value)
is Int -> target.put(key, value)
is Long -> target.put(key, value)
is Double -> target.put(key, value)
is Boolean -> target.put(key, value)
is Map<*, *> -> {
val nestedObj = JSObject()
@Suppress("UNCHECKED_CAST")
val map = value as Map<String, Any>
for ((k, v) in map) {
putValueToJSObject(nestedObj, k, v)
}
target.put(key, nestedObj)
}
is List<*> -> target.put(key, convertListToJSArray(value))
else -> target.put(key, value.toString())
}
}

/**
* Recursively convert a [List] into a [JSArray], handling nested maps, lists, and primitives.
*/
fun convertListToJSArray(list: List<*>): JSArray {
val arr = JSArray()
for (item in list) {
when (item) {
is String -> arr.put(item)
is Int -> arr.put(item)
is Long -> arr.put(item)
is Double -> arr.put(item)
is Boolean -> arr.put(item)
is Map<*, *> -> {
val nestedObj = JSObject()
@Suppress("UNCHECKED_CAST")
val map = item as Map<String, Any>
for ((k, v) in map) {
putValueToJSObject(nestedObj, k, v)
}
arr.put(nestedObj)
}
is List<*> -> arr.put(convertListToJSArray(item))
null -> arr.put(JSONObject.NULL)
else -> arr.put(item.toString())
}
}
return arr
}

/**
* Recursively convert a raw [JSONObject] value into a native Kotlin type
* (Map for objects, List for arrays, or the primitive itself).
*/
fun jsonValueToNative(value: Any): Any {
return when (value) {
is JSONObject -> {
val map = mutableMapOf<String, Any>()
for (key in value.keys()) {
map[key] = jsonValueToNative(value.get(key))
}
map
}
is JSONArray -> {
val list = mutableListOf<Any>()
for (i in 0 until value.length()) {
list.add(jsonValueToNative(value.get(i)))
}
list
}
else -> value
}
}
}

Loading
Loading