diff --git a/Cargo.toml b/Cargo.toml index 3ec8723..deb3ca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] @@ -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] diff --git a/README.md b/README.md index 4b66c0b..841df01 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 @@ -452,6 +554,69 @@ Registers the app for push notifications (mobile only). On Android, this retriev **Returns:** `Promise` - The device push token +### `unregisterForPushNotifications()` +Unregisters the app from push notifications (mobile only). Deletes the FCM token on Android. + +**Returns:** `Promise` + +> **Breaking change:** `unregisterForPushNotifications()` previously returned `Promise`. It now returns `Promise` 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` - 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` + +### `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` + +### `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` with `unlisten()` method + +### `onUnifiedPushMessage(callback: (data: Record) => void)` +Listens for UnifiedPush message events. This event is triggered when a push message is received through UnifiedPush. + +**Returns:** `Promise` 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` 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` with `unlisten()` method + +### `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` 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. diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dea21fc..52cc9a4 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -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 { @@ -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") diff --git a/android/settings.gradle b/android/settings.gradle index 29f15f5..067080a 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -23,7 +23,6 @@ dependencyResolutionManagement { repositories { mavenCentral() google() - } } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index d5f3a81..cfbcd2e 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -14,7 +14,6 @@ - @@ -22,6 +21,19 @@ + + + + + + + + + + diff --git a/android/src/main/java/app/tauri/notification/JSObjectUtils.kt b/android/src/main/java/app/tauri/notification/JSObjectUtils.kt new file mode 100644 index 0000000..3588d7e --- /dev/null +++ b/android/src/main/java/app/tauri/notification/JSObjectUtils.kt @@ -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 + 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 + 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() + for (key in value.keys()) { + map[key] = jsonValueToNative(value.get(key)) + } + map + } + is JSONArray -> { + val list = mutableListOf() + for (i in 0 until value.length()) { + list.add(jsonValueToNative(value.get(i))) + } + list + } + else -> value + } + } +} + diff --git a/android/src/main/java/app/tauri/notification/Notification.kt b/android/src/main/java/app/tauri/notification/Notification.kt index 6ce340f..d3347f7 100644 --- a/android/src/main/java/app/tauri/notification/Notification.kt +++ b/android/src/main/java/app/tauri/notification/Notification.kt @@ -9,6 +9,32 @@ import android.service.notification.StatusBarNotification import androidx.annotation.RequiresApi import app.tauri.annotation.InvokeArg import app.tauri.plugin.JSObject +import com.fasterxml.jackson.annotation.JsonProperty + +@InvokeArg +class MessagingStylePerson { + var name: String = "" + var icon: String? = null + var iconUrl: String? = null + var key: String? = null +} + +@InvokeArg +class MessagingStyleMessage { + var text: String = "" + var timestamp: Long = 0 + var sender: MessagingStylePerson? = null +} + +@InvokeArg +class MessagingStyleConfig { + var user: MessagingStylePerson = MessagingStylePerson() + var conversationTitle: String? = null + var isGroupConversation: Boolean = false + var messages: List = listOf() + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + var authToken: String? = null +} @InvokeArg class Notification { @@ -36,6 +62,17 @@ class Notification { var number: Int? = null var silent: Boolean? = null + // Progress bar support + var progress: Int? = null + var progressMax: Int? = null + var progressIndeterminate: Boolean? = null + + // System category (maps to NotificationCompat.CATEGORY_* constants) + var category: String? = null + + // MessagingStyle support + var messagingStyle: MessagingStyleConfig? = null + fun getSound(context: Context, defaultSound: Int): String? { var soundPath: String? = null var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index aae5771..8781aa9 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -20,6 +20,7 @@ import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin import com.google.firebase.messaging.FirebaseMessaging +import org.unifiedpush.android.connector.UnifiedPush const val LOCAL_NOTIFICATIONS = "permissionState" @@ -45,6 +46,7 @@ class NotificationAction { lateinit var id: String var title: String? = null var input: Boolean? = null + var icon: String? = null } @InvokeArg @@ -74,6 +76,11 @@ class RemoveActiveArgs { var notifications: List = listOf() } +@InvokeArg +class SaveUnifiedPushDistributorArgs { + var distributor: String? = null +} + @TauriPlugin( permissions = [ Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "permissionState") @@ -89,7 +96,15 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private var pendingTokenInvoke: Invoke? = null private var cachedToken: String? = null - // Click listener tracking for cold-start support + private var pendingUnifiedPushInvoke: Invoke? = null + private var cachedUnifiedPushEndpoint: String? = null + private var cachedPubKey: String? = null + private var cachedAuth: String? = null + private var unifiedPushInstance: String = "default" + + // Lock object for synchronizing compound read-check-write on UnifiedPush fields + private val unifiedPushLock = Any() + private var hasClickedListener = false private var pendingNotificationClick: JSObject? = null @@ -233,7 +248,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun show(invoke: Invoke) { val notification = invoke.parseArgs(Notification::class.java) - notification.sourceJson = invoke.getRawArgs() + notification.sourceJson = stripAuthToken(invoke.getRawArgs()) val id = manager.schedule(notification) if (notification.schedule != null) { @@ -348,6 +363,8 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } else { if (getPermissionState(LOCAL_NOTIFICATIONS) !== PermissionState.GRANTED) { requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "permissionsCallback") + } else { + permissionState(invoke) } } } @@ -451,7 +468,6 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { if (!BuildConfig.ENABLE_PUSH_NOTIFICATIONS) return cachedToken = token - // Trigger push-token event to notify the frontend about the token val data = JSObject() data.put("token", token) trigger("push-token", data) @@ -463,23 +479,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { val data = JSObject() for ((key, value) in pushData) { - when (value) { - is String -> data.put(key, value) - is Int -> data.put(key, value) - is Long -> data.put(key, value) - is Double -> data.put(key, value) - is Boolean -> data.put(key, value) - is Map<*, *> -> { - val nestedObj = JSObject() - @Suppress("UNCHECKED_CAST") - val map = value as Map - for ((k, v) in map) { - nestedObj.put(k, v.toString()) - } - data.put(key, nestedObj) - } - else -> data.put(key, value.toString()) - } + putValueToJSObject(data, key, value) } trigger("push-message", data) } @@ -506,6 +506,286 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } } + + @Command + fun registerForUnifiedPush(invoke: Invoke) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) { + invoke.reject("UnifiedPush is disabled in this build") + return + } + + // First check if notifications are enabled + if (!manager.areNotificationsEnabled()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (getPermissionState(LOCAL_NOTIFICATIONS) !== PermissionState.GRANTED) { + synchronized(unifiedPushLock) { + pendingUnifiedPushInvoke?.reject("Superseded by a new registration request") + pendingUnifiedPushInvoke = invoke + } + requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "unifiedPushPermissionsCallback") + return + } + } else { + invoke.reject("Notification permissions not granted") + return + } + } + + synchronized(unifiedPushLock) { + // If we already have a cached endpoint, return it immediately + cachedUnifiedPushEndpoint?.let { + val result = JSObject() + result.put("endpoint", it) + result.put("instance", unifiedPushInstance) + if (cachedPubKey != null && cachedAuth != null) { + val keySet = JSObject() + keySet.put("pubKey", cachedPubKey) + keySet.put("auth", cachedAuth) + result.put("pubKeySet", keySet) + } + invoke.resolve(result) + return + } + + pendingUnifiedPushInvoke?.reject("Superseded by a new registration request") + pendingUnifiedPushInvoke = invoke + } + UnifiedPush.register(activity, unifiedPushInstance) + } + + @Command + fun unregisterFromUnifiedPush(invoke: Invoke) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) { + invoke.reject("UnifiedPush is disabled in this build") + return + } + + synchronized(unifiedPushLock) { + // Reject any pending registration invoke to prevent the JS caller from hanging + pendingUnifiedPushInvoke?.reject("Unregistration requested while registration was in progress") + pendingUnifiedPushInvoke = null + cachedUnifiedPushEndpoint = null + cachedPubKey = null + cachedAuth = null + } + + UnifiedPush.unregister(activity, unifiedPushInstance) + invoke.resolve() + } + + @Command + fun getUnifiedPushDistributors(invoke: Invoke) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) { + invoke.reject("UnifiedPush is disabled in this build") + return + } + + val distributors = UnifiedPush.getDistributors(activity) + val result = JSObject() + val distributorsArray = JSArray() + distributors.forEach { distributorsArray.put(it) } + result.put("distributors", distributorsArray) + invoke.resolve(result) + } + + @Command + fun saveUnifiedPushDistributor(invoke: Invoke) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) { + invoke.reject("UnifiedPush is disabled in this build") + return + } + + val args = invoke.parseArgs(SaveUnifiedPushDistributorArgs::class.java) + val distributor = args.distributor + if (distributor == null) { + invoke.reject("Distributor parameter is required") + return + } + + UnifiedPush.saveDistributor(activity, distributor) + invoke.resolve() + } + + @Command + fun getUnifiedPushDistributor(invoke: Invoke) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) { + invoke.reject("UnifiedPush is disabled in this build") + return + } + + val distributor = UnifiedPush.getSavedDistributor(activity) ?: "" + val result = JSObject() + result.put("distributor", distributor) + invoke.resolve(result) + } + + @PermissionCallback + private fun unifiedPushPermissionsCallback(invoke: Invoke) { + val shouldRegister: Boolean + val instanceToRegister: String + synchronized(unifiedPushLock) { + val isCurrent = pendingUnifiedPushInvoke === invoke + if (!isCurrent) { + // Stale callback — a newer registerForUnifiedPush already rejected this invoke + return + } + if (!manager.areNotificationsEnabled()) { + pendingUnifiedPushInvoke = null + // Release lock before rejecting to avoid holding it during invoke callback + shouldRegister = false + instanceToRegister = unifiedPushInstance + } else { + // Capture instance while still holding the lock so a concurrent + // unregisterFromUnifiedPush cannot clear it between check and use. + shouldRegister = true + instanceToRegister = unifiedPushInstance + } + } + + if (!shouldRegister) { + invoke.reject("Notification permissions denied") + return + } + + // Double-check that the invoke is still the pending one. Between releasing + // unifiedPushLock above and reaching this point, unregisterFromUnifiedPush + // could have cleared pendingUnifiedPushInvoke to cancel the registration. + synchronized(unifiedPushLock) { + if (pendingUnifiedPushInvoke !== invoke) { + // Unregistration was requested while we were about to register — abort. + return + } + } + + UnifiedPush.register(activity, instanceToRegister) + } + + fun handleNewUnifiedPushEndpoint( + endpoint: String, + instance: String, + pubKey: String? = null, + auth: String? = null, + ) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) return + + val pendingInvoke: Invoke? + synchronized(unifiedPushLock) { + cachedUnifiedPushEndpoint = endpoint + cachedPubKey = pubKey + cachedAuth = auth + unifiedPushInstance = instance + pendingInvoke = pendingUnifiedPushInvoke + pendingUnifiedPushInvoke = null + } + + fun buildResult() = JSObject().apply { + put("endpoint", endpoint) + put("instance", instance) + if (pubKey != null && auth != null) { + val keySet = JSObject() + keySet.put("pubKey", pubKey) + keySet.put("auth", auth) + put("pubKeySet", keySet) + } + } + + pendingInvoke?.resolve(buildResult()) + trigger("unifiedpush-endpoint", buildResult()) + } + + // Called by TauriUnifiedPushMessagingService when unregistered + fun handleUnifiedPushUnregistered(instance: String) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) return + + synchronized(unifiedPushLock) { + cachedUnifiedPushEndpoint = null + cachedPubKey = null + cachedAuth = null + } + + val data = JSObject() + data.put("instance", instance) + trigger("unifiedpush-unregistered", data) + } + + /** + * Called by [TauriUnifiedPushMessagingService.onTempUnavailable] when the distributor is + * temporarily unavailable (e.g. the distributor app is being updated). + * The existing registration stays valid; callers should wait for a new + * [handleNewUnifiedPushEndpoint] before attempting to send messages. + */ + fun handleUnifiedPushTempUnavailable(instance: String) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) return + + val data = JSObject() + data.put("instance", instance) + trigger("unifiedpush-temp-unavailable", data) + } + + // Called by TauriUnifiedPushMessagingService when a push message is received + fun triggerUnifiedPushMessage(pushData: Map) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) return + + val data = JSObject() + for ((key, value) in pushData) { + putValueToJSObject(data, key, value) + } + trigger("unifiedpush-message", data) + } + + // Called by TauriUnifiedPushMessagingService when registration fails + fun handleUnifiedPushRegistrationFailed(instance: String, reason: String? = null) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) return + + val errorMessage = if (reason != null) { + "UnifiedPush registration failed for instance: $instance (reason: $reason)" + } else { + "UnifiedPush registration failed for instance: $instance" + } + val errorData = JSObject() + errorData.put("message", errorMessage) + errorData.put("instance", instance) + trigger("unifiedpush-error", errorData) + + val pendingInvoke: Invoke? + synchronized(unifiedPushLock) { + pendingInvoke = pendingUnifiedPushInvoke + pendingUnifiedPushInvoke = null + } + pendingInvoke?.reject(errorMessage) + } + + /** + * Recursively converts a native value (from JSON parsing) into the appropriate + * JSObject/JSArray type and puts it into the target [JSObject] under the given [key]. + * Delegates to [JSObjectUtils.putValueToJSObject]. + */ + private fun putValueToJSObject(target: JSObject, key: String, value: Any) = + JSObjectUtils.putValueToJSObject(target, key, value) + + /** + * Removes the `authToken` field from `messagingStyle` in the raw JSON string + * to prevent credentials from being persisted in notification storage or intents. + */ + private fun stripAuthToken(json: String?): String? { + if (json == null) return null + return try { + val obj = JSObject(json) + val messagingStyle = obj.optJSONObject("messagingStyle") + if (messagingStyle != null && messagingStyle.has("authToken")) { + messagingStyle.remove("authToken") + } + obj.toString() + } catch (e: Exception) { + Logger.error(Logger.tags(TAG), "Failed to strip authToken from JSON: ${e.message}", e) + json + } + } + + fun getNotificationManager(): TauriNotificationManager { + return manager + } + @Command fun setClickListenerActive(invoke: Invoke) { val args = invoke.parseArgs(SetClickListenerActiveArgs::class.java) diff --git a/android/src/main/java/app/tauri/notification/NotificationStorage.kt b/android/src/main/java/app/tauri/notification/NotificationStorage.kt index 13948c0..791b878 100644 --- a/android/src/main/java/app/tauri/notification/NotificationStorage.kt +++ b/android/src/main/java/app/tauri/notification/NotificationStorage.kt @@ -95,6 +95,9 @@ class NotificationStorage(private val context: Context, private val jsonMapper: editor.putString("id$index", action.id) editor.putString("title$index", action.title) editor.putBoolean("input$index", action.input ?: false) + if (action.icon != null) { + editor.putString("icon$index", action.icon) + } } editor.apply() Logger.debug(Logger.tags(STORAGE_TAG), "Saved action group ${type.id} with ${type.actions.size} actions") @@ -110,12 +113,14 @@ class NotificationStorage(private val context: Context, private val jsonMapper: val id = storage.getString("id$i", "") val title = storage.getString("title$i", "") val input = storage.getBoolean("input$i", false) - Logger.debug(Logger.tags(STORAGE_TAG), "Action $i: id=$id, title=$title, input=$input") + val icon = storage.getString("icon$i", null) + Logger.debug(Logger.tags(STORAGE_TAG), "Action $i: id=$id, title=$title, input=$input, icon=$icon") val action = NotificationAction() action.id = id ?: "" action.title = title action.input = input + action.icon = icon actions[i] = action } return actions diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index 7c66790..63c785a 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -10,6 +10,8 @@ import android.content.BroadcastReceiver import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.graphics.Color import android.media.AudioAttributes import android.net.Uri @@ -18,7 +20,9 @@ import android.os.Build.VERSION.SDK_INT import android.os.UserManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person import androidx.core.app.RemoteInput +import androidx.core.graphics.drawable.IconCompat import app.tauri.Logger import app.tauri.plugin.JSObject import app.tauri.plugin.PluginManager @@ -46,6 +50,9 @@ class TauriNotificationManager( ) { private var defaultSoundID: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE private var defaultSmallIconID: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + private val avatarExecutor: java.util.concurrent.ExecutorService = java.util.concurrent.Executors.newFixedThreadPool(4) { runnable -> + Thread(runnable).apply { isDaemon = true; name = "avatar-download" } + } fun handleNotificationActionPerformed( data: Intent, @@ -84,9 +91,6 @@ class TauriNotificationManager( return dataJson } - /** - * Create notification channel - */ fun createNotificationChannel() { // Create the NotificationChannel, but only on API 26+ because // the NotificationChannel class is new and not in the support library @@ -113,17 +117,17 @@ class TauriNotificationManager( } } - private fun trigger(notificationManager: NotificationManagerCompat, notification: Notification): Int { + private fun trigger(notificationManager: NotificationManagerCompat, notification: Notification, source: String = "local"): Int { dismissVisibleNotification(notification.id) cancelTimerForNotification(notification.id) - buildNotification(notificationManager, notification) + buildNotification(notificationManager, notification, source) return notification.id } - fun schedule(notification: Notification): Int { + fun schedule(notification: Notification, source: String = "local"): Int { val notificationManager = NotificationManagerCompat.from(context) - return trigger(notificationManager, notification) + return trigger(notificationManager, notification, source) } fun schedule(notifications: List): List { @@ -138,15 +142,119 @@ class TauriNotificationManager( return ids } - // TODO Progressbar support - // TODO System categories (DO_NOT_DISTURB etc.) - // TODO use NotificationCompat.MessagingStyle for latest API - // TODO expandable notification NotificationCompat.MessagingStyle - // TODO media style notification support NotificationCompat.MediaStyle + private fun buildPerson(person: MessagingStylePerson, prefetchedAvatars: Map = emptyMap()): Person { + val builder = Person.Builder().setName(person.name) + if (person.key != null) { + builder.setKey(person.key) + } + // Prefer iconUrl (remote avatar) over icon (drawable resource) + if (person.iconUrl != null) { + val bitmap = prefetchedAvatars[person.iconUrl] + if (bitmap != null) { + builder.setIcon(IconCompat.createWithBitmap(bitmap)) + } + } else if (person.icon != null) { + val resId = AssetUtils.getResourceID(context, person.icon, "drawable") + if (resId != AssetUtils.RESOURCE_ID_ZERO_VALUE) { + builder.setIcon(IconCompat.createWithResource(context, resId)) + } + } + return builder.build() + } + + // Downloads a remote avatar image as a circular-cropped bitmap. + // Submits the download to a background thread and returns a Future. + // The caller should collect all futures first, then resolve them to enable parallel downloads. + private fun submitAvatarDownload(url: String, authToken: String?): java.util.concurrent.Future { + return avatarExecutor.submit( + java.util.concurrent.Callable { + try { + val parsedUrl = java.net.URL(url) + if (authToken != null && !parsedUrl.protocol.equals("https", ignoreCase = true)) { + Logger.error(Logger.tags(TAG), "Refusing to send auth token over non-HTTPS URL: $url", null) + return@Callable null + } + val connection = parsedUrl.openConnection() as java.net.HttpURLConnection + connection.connectTimeout = 5_000 + connection.readTimeout = 5_000 + if (authToken != null) { + connection.setRequestProperty("Authorization", "Bearer $authToken") + } + connection.connect() + if (connection.responseCode != 200) { + connection.disconnect() + return@Callable null + } + val raw = BitmapFactory.decodeStream(connection.inputStream) + connection.disconnect() + if (raw == null) return@Callable null + cropCircle(raw) + } catch (e: Exception) { + Logger.error(Logger.tags(TAG), "Failed to download avatar: ${e.message}", e) + null + } + } + ) + } + + // Resolves a previously submitted avatar future, blocking for up to 10 seconds. + private fun resolveAvatarFuture(future: java.util.concurrent.Future): Bitmap? { + return try { + future.get(10, java.util.concurrent.TimeUnit.SECONDS) + } catch (e: Exception) { + Logger.error(Logger.tags(TAG), "Avatar download timed out or failed: ${e.message}", e) + null + } + } + + // Pre-downloads all avatar images for a MessagingStyle notification in parallel. + // Returns a map from URL to Bitmap (or null on failure). + private fun prefetchAvatars(msgStyle: MessagingStyleConfig): Map { + val authToken = msgStyle.authToken + // Collect all unique URLs that need downloading + val urlsToDownload = mutableSetOf() + msgStyle.user.iconUrl?.let { urlsToDownload.add(it) } + for (msg in msgStyle.messages) { + msg.sender?.iconUrl?.let { urlsToDownload.add(it) } + } + if (urlsToDownload.isEmpty()) return emptyMap() + + // Submit all downloads in parallel + val futures = urlsToDownload.associateWith { url -> submitAvatarDownload(url, authToken) } + + // Resolve all futures (total wait is ~10s max, not N×10s) + return futures.mapValues { (_, future) -> resolveAvatarFuture(future) } + } + + /** + * Shuts down the background executor used for avatar downloads. + * Should be called when the plugin is being destroyed / the activity is finishing. + */ + fun destroy() { + avatarExecutor.shutdown() + } + + private fun cropCircle(src: Bitmap): Bitmap { + val size = minOf(src.width, src.height) + val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(output) + val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) + val rect = android.graphics.Rect(0, 0, size, size) + canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) + paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN) + // Center-crop the source into the square + val srcLeft = (src.width - size) / 2 + val srcTop = (src.height - size) / 2 + val srcRect = android.graphics.Rect(srcLeft, srcTop, srcLeft + size, srcTop + size) + canvas.drawBitmap(src, srcRect, rect, paint) + return output + } + @SuppressLint("MissingPermission") private fun buildNotification( notificationManager: NotificationManagerCompat, notification: Notification, + source: String = "local", ) { val channelId = notification.channelId ?: DEFAULT_NOTIFICATION_CHANNEL_ID val mBuilder = NotificationCompat.Builder( @@ -158,7 +266,43 @@ class TauriNotificationManager( .setOngoing(notification.isOngoing) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setGroupSummary(notification.isGroupSummary) - if (notification.largeBody != null) { + + // Progress bar support + val progressMax = notification.progressMax + val progress = notification.progress + if (progressMax != null || progress != null || notification.progressIndeterminate == true) { + mBuilder.setProgress( + progressMax ?: 100, + progress ?: 0, + notification.progressIndeterminate ?: false + ) + } + + // System category support + val category = notification.category + if (category != null) { + mBuilder.setCategory(category) + } + + // Style selection (mutually exclusive: messagingStyle > largeBody > inboxLines) + if (notification.messagingStyle != null) { + val msgStyle = notification.messagingStyle!! + // Pre-fetch all avatar images in parallel (max ~10s total, not N×10s) + val avatars = prefetchAvatars(msgStyle) + val userPerson = buildPerson(msgStyle.user, avatars) + val messagingStyle = NotificationCompat.MessagingStyle(userPerson) + + if (msgStyle.conversationTitle != null) { + messagingStyle.conversationTitle = msgStyle.conversationTitle + } + messagingStyle.isGroupConversation = msgStyle.isGroupConversation + + for (msg in msgStyle.messages) { + val senderPerson = msg.sender?.let { buildPerson(it, avatars) } + messagingStyle.addMessage(msg.text, msg.timestamp, senderPerson) + } + mBuilder.setStyle(messagingStyle) + } else if (notification.largeBody != null) { // support multiline text mBuilder.setStyle( NotificationCompat.BigTextStyle() @@ -215,7 +359,7 @@ class TauriNotificationManager( } else { notificationManager.notify(notification.id, buildNotification) try { - NotificationPlugin.triggerNotification(notification) + NotificationPlugin.triggerNotification(notification, source) } catch (e: JSONException) { Logger.error(Logger.tags(TAG), "Failed to trigger notification event: ${e.message}", e) } @@ -241,8 +385,14 @@ class TauriNotificationManager( if (actionTypeId != null) { val actionGroup = storage.getActionGroup(actionTypeId) for (notificationAction in actionGroup) { - // TODO Add custom icons to actions - val actionIntent = buildIntent(notification, notificationAction!!.id) + // Resolve custom action icon, fall back to transparent + val actionIconResId = if (notificationAction!!.icon != null) { + val resId = AssetUtils.getResourceID(context, notificationAction.icon, "drawable") + if (resId != AssetUtils.RESOURCE_ID_ZERO_VALUE) resId else R.drawable.ic_transparent + } else { + R.drawable.ic_transparent + } + val actionIntent = buildIntent(notification, notificationAction.id) val actionPendingIntent = PendingIntent.getActivity( context, (notification.id) + notificationAction.id.hashCode(), @@ -250,7 +400,7 @@ class TauriNotificationManager( flags ) val actionBuilder: NotificationCompat.Action.Builder = NotificationCompat.Action.Builder( - R.drawable.ic_transparent, + actionIconResId, notificationAction.title, actionPendingIntent ) @@ -304,11 +454,8 @@ class TauriNotificationManager( return intent } - /** - * Build a notification trigger, such as triggering each N seconds, or - * on a certain date "shape" (such as every first of the month) - */ - // TODO support different AlarmManager.RTC modes depending on priority + // Build a notification trigger, such as triggering each N seconds, or + // on a certain date "shape" (such as every first of the month) @SuppressLint("SimpleDateFormat") private fun triggerScheduledNotification(notification: android.app.Notification, request: Notification) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager @@ -326,6 +473,10 @@ class TauriNotificationManager( var pendingIntent = PendingIntent.getBroadcast(context, request.id, notificationIntent, flags) + // Choose RTC mode: use RTC_WAKEUP when allowWhileIdle is set (the alarm needs to + // wake the device), plain RTC otherwise to be more battery-friendly. + val rtcMode = if (schedule?.allowWhileIdle() == true) AlarmManager.RTC_WAKEUP else AlarmManager.RTC + when (schedule) { is NotificationSchedule.At -> { if (schedule.date.time < Date().time) { @@ -334,9 +485,9 @@ class TauriNotificationManager( } if (schedule.repeating) { val interval: Long = schedule.date.time - Date().time - alarmManager.setRepeating(AlarmManager.RTC, schedule.date.time, interval, pendingIntent) + alarmManager.setRepeating(rtcMode, schedule.date.time, interval, pendingIntent) } else { - setExactIfPossible(alarmManager, schedule, schedule.date.time, pendingIntent) + setExactIfPossible(alarmManager, schedule, schedule.date.time, pendingIntent, rtcMode) } } is NotificationSchedule.Interval -> { @@ -344,7 +495,7 @@ class TauriNotificationManager( notificationIntent.putExtra(TimedNotificationPublisher.CRON_KEY, schedule.interval.toMatchString()) pendingIntent = PendingIntent.getBroadcast(context, request.id, notificationIntent, flags) - setExactIfPossible(alarmManager, schedule, trigger, pendingIntent) + setExactIfPossible(alarmManager, schedule, trigger, pendingIntent, rtcMode) val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss") Logger.debug( Logger.tags(TAG), @@ -354,7 +505,7 @@ class TauriNotificationManager( is NotificationSchedule.Every -> { val everyInterval = getIntervalTime(schedule.interval, schedule.count) val startTime: Long = Date().time + everyInterval - alarmManager.setRepeating(AlarmManager.RTC, startTime, everyInterval, pendingIntent) + alarmManager.setRepeating(rtcMode, startTime, everyInterval, pendingIntent) } else -> {} } @@ -365,7 +516,8 @@ class TauriNotificationManager( alarmManager: AlarmManager, schedule: NotificationSchedule, trigger: Long, - pendingIntent: PendingIntent + pendingIntent: PendingIntent, + rtcMode: Int = AlarmManager.RTC ) { Logger.debug(Logger.tags(TAG), "Scheduling notification for " + Date(trigger).toString()) @@ -373,15 +525,15 @@ class TauriNotificationManager( Logger.warn(Logger.tags(TAG), "SCHEDULE_EXACT_ALARM permission not granted. Using inexact alarm.") if (SDK_INT >= Build.VERSION_CODES.M && schedule.allowWhileIdle()) { - alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent) + alarmManager.setAndAllowWhileIdle(rtcMode, trigger, pendingIntent) } else { - alarmManager[AlarmManager.RTC, trigger] = pendingIntent + alarmManager[rtcMode, trigger] = pendingIntent } } else { if (SDK_INT >= Build.VERSION_CODES.M && schedule.allowWhileIdle()) { - alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent) + alarmManager.setExactAndAllowWhileIdle(rtcMode, trigger, pendingIntent) } else { - alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent) + alarmManager.setExact(rtcMode, trigger, pendingIntent) } } } @@ -444,6 +596,9 @@ class TauriNotificationManager( if (smallIconConfigResourceName != null) { resId = AssetUtils.getResourceID(context, smallIconConfigResourceName, "drawable") } + if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { + resId = context.resources.getIdentifier("ic_notification", "drawable", context.packageName) + } if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { resId = android.R.drawable.ic_dialog_info } @@ -470,9 +625,6 @@ class NotificationDismissReceiver : BroadcastReceiver() { } class TimedNotificationPublisher : BroadcastReceiver() { - /** - * Restore and present notification - */ override fun onReceive(context: Context, intent: Intent) { val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager diff --git a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt new file mode 100644 index 0000000..ab1ed96 --- /dev/null +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -0,0 +1,147 @@ +package app.tauri.notification + +import android.content.Context +import android.util.Log +import androidx.annotation.VisibleForTesting +import app.tauri.plugin.JSObject +import org.json.JSONObject +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.MessagingReceiver +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +/** + * Generic UnifiedPush receiver that forwards messages to the JS layer + * and optionally delegates to a custom [UnifiedPushMessageHandler]. + */ +open class TauriUnifiedPushMessagingService : MessagingReceiver() { + + companion object { + private const val TAG = "TauriUnifiedPush" + @Volatile + private var executor: Executor = Executors.newSingleThreadExecutor() + + @Volatile + private var messageHandler: UnifiedPushMessageHandler? = null + + /** + * Register a custom handler for incoming UnifiedPush messages. + * The handler runs on a background thread, so network I/O is safe. + * If the handler returns `true`, the default fallback notification is suppressed. + */ + @JvmStatic + fun setMessageHandler(handler: UnifiedPushMessageHandler?) { + messageHandler = handler + } + + /** + * Replace the executor used for running custom message handlers. + * Intended for testing only — pass a direct/synchronous executor to avoid + * flaky `Thread.sleep()` calls in tests. + */ + @VisibleForTesting + @JvmStatic + internal fun setExecutorForTesting(testExecutor: Executor) { + executor = testExecutor + } + } + + override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { + Log.d(TAG, "New endpoint registered: ${endpoint.url}") + val pubKeySet = endpoint.pubKeySet + NotificationPlugin.instance?.handleNewUnifiedPushEndpoint( + endpoint.url, + instance, + pubKeySet?.pubKey, + pubKeySet?.auth, + ) + } + + override fun onUnregistered(context: Context, instance: String) { + Log.d(TAG, "Unregistered for instance: $instance") + NotificationPlugin.instance?.handleUnifiedPushUnregistered(instance) + } + + /** + * Called when the distributor is temporarily unavailable (e.g. the distributor app + * is being updated). The registration remains valid; the app should wait for a new + * [onNewEndpoint] callback before sending push messages. + */ + override fun onTempUnavailable(context: Context, instance: String) { + Log.d(TAG, "Temporarily unavailable for instance: $instance") + NotificationPlugin.instance?.handleUnifiedPushTempUnavailable(instance) + } + + override fun onMessage(context: Context, message: PushMessage, instance: String) { + Log.d(TAG, "Message received for instance: $instance") + + try { + val messageString = message.content.toString(Charsets.UTF_8) + + val pushData = mutableMapOf() + try { + val json = JSONObject(messageString) + for (key in json.keys()) { + pushData[key] = JSObjectUtils.jsonValueToNative(json.get(key)) + } + } catch (e: Exception) { + Log.w(TAG, "Message is not valid JSON, forwarding as raw text") + pushData["body"] = messageString + } + + pushData["instance"] = instance + pushData["source"] = "unifiedpush" + + NotificationPlugin.instance?.triggerUnifiedPushMessage(pushData) + + val handler = messageHandler + if (handler != null) { + executor.execute { + try { + val handled = handler.onMessage(context, message.content, instance) + if (!handled) showFallbackNotification(pushData) + } catch (e: Exception) { + Log.e(TAG, "Message handler error: ${e.message}", e) + showFallbackNotification(pushData) + } + } + } else { + showFallbackNotification(pushData) + } + } catch (e: Exception) { + Log.e(TAG, "Error processing message: ${e.message}", e) + } + } + + private fun showFallbackNotification(pushData: Map) { + val title = pushData["title"]?.toString() + val body = pushData["body"]?.toString() + if (title == null && body == null) return + + val extraData = JSObject() + for ((key, value) in pushData) { + JSObjectUtils.putValueToJSObject(extraData, key, value) + } + val notification = Notification().apply { + id = (System.nanoTime() % Int.MAX_VALUE).toInt() + this.title = title ?: "" + this.body = body + this.isAutoCancel = true + this.extra = extraData + } + + val plugin = NotificationPlugin.instance + if (plugin != null) { + plugin.getNotificationManager().schedule(notification, "unifiedpush") + } else { + Log.w(TAG, "NotificationPlugin not initialized, cannot show fallback notification") + } + } + + override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { + Log.e(TAG, "Registration failed for instance: $instance (reason: $reason)") + NotificationPlugin.instance?.handleUnifiedPushRegistrationFailed(instance, reason.toString()) + } +} diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushMessageHandler.kt b/android/src/main/java/app/tauri/notification/UnifiedPushMessageHandler.kt new file mode 100644 index 0000000..dbd2b7d --- /dev/null +++ b/android/src/main/java/app/tauri/notification/UnifiedPushMessageHandler.kt @@ -0,0 +1,20 @@ +package app.tauri.notification + +import android.content.Context + +/** + * Interface for custom handling of incoming UnifiedPush messages. + * + * Register via [TauriUnifiedPushMessagingService.setMessageHandler]. + * Implementations run on a background thread — network I/O is safe. + */ +interface UnifiedPushMessageHandler { + /** + * @param context application context + * @param message raw push payload as bytes + * @param instance UnifiedPush registration instance identifier + * @return `true` if handled (suppresses the default notification), `false` for fallback + */ + fun onMessage(context: Context, message: ByteArray, instance: String): Boolean +} + diff --git a/android/src/test/java/app/tauri/notification/JSObjectUtilsTest.kt b/android/src/test/java/app/tauri/notification/JSObjectUtilsTest.kt new file mode 100644 index 0000000..12a1445 --- /dev/null +++ b/android/src/test/java/app/tauri/notification/JSObjectUtilsTest.kt @@ -0,0 +1,187 @@ +package app.tauri.notification + +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class JSObjectUtilsTest { + + // --- putValueToJSObject primitive type tests --- + + @Test + fun testPutString() { + val obj = JSObject() + JSObjectUtils.putValueToJSObject(obj, "key", "value") + assertEquals("value", obj.getString("key")) + } + + @Test + fun testPutInt() { + val obj = JSObject() + JSObjectUtils.putValueToJSObject(obj, "n", 42) + assertEquals(42, obj.getInt("n")) + } + + @Test + fun testPutLong() { + val obj = JSObject() + JSObjectUtils.putValueToJSObject(obj, "n", 9999999999L) + assertEquals(9999999999L, obj.getLong("n")) + } + + @Test + fun testPutDouble() { + val obj = JSObject() + JSObjectUtils.putValueToJSObject(obj, "pi", 3.14) + assertEquals(3.14, obj.getDouble("pi"), 0.001) + } + + @Test + fun testPutBoolean() { + val obj = JSObject() + JSObjectUtils.putValueToJSObject(obj, "flag", true) + assertTrue(obj.getBoolean("flag")) + } + + @Test + fun testPutUnknownTypeUsesToString() { + val obj = JSObject() + JSObjectUtils.putValueToJSObject(obj, "misc", object : Any() { + override fun toString() = "custom" + }) + assertEquals("custom", obj.getString("misc")) + } + + // --- putValueToJSObject nested map test --- + + @Test + fun testPutNestedMap() { + val obj = JSObject() + JSObjectUtils.putValueToJSObject(obj, "nested", mapOf("inner" to "val", "n" to 7)) + + val nested = obj.getJSObject("nested") + assertNotNull(nested) + assertEquals("val", nested!!.getString("inner")) + assertEquals(7, nested.getInt("n")) + } + + // --- putValueToJSObject list tests --- + + @Test + fun testPutList_primitives() { + val obj = JSObject() + JSObjectUtils.putValueToJSObject(obj, "items", listOf(1, 2, 3)) + + val arr = obj.getJSONArray("items") + assertEquals(3, arr.length()) + assertEquals(1, arr.getInt(0)) + assertEquals(2, arr.getInt(1)) + assertEquals(3, arr.getInt(2)) + } + + @Test + fun testPutList_strings() { + val obj = JSObject() + JSObjectUtils.putValueToJSObject(obj, "tags", listOf("a", "b", "c")) + + val arr = obj.getJSONArray("tags") + assertEquals(3, arr.length()) + assertEquals("a", arr.getString(0)) + } + + @Test + fun testPutList_withNestedMap() { + val obj = JSObject() + JSObjectUtils.putValueToJSObject( + obj, "mixed", + listOf("text", 42, mapOf("inner" to "obj"), listOf(1, 2)) + ) + + val arr = obj.getJSONArray("mixed") + assertEquals(4, arr.length()) + assertEquals("text", arr.getString(0)) + assertEquals(42, arr.getInt(1)) + assertEquals("obj", arr.getJSONObject(2).getString("inner")) + assertEquals(2, arr.getJSONArray(3).length()) + } + + // --- convertListToJSArray tests --- + + @Test + fun testConvertListToJSArray_booleans() { + val arr = JSObjectUtils.convertListToJSArray(listOf(true, false, true)) + assertEquals(3, arr.length()) + assertTrue(arr.getBoolean(0)) + assertFalse(arr.getBoolean(1)) + } + + @Test + fun testConvertListToJSArray_nullElement() { + val list = listOf(null, "after") + val arr = JSObjectUtils.convertListToJSArray(list) + assertEquals(2, arr.length()) + assertTrue(arr.isNull(0)) + assertEquals("after", arr.getString(1)) + } + + @Test + fun testConvertListToJSArray_nestedLists() { + val arr = JSObjectUtils.convertListToJSArray(listOf(listOf(1, 2), listOf(3, 4))) + assertEquals(2, arr.length()) + val inner0 = arr.getJSONArray(0) + assertEquals(1, inner0.getInt(0)) + assertEquals(2, inner0.getInt(1)) + } + + // --- jsonValueToNative tests --- + + @Test + fun testJsonValueToNative_primitivePassThrough() { + assertEquals("hello", JSObjectUtils.jsonValueToNative("hello")) + assertEquals(42, JSObjectUtils.jsonValueToNative(42)) + assertEquals(3.14, JSObjectUtils.jsonValueToNative(3.14)) + assertEquals(true, JSObjectUtils.jsonValueToNative(true)) + } + + @Test + fun testJsonValueToNative_jsonObject() { + val json = org.json.JSONObject().apply { + put("k", "v") + put("n", 5) + } + @Suppress("UNCHECKED_CAST") + val result = JSObjectUtils.jsonValueToNative(json) as Map + assertEquals("v", result["k"]) + assertEquals(5, result["n"]) + } + + @Test + fun testJsonValueToNative_jsonArray() { + val json = org.json.JSONArray().apply { + put(1) + put("two") + } + @Suppress("UNCHECKED_CAST") + val result = JSObjectUtils.jsonValueToNative(json) as List + assertEquals(2, result.size) + assertEquals(1, result[0]) + assertEquals("two", result[1]) + } + + @Test + fun testJsonValueToNative_nestedObject() { + val inner = org.json.JSONObject().apply { put("x", 99) } + val outer = org.json.JSONObject().apply { put("inner", inner) } + + @Suppress("UNCHECKED_CAST") + val result = JSObjectUtils.jsonValueToNative(outer) as Map + @Suppress("UNCHECKED_CAST") + val innerResult = result["inner"] as Map + assertEquals(99, innerResult["x"]) + } +} + diff --git a/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt b/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt new file mode 100644 index 0000000..b741629 --- /dev/null +++ b/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt @@ -0,0 +1,463 @@ +package app.tauri.notification + +import android.app.Activity +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin +import io.mockk.* +import org.junit.Assert.* +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +/** + * Tests for the UnifiedPush-related behaviors in NotificationPlugin. + * + * These tests exercise the actual plugin methods (handleNewUnifiedPushEndpoint, + * handleUnifiedPushRegistrationFailed, handleUnifiedPushUnregistered, + * triggerUnifiedPushMessage) via a real NotificationPlugin instance, using + * reflection to set up internal state (pendingUnifiedPushInvoke, + * cachedUnifiedPushEndpoint, unifiedPushInstance) since those fields are private. + */ +@RunWith(RobolectricTestRunner::class) +class NotificationPluginUnifiedPushTest { + + private lateinit var plugin: NotificationPlugin + private lateinit var mockInvoke: Invoke + private lateinit var mockInvoke2: Invoke + + @Before + fun setup() { + assumeTrue("UnifiedPush tests require ENABLE_UNIFIED_PUSH", BuildConfig.ENABLE_UNIFIED_PUSH) + + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + plugin = NotificationPlugin(activity) + NotificationPlugin.instance = plugin + + mockInvoke = mockk(relaxed = true) + mockInvoke2 = mockk(relaxed = true) + } + + @After + fun teardown() { + NotificationPlugin.instance = null + } + + // --- Helper methods to access private fields via reflection --- + + private fun setPendingUnifiedPushInvoke(invoke: Invoke?) { + val field = NotificationPlugin::class.java.getDeclaredField("pendingUnifiedPushInvoke") + field.isAccessible = true + field.set(plugin, invoke) + } + + private fun getPendingUnifiedPushInvoke(): Invoke? { + val field = NotificationPlugin::class.java.getDeclaredField("pendingUnifiedPushInvoke") + field.isAccessible = true + return field.get(plugin) as? Invoke + } + + private fun setCachedUnifiedPushEndpoint(endpoint: String?) { + val field = NotificationPlugin::class.java.getDeclaredField("cachedUnifiedPushEndpoint") + field.isAccessible = true + field.set(plugin, endpoint) + } + + private fun getCachedUnifiedPushEndpoint(): String? { + val field = NotificationPlugin::class.java.getDeclaredField("cachedUnifiedPushEndpoint") + field.isAccessible = true + return field.get(plugin) as? String + } + + private fun setUnifiedPushInstance(instance: String) { + val field = NotificationPlugin::class.java.getDeclaredField("unifiedPushInstance") + field.isAccessible = true + field.set(plugin, instance) + } + + private fun getUnifiedPushInstance(): String { + val field = NotificationPlugin::class.java.getDeclaredField("unifiedPushInstance") + field.isAccessible = true + return field.get(plugin) as String + } + + // --- handleNewUnifiedPushEndpoint tests --- + + @Test + fun testHandleNewUnifiedPushEndpoint_resolvesPendingInvoke() { + setPendingUnifiedPushInvoke(mockInvoke) + + plugin.handleNewUnifiedPushEndpoint("https://push.example.com/abc", "test-instance") + + verify { mockInvoke.resolve(match { + it.getString("endpoint") == "https://push.example.com/abc" && + it.getString("instance") == "test-instance" + }) } + assertNull(getPendingUnifiedPushInvoke()) + } + + @Test + fun testHandleNewUnifiedPushEndpoint_resolvesPendingInvoke_withPubKeySet() { + setPendingUnifiedPushInvoke(mockInvoke) + + plugin.handleNewUnifiedPushEndpoint( + "https://nextpush.example.com/endpoint/xyz", + "default", + "BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF", + "8eDyX_uCN0XRhSbY5hs7Hg", + ) + + verify { mockInvoke.resolve(match { + it.getString("endpoint") == "https://nextpush.example.com/endpoint/xyz" && + it.getString("instance") == "default" && + it.getJSObject("pubKeySet")?.getString("pubKey") == "BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF" && + it.getJSObject("pubKeySet")?.getString("auth") == "8eDyX_uCN0XRhSbY5hs7Hg" + }) } + assertNull(getPendingUnifiedPushInvoke()) + } + + @Test + fun testHandleNewUnifiedPushEndpoint_noPubKeySet_omitsField() { + setPendingUnifiedPushInvoke(mockInvoke) + + plugin.handleNewUnifiedPushEndpoint("https://push.example.com/abc", "default", null, null) + + verify { mockInvoke.resolve(match { + it.getString("endpoint") == "https://push.example.com/abc" && + !it.has("pubKeySet") + }) } + } + + @Test + fun testHandleNewUnifiedPushEndpoint_updatesCachedEndpointAndInstance() { + setPendingUnifiedPushInvoke(null) + + plugin.handleNewUnifiedPushEndpoint("https://push.example.com/new", "new-instance") + + assertEquals("https://push.example.com/new", getCachedUnifiedPushEndpoint()) + assertEquals("new-instance", getUnifiedPushInstance()) + } + + @Test + fun testHandleNewUnifiedPushEndpoint_noPendingInvoke_doesNotCrash() { + setPendingUnifiedPushInvoke(null) + + // Should not throw even with no pending invoke + plugin.handleNewUnifiedPushEndpoint("https://push.example.com/abc", "default") + + assertEquals("https://push.example.com/abc", getCachedUnifiedPushEndpoint()) + } + + // --- handleUnifiedPushTempUnavailable tests --- + + @Test + fun testHandleUnifiedPushTempUnavailable_triggersEvent() { + // Should not throw and should trigger the unifiedpush-temp-unavailable event + // (trigger() is a no-op without a loaded WebView, but must not crash) + plugin.handleUnifiedPushTempUnavailable("test-instance") + } + + @Test + fun testHandleUnifiedPushTempUnavailable_doesNotClearCachedEndpoint() { + setCachedUnifiedPushEndpoint("https://push.example.com/cached") + + plugin.handleUnifiedPushTempUnavailable("test-instance") + + // Temp-unavailable should NOT clear the cache — the registration is still valid + assertEquals("https://push.example.com/cached", getCachedUnifiedPushEndpoint()) + } + + // --- handleUnifiedPushRegistrationFailed tests --- + + @Test + fun testHandleUnifiedPushRegistrationFailed_rejectsPendingInvoke() { + setPendingUnifiedPushInvoke(mockInvoke) + + plugin.handleUnifiedPushRegistrationFailed("test-instance", "NETWORK") + + verify { mockInvoke.reject(match { + it.contains("registration failed") && it.contains("test-instance") && it.contains("NETWORK") + }) } + assertNull(getPendingUnifiedPushInvoke()) + } + + @Test + fun testHandleUnifiedPushRegistrationFailed_noPendingInvoke_doesNotCrash() { + setPendingUnifiedPushInvoke(null) + + // Should not throw + plugin.handleUnifiedPushRegistrationFailed("test-instance") + + assertNull(getPendingUnifiedPushInvoke()) + } + + @Test + fun testHandleUnifiedPushRegistrationFailed_withoutReason() { + setPendingUnifiedPushInvoke(mockInvoke) + + plugin.handleUnifiedPushRegistrationFailed("test-instance") + + verify { mockInvoke.reject(match { + it.contains("registration failed") && it.contains("test-instance") && !it.contains("reason") + }) } + } + + // --- handleUnifiedPushUnregistered tests --- + + @Test + fun testHandleUnifiedPushUnregistered_clearsCachedEndpoint() { + setCachedUnifiedPushEndpoint("https://push.example.com/cached") + + plugin.handleUnifiedPushUnregistered("test-instance") + + assertNull(getCachedUnifiedPushEndpoint()) + } + + // --- Pending invoke lifecycle tests (using actual plugin methods) --- + + @Test + fun testNewEndpoint_resolvesPending_thenClearsIt() { + setPendingUnifiedPushInvoke(mockInvoke) + + plugin.handleNewUnifiedPushEndpoint("https://push.example.com/abc", "default") + + verify { mockInvoke.resolve(any()) } + assertNull(getPendingUnifiedPushInvoke()) + } + + @Test + fun testRegistrationFailed_rejectsPending_thenClearsIt() { + setPendingUnifiedPushInvoke(mockInvoke) + + plugin.handleUnifiedPushRegistrationFailed("default", "TIMEOUT") + + verify { mockInvoke.reject(any()) } + assertNull(getPendingUnifiedPushInvoke()) + } + + // --- triggerUnifiedPushMessage data mapping tests --- + + @Test + fun testTriggerUnifiedPushMessage_mapsStringValues() { + val pushData = mapOf( + "title" to "Test Title", + "body" to "Test Body", + "instance" to "default", + "source" to "unifiedpush" + ) + + // Exercises the actual when-expression in the plugin method; + // any type errors will throw. trigger() is a no-op without a loaded WebView. + plugin.triggerUnifiedPushMessage(pushData) + } + + @Test + fun testTriggerUnifiedPushMessage_mapsNumericValues() { + val pushData = mapOf( + "count" to 42, + "timestamp" to 1234567890L, + "ratio" to 3.14 + ) + + plugin.triggerUnifiedPushMessage(pushData) + } + + @Test + fun testTriggerUnifiedPushMessage_mapsBooleanValues() { + val pushData = mapOf( + "read" to true, + "archived" to false + ) + + plugin.triggerUnifiedPushMessage(pushData) + } + + @Test + fun testTriggerUnifiedPushMessage_mapsNestedObjects() { + val nestedMap = mapOf("innerKey" to "innerValue", "innerNum" to 99) + val pushData = mapOf( + "nested" to nestedMap + ) + + plugin.triggerUnifiedPushMessage(pushData) + } + + @Test + fun testTriggerUnifiedPushMessage_mapsListValues() { + val pushData = mapOf( + "items" to listOf(1, 2, 3), + "tags" to listOf("a", "b", "c") + ) + + // This exercises the is List<*> branch — previously this would + // fall through to toString() and mangle the data + plugin.triggerUnifiedPushMessage(pushData) + } + + @Test + fun testTriggerUnifiedPushMessage_mapsNestedListsAndMaps() { + val pushData = mapOf( + "complex" to listOf( + mapOf("key" to "value"), + listOf(1, 2), + "plain" + ) + ) + + plugin.triggerUnifiedPushMessage(pushData) + } + + // --- putValueToJSObject / convertListToJSArray validation tests --- + // These test the private data-mapping helpers via reflection, + // verifying correct JSObject/JSArray output. + + @Test + fun testPutValueToJSObject_listConvertedToJSArray() { + val method = NotificationPlugin::class.java.getDeclaredMethod( + "putValueToJSObject", JSObject::class.java, String::class.java, Any::class.java + ) + method.isAccessible = true + + val target = JSObject() + method.invoke(plugin, target, "items", listOf(1, 2, 3)) + + val arr = target.getJSONArray("items") + assertEquals(3, arr.length()) + assertEquals(1, arr.getInt(0)) + assertEquals(2, arr.getInt(1)) + assertEquals(3, arr.getInt(2)) + } + + @Test + fun testPutValueToJSObject_nestedMapConvertedRecursively() { + val method = NotificationPlugin::class.java.getDeclaredMethod( + "putValueToJSObject", JSObject::class.java, String::class.java, Any::class.java + ) + method.isAccessible = true + + val target = JSObject() + method.invoke(plugin, target, "nested", mapOf("key" to "value", "num" to 42)) + + val nested = target.getJSObject("nested") + assertNotNull(nested) + assertEquals("value", nested!!.getString("key")) + assertEquals(42, nested.getInt("num")) + } + + @Test + fun testPutValueToJSObject_mixedListWithMapsAndPrimitives() { + val method = NotificationPlugin::class.java.getDeclaredMethod( + "putValueToJSObject", JSObject::class.java, String::class.java, Any::class.java + ) + method.isAccessible = true + + val target = JSObject() + val mixedList = listOf( + "text", + 42, + mapOf("inner" to "obj"), + listOf(1, 2) + ) + method.invoke(plugin, target, "mixed", mixedList) + + val arr = target.getJSONArray("mixed") + assertEquals(4, arr.length()) + assertEquals("text", arr.getString(0)) + assertEquals(42, arr.getInt(1)) + // Element 2 is a JSONObject + val innerObj = arr.getJSONObject(2) + assertEquals("obj", innerObj.getString("inner")) + // Element 3 is a nested JSONArray + val innerArr = arr.getJSONArray(3) + assertEquals(2, innerArr.length()) + } + + // --- Cached endpoint behavior tests (using actual plugin state) --- + + @Test + fun testCachedEndpoint_clearedOnUnregister() { + setCachedUnifiedPushEndpoint("https://push.example.com/cached") + + plugin.handleUnifiedPushUnregistered("default") + + assertNull(getCachedUnifiedPushEndpoint()) + } + + @Test + fun testCachedEndpoint_updatedOnNewEndpoint() { + setCachedUnifiedPushEndpoint(null) + setUnifiedPushInstance("default") + + plugin.handleNewUnifiedPushEndpoint("https://push.example.com/new-endpoint", "new-instance") + + assertEquals("https://push.example.com/new-endpoint", getCachedUnifiedPushEndpoint()) + assertEquals("new-instance", getUnifiedPushInstance()) + } + + // --- Distributors data structure tests --- + + @Test + fun testGetUnifiedPushDistributors_resultStructure() { + val distributors = listOf("org.unifiedpush.distributor.fcm", "org.unifiedpush.distributor.nextpush") + + val result = JSObject() + val distributorsArray = org.json.JSONArray() + distributors.forEach { distributorsArray.put(it) } + result.put("distributors", distributorsArray) + + val arr = result.getJSONArray("distributors") + assertEquals(2, arr.length()) + assertEquals("org.unifiedpush.distributor.fcm", arr.getString(0)) + assertEquals("org.unifiedpush.distributor.nextpush", arr.getString(1)) + } + + @Test + fun testGetUnifiedPushDistributors_emptyList() { + val distributors = emptyList() + + val result = JSObject() + val distributorsArray = org.json.JSONArray() + distributors.forEach { distributorsArray.put(it) } + result.put("distributors", distributorsArray) + + val arr = result.getJSONArray("distributors") + assertEquals(0, arr.length()) + } + + @Test + fun testGetUnifiedPushDistributor_resultStructure() { + val distributor = "org.unifiedpush.distributor.fcm" + val result = JSObject() + result.put("distributor", distributor) + + assertEquals("org.unifiedpush.distributor.fcm", result.getString("distributor")) + } + + @Test + fun testGetUnifiedPushDistributor_emptyWhenNotSaved() { + val distributor = "" + val result = JSObject() + result.put("distributor", distributor) + + assertEquals("", result.getString("distributor")) + } + + @Test + fun testSaveUnifiedPushDistributor_requiresNonNullDistributor() { + // Build a mock Invoke that returns a SaveUnifiedPushDistributorArgs with null distributor + val args = SaveUnifiedPushDistributorArgs() + // distributor defaults to null + + val invoke = mockk(relaxed = true) + every { invoke.parseArgs(SaveUnifiedPushDistributorArgs::class.java) } returns args + + plugin.saveUnifiedPushDistributor(invoke) + + verify { invoke.reject("Distributor parameter is required") } + } +} diff --git a/android/src/test/java/app/tauri/notification/NotificationStorageTest.kt b/android/src/test/java/app/tauri/notification/NotificationStorageTest.kt index b7b204c..b5f7f43 100644 --- a/android/src/test/java/app/tauri/notification/NotificationStorageTest.kt +++ b/android/src/test/java/app/tauri/notification/NotificationStorageTest.kt @@ -241,9 +241,11 @@ class NotificationStorageTest { every { mockSharedPreferences.getString("id0", "") } returns "action1" every { mockSharedPreferences.getString("title0", "") } returns "Title 1" every { mockSharedPreferences.getBoolean("input0", false) } returns false + every { mockSharedPreferences.getString("icon0", null) } returns null every { mockSharedPreferences.getString("id1", "") } returns "action2" every { mockSharedPreferences.getString("title1", "") } returns "Title 2" every { mockSharedPreferences.getBoolean("input1", false) } returns true + every { mockSharedPreferences.getString("icon1", null) } returns null val result = notificationStorage.getActionGroup("type1") @@ -271,6 +273,7 @@ class NotificationStorageTest { every { mockSharedPreferences.getString("id0", "") } returns null every { mockSharedPreferences.getString("title0", "") } returns null every { mockSharedPreferences.getBoolean("input0", false) } returns false + every { mockSharedPreferences.getString("icon0", null) } returns null val result = notificationStorage.getActionGroup("type1") diff --git a/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt new file mode 100644 index 0000000..cce1d5e --- /dev/null +++ b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt @@ -0,0 +1,411 @@ +package app.tauri.notification + +import android.content.Context +import app.tauri.plugin.JSObject +import io.mockk.* +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.data.PublicKeySet +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage +import java.util.concurrent.Executor + +@RunWith(RobolectricTestRunner::class) +class TauriUnifiedPushMessagingServiceTest { + + private lateinit var service: TauriUnifiedPushMessagingService + private lateinit var mockContext: Context + private lateinit var mockPlugin: NotificationPlugin + + @Before + fun setup() { + service = TauriUnifiedPushMessagingService() + mockContext = mockk(relaxed = true) + mockPlugin = mockk(relaxed = true) + NotificationPlugin.instance = mockPlugin + + // Clear any custom message handler between tests + TauriUnifiedPushMessagingService.setMessageHandler(null) + + // Use a synchronous executor so handler tests don't need Thread.sleep + TauriUnifiedPushMessagingService.setExecutorForTesting(Executor { it.run() }) + } + + // --- onMessage JSON parsing tests --- + + @Test + fun testOnMessage_validJsonParsedCorrectly() { + val json = """{"title":"Test Title","body":"Test Body","extra_key":"extra_value"}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + val capturedData = slot>() + every { mockPlugin.triggerUnifiedPushMessage(capture(capturedData)) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "test-instance") + + assertTrue(capturedData.isCaptured) + val data = capturedData.captured + assertEquals("Test Title", data["title"]) + assertEquals("Test Body", data["body"]) + assertEquals("extra_value", data["extra_key"]) + assertEquals("test-instance", data["instance"]) + assertEquals("unifiedpush", data["source"]) + } + + @Test + fun testOnMessage_invalidJsonForwardedAsRawText() { + val rawText = "This is not JSON" + val message = mockk() + every { message.content } returns rawText.toByteArray(Charsets.UTF_8) + + val capturedData = slot>() + every { mockPlugin.triggerUnifiedPushMessage(capture(capturedData)) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "test-instance") + + assertTrue(capturedData.isCaptured) + val data = capturedData.captured + assertEquals(rawText, data["body"]) + assertEquals("test-instance", data["instance"]) + assertEquals("unifiedpush", data["source"]) + } + + @Test + fun testOnMessage_nestedJsonObjectsParsedCorrectly() { + val json = """{"title":"Nested","data":{"key":"value","num":42}}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + val capturedData = slot>() + every { mockPlugin.triggerUnifiedPushMessage(capture(capturedData)) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "default") + + assertTrue(capturedData.isCaptured) + val data = capturedData.captured + assertEquals("Nested", data["title"]) + // Nested JSON objects are recursively converted to maps + @Suppress("UNCHECKED_CAST") + val nestedData = data["data"] as Map + assertEquals("value", nestedData["key"]) + assertEquals(42, nestedData["num"]) + } + + @Test + fun testOnMessage_jsonArrayParsedCorrectly() { + val json = """{"title":"Array","items":[1,2,3]}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + val capturedData = slot>() + every { mockPlugin.triggerUnifiedPushMessage(capture(capturedData)) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "default") + + assertTrue(capturedData.isCaptured) + val data = capturedData.captured + @Suppress("UNCHECKED_CAST") + val items = data["items"] as List + assertEquals(3, items.size) + assertEquals(1, items[0]) + assertEquals(2, items[1]) + assertEquals(3, items[2]) + } + + @Test + fun testOnMessage_emptyJsonObject() { + val json = """{}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + val capturedData = slot>() + every { mockPlugin.triggerUnifiedPushMessage(capture(capturedData)) } just Runs + + service.onMessage(mockContext, message, "default") + + assertTrue(capturedData.isCaptured) + val data = capturedData.captured + // Only instance and source should be present + assertEquals("default", data["instance"]) + assertEquals("unifiedpush", data["source"]) + assertNull(data["title"]) + assertNull(data["body"]) + } + + // --- Fallback notification tests --- + + @Test + fun testOnMessage_fallbackNotificationShownWhenNoHandler() { + val json = """{"title":"Fallback Title","body":"Fallback Body"}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + every { mockPlugin.triggerUnifiedPushMessage(any()) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "default") + + // Verify that schedule was called on the manager (fallback notification) + verify { mockManager.schedule(match { + it.title == "Fallback Title" && it.body == "Fallback Body" + }, "unifiedpush") } + } + + @Test + fun testOnMessage_noFallbackWhenNoTitleAndNoBody() { + val json = """{"extra_key":"extra_value"}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + every { mockPlugin.triggerUnifiedPushMessage(any()) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "default") + + // Verify that schedule was NOT called (no title or body) + verify(exactly = 0) { mockManager.schedule(any(), any()) } + } + + @Test + fun testOnMessage_fallbackNotificationWithBodyOnly() { + val json = """{"body":"Body Only"}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + every { mockPlugin.triggerUnifiedPushMessage(any()) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "default") + + verify { mockManager.schedule(match { + it.title == "" && it.body == "Body Only" + }, "unifiedpush") } + } + + // --- Custom message handler tests --- + + @Test + fun testOnMessage_customHandlerSuppressesFallback() { + val handler = mockk() + every { handler.onMessage(any(), any(), any()) } returns true + TauriUnifiedPushMessagingService.setMessageHandler(handler) + + val json = """{"title":"Custom","body":"Handled"}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + every { mockPlugin.triggerUnifiedPushMessage(any()) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "default") + + + verify { handler.onMessage(mockContext, any(), "default") } + // Fallback should NOT be called since handler returned true + verify(exactly = 0) { mockManager.schedule(any(), any()) } + } + + @Test + fun testOnMessage_customHandlerReturnsFalseShowsFallback() { + val handler = mockk() + every { handler.onMessage(any(), any(), any()) } returns false + TauriUnifiedPushMessagingService.setMessageHandler(handler) + + val json = """{"title":"Not Handled","body":"Show Fallback"}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + every { mockPlugin.triggerUnifiedPushMessage(any()) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "default") + + + verify { handler.onMessage(mockContext, any(), "default") } + // Fallback SHOULD be called since handler returned false + verify { mockManager.schedule(match { + it.title == "Not Handled" && it.body == "Show Fallback" + }, "unifiedpush") } + } + + @Test + fun testOnMessage_customHandlerExceptionShowsFallback() { + val handler = mockk() + every { handler.onMessage(any(), any(), any()) } throws RuntimeException("Handler error") + TauriUnifiedPushMessagingService.setMessageHandler(handler) + + val json = """{"title":"Error","body":"Fallback on error"}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + every { mockPlugin.triggerUnifiedPushMessage(any()) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "default") + + + // Fallback SHOULD be called since handler threw exception + verify { mockManager.schedule(match { + it.title == "Error" && it.body == "Fallback on error" + }, "unifiedpush") } + } + + // --- onNewEndpoint tests --- + + @Test + fun testOnNewEndpoint_forwardsToPlugin_withoutPubKeySet() { + val endpoint = mockk() + every { endpoint.url } returns "https://push.example.com/endpoint/abc123" + every { endpoint.pubKeySet } returns null + + service.onNewEndpoint(mockContext, endpoint, "test-instance") + + verify { + mockPlugin.handleNewUnifiedPushEndpoint( + "https://push.example.com/endpoint/abc123", + "test-instance", + null, + null, + ) + } + } + + @Test + fun testOnNewEndpoint_forwardsToPlugin_withPubKeySet() { + val pubKeySet = mockk() + every { pubKeySet.pubKey } returns "BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF" + every { pubKeySet.auth } returns "8eDyX_uCN0XRhSbY5hs7Hg" + + val endpoint = mockk() + every { endpoint.url } returns "https://nextpush.example.com/endpoint/xyz" + every { endpoint.pubKeySet } returns pubKeySet + + service.onNewEndpoint(mockContext, endpoint, "default") + + verify { + mockPlugin.handleNewUnifiedPushEndpoint( + "https://nextpush.example.com/endpoint/xyz", + "default", + "BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF", + "8eDyX_uCN0XRhSbY5hs7Hg", + ) + } + } + + // --- onTempUnavailable tests --- + + @Test + fun testOnTempUnavailable_forwardsToPlugin() { + service.onTempUnavailable(mockContext, "test-instance") + + verify { mockPlugin.handleUnifiedPushTempUnavailable("test-instance") } + } + + @Test + fun testOnTempUnavailable_pluginNotInitialized_doesNotCrash() { + NotificationPlugin.instance = null + + // Should not throw + service.onTempUnavailable(mockContext, "test-instance") + } + + // --- onRegistrationFailed tests --- + + @Test + fun testOnRegistrationFailed_forwardsToPlugin() { + service.onRegistrationFailed(mockContext, FailedReason.NETWORK, "test-instance") + + verify { mockPlugin.handleUnifiedPushRegistrationFailed("test-instance", FailedReason.NETWORK.toString()) } + } + + // --- onUnregistered tests --- + + @Test + fun testOnUnregistered_forwardsToPlugin() { + service.onUnregistered(mockContext, "test-instance") + + verify { mockPlugin.handleUnifiedPushUnregistered("test-instance") } + } + + // --- setMessageHandler tests --- + + @Test + fun testSetMessageHandler_canBeSetToNull() { + val handler = mockk() + TauriUnifiedPushMessagingService.setMessageHandler(handler) + TauriUnifiedPushMessagingService.setMessageHandler(null) + + // After setting to null, fallback notification path should be taken + val json = """{"title":"Test","body":"After null handler"}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + every { mockPlugin.triggerUnifiedPushMessage(any()) } just Runs + + val mockManager = mockk(relaxed = true) + every { mockPlugin.getNotificationManager() } returns mockManager + + service.onMessage(mockContext, message, "default") + + // handler should not be called + verify(exactly = 0) { handler.onMessage(any(), any(), any()) } + // fallback should be shown + verify { mockManager.schedule(any(), eq("unifiedpush")) } + } + + // --- Plugin not initialized tests --- + + @Test + fun testOnMessage_pluginNotInitialized_doesNotCrash() { + NotificationPlugin.instance = null + + val json = """{"title":"No Plugin","body":"Should not crash"}""" + val message = mockk() + every { message.content } returns json.toByteArray(Charsets.UTF_8) + + // Should not throw + service.onMessage(mockContext, message, "default") + } + + @Test + fun testOnNewEndpoint_pluginNotInitialized_doesNotCrash() { + NotificationPlugin.instance = null + + val endpoint = mockk() + every { endpoint.url } returns "https://push.example.com/endpoint/abc123" + every { endpoint.pubKeySet } returns null + + // Should not throw + service.onNewEndpoint(mockContext, endpoint, "test-instance") + } +} + diff --git a/build.rs b/build.rs index 2029470..6526968 100644 --- a/build.rs +++ b/build.rs @@ -9,6 +9,11 @@ const COMMANDS: &[&str] = &[ "is_permission_granted", "register_for_push_notifications", "unregister_for_push_notifications", + "register_for_unified_push", + "unregister_from_unified_push", + "get_unified_push_distributors", + "save_unified_push_distributor", + "get_unified_push_distributor", "register_action_types", "cancel", "cancel_all", @@ -26,21 +31,21 @@ const COMMANDS: &[&str] = &[ ]; fn main() { - // Check if push-notifications feature is enabled let enable_push = cfg!(feature = "push-notifications"); + let enable_unified_push = cfg!(feature = "unified-push"); - // Generate build.properties file for Android if std::env::var("TARGET") .unwrap_or_default() .contains("android") { - let properties_content = format!("enablePushNotifications={}", enable_push); + let properties_content = format!( + "enablePushNotifications={}\nenableUnifiedPush={}", + enable_push, enable_unified_push + ); std::fs::write("android/build.properties", properties_content) .expect("Failed to write build.properties"); } - // Generate marker file for iOS/macOS Swift build - // Package.swift reads this file to conditionally enable ENABLE_PUSH_NOTIFICATIONS let ios_marker_path = std::path::Path::new("ios/.push-notifications-enabled"); let macos_marker_path = std::path::Path::new("macos/.push-notifications-enabled"); if enable_push { diff --git a/cspell.json b/cspell.json index 06b7e1a..36b5df2 100644 --- a/cspell.json +++ b/cspell.json @@ -33,7 +33,11 @@ "unlisten", "twoweeks", "xcconfig", - "buildscript" + "buildscript", + "unifiedpush", + "ntfy", + "unregistration", + "hostable" ], "useGitignore": true, "ignorePaths": [ diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index 60518bf..3ffb646 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -19,6 +19,16 @@ import { requestPermission, registerForPushNotifications, unregisterForPushNotifications, + registerForUnifiedPush, + unregisterFromUnifiedPush, + getUnifiedPushDistributors, + saveUnifiedPushDistributor, + getUnifiedPushDistributor, + onUnifiedPushEndpoint, + onUnifiedPushMessage, + onUnifiedPushUnregistered, + onUnifiedPushError, + onUnifiedPushTempUnavailable, registerActionTypes, pending, cancel, @@ -489,7 +499,7 @@ describe("Notification Functions", () => { describe("unregisterForPushNotifications", () => { it("should call invoke with correct plugin command", async () => { - mockInvoke.mockResolvedValue(""); + mockInvoke.mockResolvedValue(undefined); await unregisterForPushNotifications(); @@ -498,13 +508,302 @@ describe("Notification Functions", () => { ); }); - it("should return the result from invoke", async () => { - const mockResult = "unregistered"; - mockInvoke.mockResolvedValue(mockResult); + it("should resolve without a return value", async () => { + mockInvoke.mockResolvedValue(undefined); const result = await unregisterForPushNotifications(); - expect(result).toBe(mockResult); + expect(result).toBeUndefined(); + }); + }); + + describe("registerForUnifiedPush", () => { + it("should call invoke with correct plugin command", async () => { + const mockEndpoint = { + endpoint: "https://example.com/push", + instance: "default", + }; + mockInvoke.mockResolvedValue(mockEndpoint); + + const result = await registerForUnifiedPush(); + + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|register_for_unified_push", + ); + expect(result).toEqual(mockEndpoint); + }); + }); + + describe("unregisterFromUnifiedPush", () => { + it("should call invoke with correct plugin command", async () => { + mockInvoke.mockResolvedValue(undefined); + + await unregisterFromUnifiedPush(); + + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|unregister_from_unified_push", + ); + }); + }); + + describe("getUnifiedPushDistributors", () => { + it("should return the list of distributors", async () => { + const mockDistributors = { + distributors: [ + "org.unifiedpush.distributor.nextpush", + "io.heckel.ntfy", + ], + }; + mockInvoke.mockResolvedValue(mockDistributors); + + const result = await getUnifiedPushDistributors(); + + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|get_unified_push_distributors", + ); + expect(result).toEqual(mockDistributors); + }); + + it("should handle empty distributors list", async () => { + mockInvoke.mockResolvedValue({ distributors: [] }); + + const result = await getUnifiedPushDistributors(); + + expect(result.distributors).toEqual([]); + }); + }); + + describe("saveUnifiedPushDistributor", () => { + it("should call invoke with distributor parameter", async () => { + mockInvoke.mockResolvedValue(undefined); + + await saveUnifiedPushDistributor("org.unifiedpush.distributor.nextpush"); + + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|save_unified_push_distributor", + { distributor: "org.unifiedpush.distributor.nextpush" }, + ); + }); + }); + + describe("getUnifiedPushDistributor", () => { + it("should return the current distributor", async () => { + const mockDistributor = { + distributor: "org.unifiedpush.distributor.nextpush", + }; + mockInvoke.mockResolvedValue(mockDistributor); + + const result = await getUnifiedPushDistributor(); + + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|get_unified_push_distributor", + ); + expect(result).toEqual(mockDistributor); + }); + + it("should handle empty distributor", async () => { + mockInvoke.mockResolvedValue({ distributor: "" }); + + const result = await getUnifiedPushDistributor(); + + expect(result.distributor).toBe(""); + }); + }); + + describe("onUnifiedPushEndpoint", () => { + it("should register endpoint listener", async () => { + const mockUnlisten = vi.fn(); + mockAddPluginListener.mockResolvedValue(mockUnlisten); + + const callback = vi.fn(); + const unlisten = await onUnifiedPushEndpoint(callback); + + expect(mockAddPluginListener).toHaveBeenCalledWith( + "notifications", + "unifiedpush-endpoint", + callback, + ); + expect(unlisten).toBe(mockUnlisten); + }); + + it("should call callback with endpoint data", async () => { + let capturedCallback: ((data: any) => void) | undefined; + mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { + capturedCallback = cb; + return Promise.resolve(vi.fn()); + }); + + const callback = vi.fn(); + await onUnifiedPushEndpoint(callback); + + const endpointData = { + endpoint: "https://example.com/push", + instance: "default", + }; + capturedCallback?.(endpointData); + + expect(callback).toHaveBeenCalledWith(endpointData); + }); + + it("should call callback with pubKeySet when distributor provides VAPID keys", async () => { + let capturedCallback: ((data: any) => void) | undefined; + mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { + capturedCallback = cb; + return Promise.resolve(vi.fn()); + }); + + const callback = vi.fn(); + await onUnifiedPushEndpoint(callback); + + const endpointData = { + endpoint: "https://nextpush.example.com/push/xyz", + instance: "default", + pubKeySet: { + pubKey: + "BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF", + auth: "8eDyX_uCN0XRhSbY5hs7Hg", + }, + }; + capturedCallback?.(endpointData); + + expect(callback).toHaveBeenCalledWith(endpointData); + expect(callback.mock.calls[0][0].pubKeySet.pubKey).toBeDefined(); + expect(callback.mock.calls[0][0].pubKeySet.auth).toBeDefined(); + }); + }); + + describe("onUnifiedPushMessage", () => { + it("should register message listener", async () => { + const mockUnlisten = vi.fn(); + mockAddPluginListener.mockResolvedValue(mockUnlisten); + + const callback = vi.fn(); + const unlisten = await onUnifiedPushMessage(callback); + + expect(mockAddPluginListener).toHaveBeenCalledWith( + "notifications", + "unifiedpush-message", + callback, + ); + expect(unlisten).toBe(mockUnlisten); + }); + + it("should call callback with message data", async () => { + let capturedCallback: ((data: any) => void) | undefined; + mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { + capturedCallback = cb; + return Promise.resolve(vi.fn()); + }); + + const callback = vi.fn(); + await onUnifiedPushMessage(callback); + + const messageData = { + title: "Hello", + body: "World", + instance: "default", + source: "unifiedpush", + }; + capturedCallback?.(messageData); + + expect(callback).toHaveBeenCalledWith(messageData); + }); + }); + + describe("onUnifiedPushUnregistered", () => { + it("should register unregistered listener", async () => { + const mockUnlisten = vi.fn(); + mockAddPluginListener.mockResolvedValue(mockUnlisten); + + const callback = vi.fn(); + const unlisten = await onUnifiedPushUnregistered(callback); + + expect(mockAddPluginListener).toHaveBeenCalledWith( + "notifications", + "unifiedpush-unregistered", + callback, + ); + expect(unlisten).toBe(mockUnlisten); + }); + + it("should call callback with instance data", async () => { + let capturedCallback: ((data: any) => void) | undefined; + mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { + capturedCallback = cb; + return Promise.resolve(vi.fn()); + }); + + const callback = vi.fn(); + await onUnifiedPushUnregistered(callback); + + capturedCallback?.({ instance: "default" }); + + expect(callback).toHaveBeenCalledWith({ instance: "default" }); + }); + }); + + describe("onUnifiedPushError", () => { + it("should register error listener", async () => { + const mockUnlisten = vi.fn(); + mockAddPluginListener.mockResolvedValue(mockUnlisten); + + const callback = vi.fn(); + const unlisten = await onUnifiedPushError(callback); + + expect(mockAddPluginListener).toHaveBeenCalledWith( + "notifications", + "unifiedpush-error", + callback, + ); + expect(unlisten).toBe(mockUnlisten); + }); + + it("should call callback with error data", async () => { + let capturedCallback: ((data: any) => void) | undefined; + mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { + capturedCallback = cb; + return Promise.resolve(vi.fn()); + }); + + const callback = vi.fn(); + await onUnifiedPushError(callback); + + const errorData = { message: "Registration failed", instance: "default" }; + capturedCallback?.(errorData); + + expect(callback).toHaveBeenCalledWith(errorData); + }); + }); + + describe("onUnifiedPushTempUnavailable", () => { + it("should register temp-unavailable listener", async () => { + const mockUnlisten = vi.fn(); + mockAddPluginListener.mockResolvedValue(mockUnlisten); + + const callback = vi.fn(); + const unlisten = await onUnifiedPushTempUnavailable(callback); + + expect(mockAddPluginListener).toHaveBeenCalledWith( + "notifications", + "unifiedpush-temp-unavailable", + callback, + ); + expect(unlisten).toBe(mockUnlisten); + }); + + it("should call callback with instance data", async () => { + let capturedCallback: ((data: any) => void) | undefined; + mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { + capturedCallback = cb; + return Promise.resolve(vi.fn()); + }); + + const callback = vi.fn(); + await onUnifiedPushTempUnavailable(callback); + + capturedCallback?.({ instance: "default" }); + + expect(callback).toHaveBeenCalledWith({ instance: "default" }); }); }); @@ -602,7 +901,9 @@ describe("Notification Functions", () => { expect(mockInvoke).toHaveBeenCalledWith( "plugin:notifications|register_action_types", - { types }, + { + types, + }, ); }); @@ -636,7 +937,9 @@ describe("Notification Functions", () => { expect(mockInvoke).toHaveBeenCalledWith( "plugin:notifications|register_action_types", - { types }, + { + types, + }, ); }); }); @@ -972,7 +1275,9 @@ describe("Notification Functions", () => { ); expect(mockInvoke).toHaveBeenCalledWith( "plugin:notifications|set_click_listener_active", - { active: true }, + { + active: true, + }, ); expect(listener).toHaveProperty("unregister"); }); @@ -990,7 +1295,9 @@ describe("Notification Functions", () => { expect(mockInvoke).toHaveBeenCalledWith( "plugin:notifications|set_click_listener_active", - { active: false }, + { + active: false, + }, ); expect(mockUnregister).toHaveBeenCalled(); }); @@ -1030,4 +1337,218 @@ describe("Notification Functions", () => { expect(callback.mock.calls[0][0].data).toBeUndefined(); }); }); + + describe("sendNotification with progress bar", () => { + it("should send notification with determinate progress", async () => { + mockInvoke.mockResolvedValue(undefined); + + const options = { + title: "Downloading...", + progress: 45, + progressMax: 100, + }; + + await sendNotification(options); + + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + options, + }); + }); + + it("should send notification with indeterminate progress", async () => { + mockInvoke.mockResolvedValue(undefined); + + const options = { + title: "Loading...", + progressIndeterminate: true, + }; + + await sendNotification(options); + + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + options, + }); + }); + + it("should send notification with progress and body", async () => { + mockInvoke.mockResolvedValue(undefined); + + const options = { + title: "Upload", + body: "Uploading file.txt", + progress: 75, + progressMax: 100, + ongoing: true, + }; + + await sendNotification(options); + + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + options, + }); + }); + }); + + describe("sendNotification with category", () => { + it("should send notification with message category", async () => { + mockInvoke.mockResolvedValue(undefined); + + const options = { + title: "New Message", + body: "Hello!", + category: "msg", + }; + + await sendNotification(options); + + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + options, + }); + }); + + it("should send notification with alarm category", async () => { + mockInvoke.mockResolvedValue(undefined); + + const options = { + title: "Alarm", + category: "alarm", + }; + + await sendNotification(options); + + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + options, + }); + }); + }); + + describe("sendNotification with messagingStyle", () => { + it("should send notification with simple messaging style", async () => { + mockInvoke.mockResolvedValue(undefined); + + const options = { + title: "Chat", + messagingStyle: { + user: { name: "Me" }, + messages: [ + { text: "Hello!", timestamp: 1700000000000 }, + { + text: "Hi there!", + timestamp: 1700000060000, + sender: { name: "Alice" }, + }, + ], + }, + }; + + await sendNotification(options); + + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + options, + }); + }); + + it("should send notification with group conversation", async () => { + mockInvoke.mockResolvedValue(undefined); + + const options = { + title: "Group Chat", + messagingStyle: { + user: { name: "Me", key: "user-1" }, + conversationTitle: "Project Team", + isGroupConversation: true, + messages: [ + { + text: "Meeting at 3pm", + timestamp: 1700000000000, + sender: { name: "Bob", key: "user-2", icon: "ic_bob" }, + }, + { + text: "Sounds good!", + timestamp: 1700000060000, + sender: { name: "Carol", key: "user-3" }, + }, + { text: "I'll be there", timestamp: 1700000120000 }, + ], + }, + }; + + await sendNotification(options); + + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + options, + }); + }); + + it("should send notification with user icon in messaging style", async () => { + mockInvoke.mockResolvedValue(undefined); + + const options = { + title: "Chat", + messagingStyle: { + user: { name: "Me", icon: "ic_me", key: "self" }, + messages: [{ text: "Hey!", timestamp: 1700000000000 }], + }, + }; + + await sendNotification(options); + + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + options, + }); + }); + }); + + describe("registerActionTypes with icon", () => { + it("should register action types with custom icons", async () => { + mockInvoke.mockResolvedValue(undefined); + + const types = [ + { + id: "message-actions", + actions: [ + { id: "reply", title: "Reply", input: true, icon: "ic_reply" }, + { + id: "delete", + title: "Delete", + destructive: true, + icon: "ic_delete", + }, + ], + }, + ]; + + await registerActionTypes(types); + + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|register_action_types", + { + types, + }, + ); + }); + + it("should register action types mixing icons and no icons", async () => { + mockInvoke.mockResolvedValue(undefined); + + const types = [ + { + id: "mixed-actions", + actions: [ + { id: "action-with-icon", title: "With Icon", icon: "ic_star" }, + { id: "action-without-icon", title: "Without Icon" }, + ], + }, + ]; + + await registerActionTypes(types); + + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|register_action_types", + { + types, + }, + ); + }); + }); }); diff --git a/guest-js/index.ts b/guest-js/index.ts index e166d7d..997f549 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -119,9 +119,10 @@ interface Options { /** * The source of the notification. Only present in `onNotificationReceived` callbacks. * - `"push"` — notification received from a remote push (FCM/APNs). + * - `"unifiedpush"` — notification received from a UnifiedPush distributor. * - `"local"` — notification created locally (immediate or scheduled). */ - source?: "push" | "local"; + source?: "push" | "unifiedpush" | "local"; /** * Notification visibility. */ @@ -130,6 +131,88 @@ interface Options { * Sets the number of items this notification represents on Android. */ number?: number; + /** + * Current progress value for a progress bar notification (Android). + * Use with `progressMax` to show a determinate progress bar. + */ + progress?: number; + /** + * Maximum progress value for a progress bar notification (Android). + * Defaults to 100 when `progress` is set. + */ + progressMax?: number; + /** + * If true, shows an indeterminate progress bar (Android). + * When set, `progress` and `progressMax` are ignored. + */ + progressIndeterminate?: boolean; + /** + * System notification category (Android). + * Maps to `NotificationCompat.CATEGORY_*` constants. + * Common values: `"alarm"`, `"call"`, `"email"`, `"err"`, `"event"`, + * `"msg"`, `"progress"`, `"promo"`, `"recommendation"`, + * `"reminder"`, `"service"`, `"social"`, `"status"`, `"sys"`, `"transport"`. + */ + category?: string; + /** + * MessagingStyle configuration for conversation-style notifications (Android). + * Cannot be used with `largeBody` or `inboxLines`. + */ + messagingStyle?: MessagingStyleConfig; +} + +/** + * A person in a MessagingStyle notification. + */ +interface MessagingStylePerson { + /** Display name of the person. */ + name: string; + /** + * Icon resource name for the person's avatar (Android). + * The icon must be placed in the app's `res/drawable` folder. + */ + icon?: string; + /** + * HTTP(S) URL to the person's avatar image (Android). + * When set, the image is downloaded and used as a circular avatar icon. + * Takes priority over the `icon` drawable resource. + * Use with `MessagingStyleConfig.authToken` for authenticated endpoints. + */ + iconUrl?: string; + /** A unique key to identify this person across messages. */ + key?: string; +} + +/** + * A single message in a MessagingStyle notification. + */ +interface MessagingStyleMessage { + /** The message text. */ + text: string; + /** Timestamp of the message in milliseconds since epoch. */ + timestamp: number; + /** The sender of the message. If null, the message is from the user. */ + sender?: MessagingStylePerson; +} + +/** + * Configuration for a conversation-style (MessagingStyle) notification (Android). + * This creates an expandable notification that shows a conversation thread. + */ +interface MessagingStyleConfig { + /** The user (device owner) participating in the conversation. */ + user: MessagingStylePerson; + /** Title for the conversation (e.g., group chat name). */ + conversationTitle?: string; + /** Whether this is a group conversation. */ + isGroupConversation?: boolean; + /** The list of messages in the conversation. */ + messages: MessagingStyleMessage[]; + /** + * Bearer token for downloading authenticated avatar images (e.g. Matrix media). + * Passed as `Authorization: Bearer ` when fetching `iconUrl` images. + */ + authToken?: string; } /** @@ -293,6 +376,12 @@ interface Action { inputButtonTitle?: string; /** Placeholder text for the input field when `input` is true. */ inputPlaceholder?: string; + /** + * Icon resource name for the action (Android). + * The icon must be placed in the app's `res/drawable` folder. + * Note: action icons are primarily visible on wearables and Android Auto. + */ + icon?: string; } /** @@ -472,12 +561,228 @@ async function registerForPushNotifications(): Promise { * * @returns A promise resolving when unregistration is complete. */ -async function unregisterForPushNotifications(): Promise { +async function unregisterForPushNotifications(): Promise { return await invoke("plugin:notifications|unregister_for_push_notifications"); } +/** VAPID / Web Push public key set provided by the distributor for encrypted push. */ +interface UnifiedPushPublicKeySet { + /** The P-256 ECDH public key (base64url-encoded, uncompressed). */ + pubKey: string; + /** The authentication secret (base64url-encoded). */ + auth: string; +} + +interface UnifiedPushEndpoint { + /** The endpoint URL where push messages should be sent. */ + endpoint: string; + /** The instance identifier for this registration. */ + instance: string; + /** + * VAPID public-key set provided by the distributor. + * Present only when the distributor supports encrypted push (e.g. NextPush with VAPID). + * Pass `pubKeySet.pubKey` as `p256dh` and `pubKeySet.auth` as the auth secret to the push gateway. + */ + pubKeySet?: UnifiedPushPublicKeySet; +} + +/** + * Registers the app for UnifiedPush notifications (Android only). + * + * @example + * ```typescript + * import { registerForUnifiedPush } from '@choochmeque/tauri-plugin-notifications-api'; + * const { endpoint, instance } = await registerForUnifiedPush(); + * console.log('UnifiedPush endpoint:', endpoint); + * ``` + * + * @returns A promise resolving to the UnifiedPush endpoint information. + */ +async function registerForUnifiedPush(): Promise { + return await invoke("plugin:notifications|register_for_unified_push"); +} + +/** + * Unregisters the app from UnifiedPush notifications (Android only). + * + * @example + * ```typescript + * import { unregisterFromUnifiedPush } from '@choochmeque/tauri-plugin-notifications-api'; + * await unregisterFromUnifiedPush(); + * ``` + * + * @returns A promise resolving when unregistration is complete. + */ +async function unregisterFromUnifiedPush(): Promise { + return await invoke("plugin:notifications|unregister_from_unified_push"); +} + +/** + * Gets the list of available UnifiedPush distributors installed on the device. + * + * @example + * ```typescript + * import { getUnifiedPushDistributors } from '@choochmeque/tauri-plugin-notifications-api'; + * const { distributors } = await getUnifiedPushDistributors(); + * console.log('Available distributors:', distributors); + * ``` + * + * @returns A promise resolving to an object with a distributors array. + */ +async function getUnifiedPushDistributors(): Promise<{ + distributors: string[]; +}> { + return await invoke("plugin:notifications|get_unified_push_distributors"); +} + +/** + * Saves the selected UnifiedPush distributor. + * + * @example + * ```typescript + * import { saveUnifiedPushDistributor } from '@choochmeque/tauri-plugin-notifications-api'; + * await saveUnifiedPushDistributor('org.unifiedpush.distributor.nextpush'); + * ``` + * + * @param distributor - The package name of the distributor to use. + * @returns A promise resolving when the distributor is saved. + */ +async function saveUnifiedPushDistributor(distributor: string): Promise { + return await invoke("plugin:notifications|save_unified_push_distributor", { + distributor, + }); +} + +/** + * Gets the currently selected UnifiedPush distributor. + * + * @example + * ```typescript + * import { getUnifiedPushDistributor } from '@choochmeque/tauri-plugin-notifications-api'; + * const { distributor } = await getUnifiedPushDistributor(); + * ``` + * + * @returns A promise resolving to an object with the distributor package name. + */ +async function getUnifiedPushDistributor(): Promise<{ distributor: string }> { + return await invoke("plugin:notifications|get_unified_push_distributor"); +} + +/** + * Registers a listener for new UnifiedPush endpoint events. + * + * @example + * ```typescript + * import { onUnifiedPushEndpoint } from '@choochmeque/tauri-plugin-notifications-api'; + * const unlisten = await onUnifiedPushEndpoint((data) => { + * console.log('New UnifiedPush endpoint:', data.endpoint); + * }); + * ``` + * + * @param cb - Callback function to handle the endpoint event. + * @returns A promise resolving to a function that removes the listener. + */ +async function onUnifiedPushEndpoint( + cb: (data: UnifiedPushEndpoint) => void, +): Promise { + return await addPluginListener("notifications", "unifiedpush-endpoint", cb); +} + +/** + * Registers a listener for UnifiedPush message events. + * + * @example + * ```typescript + * import { onUnifiedPushMessage } from '@choochmeque/tauri-plugin-notifications-api'; + * const unlisten = await onUnifiedPushMessage((data) => { + * console.log('UnifiedPush message received:', data); + * }); + * ``` + * + * @param cb - Callback function to handle the message event. + * @returns A promise resolving to a function that removes the listener. + */ +async function onUnifiedPushMessage( + cb: (data: Record) => void, +): Promise { + return await addPluginListener("notifications", "unifiedpush-message", cb); +} + +/** + * Registers a listener for UnifiedPush unregistration events. + * + * @example + * ```typescript + * import { onUnifiedPushUnregistered } from '@choochmeque/tauri-plugin-notifications-api'; + * const unlisten = await onUnifiedPushUnregistered((data) => { + * console.log('UnifiedPush unregistered for instance:', data.instance); + * }); + * ``` + * + * @param cb - Callback function to handle the unregistration event. + * @returns A promise resolving to a function that removes the listener. + */ +async function onUnifiedPushUnregistered( + cb: (data: { instance: string }) => void, +): Promise { + return await addPluginListener( + "notifications", + "unifiedpush-unregistered", + cb, + ); +} + +/** + * Registers a listener for UnifiedPush error events. + * + * @example + * ```typescript + * import { onUnifiedPushError } from '@choochmeque/tauri-plugin-notifications-api'; + * const unlisten = await onUnifiedPushError((data) => { + * console.error('UnifiedPush error:', data.message); + * }); + * ``` + * + * @param cb - Callback function to handle the error event. + * @returns A promise resolving to a function that removes the listener. + */ +async function onUnifiedPushError( + cb: (data: { message: string; instance?: string }) => void, +): Promise { + return await addPluginListener("notifications", "unifiedpush-error", cb); +} + +/** + * Registers a listener 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 {@link onUnifiedPushEndpoint} + * callback before sending push messages again. + * + * @example + * ```typescript + * import { onUnifiedPushTempUnavailable } from '@choochmeque/tauri-plugin-notifications-api'; + * const unlisten = await onUnifiedPushTempUnavailable((data) => { + * console.warn('UnifiedPush temporarily unavailable for instance:', data.instance); + * }); + * ``` + * + * @param cb - Callback function to handle the temp-unavailable event. + * @returns A promise resolving to a function that removes the listener. + */ +async function onUnifiedPushTempUnavailable( + cb: (data: { instance: string }) => void, +): Promise { + return await addPluginListener( + "notifications", + "unifiedpush-temp-unavailable", + cb, + ); +} + /** * Sends a notification to the user. + * * @example * ```typescript * import { isPermissionGranted, requestPermission, sendNotification } from '@choochmeque/tauri-plugin-notifications-api'; @@ -751,12 +1056,11 @@ async function onNotificationClicked( cb, ); - // Tell native side listener is active (triggers pending if any) + // Notify native side so pending cold-start clicks are delivered await invoke("plugin:notifications|set_click_listener_active", { active: true, }); - // Return wrapped listener that notifies native side on unregister return { unregister: async () => { await invoke("plugin:notifications|set_click_listener_active", { @@ -777,6 +1081,11 @@ export type { Channel, ScheduleInterval, NotificationClickedData, + UnifiedPushPublicKeySet, + UnifiedPushEndpoint, + MessagingStylePerson, + MessagingStyleMessage, + MessagingStyleConfig, }; export { @@ -787,6 +1096,16 @@ export { isPermissionGranted, registerForPushNotifications, unregisterForPushNotifications, + registerForUnifiedPush, + unregisterFromUnifiedPush, + getUnifiedPushDistributors, + saveUnifiedPushDistributor, + getUnifiedPushDistributor, + onUnifiedPushEndpoint, + onUnifiedPushMessage, + onUnifiedPushUnregistered, + onUnifiedPushError, + onUnifiedPushTempUnavailable, registerActionTypes, pending, cancel, diff --git a/macos/Sources/Notification.swift b/macos/Sources/Notification.swift index c12179b..6c82235 100644 --- a/macos/Sources/Notification.swift +++ b/macos/Sources/Notification.swift @@ -1,5 +1,8 @@ +import os import UserNotifications +private let logger = Logger(subsystem: "tauri-plugin-notifications", category: "Notification") + enum NotificationError: LocalizedError { case triggerRepeatIntervalTooShort case attachmentFileNotFound(path: String) @@ -126,8 +129,7 @@ func handleScheduledNotification(_ schedule: NotificationSchedule) throws let dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: at) if dateInfo.date! < Date() { - // TODO: - //Logger.debug("Scheduled time is in the past: \(dateInfo.date!) < \(Date())") + logger.debug("Scheduled time is in the past: \(dateInfo.date!) < \(Date())") throw NotificationError.pastScheduledTime } diff --git a/permissions/autogenerated/commands/get_unified_push_distributor.toml b/permissions/autogenerated/commands/get_unified_push_distributor.toml new file mode 100644 index 0000000..fc89ae1 --- /dev/null +++ b/permissions/autogenerated/commands/get_unified_push_distributor.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-unified-push-distributor" +description = "Enables the get_unified_push_distributor command without any pre-configured scope." +commands.allow = ["get_unified_push_distributor"] + +[[permission]] +identifier = "deny-get-unified-push-distributor" +description = "Denies the get_unified_push_distributor command without any pre-configured scope." +commands.deny = ["get_unified_push_distributor"] diff --git a/permissions/autogenerated/commands/get_unified_push_distributors.toml b/permissions/autogenerated/commands/get_unified_push_distributors.toml new file mode 100644 index 0000000..c4cb4cd --- /dev/null +++ b/permissions/autogenerated/commands/get_unified_push_distributors.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-unified-push-distributors" +description = "Enables the get_unified_push_distributors command without any pre-configured scope." +commands.allow = ["get_unified_push_distributors"] + +[[permission]] +identifier = "deny-get-unified-push-distributors" +description = "Denies the get_unified_push_distributors command without any pre-configured scope." +commands.deny = ["get_unified_push_distributors"] diff --git a/permissions/autogenerated/commands/register_for_unified_push.toml b/permissions/autogenerated/commands/register_for_unified_push.toml new file mode 100644 index 0000000..c4a6128 --- /dev/null +++ b/permissions/autogenerated/commands/register_for_unified_push.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-register-for-unified-push" +description = "Enables the register_for_unified_push command without any pre-configured scope." +commands.allow = ["register_for_unified_push"] + +[[permission]] +identifier = "deny-register-for-unified-push" +description = "Denies the register_for_unified_push command without any pre-configured scope." +commands.deny = ["register_for_unified_push"] diff --git a/permissions/autogenerated/commands/save_unified_push_distributor.toml b/permissions/autogenerated/commands/save_unified_push_distributor.toml new file mode 100644 index 0000000..c26c089 --- /dev/null +++ b/permissions/autogenerated/commands/save_unified_push_distributor.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-save-unified-push-distributor" +description = "Enables the save_unified_push_distributor command without any pre-configured scope." +commands.allow = ["save_unified_push_distributor"] + +[[permission]] +identifier = "deny-save-unified-push-distributor" +description = "Denies the save_unified_push_distributor command without any pre-configured scope." +commands.deny = ["save_unified_push_distributor"] diff --git a/permissions/autogenerated/commands/unregister_from_unified_push.toml b/permissions/autogenerated/commands/unregister_from_unified_push.toml new file mode 100644 index 0000000..a25586a --- /dev/null +++ b/permissions/autogenerated/commands/unregister_from_unified_push.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-unregister-from-unified-push" +description = "Enables the unregister_from_unified_push command without any pre-configured scope." +commands.allow = ["unregister_from_unified_push"] + +[[permission]] +identifier = "deny-unregister-from-unified-push" +description = "Denies the unregister_from_unified_push command without any pre-configured scope." +commands.deny = ["unregister_from_unified_push"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index a7cd453..2553d95 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -251,6 +251,58 @@ Denies the get_pending command without any pre-configured scope. +`notifications:allow-get-unified-push-distributor` + + + + +Enables the get_unified_push_distributor command without any pre-configured scope. + + + + + + + +`notifications:deny-get-unified-push-distributor` + + + + +Denies the get_unified_push_distributor command without any pre-configured scope. + + + + + + + +`notifications:allow-get-unified-push-distributors` + + + + +Enables the get_unified_push_distributors command without any pre-configured scope. + + + + + + + +`notifications:deny-get-unified-push-distributors` + + + + +Denies the get_unified_push_distributors command without any pre-configured scope. + + + + + + + `notifications:allow-is-permission-granted` @@ -407,6 +459,32 @@ Denies the register_for_push_notifications command without any pre-configured sc +`notifications:allow-register-for-unified-push` + + + + +Enables the register_for_unified_push command without any pre-configured scope. + + + + + + + +`notifications:deny-register-for-unified-push` + + + + +Denies the register_for_unified_push command without any pre-configured scope. + + + + + + + `notifications:allow-register-listener` @@ -511,6 +589,32 @@ Denies the request_permission command without any pre-configured scope. +`notifications:allow-save-unified-push-distributor` + + + + +Enables the save_unified_push_distributor command without any pre-configured scope. + + + + + + + +`notifications:deny-save-unified-push-distributor` + + + + +Denies the save_unified_push_distributor command without any pre-configured scope. + + + + + + + `notifications:allow-set-click-listener-active` @@ -583,6 +687,49 @@ Enables the unregister_for_push_notifications command without any pre-configured Denies the unregister_for_push_notifications command without any pre-configured scope. + + + + + + +`notifications:allow-unregister-from-unified-push` + + + + +Enables the unregister_from_unified_push command without any pre-configured scope. + + + + + + + +`notifications:deny-unregister-from-unified-push` + + + + +Denies the unregister_from_unified_push command without any pre-configured scope. + + + + + + + +`notifications:allow-unified-push` + + + + +Enables UnifiedPush notification commands. + +Add this permission set alongside `notifications:default` +when the `unified-push` Cargo feature is enabled. + + diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index 016923d..0271d7c 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -390,6 +390,30 @@ "const": "deny-get-pending", "markdownDescription": "Denies the get_pending command without any pre-configured scope." }, + { + "description": "Enables the get_unified_push_distributor command without any pre-configured scope.", + "type": "string", + "const": "allow-get-unified-push-distributor", + "markdownDescription": "Enables the get_unified_push_distributor command without any pre-configured scope." + }, + { + "description": "Denies the get_unified_push_distributor command without any pre-configured scope.", + "type": "string", + "const": "deny-get-unified-push-distributor", + "markdownDescription": "Denies the get_unified_push_distributor command without any pre-configured scope." + }, + { + "description": "Enables the get_unified_push_distributors command without any pre-configured scope.", + "type": "string", + "const": "allow-get-unified-push-distributors", + "markdownDescription": "Enables the get_unified_push_distributors command without any pre-configured scope." + }, + { + "description": "Denies the get_unified_push_distributors command without any pre-configured scope.", + "type": "string", + "const": "deny-get-unified-push-distributors", + "markdownDescription": "Denies the get_unified_push_distributors command without any pre-configured scope." + }, { "description": "Enables the is_permission_granted command without any pre-configured scope.", "type": "string", @@ -462,6 +486,18 @@ "const": "deny-register-for-push-notifications", "markdownDescription": "Denies the register_for_push_notifications command without any pre-configured scope." }, + { + "description": "Enables the register_for_unified_push command without any pre-configured scope.", + "type": "string", + "const": "allow-register-for-unified-push", + "markdownDescription": "Enables the register_for_unified_push command without any pre-configured scope." + }, + { + "description": "Denies the register_for_unified_push command without any pre-configured scope.", + "type": "string", + "const": "deny-register-for-unified-push", + "markdownDescription": "Denies the register_for_unified_push command without any pre-configured scope." + }, { "description": "Enables the register_listener command without any pre-configured scope.", "type": "string", @@ -510,6 +546,18 @@ "const": "deny-request-permission", "markdownDescription": "Denies the request_permission command without any pre-configured scope." }, + { + "description": "Enables the save_unified_push_distributor command without any pre-configured scope.", + "type": "string", + "const": "allow-save-unified-push-distributor", + "markdownDescription": "Enables the save_unified_push_distributor command without any pre-configured scope." + }, + { + "description": "Denies the save_unified_push_distributor command without any pre-configured scope.", + "type": "string", + "const": "deny-save-unified-push-distributor", + "markdownDescription": "Denies the save_unified_push_distributor command without any pre-configured scope." + }, { "description": "Enables the set_click_listener_active command without any pre-configured scope.", "type": "string", @@ -546,11 +594,29 @@ "const": "deny-unregister-for-push-notifications", "markdownDescription": "Denies the unregister_for_push_notifications command without any pre-configured scope." }, + { + "description": "Enables the unregister_from_unified_push command without any pre-configured scope.", + "type": "string", + "const": "allow-unregister-from-unified-push", + "markdownDescription": "Enables the unregister_from_unified_push command without any pre-configured scope." + }, + { + "description": "Denies the unregister_from_unified_push command without any pre-configured scope.", + "type": "string", + "const": "deny-unregister-from-unified-push", + "markdownDescription": "Denies the unregister_from_unified_push 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-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`", "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-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`" + }, + { + "description": "Enables UnifiedPush notification commands.\n\nAdd this permission set alongside `notifications:default`\nwhen the `unified-push` Cargo feature is enabled.\n\n#### This permission set includes:\n\n- `allow-register-for-unified-push`\n- `allow-unregister-from-unified-push`\n- `allow-get-unified-push-distributors`\n- `allow-save-unified-push-distributor`\n- `allow-get-unified-push-distributor`", + "type": "string", + "const": "allow-unified-push", + "markdownDescription": "Enables UnifiedPush notification commands.\n\nAdd this permission set alongside `notifications:default`\nwhen the `unified-push` Cargo feature is enabled.\n\n#### This permission set includes:\n\n- `allow-register-for-unified-push`\n- `allow-unregister-from-unified-push`\n- `allow-get-unified-push-distributors`\n- `allow-save-unified-push-distributor`\n- `allow-get-unified-push-distributor`" } ] } diff --git a/permissions/unified-push.toml b/permissions/unified-push.toml new file mode 100644 index 0000000..52212ee --- /dev/null +++ b/permissions/unified-push.toml @@ -0,0 +1,18 @@ +"$schema" = "schemas/schema.json" + +[[set]] +identifier = "allow-unified-push" +description = """ +Enables UnifiedPush notification commands. + +Add this permission set alongside `notifications:default` +when the `unified-push` Cargo feature is enabled. +""" +permissions = [ + "allow-register-for-unified-push", + "allow-unregister-from-unified-push", + "allow-get-unified-push-distributors", + "allow-save-unified-push-distributor", + "allow-get-unified-push-distributor", +] + diff --git a/src/commands.rs b/src/commands.rs index e00c375..e8662d3 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -48,6 +48,47 @@ pub(crate) async fn unregister_for_push_notifications( notification.unregister_for_push_notifications() } +#[command] +pub(crate) async fn register_for_unified_push( + _app: AppHandle, + notification: State<'_, Notifications>, +) -> Result { + notification.register_for_unified_push().await +} + +#[command] +pub(crate) async fn unregister_from_unified_push( + _app: AppHandle, + notification: State<'_, Notifications>, +) -> Result<()> { + notification.unregister_from_unified_push() +} + +#[command] +pub(crate) async fn get_unified_push_distributors( + _app: AppHandle, + notification: State<'_, Notifications>, +) -> Result { + notification.get_unified_push_distributors() +} + +#[command] +pub(crate) async fn save_unified_push_distributor( + _app: AppHandle, + notification: State<'_, Notifications>, + distributor: String, +) -> Result<()> { + notification.save_unified_push_distributor(distributor) +} + +#[command] +pub(crate) async fn get_unified_push_distributor( + _app: AppHandle, + notification: State<'_, Notifications>, +) -> Result { + notification.get_unified_push_distributor() +} + #[command] pub(crate) async fn notify( _app: AppHandle, diff --git a/src/desktop.rs b/src/desktop.rs index a140030..4f21ee9 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -63,6 +63,36 @@ impl Notifications { ))) } + pub async fn register_for_unified_push(&self) -> crate::Result { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is not supported on desktop platforms", + ))) + } + + pub fn unregister_from_unified_push(&self) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is not supported on desktop platforms", + ))) + } + + pub fn get_unified_push_distributors(&self) -> crate::Result { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is not supported on desktop platforms", + ))) + } + + pub fn save_unified_push_distributor(&self, _distributor: String) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is not supported on desktop platforms", + ))) + } + + pub fn get_unified_push_distributor(&self) -> crate::Result { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is not supported on desktop platforms", + ))) + } + pub async fn permission_state(&self) -> crate::Result { Ok(PermissionState::Granted) } diff --git a/src/lib.rs b/src/lib.rs index 1a0279a..9536e43 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -235,6 +235,11 @@ pub fn init() -> TauriPlugin { commands::request_permission, commands::register_for_push_notifications, commands::unregister_for_push_notifications, + commands::register_for_unified_push, + commands::unregister_from_unified_push, + commands::get_unified_push_distributors, + commands::save_unified_push_distributor, + commands::get_unified_push_distributor, commands::is_permission_granted, commands::register_action_types, commands::get_pending, diff --git a/src/macos.rs b/src/macos.rs index 8e9b849..e96e7bb 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -221,6 +221,36 @@ impl Notifications { } } + pub async fn register_for_unified_push(&self) -> crate::Result { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } + + pub fn unregister_from_unified_push(&self) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } + + pub fn get_unified_push_distributors(&self) -> crate::Result { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } + + pub fn save_unified_push_distributor(&self, _distributor: String) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } + + pub fn get_unified_push_distributor(&self) -> crate::Result { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } + pub async fn permission_state(&self) -> crate::Result { validation::require_bundle()?; diff --git a/src/mobile.rs b/src/mobile.rs index a3c48ef..eb37e8d 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -89,6 +89,140 @@ impl Notifications { } } + pub async fn register_for_unified_push(&self) -> crate::Result { + #[cfg(all(feature = "unified-push", target_os = "android"))] + { + self.0 + .run_mobile_plugin_async::( + "registerForUnifiedPush", + (), + ) + .await + .map(|r| { + let mut obj = serde_json::json!({ + "endpoint": r.endpoint, + "instance": r.instance, + }); + if let Some(keys) = r.pub_key_set { + obj["pubKeySet"] = serde_json::json!({ + "pubKey": keys.pub_key, + "auth": keys.auth, + }); + } + obj + }) + .map_err(Into::into) + } + #[cfg(all(feature = "unified-push", not(target_os = "android")))] + { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } + #[cfg(not(feature = "unified-push"))] + { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush feature is not enabled", + ))) + } + } + + pub fn unregister_from_unified_push(&self) -> crate::Result<()> { + #[cfg(all(feature = "unified-push", target_os = "android"))] + { + self.0 + .run_mobile_plugin::<()>("unregisterFromUnifiedPush", ()) + .map_err(Into::into) + } + #[cfg(all(feature = "unified-push", not(target_os = "android")))] + { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } + #[cfg(not(feature = "unified-push"))] + { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush feature is not enabled", + ))) + } + } + + pub fn get_unified_push_distributors(&self) -> crate::Result { + #[cfg(all(feature = "unified-push", target_os = "android"))] + { + self.0 + .run_mobile_plugin::( + "getUnifiedPushDistributors", + (), + ) + .map(|r| serde_json::json!({ "distributors": r.distributors })) + .map_err(Into::into) + } + #[cfg(all(feature = "unified-push", not(target_os = "android")))] + { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } + #[cfg(not(feature = "unified-push"))] + { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush feature is not enabled", + ))) + } + } + + pub fn save_unified_push_distributor(&self, distributor: String) -> crate::Result<()> { + #[cfg(all(feature = "unified-push", target_os = "android"))] + { + let mut args = HashMap::new(); + args.insert("distributor", distributor); + self.0 + .run_mobile_plugin::<()>("saveUnifiedPushDistributor", args) + .map_err(Into::into) + } + #[cfg(all(feature = "unified-push", not(target_os = "android")))] + { + let _ = distributor; + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } + #[cfg(not(feature = "unified-push"))] + { + let _ = distributor; + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush feature is not enabled", + ))) + } + } + + pub fn get_unified_push_distributor(&self) -> crate::Result { + #[cfg(all(feature = "unified-push", target_os = "android"))] + { + self.0 + .run_mobile_plugin::( + "getUnifiedPushDistributor", + (), + ) + .map(|r| serde_json::json!({ "distributor": r.distributor })) + .map_err(Into::into) + } + #[cfg(all(feature = "unified-push", not(target_os = "android")))] + { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } + #[cfg(not(feature = "unified-push"))] + { + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush feature is not enabled", + ))) + } + } + pub async fn permission_state(&self) -> crate::Result { self.0 .run_mobile_plugin_async::("checkPermissions", ()) @@ -198,8 +332,6 @@ impl Notifications { ))); } - /// Set click listener active state. - /// Used internally to track if JS listener is registered. pub fn set_click_listener_active(&self, active: bool) -> crate::Result<()> { let mut args = HashMap::new(); args.insert("active", active); diff --git a/src/models.rs b/src/models.rs index 61fd3cb..84a9132 100644 --- a/src/models.rs +++ b/src/models.rs @@ -18,6 +18,37 @@ pub struct PushNotificationResponse { pub device_token: String, } +#[cfg(feature = "unified-push")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnifiedPushPublicKeySet { + pub pub_key: String, + pub auth: String, +} + +#[cfg(feature = "unified-push")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnifiedPushEndpointResponse { + pub endpoint: String, + pub instance: String, + pub pub_key_set: Option, +} + +#[cfg(feature = "unified-push")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnifiedPushDistributorsResponse { + pub distributors: Vec, +} + +#[cfg(feature = "unified-push")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnifiedPushDistributorResponse { + pub distributor: String, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Attachment { @@ -186,6 +217,17 @@ pub struct NotificationData { pub(crate) auto_cancel: bool, #[serde(default)] pub(crate) silent: bool, + /// Current progress value for a progress bar notification (Android). + pub(crate) progress: Option, + /// Maximum progress value for a progress bar notification (Android). + pub(crate) progress_max: Option, + /// If true, shows an indeterminate progress bar (Android). + #[serde(default)] + pub(crate) progress_indeterminate: bool, + /// System notification category, e.g. "msg", "alarm", "call" (Android). + pub(crate) category: Option, + /// Conversation-style (MessagingStyle) notification configuration (Android). + pub(crate) messaging_style: Option, } fn default_id() -> i32 { @@ -215,6 +257,11 @@ impl Default for NotificationData { ongoing: false, auto_cancel: false, silent: false, + progress: None, + progress_max: None, + progress_indeterminate: false, + category: None, + messaging_style: None, } } } @@ -348,6 +395,38 @@ pub struct Action { input: bool, input_button_title: Option, input_placeholder: Option, + /// Icon resource name for the action (Android). + icon: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessagingStylePerson { + pub name: String, + pub icon: Option, + pub icon_url: Option, + pub key: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessagingStyleMessage { + pub text: String, + pub timestamp: i64, + pub sender: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessagingStyleConfig { + pub user: MessagingStylePerson, + pub conversation_title: Option, + #[serde(default)] + pub is_group_conversation: bool, + #[serde(default)] + pub messages: Vec, + #[serde(skip_serializing)] + pub auth_token: Option, } pub use android::*;