From 988ee337b02bcd923ece0bf21c455eb79a086828 Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Sun, 8 Mar 2026 13:08:39 +0100 Subject: [PATCH 01/24] Added UnifiedPush support --- Cargo.toml | 3 +- README.md | 141 ++++++++++++ android/build.gradle.kts | 7 +- android/settings.gradle | 1 + android/src/main/AndroidManifest.xml | 13 +- .../tauri/notification/NotificationPlugin.kt | 196 ++++++++++++++++- .../notification/TauriNotificationManager.kt | 6 + .../TauriUnifiedPushMessagingService.kt | 136 ++++++++++++ .../notification/UnifiedPushMessageHandler.kt | 20 ++ build.rs | 15 +- guest-js/index.ts | 200 +++++++++++++++++- .../get_unified_push_distributor.toml | 13 ++ .../get_unified_push_distributors.toml | 13 ++ .../commands/register_for_unified_push.toml | 13 ++ .../save_unified_push_distributor.toml | 13 ++ .../unregister_from_unified_push.toml | 13 ++ permissions/autogenerated/reference.md | 135 ++++++++++++ permissions/default.toml | 5 + permissions/schemas/schema.json | 64 +++++- src/commands.rs | 41 ++++ src/desktop.rs | 30 +++ src/lib.rs | 5 + src/macos.rs | 30 +++ src/mobile.rs | 93 +++++++- src/models.rs | 22 ++ 25 files changed, 1211 insertions(+), 17 deletions(-) create mode 100644 android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt create mode 100644 android/src/main/java/app/tauri/notification/UnifiedPushMessageHandler.kt create mode 100644 permissions/autogenerated/commands/get_unified_push_distributor.toml create mode 100644 permissions/autogenerated/commands/get_unified_push_distributors.toml create mode 100644 permissions/autogenerated/commands/register_for_unified_push.toml create mode 100644 permissions/autogenerated/commands/save_unified_push_distributor.toml create mode 100644 permissions/autogenerated/commands/unregister_from_unified_push.toml 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..ba233bd 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. @@ -390,6 +424,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 +543,56 @@ Registers the app for push notifications (mobile only). On Android, this retriev **Returns:** `Promise` - The device push token +### `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 + +### `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 + ### `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..f42f186 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -25,9 +25,11 @@ 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") } buildTypes { @@ -73,6 +75,9 @@ dependencies { implementation(platform("com.google.firebase:firebase-bom:34.7.0")) implementation("com.google.firebase:firebase-messaging-ktx:24.1.2") + + api("com.github.UnifiedPush:android-connector:2.4.0") + 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..ba62aef 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -23,6 +23,7 @@ dependencyResolutionManagement { repositories { mavenCentral() google() + maven { url 'https://jitpack.io' } } } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index d5f3a81..4282cfb 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -14,7 +14,6 @@ - @@ -22,6 +21,18 @@ + + + + + + + + + + diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index aae5771..1f9e463 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" @@ -74,6 +75,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 +95,10 @@ 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 unifiedPushInstance: String = "default" + private var hasClickedListener = false private var pendingNotificationClick: JSObject? = null @@ -348,6 +357,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 +462,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) @@ -506,6 +516,188 @@ 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) { + pendingUnifiedPushInvoke = invoke + requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "unifiedPushPermissionsCallback") + return + } + } else { + invoke.reject("Notification permissions not granted") + return + } + } + + // If we already have a cached endpoint, return it immediately + cachedUnifiedPushEndpoint?.let { + val result = JSObject() + result.put("endpoint", it) + result.put("instance", unifiedPushInstance) + invoke.resolve(result) + return + } + + // Store the invoke to respond later when we get the endpoint + pendingUnifiedPushInvoke = invoke + + // Register with UnifiedPush + UnifiedPush.registerApp(activity, unifiedPushInstance) + } + + @Command + fun unregisterFromUnifiedPush(invoke: Invoke) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) { + invoke.reject("UnifiedPush is disabled in this build") + return + } + + UnifiedPush.unregisterApp(activity, unifiedPushInstance) + cachedUnifiedPushEndpoint = null + 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.getDistributor(activity) + val result = JSObject() + result.put("distributor", distributor) + invoke.resolve(result) + } + + @PermissionCallback + private fun unifiedPushPermissionsCallback(invoke: Invoke) { + if (!manager.areNotificationsEnabled()) { + invoke.reject("Notification permissions denied") + pendingUnifiedPushInvoke = null + return + } + + UnifiedPush.registerApp(activity, unifiedPushInstance) + } + + fun handleNewUnifiedPushEndpoint(endpoint: String, instance: String) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) return + + cachedUnifiedPushEndpoint = endpoint + unifiedPushInstance = instance + + val result = JSObject() + result.put("endpoint", endpoint) + result.put("instance", instance) + + pendingUnifiedPushInvoke?.resolve(result) + pendingUnifiedPushInvoke = null + + val data = JSObject() + data.put("endpoint", endpoint) + data.put("instance", instance) + trigger("unifiedpush-endpoint", data) + } + + // Called by TauriUnifiedPushMessagingService when unregistered + fun handleUnifiedPushUnregistered(instance: String) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) return + + cachedUnifiedPushEndpoint = null + + val data = JSObject() + data.put("instance", instance) + trigger("unifiedpush-unregistered", 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) { + 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()) + } + } + trigger("unifiedpush-message", data) + } + + // Called by TauriUnifiedPushMessagingService when registration fails + fun handleUnifiedPushRegistrationFailed(instance: String) { + if (!BuildConfig.ENABLE_UNIFIED_PUSH) return + + val errorMessage = "UnifiedPush registration failed for instance: $instance" + val errorData = JSObject() + errorData.put("message", errorMessage) + errorData.put("instance", instance) + trigger("unifiedpush-error", errorData) + + pendingUnifiedPushInvoke?.reject(errorMessage) + pendingUnifiedPushInvoke = null + } + + 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/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index 7c66790..2776d17 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -444,6 +444,12 @@ 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 = context.applicationInfo.icon + } if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { resId = android.R.drawable.ic_dialog_info } 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..2ecb00e --- /dev/null +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -0,0 +1,136 @@ +package app.tauri.notification + +import android.content.Context +import android.util.Log +import app.tauri.plugin.JSObject +import org.json.JSONArray +import org.json.JSONObject +import org.unifiedpush.android.connector.MessagingReceiver +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" + private val 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 + } + } + + override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + Log.d(TAG, "New endpoint registered: $endpoint") + NotificationPlugin.instance?.handleNewUnifiedPushEndpoint(endpoint, instance) + } + + override fun onUnregistered(context: Context, instance: String) { + Log.d(TAG, "Unregistered for instance: $instance") + NotificationPlugin.instance?.handleUnifiedPushUnregistered(instance) + } + + override fun onMessage(context: Context, message: ByteArray, instance: String) { + Log.d(TAG, "Message received for instance: $instance") + + try { + val messageString = message.toString(Charsets.UTF_8) + + val pushData = mutableMapOf() + try { + val json = JSONObject(messageString) + for (key in json.keys()) { + pushData[key] = 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, 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) { + when (value) { + is String -> extraData.put(key, value) + is Int -> extraData.put(key, value) + is Long -> extraData.put(key, value) + is Double -> extraData.put(key, value) + is Boolean -> extraData.put(key, value) + else -> extraData.put(key, value.toString()) + } + } + val notification = Notification().apply { + id = System.currentTimeMillis().toInt() + this.title = title ?: "" + this.body = body + this.isAutoCancel = true + this.extra = extraData + } + NotificationPlugin.triggerNotification(notification, "unifiedpush") + } + + override fun onRegistrationFailed(context: Context, instance: String) { + Log.e(TAG, "Registration failed for instance: $instance") + NotificationPlugin.instance?.handleUnifiedPushRegistrationFailed(instance) + } + + private 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/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/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/guest-js/index.ts b/guest-js/index.ts index e166d7d..80d7a9f 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. */ @@ -476,8 +477,192 @@ async function unregisterForPushNotifications(): Promise { return await invoke("plugin:notifications|unregister_for_push_notifications"); } +interface UnifiedPushEndpoint { + /** The endpoint URL where push messages should be sent. */ + endpoint: string; + /** The instance identifier for this registration. */ + instance: string; +} + +/** + * 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, + ); +} + /** * Sends a notification to the user. + * @example * ```typescript * import { isPermissionGranted, requestPermission, sendNotification } from '@choochmeque/tauri-plugin-notifications-api'; @@ -751,12 +936,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 +961,7 @@ export type { Channel, ScheduleInterval, NotificationClickedData, + UnifiedPushEndpoint, }; export { @@ -787,6 +972,15 @@ export { isPermissionGranted, registerForPushNotifications, unregisterForPushNotifications, + registerForUnifiedPush, + unregisterFromUnifiedPush, + getUnifiedPushDistributors, + saveUnifiedPushDistributor, + getUnifiedPushDistributor, + onUnifiedPushEndpoint, + onUnifiedPushMessage, + onUnifiedPushUnregistered, + onUnifiedPushError, registerActionTypes, pending, cancel, 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..4c97e6c 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -13,6 +13,11 @@ It allows all notification related features. - `allow-request-permission` - `allow-register-for-push-notifications` - `allow-unregister-for-push-notifications` +- `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` - `allow-notify` - `allow-register-action-types` - `allow-register-listener` @@ -251,6 +256,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 +464,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 +594,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 +692,32 @@ 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. + diff --git a/permissions/default.toml b/permissions/default.toml index c0a3805..cf56884 100644 --- a/permissions/default.toml +++ b/permissions/default.toml @@ -15,6 +15,11 @@ permissions = [ "allow-request-permission", "allow-register-for-push-notifications", "allow-unregister-for-push-notifications", + "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", "allow-notify", "allow-register-action-types", "allow-register-listener", diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index 016923d..0a68326 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", @@ -547,10 +595,22 @@ "markdownDescription": "Denies the unregister_for_push_notifications 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`", + "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-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`\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`" + "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-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`\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`" } ] } 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..441a00a 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -89,6 +89,97 @@ impl Notifications { } } + pub async fn register_for_unified_push(&self) -> crate::Result { + #[cfg(feature = "unified-push")] + { + self.0 + .run_mobile_plugin_async::( + "registerForUnifiedPush", + (), + ) + .await + .map(|r| serde_json::json!({ "endpoint": r.endpoint, "instance": r.instance })) + .map_err(Into::into) + } + #[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(feature = "unified-push")] + { + self.0 + .run_mobile_plugin::<()>("unregisterFromUnifiedPush", ()) + .map_err(Into::into) + } + #[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(feature = "unified-push")] + { + self.0 + .run_mobile_plugin::( + "getUnifiedPushDistributors", + (), + ) + .map(|r| serde_json::json!({ "distributors": r.distributors })) + .map_err(Into::into) + } + #[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(feature = "unified-push")] + { + let mut args = std::collections::HashMap::new(); + args.insert("distributor", distributor); + self.0 + .run_mobile_plugin::<()>("saveUnifiedPushDistributor", args) + .map_err(Into::into) + } + #[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(feature = "unified-push")] + { + self.0 + .run_mobile_plugin::( + "getUnifiedPushDistributor", + (), + ) + .map(|r| serde_json::json!({ "distributor": r.distributor })) + .map_err(Into::into) + } + #[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 +289,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..62dfd27 100644 --- a/src/models.rs +++ b/src/models.rs @@ -18,6 +18,28 @@ pub struct PushNotificationResponse { pub device_token: String, } +#[cfg(feature = "unified-push")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnifiedPushEndpointResponse { + pub endpoint: String, + pub instance: String, +} + +#[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 { From f26e0fa7f60dab0351f7e26fd8cf94d425c6c571 Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Sun, 8 Mar 2026 13:48:23 +0100 Subject: [PATCH 02/24] Move to UnifiedPush v3 --- android/build.gradle.kts | 2 +- android/settings.gradle | 2 -- android/src/main/AndroidManifest.xml | 2 +- .../tauri/notification/NotificationPlugin.kt | 8 ++++---- .../TauriUnifiedPushMessagingService.kt | 19 +++++++++++-------- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index f42f186..d3a8a8d 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -76,7 +76,7 @@ dependencies { implementation(platform("com.google.firebase:firebase-bom:34.7.0")) implementation("com.google.firebase:firebase-messaging-ktx:24.1.2") - api("com.github.UnifiedPush:android-connector:2.4.0") + api("org.unifiedpush.android:connector:3.3.2") testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk-android:1.14.9") diff --git a/android/settings.gradle b/android/settings.gradle index ba62aef..067080a 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -23,8 +23,6 @@ dependencyResolutionManagement { repositories { mavenCentral() google() - maven { url 'https://jitpack.io' } - } } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 4282cfb..2982dbe 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -30,7 +30,7 @@ - + diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 1f9e463..e237604 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -551,7 +551,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { pendingUnifiedPushInvoke = invoke // Register with UnifiedPush - UnifiedPush.registerApp(activity, unifiedPushInstance) + UnifiedPush.register(activity, unifiedPushInstance) } @Command @@ -561,7 +561,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } - UnifiedPush.unregisterApp(activity, unifiedPushInstance) + UnifiedPush.unregister(activity, unifiedPushInstance) cachedUnifiedPushEndpoint = null invoke.resolve() } @@ -606,7 +606,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } - val distributor = UnifiedPush.getDistributor(activity) + val distributor = UnifiedPush.getSavedDistributor(activity) ?: "" val result = JSObject() result.put("distributor", distributor) invoke.resolve(result) @@ -620,7 +620,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } - UnifiedPush.registerApp(activity, unifiedPushInstance) + UnifiedPush.register(activity, unifiedPushInstance) } fun handleNewUnifiedPushEndpoint(endpoint: String, instance: String) { diff --git a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt index 2ecb00e..0fe3481 100644 --- a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -5,7 +5,10 @@ import android.util.Log import app.tauri.plugin.JSObject import org.json.JSONArray 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.Executors /** @@ -32,9 +35,9 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { } } - override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { - Log.d(TAG, "New endpoint registered: $endpoint") - NotificationPlugin.instance?.handleNewUnifiedPushEndpoint(endpoint, instance) + override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { + Log.d(TAG, "New endpoint registered: ${endpoint.url}") + NotificationPlugin.instance?.handleNewUnifiedPushEndpoint(endpoint.url, instance) } override fun onUnregistered(context: Context, instance: String) { @@ -42,11 +45,11 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { NotificationPlugin.instance?.handleUnifiedPushUnregistered(instance) } - override fun onMessage(context: Context, message: ByteArray, instance: String) { + override fun onMessage(context: Context, message: PushMessage, instance: String) { Log.d(TAG, "Message received for instance: $instance") try { - val messageString = message.toString(Charsets.UTF_8) + val messageString = message.content.toString(Charsets.UTF_8) val pushData = mutableMapOf() try { @@ -68,7 +71,7 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { if (handler != null) { executor.execute { try { - val handled = handler.onMessage(context, message, instance) + val handled = handler.onMessage(context, message.content, instance) if (!handled) showFallbackNotification(pushData) } catch (e: Exception) { Log.e(TAG, "Message handler error: ${e.message}", e) @@ -109,8 +112,8 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { NotificationPlugin.triggerNotification(notification, "unifiedpush") } - override fun onRegistrationFailed(context: Context, instance: String) { - Log.e(TAG, "Registration failed for instance: $instance") + override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { + Log.e(TAG, "Registration failed for instance: $instance (reason: $reason)") NotificationPlugin.instance?.handleUnifiedPushRegistrationFailed(instance) } From 15d353615c49a2db5beb6c7b2d3c20725c5f886c Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Sun, 8 Mar 2026 13:56:02 +0100 Subject: [PATCH 03/24] Minor fixes --- README.md | 11 + android/build.gradle.kts | 4 +- android/src/main/AndroidManifest.xml | 3 +- .../tauri/notification/NotificationPlugin.kt | 5 +- .../TauriUnifiedPushMessagingService.kt | 11 +- guest-js/index.test.ts | 223 ++++++++++++++++++ permissions/autogenerated/reference.md | 22 +- permissions/default.toml | 5 - permissions/schemas/schema.json | 10 +- permissions/unified-push.toml | 18 ++ 10 files changed, 293 insertions(+), 19 deletions(-) create mode 100644 permissions/unified-push.toml diff --git a/README.md b/README.md index ba233bd..b3ce5c9 100644 --- a/README.md +++ b/README.md @@ -139,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 diff --git a/android/build.gradle.kts b/android/build.gradle.kts index d3a8a8d..52cc9a4 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -30,6 +30,8 @@ android { val enableUnifiedPush = buildProperties.getProperty("enableUnifiedPush", "false").toBoolean() buildConfigField("boolean", "ENABLE_UNIFIED_PUSH", "$enableUnifiedPush") + + manifestPlaceholders["unifiedPushReceiverEnabled"] = "$enableUnifiedPush" } buildTypes { @@ -76,7 +78,7 @@ dependencies { implementation(platform("com.google.firebase:firebase-bom:34.7.0")) implementation("com.google.firebase:firebase-messaging-ktx:24.1.2") - api("org.unifiedpush.android:connector:3.3.2") + implementation("org.unifiedpush.android:connector:3.3.2") testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk-android:1.14.9") diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 2982dbe..cfbcd2e 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -24,7 +24,8 @@ + android:exported="true" + android:enabled="${unifiedPushReceiverEnabled}"> diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index e237604..374ae18 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -528,6 +528,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { if (!manager.areNotificationsEnabled()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (getPermissionState(LOCAL_NOTIFICATIONS) !== PermissionState.GRANTED) { + pendingUnifiedPushInvoke?.reject("Superseded by a new registration request") pendingUnifiedPushInvoke = invoke requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "unifiedPushPermissionsCallback") return @@ -547,10 +548,8 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } - // Store the invoke to respond later when we get the endpoint + pendingUnifiedPushInvoke?.reject("Superseded by a new registration request") pendingUnifiedPushInvoke = invoke - - // Register with UnifiedPush UnifiedPush.register(activity, unifiedPushInstance) } diff --git a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt index 0fe3481..39c5592 100644 --- a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -103,13 +103,20 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { } } val notification = Notification().apply { - id = System.currentTimeMillis().toInt() + id = (System.nanoTime() % Int.MAX_VALUE).toInt() this.title = title ?: "" this.body = body this.isAutoCancel = true this.extra = extraData } - NotificationPlugin.triggerNotification(notification, "unifiedpush") + + val plugin = NotificationPlugin.instance + if (plugin != null) { + plugin.getNotificationManager().schedule(notification) + NotificationPlugin.triggerNotification(notification, "unifiedpush") + } else { + Log.w(TAG, "NotificationPlugin not initialized, cannot show fallback notification") + } } override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index 60518bf..57d9053 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -19,6 +19,15 @@ import { requestPermission, registerForPushNotifications, unregisterForPushNotifications, + registerForUnifiedPush, + unregisterFromUnifiedPush, + getUnifiedPushDistributors, + saveUnifiedPushDistributor, + getUnifiedPushDistributor, + onUnifiedPushEndpoint, + onUnifiedPushMessage, + onUnifiedPushUnregistered, + onUnifiedPushError, registerActionTypes, pending, cancel, @@ -508,6 +517,220 @@ describe("Notification Functions", () => { }); }); + 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); + }); + }); + + 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("sendNotification", () => { it("should send notification with string title", async () => { mockInvoke.mockResolvedValue(undefined); diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index 4c97e6c..2553d95 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -13,11 +13,6 @@ It allows all notification related features. - `allow-request-permission` - `allow-register-for-push-notifications` - `allow-unregister-for-push-notifications` -- `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` - `allow-notify` - `allow-register-action-types` - `allow-register-listener` @@ -718,6 +713,23 @@ Enables the unregister_from_unified_push command without any pre-configured scop 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/default.toml b/permissions/default.toml index cf56884..c0a3805 100644 --- a/permissions/default.toml +++ b/permissions/default.toml @@ -15,11 +15,6 @@ permissions = [ "allow-request-permission", "allow-register-for-push-notifications", "allow-unregister-for-push-notifications", - "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", "allow-notify", "allow-register-action-types", "allow-register-listener", diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index 0a68326..0271d7c 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -607,10 +607,16 @@ "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-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`\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": "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-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`\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`" + "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", +] + From 3fdf3e748f314e0845cf6fddf2cee9af834fb4f0 Mon Sep 17 00:00:00 2001 From: Tasteless Void Date: Sun, 8 Mar 2026 14:08:16 +0100 Subject: [PATCH 04/24] Update src/mobile.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/mobile.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mobile.rs b/src/mobile.rs index 441a00a..9b67519 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -146,7 +146,7 @@ impl Notifications { pub fn save_unified_push_distributor(&self, distributor: String) -> crate::Result<()> { #[cfg(feature = "unified-push")] { - let mut args = std::collections::HashMap::new(); + let mut args = HashMap::new(); args.insert("distributor", distributor); self.0 .run_mobile_plugin::<()>("saveUnifiedPushDistributor", args) From 7bd435d3a7e1c55e59ce101a3056c2dfba42e55c Mon Sep 17 00:00:00 2001 From: Tasteless Void Date: Sun, 8 Mar 2026 14:08:32 +0100 Subject: [PATCH 05/24] Update android/src/main/java/app/tauri/notification/NotificationPlugin.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../main/java/app/tauri/notification/NotificationPlugin.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 374ae18..1454863 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -615,7 +615,9 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private fun unifiedPushPermissionsCallback(invoke: Invoke) { if (!manager.areNotificationsEnabled()) { invoke.reject("Notification permissions denied") - pendingUnifiedPushInvoke = null + if (pendingUnifiedPushInvoke === invoke) { + pendingUnifiedPushInvoke = null + } return } From aa3db73e2b5bcaa63cd84ff055970b72ee367f54 Mon Sep 17 00:00:00 2001 From: Tasteless Void Date: Sun, 8 Mar 2026 14:14:19 +0100 Subject: [PATCH 06/24] Update android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../app/tauri/notification/TauriUnifiedPushMessagingService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt index 39c5592..1157944 100644 --- a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -121,7 +121,7 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { Log.e(TAG, "Registration failed for instance: $instance (reason: $reason)") - NotificationPlugin.instance?.handleUnifiedPushRegistrationFailed(instance) + NotificationPlugin.instance?.handleUnifiedPushRegistrationFailed(instance, reason.toString()) } private fun jsonValueToNative(value: Any): Any { From c9710f3a05c304a633d265122bacdc714839f6c9 Mon Sep 17 00:00:00 2001 From: Tasteless Void Date: Sun, 8 Mar 2026 14:14:53 +0100 Subject: [PATCH 07/24] Update android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../tauri/notification/TauriUnifiedPushMessagingService.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt index 1157944..52fbd59 100644 --- a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -124,6 +124,11 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { NotificationPlugin.instance?.handleUnifiedPushRegistrationFailed(instance, reason.toString()) } + override fun onRegistrationRefused(context: Context, instance: String) { + Log.e(TAG, "Registration refused for instance: $instance") + // Treat refused registrations as failures for the JS layer. + NotificationPlugin.instance?.handleUnifiedPushRegistrationFailed(instance) + } private fun jsonValueToNative(value: Any): Any { return when (value) { is JSONObject -> { From 237ea4eb7fb0cf85a2462dbab4209af43280cf4e Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Mon, 9 Mar 2026 14:41:10 +0100 Subject: [PATCH 08/24] Added tests and locked UnifiedPush to Android --- android/build.gradle.kts | 5 +- .../tauri/notification/NotificationPlugin.kt | 4 + .../notification/TauriNotificationManager.kt | 3 - .../NotificationPluginUnifiedPushTest.kt | 359 +++++++++++++++++ .../TauriUnifiedPushMessagingServiceTest.kt | 365 ++++++++++++++++++ src/mobile.rs | 41 +- 6 files changed, 768 insertions(+), 9 deletions(-) create mode 100644 android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt create mode 100644 android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 52cc9a4..c7f6d75 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -78,8 +78,11 @@ 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") + if (buildProperties.getProperty("enableUnifiedPush", "false").toBoolean()) { + implementation("org.unifiedpush.android:connector:3.3.2") + } + testImplementation("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/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 374ae18..a43f3e2 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -560,6 +560,10 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } + // Reject any pending registration invoke to prevent the JS caller from hanging + pendingUnifiedPushInvoke?.reject("Unregistration requested while registration was in progress") + pendingUnifiedPushInvoke = null + UnifiedPush.unregister(activity, unifiedPushInstance) cachedUnifiedPushEndpoint = null invoke.resolve() diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index 2776d17..efbaadd 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -447,9 +447,6 @@ class TauriNotificationManager( if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { resId = context.resources.getIdentifier("ic_notification", "drawable", context.packageName) } - if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { - resId = context.applicationInfo.icon - } if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { resId = android.R.drawable.ic_dialog_info } 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..50422a6 --- /dev/null +++ b/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt @@ -0,0 +1,359 @@ +package app.tauri.notification + +import app.tauri.plugin.Invoke +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 + +/** + * Tests for the UnifiedPush-related behaviors in NotificationPlugin, + * focusing on handleNewUnifiedPushEndpoint, handleUnifiedPushRegistrationFailed, + * handleUnifiedPushUnregistered, and the pendingUnifiedPushInvoke lifecycle. + */ +@RunWith(RobolectricTestRunner::class) +class NotificationPluginUnifiedPushTest { + + private lateinit var mockInvoke: Invoke + private lateinit var mockInvoke2: Invoke + + @Before + fun setup() { + mockInvoke = mockk(relaxed = true) + mockInvoke2 = mockk(relaxed = true) + } + + // --- handleNewUnifiedPushEndpoint tests --- + + @Test + fun testHandleNewUnifiedPushEndpoint_resolvesData() { + // Test that endpoint and instance data is correctly structured + val result = JSObject() + result.put("endpoint", "https://push.example.com/abc") + result.put("instance", "test-instance") + + assertEquals("https://push.example.com/abc", result.getString("endpoint")) + assertEquals("test-instance", result.getString("instance")) + } + + @Test + fun testHandleNewUnifiedPushEndpoint_endpointContainsUrl() { + val data = JSObject() + data.put("endpoint", "https://push.example.com/endpoint/12345") + data.put("instance", "default") + + assertTrue(data.getString("endpoint")!!.startsWith("https://")) + assertEquals("default", data.getString("instance")) + } + + // --- handleUnifiedPushRegistrationFailed tests --- + + @Test + fun testHandleUnifiedPushRegistrationFailed_errorDataStructure() { + val instance = "test-instance" + val errorMessage = "UnifiedPush registration failed for instance: $instance" + val errorData = JSObject() + errorData.put("message", errorMessage) + errorData.put("instance", instance) + + assertTrue(errorData.getString("message")!!.contains("registration failed")) + assertEquals("test-instance", errorData.getString("instance")) + } + + // --- handleUnifiedPushUnregistered tests --- + + @Test + fun testHandleUnifiedPushUnregistered_dataStructure() { + val data = JSObject() + data.put("instance", "test-instance") + + assertEquals("test-instance", data.getString("instance")) + } + + // --- Pending invoke lifecycle tests --- + + @Test + fun testPendingInvoke_rejectedWhenNewRegistrationRequested() { + // Simulates the behavior where a new registerForUnifiedPush call + // rejects the previous pending invoke + val firstInvoke = mockInvoke + val secondInvoke = mockInvoke2 + + // First registration stores pendingUnifiedPushInvoke + var pendingUnifiedPushInvoke: Invoke? = firstInvoke + + // Second registration should reject the first + pendingUnifiedPushInvoke?.reject("Superseded by a new registration request") + pendingUnifiedPushInvoke = secondInvoke + + verify { firstInvoke.reject("Superseded by a new registration request") } + assertSame(secondInvoke, pendingUnifiedPushInvoke) + } + + @Test + fun testPendingInvoke_rejectedOnUnregister() { + // Simulates the fix: unregisterFromUnifiedPush rejects pending invoke + var pendingUnifiedPushInvoke: Invoke? = mockInvoke + + // Unregister should reject pending invoke + pendingUnifiedPushInvoke?.reject("Unregistration requested while registration was in progress") + pendingUnifiedPushInvoke = null + + verify { mockInvoke.reject("Unregistration requested while registration was in progress") } + assertNull(pendingUnifiedPushInvoke) + } + + @Test + fun testPendingInvoke_nullWhenNoRegistrationInProgress() { + // Unregister with no pending invoke should not crash + var pendingUnifiedPushInvoke: Invoke? = null + + pendingUnifiedPushInvoke?.reject("Unregistration requested while registration was in progress") + pendingUnifiedPushInvoke = null + + // No verification needed - just ensuring no NPE + assertNull(pendingUnifiedPushInvoke) + } + + @Test + fun testPendingInvoke_resolvedOnNewEndpoint() { + // Simulates handleNewUnifiedPushEndpoint resolving the pending invoke + var pendingUnifiedPushInvoke: Invoke? = mockInvoke + + val result = JSObject() + result.put("endpoint", "https://push.example.com/abc") + result.put("instance", "default") + + pendingUnifiedPushInvoke?.resolve(result) + pendingUnifiedPushInvoke = null + + verify { mockInvoke.resolve(match { + it.getString("endpoint") == "https://push.example.com/abc" && + it.getString("instance") == "default" + }) } + assertNull(pendingUnifiedPushInvoke) + } + + @Test + fun testPendingInvoke_rejectedOnRegistrationFailed() { + // Simulates handleUnifiedPushRegistrationFailed rejecting the pending invoke + var pendingUnifiedPushInvoke: Invoke? = mockInvoke + val instance = "test-instance" + val errorMessage = "UnifiedPush registration failed for instance: $instance" + + pendingUnifiedPushInvoke?.reject(errorMessage) + pendingUnifiedPushInvoke = null + + verify { mockInvoke.reject(match { it.contains("registration failed") }) } + assertNull(pendingUnifiedPushInvoke) + } + + // --- 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" + ) + + 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) + else -> data.put(key, value.toString()) + } + } + + assertEquals("Test Title", data.getString("title")) + assertEquals("Test Body", data.getString("body")) + assertEquals("default", data.getString("instance")) + assertEquals("unifiedpush", data.getString("source")) + } + + @Test + fun testTriggerUnifiedPushMessage_mapsNumericValues() { + val pushData = mapOf( + "count" to 42, + "timestamp" to 1234567890L, + "ratio" to 3.14 + ) + + 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) + else -> data.put(key, value.toString()) + } + } + + assertEquals(42, data.getInteger("count")) + assertEquals(1234567890L, data.getLong("timestamp")) + assertEquals(3.14, data.getDouble("ratio"), 0.001) + } + + @Test + fun testTriggerUnifiedPushMessage_mapsBooleanValues() { + val pushData = mapOf( + "read" to true, + "archived" to false + ) + + val data = JSObject() + for ((key, value) in pushData) { + when (value) { + is Boolean -> data.put(key, value) + else -> data.put(key, value.toString()) + } + } + + assertTrue(data.getBoolean("read")) + assertFalse(data.getBoolean("archived")) + } + + @Test + fun testTriggerUnifiedPushMessage_mapsNestedObjects() { + val nestedMap = mapOf("innerKey" to "innerValue", "innerNum" to "99") + val pushData = mapOf( + "nested" to nestedMap + ) + + val data = JSObject() + for ((key, value) in pushData) { + when (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()) + } + } + + val nested = data.getJSObject("nested") + assertNotNull(nested) + assertEquals("innerValue", nested!!.getString("innerKey")) + assertEquals("99", nested.getString("innerNum")) + } + + // --- Cached endpoint behavior tests --- + + @Test + fun testCachedEndpoint_clearedOnUnregister() { + var cachedUnifiedPushEndpoint: String? = "https://push.example.com/cached" + + // Simulate unregister + cachedUnifiedPushEndpoint = null + + assertNull(cachedUnifiedPushEndpoint) + } + + @Test + fun testCachedEndpoint_updatedOnNewEndpoint() { + var cachedUnifiedPushEndpoint: String? = null + var unifiedPushInstance = "default" + + // Simulate new endpoint + cachedUnifiedPushEndpoint = "https://push.example.com/new-endpoint" + unifiedPushInstance = "new-instance" + + assertEquals("https://push.example.com/new-endpoint", cachedUnifiedPushEndpoint) + assertEquals("new-instance", unifiedPushInstance) + } + + @Test + fun testCachedEndpoint_returnedImmediatelyIfAvailable() { + val cachedUnifiedPushEndpoint: String? = "https://push.example.com/cached" + val unifiedPushInstance = "cached-instance" + + // If cached endpoint exists, resolve immediately + if (cachedUnifiedPushEndpoint != null) { + val result = JSObject() + result.put("endpoint", cachedUnifiedPushEndpoint) + result.put("instance", unifiedPushInstance) + + assertEquals("https://push.example.com/cached", result.getString("endpoint")) + assertEquals("cached-instance", result.getString("instance")) + } else { + fail("Cached endpoint should not be null in this test") + } + } + + // --- 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() { + val distributor: String? = null + + if (distributor == null) { + // Should reject with "Distributor parameter is required" + assertTrue(true) + } else { + fail("Distributor should be null in this test case") + } + } +} + 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..28336e0 --- /dev/null +++ b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt @@ -0,0 +1,365 @@ +package app.tauri.notification + +import android.content.Context +import app.tauri.plugin.JSObject +import io.mockk.* +import org.json.JSONObject +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.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +@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) + } + + // --- 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" + }) } + } + + @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()) } + } + + @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" + }) } + } + + // --- 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") + + // Wait for executor to finish + Thread.sleep(200) + + verify { handler.onMessage(mockContext, any(), "default") } + // Fallback should NOT be called since handler returned true + verify(exactly = 0) { mockManager.schedule(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") + + // Wait for executor to finish + Thread.sleep(200) + + 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" + }) } + } + + @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") + + // Wait for executor to finish + Thread.sleep(200) + + // Fallback SHOULD be called since handler threw exception + verify { mockManager.schedule(match { + it.title == "Error" && it.body == "Fallback on error" + }) } + } + + // --- onNewEndpoint tests --- + + @Test + fun testOnNewEndpoint_forwardsToPlugin() { + val endpoint = mockk() + every { endpoint.url } returns "https://push.example.com/endpoint/abc123" + + service.onNewEndpoint(mockContext, endpoint, "test-instance") + + verify { mockPlugin.handleNewUnifiedPushEndpoint("https://push.example.com/endpoint/abc123", "test-instance") } + } + + // --- onRegistrationFailed tests --- + + @Test + fun testOnRegistrationFailed_forwardsToPlugin() { + service.onRegistrationFailed(mockContext, FailedReason.NETWORK, "test-instance") + + verify { mockPlugin.handleUnifiedPushRegistrationFailed("test-instance") } + } + + // --- 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()) } + } + + // --- 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" + + // Should not throw + service.onNewEndpoint(mockContext, endpoint, "test-instance") + } +} + diff --git a/src/mobile.rs b/src/mobile.rs index 441a00a..8943e4e 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -90,7 +90,7 @@ impl Notifications { } pub async fn register_for_unified_push(&self) -> crate::Result { - #[cfg(feature = "unified-push")] + #[cfg(all(feature = "unified-push", target_os = "android"))] { self.0 .run_mobile_plugin_async::( @@ -101,6 +101,12 @@ impl Notifications { .map(|r| serde_json::json!({ "endpoint": r.endpoint, "instance": r.instance })) .map_err(Into::into) } + #[cfg(all(feature = "unified-push", target_os = "ios"))] + { + 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( @@ -110,12 +116,18 @@ impl Notifications { } pub fn unregister_from_unified_push(&self) -> crate::Result<()> { - #[cfg(feature = "unified-push")] + #[cfg(all(feature = "unified-push", target_os = "android"))] { self.0 .run_mobile_plugin::<()>("unregisterFromUnifiedPush", ()) .map_err(Into::into) } + #[cfg(all(feature = "unified-push", target_os = "ios"))] + { + 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( @@ -125,7 +137,7 @@ impl Notifications { } pub fn get_unified_push_distributors(&self) -> crate::Result { - #[cfg(feature = "unified-push")] + #[cfg(all(feature = "unified-push", target_os = "android"))] { self.0 .run_mobile_plugin::( @@ -135,6 +147,12 @@ impl Notifications { .map(|r| serde_json::json!({ "distributors": r.distributors })) .map_err(Into::into) } + #[cfg(all(feature = "unified-push", target_os = "ios"))] + { + 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( @@ -144,7 +162,7 @@ impl Notifications { } pub fn save_unified_push_distributor(&self, distributor: String) -> crate::Result<()> { - #[cfg(feature = "unified-push")] + #[cfg(all(feature = "unified-push", target_os = "android"))] { let mut args = std::collections::HashMap::new(); args.insert("distributor", distributor); @@ -152,6 +170,13 @@ impl Notifications { .run_mobile_plugin::<()>("saveUnifiedPushDistributor", args) .map_err(Into::into) } + #[cfg(all(feature = "unified-push", target_os = "ios"))] + { + let _ = distributor; + Err(crate::Error::Io(std::io::Error::other( + "UnifiedPush is only supported on Android", + ))) + } #[cfg(not(feature = "unified-push"))] { let _ = distributor; @@ -162,7 +187,7 @@ impl Notifications { } pub fn get_unified_push_distributor(&self) -> crate::Result { - #[cfg(feature = "unified-push")] + #[cfg(all(feature = "unified-push", target_os = "android"))] { self.0 .run_mobile_plugin::( @@ -172,6 +197,12 @@ impl Notifications { .map(|r| serde_json::json!({ "distributor": r.distributor })) .map_err(Into::into) } + #[cfg(all(feature = "unified-push", target_os = "ios"))] + { + 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( From 2753478c033ba4b206b744c06c76bd7d16fe7960 Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Mon, 9 Mar 2026 14:42:16 +0100 Subject: [PATCH 09/24] Added tests and locked UnifiedPush to Android --- guest-js/index.test.ts | 28 +++++++++++++++++++++++----- guest-js/index.ts | 22 ++++++---------------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index 57d9053..d002fb2 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -519,7 +519,10 @@ describe("Notification Functions", () => { describe("registerForUnifiedPush", () => { it("should call invoke with correct plugin command", async () => { - const mockEndpoint = { endpoint: "https://example.com/push", instance: "default" }; + const mockEndpoint = { + endpoint: "https://example.com/push", + instance: "default", + }; mockInvoke.mockResolvedValue(mockEndpoint); const result = await registerForUnifiedPush(); @@ -545,7 +548,12 @@ describe("Notification Functions", () => { describe("getUnifiedPushDistributors", () => { it("should return the list of distributors", async () => { - const mockDistributors = { distributors: ["org.unifiedpush.distributor.nextpush", "io.heckel.ntfy"] }; + const mockDistributors = { + distributors: [ + "org.unifiedpush.distributor.nextpush", + "io.heckel.ntfy", + ], + }; mockInvoke.mockResolvedValue(mockDistributors); const result = await getUnifiedPushDistributors(); @@ -580,7 +588,9 @@ describe("Notification Functions", () => { describe("getUnifiedPushDistributor", () => { it("should return the current distributor", async () => { - const mockDistributor = { distributor: "org.unifiedpush.distributor.nextpush" }; + const mockDistributor = { + distributor: "org.unifiedpush.distributor.nextpush", + }; mockInvoke.mockResolvedValue(mockDistributor); const result = await getUnifiedPushDistributor(); @@ -626,7 +636,10 @@ describe("Notification Functions", () => { const callback = vi.fn(); await onUnifiedPushEndpoint(callback); - const endpointData = { endpoint: "https://example.com/push", instance: "default" }; + const endpointData = { + endpoint: "https://example.com/push", + instance: "default", + }; capturedCallback?.(endpointData); expect(callback).toHaveBeenCalledWith(endpointData); @@ -659,7 +672,12 @@ describe("Notification Functions", () => { const callback = vi.fn(); await onUnifiedPushMessage(callback); - const messageData = { title: "Hello", body: "World", instance: "default", source: "unifiedpush" }; + const messageData = { + title: "Hello", + body: "World", + instance: "default", + source: "unifiedpush", + }; capturedCallback?.(messageData); expect(callback).toHaveBeenCalledWith(messageData); diff --git a/guest-js/index.ts b/guest-js/index.ts index 80d7a9f..fae66ab 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -527,7 +527,9 @@ async function unregisterFromUnifiedPush(): Promise { * * @returns A promise resolving to an object with a distributors array. */ -async function getUnifiedPushDistributors(): Promise<{ distributors: string[] }> { +async function getUnifiedPushDistributors(): Promise<{ + distributors: string[]; +}> { return await invoke("plugin:notifications|get_unified_push_distributors"); } @@ -581,11 +583,7 @@ async function getUnifiedPushDistributor(): Promise<{ distributor: string }> { async function onUnifiedPushEndpoint( cb: (data: UnifiedPushEndpoint) => void, ): Promise { - return await addPluginListener( - "notifications", - "unifiedpush-endpoint", - cb, - ); + return await addPluginListener("notifications", "unifiedpush-endpoint", cb); } /** @@ -605,11 +603,7 @@ async function onUnifiedPushEndpoint( async function onUnifiedPushMessage( cb: (data: Record) => void, ): Promise { - return await addPluginListener( - "notifications", - "unifiedpush-message", - cb, - ); + return await addPluginListener("notifications", "unifiedpush-message", cb); } /** @@ -653,11 +647,7 @@ async function onUnifiedPushUnregistered( async function onUnifiedPushError( cb: (data: { message: string; instance?: string }) => void, ): Promise { - return await addPluginListener( - "notifications", - "unifiedpush-error", - cb, - ); + return await addPluginListener("notifications", "unifiedpush-error", cb); } /** From 21a1b0c5a7882ce2b84770fa40f691b30e84923d Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Mon, 9 Mar 2026 14:56:17 +0100 Subject: [PATCH 10/24] Fixed compiling errors --- .../java/app/tauri/notification/NotificationPlugin.kt | 8 ++++++-- .../notification/TauriUnifiedPushMessagingService.kt | 5 ----- .../notification/TauriUnifiedPushMessagingServiceTest.kt | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index f5abe02..8a2b571 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -686,10 +686,14 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } // Called by TauriUnifiedPushMessagingService when registration fails - fun handleUnifiedPushRegistrationFailed(instance: String) { + fun handleUnifiedPushRegistrationFailed(instance: String, reason: String? = null) { if (!BuildConfig.ENABLE_UNIFIED_PUSH) return - val errorMessage = "UnifiedPush registration failed for instance: $instance" + 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) diff --git a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt index 52fbd59..1157944 100644 --- a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -124,11 +124,6 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { NotificationPlugin.instance?.handleUnifiedPushRegistrationFailed(instance, reason.toString()) } - override fun onRegistrationRefused(context: Context, instance: String) { - Log.e(TAG, "Registration refused for instance: $instance") - // Treat refused registrations as failures for the JS layer. - NotificationPlugin.instance?.handleUnifiedPushRegistrationFailed(instance) - } private fun jsonValueToNative(value: Any): Any { return when (value) { is JSONObject -> { diff --git a/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt index 28336e0..d390357 100644 --- a/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt +++ b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt @@ -299,7 +299,7 @@ class TauriUnifiedPushMessagingServiceTest { fun testOnRegistrationFailed_forwardsToPlugin() { service.onRegistrationFailed(mockContext, FailedReason.NETWORK, "test-instance") - verify { mockPlugin.handleUnifiedPushRegistrationFailed("test-instance") } + verify { mockPlugin.handleUnifiedPushRegistrationFailed("test-instance", FailedReason.NETWORK.toString()) } } // --- onUnregistered tests --- From 61a4d8389690aa5a650e3a05b6bf21f2a0ab14db Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Mon, 9 Mar 2026 15:12:59 +0100 Subject: [PATCH 11/24] Do not import UnifiedPush unconditionally or the CI/Unit test fail --- android/build.gradle.kts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index c7f6d75..52cc9a4 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -78,11 +78,8 @@ dependencies { implementation(platform("com.google.firebase:firebase-bom:34.7.0")) implementation("com.google.firebase:firebase-messaging-ktx:24.1.2") - if (buildProperties.getProperty("enableUnifiedPush", "false").toBoolean()) { - implementation("org.unifiedpush.android:connector:3.3.2") - } + implementation("org.unifiedpush.android:connector:3.3.2") - testImplementation("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") From 3d55514246037665192103dc0feeeffafda00557 Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Mon, 9 Mar 2026 15:40:51 +0100 Subject: [PATCH 12/24] Implement review suggestions --- .../tauri/notification/NotificationPlugin.kt | 168 +++++--- .../TauriUnifiedPushMessagingService.kt | 72 +++- .../NotificationPluginUnifiedPushTest.kt | 404 ++++++++++-------- .../TauriUnifiedPushMessagingServiceTest.kt | 10 +- guest-js/index.ts | 2 +- 5 files changed, 401 insertions(+), 255 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 8a2b571..1584ba1 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -95,10 +95,16 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private var pendingTokenInvoke: Invoke? = null private var cachedToken: String? = null + @Volatile private var pendingUnifiedPushInvoke: Invoke? = null + @Volatile private var cachedUnifiedPushEndpoint: String? = null + @Volatile 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 @@ -473,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) } @@ -528,8 +518,10 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { if (!manager.areNotificationsEnabled()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (getPermissionState(LOCAL_NOTIFICATIONS) !== PermissionState.GRANTED) { - pendingUnifiedPushInvoke?.reject("Superseded by a new registration request") - pendingUnifiedPushInvoke = invoke + synchronized(unifiedPushLock) { + pendingUnifiedPushInvoke?.reject("Superseded by a new registration request") + pendingUnifiedPushInvoke = invoke + } requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "unifiedPushPermissionsCallback") return } @@ -539,17 +531,19 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } } - // If we already have a cached endpoint, return it immediately - cachedUnifiedPushEndpoint?.let { - val result = JSObject() - result.put("endpoint", it) - result.put("instance", unifiedPushInstance) - invoke.resolve(result) - 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) + invoke.resolve(result) + return + } - pendingUnifiedPushInvoke?.reject("Superseded by a new registration request") - pendingUnifiedPushInvoke = invoke + pendingUnifiedPushInvoke?.reject("Superseded by a new registration request") + pendingUnifiedPushInvoke = invoke + } UnifiedPush.register(activity, unifiedPushInstance) } @@ -560,12 +554,14 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } - // Reject any pending registration invoke to prevent the JS caller from hanging - pendingUnifiedPushInvoke?.reject("Unregistration requested while registration was in progress") - pendingUnifiedPushInvoke = null + 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 + } UnifiedPush.unregister(activity, unifiedPushInstance) - cachedUnifiedPushEndpoint = null invoke.resolve() } @@ -619,8 +615,10 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private fun unifiedPushPermissionsCallback(invoke: Invoke) { if (!manager.areNotificationsEnabled()) { invoke.reject("Notification permissions denied") - if (pendingUnifiedPushInvoke === invoke) { - pendingUnifiedPushInvoke = null + synchronized(unifiedPushLock) { + if (pendingUnifiedPushInvoke === invoke) { + pendingUnifiedPushInvoke = null + } } return } @@ -631,15 +629,19 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { fun handleNewUnifiedPushEndpoint(endpoint: String, instance: String) { if (!BuildConfig.ENABLE_UNIFIED_PUSH) return - cachedUnifiedPushEndpoint = endpoint - unifiedPushInstance = instance + val pendingInvoke: Invoke? + synchronized(unifiedPushLock) { + cachedUnifiedPushEndpoint = endpoint + unifiedPushInstance = instance + pendingInvoke = pendingUnifiedPushInvoke + pendingUnifiedPushInvoke = null + } val result = JSObject() result.put("endpoint", endpoint) result.put("instance", instance) - pendingUnifiedPushInvoke?.resolve(result) - pendingUnifiedPushInvoke = null + pendingInvoke?.resolve(result) val data = JSObject() data.put("endpoint", endpoint) @@ -651,7 +653,9 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { fun handleUnifiedPushUnregistered(instance: String) { if (!BuildConfig.ENABLE_UNIFIED_PUSH) return - cachedUnifiedPushEndpoint = null + synchronized(unifiedPushLock) { + cachedUnifiedPushEndpoint = null + } val data = JSObject() data.put("instance", instance) @@ -664,23 +668,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("unifiedpush-message", data) } @@ -699,8 +687,70 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { errorData.put("instance", instance) trigger("unifiedpush-error", errorData) - pendingUnifiedPushInvoke?.reject(errorMessage) - pendingUnifiedPushInvoke = null + 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]. + * Handles String, Int, Long, Double, Boolean, Map, and List types. + */ + private 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 converts a [List] (from JSON array parsing) into a [JSArray], + * properly handling nested maps, lists, and primitive types. + */ + private 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(org.json.JSONObject.NULL) + else -> arr.put(item.toString()) + } + } + return arr } fun getNotificationManager(): TauriNotificationManager { diff --git a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt index 1157944..e072266 100644 --- a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -2,6 +2,7 @@ package app.tauri.notification import android.content.Context import android.util.Log +import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject import org.json.JSONArray import org.json.JSONObject @@ -9,6 +10,7 @@ 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 /** @@ -19,7 +21,7 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { companion object { private const val TAG = "TauriUnifiedPush" - private val executor = Executors.newSingleThreadExecutor() + private var executor: Executor = Executors.newSingleThreadExecutor() @Volatile private var messageHandler: UnifiedPushMessageHandler? = null @@ -33,6 +35,16 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { 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. + */ + @JvmStatic + fun setExecutorForTesting(testExecutor: Executor) { + executor = testExecutor + } } override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { @@ -93,14 +105,7 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { val extraData = JSObject() for ((key, value) in pushData) { - when (value) { - is String -> extraData.put(key, value) - is Int -> extraData.put(key, value) - is Long -> extraData.put(key, value) - is Double -> extraData.put(key, value) - is Boolean -> extraData.put(key, value) - else -> extraData.put(key, value.toString()) - } + putValueToJSObject(extraData, key, value) } val notification = Notification().apply { id = (System.nanoTime() % Int.MAX_VALUE).toInt() @@ -143,4 +148,53 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { else -> value } } + + private 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()) + } + } + + private 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(org.json.JSONObject.NULL) + else -> arr.put(item.toString()) + } + } + return arr + } } diff --git a/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt b/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt index 50422a6..1e7f063 100644 --- a/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt +++ b/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt @@ -1,154 +1,189 @@ 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.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, - * focusing on handleNewUnifiedPushEndpoint, handleUnifiedPushRegistrationFailed, - * handleUnifiedPushUnregistered, and the pendingUnifiedPushInvoke lifecycle. + * 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() { + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + plugin = NotificationPlugin(activity) + NotificationPlugin.instance = plugin + mockInvoke = mockk(relaxed = true) mockInvoke2 = mockk(relaxed = true) } - // --- handleNewUnifiedPushEndpoint tests --- + @After + fun teardown() { + NotificationPlugin.instance = null + } - @Test - fun testHandleNewUnifiedPushEndpoint_resolvesData() { - // Test that endpoint and instance data is correctly structured - val result = JSObject() - result.put("endpoint", "https://push.example.com/abc") - result.put("instance", "test-instance") + // --- Helper methods to access private fields via reflection --- - assertEquals("https://push.example.com/abc", result.getString("endpoint")) - assertEquals("test-instance", result.getString("instance")) + private fun setPendingUnifiedPushInvoke(invoke: Invoke?) { + val field = NotificationPlugin::class.java.getDeclaredField("pendingUnifiedPushInvoke") + field.isAccessible = true + field.set(plugin, invoke) } - @Test - fun testHandleNewUnifiedPushEndpoint_endpointContainsUrl() { - val data = JSObject() - data.put("endpoint", "https://push.example.com/endpoint/12345") - data.put("instance", "default") + private fun getPendingUnifiedPushInvoke(): Invoke? { + val field = NotificationPlugin::class.java.getDeclaredField("pendingUnifiedPushInvoke") + field.isAccessible = true + return field.get(plugin) as? Invoke + } - assertTrue(data.getString("endpoint")!!.startsWith("https://")) - assertEquals("default", data.getString("instance")) + private fun setCachedUnifiedPushEndpoint(endpoint: String?) { + val field = NotificationPlugin::class.java.getDeclaredField("cachedUnifiedPushEndpoint") + field.isAccessible = true + field.set(plugin, endpoint) } - // --- handleUnifiedPushRegistrationFailed tests --- + private fun getCachedUnifiedPushEndpoint(): String? { + val field = NotificationPlugin::class.java.getDeclaredField("cachedUnifiedPushEndpoint") + field.isAccessible = true + return field.get(plugin) as? String + } - @Test - fun testHandleUnifiedPushRegistrationFailed_errorDataStructure() { - val instance = "test-instance" - val errorMessage = "UnifiedPush registration failed for instance: $instance" - val errorData = JSObject() - errorData.put("message", errorMessage) - errorData.put("instance", instance) + private fun setUnifiedPushInstance(instance: String) { + val field = NotificationPlugin::class.java.getDeclaredField("unifiedPushInstance") + field.isAccessible = true + field.set(plugin, instance) + } - assertTrue(errorData.getString("message")!!.contains("registration failed")) - assertEquals("test-instance", errorData.getString("instance")) + private fun getUnifiedPushInstance(): String { + val field = NotificationPlugin::class.java.getDeclaredField("unifiedPushInstance") + field.isAccessible = true + return field.get(plugin) as String } - // --- handleUnifiedPushUnregistered tests --- + // --- handleNewUnifiedPushEndpoint tests --- @Test - fun testHandleUnifiedPushUnregistered_dataStructure() { - val data = JSObject() - data.put("instance", "test-instance") + fun testHandleNewUnifiedPushEndpoint_resolvesPendingInvoke() { + setPendingUnifiedPushInvoke(mockInvoke) - assertEquals("test-instance", data.getString("instance")) + 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()) } - // --- Pending invoke lifecycle tests --- + @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 testPendingInvoke_rejectedWhenNewRegistrationRequested() { - // Simulates the behavior where a new registerForUnifiedPush call - // rejects the previous pending invoke - val firstInvoke = mockInvoke - val secondInvoke = mockInvoke2 + 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()) + } - // First registration stores pendingUnifiedPushInvoke - var pendingUnifiedPushInvoke: Invoke? = firstInvoke + // --- handleUnifiedPushRegistrationFailed tests --- + + @Test + fun testHandleUnifiedPushRegistrationFailed_rejectsPendingInvoke() { + setPendingUnifiedPushInvoke(mockInvoke) - // Second registration should reject the first - pendingUnifiedPushInvoke?.reject("Superseded by a new registration request") - pendingUnifiedPushInvoke = secondInvoke + plugin.handleUnifiedPushRegistrationFailed("test-instance", "NETWORK") - verify { firstInvoke.reject("Superseded by a new registration request") } - assertSame(secondInvoke, pendingUnifiedPushInvoke) + verify { mockInvoke.reject(match { + it.contains("registration failed") && it.contains("test-instance") && it.contains("NETWORK") + }) } + assertNull(getPendingUnifiedPushInvoke()) } @Test - fun testPendingInvoke_rejectedOnUnregister() { - // Simulates the fix: unregisterFromUnifiedPush rejects pending invoke - var pendingUnifiedPushInvoke: Invoke? = mockInvoke + fun testHandleUnifiedPushRegistrationFailed_noPendingInvoke_doesNotCrash() { + setPendingUnifiedPushInvoke(null) - // Unregister should reject pending invoke - pendingUnifiedPushInvoke?.reject("Unregistration requested while registration was in progress") - pendingUnifiedPushInvoke = null + // Should not throw + plugin.handleUnifiedPushRegistrationFailed("test-instance") - verify { mockInvoke.reject("Unregistration requested while registration was in progress") } - assertNull(pendingUnifiedPushInvoke) + assertNull(getPendingUnifiedPushInvoke()) } @Test - fun testPendingInvoke_nullWhenNoRegistrationInProgress() { - // Unregister with no pending invoke should not crash - var pendingUnifiedPushInvoke: Invoke? = null + fun testHandleUnifiedPushRegistrationFailed_withoutReason() { + setPendingUnifiedPushInvoke(mockInvoke) - pendingUnifiedPushInvoke?.reject("Unregistration requested while registration was in progress") - pendingUnifiedPushInvoke = null + plugin.handleUnifiedPushRegistrationFailed("test-instance") - // No verification needed - just ensuring no NPE - assertNull(pendingUnifiedPushInvoke) + verify { mockInvoke.reject(match { + it.contains("registration failed") && it.contains("test-instance") && !it.contains("reason") + }) } } + // --- handleUnifiedPushUnregistered tests --- + @Test - fun testPendingInvoke_resolvedOnNewEndpoint() { - // Simulates handleNewUnifiedPushEndpoint resolving the pending invoke - var pendingUnifiedPushInvoke: Invoke? = mockInvoke + fun testHandleUnifiedPushUnregistered_clearsCachedEndpoint() { + setCachedUnifiedPushEndpoint("https://push.example.com/cached") - val result = JSObject() - result.put("endpoint", "https://push.example.com/abc") - result.put("instance", "default") + plugin.handleUnifiedPushUnregistered("test-instance") - pendingUnifiedPushInvoke?.resolve(result) - pendingUnifiedPushInvoke = null + assertNull(getCachedUnifiedPushEndpoint()) + } - verify { mockInvoke.resolve(match { - it.getString("endpoint") == "https://push.example.com/abc" && - it.getString("instance") == "default" - }) } - assertNull(pendingUnifiedPushInvoke) + // --- 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 testPendingInvoke_rejectedOnRegistrationFailed() { - // Simulates handleUnifiedPushRegistrationFailed rejecting the pending invoke - var pendingUnifiedPushInvoke: Invoke? = mockInvoke - val instance = "test-instance" - val errorMessage = "UnifiedPush registration failed for instance: $instance" + fun testRegistrationFailed_rejectsPending_thenClearsIt() { + setPendingUnifiedPushInvoke(mockInvoke) - pendingUnifiedPushInvoke?.reject(errorMessage) - pendingUnifiedPushInvoke = null + plugin.handleUnifiedPushRegistrationFailed("default", "TIMEOUT") - verify { mockInvoke.reject(match { it.contains("registration failed") }) } - assertNull(pendingUnifiedPushInvoke) + verify { mockInvoke.reject(any()) } + assertNull(getPendingUnifiedPushInvoke()) } // --- triggerUnifiedPushMessage data mapping tests --- @@ -162,22 +197,9 @@ class NotificationPluginUnifiedPushTest { "source" to "unifiedpush" ) - 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) - else -> data.put(key, value.toString()) - } - } - - assertEquals("Test Title", data.getString("title")) - assertEquals("Test Body", data.getString("body")) - assertEquals("default", data.getString("instance")) - assertEquals("unifiedpush", data.getString("source")) + // 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 @@ -188,21 +210,7 @@ class NotificationPluginUnifiedPushTest { "ratio" to 3.14 ) - 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) - else -> data.put(key, value.toString()) - } - } - - assertEquals(42, data.getInteger("count")) - assertEquals(1234567890L, data.getLong("timestamp")) - assertEquals(3.14, data.getDouble("ratio"), 0.001) + plugin.triggerUnifiedPushMessage(pushData) } @Test @@ -212,88 +220,129 @@ class NotificationPluginUnifiedPushTest { "archived" to false ) - val data = JSObject() - for ((key, value) in pushData) { - when (value) { - is Boolean -> data.put(key, value) - else -> data.put(key, value.toString()) - } - } - - assertTrue(data.getBoolean("read")) - assertFalse(data.getBoolean("archived")) + plugin.triggerUnifiedPushMessage(pushData) } @Test fun testTriggerUnifiedPushMessage_mapsNestedObjects() { - val nestedMap = mapOf("innerKey" to "innerValue", "innerNum" to "99") + val nestedMap = mapOf("innerKey" to "innerValue", "innerNum" to 99) val pushData = mapOf( "nested" to nestedMap ) - val data = JSObject() - for ((key, value) in pushData) { - when (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()) - } - } - - val nested = data.getJSObject("nested") - assertNotNull(nested) - assertEquals("innerValue", nested!!.getString("innerKey")) - assertEquals("99", nested.getString("innerNum")) + plugin.triggerUnifiedPushMessage(pushData) } - // --- Cached endpoint behavior tests --- + @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 testCachedEndpoint_clearedOnUnregister() { - var cachedUnifiedPushEndpoint: String? = "https://push.example.com/cached" + fun testTriggerUnifiedPushMessage_mapsNestedListsAndMaps() { + val pushData = mapOf( + "complex" to listOf( + mapOf("key" to "value"), + listOf(1, 2), + "plain" + ) + ) - // Simulate unregister - cachedUnifiedPushEndpoint = null + 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 - assertNull(cachedUnifiedPushEndpoint) + 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 testCachedEndpoint_updatedOnNewEndpoint() { - var cachedUnifiedPushEndpoint: String? = null - var unifiedPushInstance = "default" + 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()) + } - // Simulate new endpoint - cachedUnifiedPushEndpoint = "https://push.example.com/new-endpoint" - unifiedPushInstance = "new-instance" + // --- Cached endpoint behavior tests (using actual plugin state) --- - assertEquals("https://push.example.com/new-endpoint", cachedUnifiedPushEndpoint) - assertEquals("new-instance", unifiedPushInstance) + @Test + fun testCachedEndpoint_clearedOnUnregister() { + setCachedUnifiedPushEndpoint("https://push.example.com/cached") + + plugin.handleUnifiedPushUnregistered("default") + + assertNull(getCachedUnifiedPushEndpoint()) } @Test - fun testCachedEndpoint_returnedImmediatelyIfAvailable() { - val cachedUnifiedPushEndpoint: String? = "https://push.example.com/cached" - val unifiedPushInstance = "cached-instance" + fun testCachedEndpoint_updatedOnNewEndpoint() { + setCachedUnifiedPushEndpoint(null) + setUnifiedPushInstance("default") - // If cached endpoint exists, resolve immediately - if (cachedUnifiedPushEndpoint != null) { - val result = JSObject() - result.put("endpoint", cachedUnifiedPushEndpoint) - result.put("instance", unifiedPushInstance) + plugin.handleNewUnifiedPushEndpoint("https://push.example.com/new-endpoint", "new-instance") - assertEquals("https://push.example.com/cached", result.getString("endpoint")) - assertEquals("cached-instance", result.getString("instance")) - } else { - fail("Cached endpoint should not be null in this test") - } + assertEquals("https://push.example.com/new-endpoint", getCachedUnifiedPushEndpoint()) + assertEquals("new-instance", getUnifiedPushInstance()) } // --- Distributors data structure tests --- @@ -348,12 +397,7 @@ class NotificationPluginUnifiedPushTest { fun testSaveUnifiedPushDistributor_requiresNonNullDistributor() { val distributor: String? = null - if (distributor == null) { - // Should reject with "Distributor parameter is required" - assertTrue(true) - } else { - fail("Distributor should be null in this test case") - } + // Verifies the null-check logic that the plugin uses + assertNull(distributor) } } - diff --git a/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt index d390357..6b8e26e 100644 --- a/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt +++ b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt @@ -12,6 +12,7 @@ import org.robolectric.RobolectricTestRunner import org.unifiedpush.android.connector.FailedReason import org.unifiedpush.android.connector.data.PushEndpoint import org.unifiedpush.android.connector.data.PushMessage +import java.util.concurrent.Executor @RunWith(RobolectricTestRunner::class) class TauriUnifiedPushMessagingServiceTest { @@ -29,6 +30,9 @@ class TauriUnifiedPushMessagingServiceTest { // 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 --- @@ -220,8 +224,6 @@ class TauriUnifiedPushMessagingServiceTest { service.onMessage(mockContext, message, "default") - // Wait for executor to finish - Thread.sleep(200) verify { handler.onMessage(mockContext, any(), "default") } // Fallback should NOT be called since handler returned true @@ -245,8 +247,6 @@ class TauriUnifiedPushMessagingServiceTest { service.onMessage(mockContext, message, "default") - // Wait for executor to finish - Thread.sleep(200) verify { handler.onMessage(mockContext, any(), "default") } // Fallback SHOULD be called since handler returned false @@ -272,8 +272,6 @@ class TauriUnifiedPushMessagingServiceTest { service.onMessage(mockContext, message, "default") - // Wait for executor to finish - Thread.sleep(200) // Fallback SHOULD be called since handler threw exception verify { mockManager.schedule(match { diff --git a/guest-js/index.ts b/guest-js/index.ts index fae66ab..b5d70dc 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -652,7 +652,7 @@ async function onUnifiedPushError( /** * Sends a notification to the user. - + * * @example * ```typescript * import { isPermissionGranted, requestPermission, sendNotification } from '@choochmeque/tauri-plugin-notifications-api'; From b7459631edcd0bd2f0103a081373acada0c438a2 Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Mon, 9 Mar 2026 18:15:56 +0100 Subject: [PATCH 13/24] Added UnifiedPush related terms to cspell.json --- cspell.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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": [ From 17ecb04e39fd093fdae2ff3fb0ff4ce340d740c8 Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Mon, 9 Mar 2026 18:25:29 +0100 Subject: [PATCH 14/24] Implement review suggestions from Copilot --- .../tauri/notification/TauriNotificationManager.kt | 11 ++++++----- .../TauriUnifiedPushMessagingService.kt | 7 ++++--- .../NotificationPluginUnifiedPushTest.kt | 12 +++++++++--- .../TauriUnifiedPushMessagingServiceTest.kt | 14 +++++++------- src/mobile.rs | 10 +++++----- 5 files changed, 31 insertions(+), 23 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index efbaadd..e7d3156 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -113,17 +113,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 { @@ -147,6 +147,7 @@ class TauriNotificationManager( private fun buildNotification( notificationManager: NotificationManagerCompat, notification: Notification, + source: String = "local", ) { val channelId = notification.channelId ?: DEFAULT_NOTIFICATION_CHANNEL_ID val mBuilder = NotificationCompat.Builder( @@ -215,7 +216,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) } diff --git a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt index e072266..7b22444 100644 --- a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -2,6 +2,7 @@ package app.tauri.notification import android.content.Context import android.util.Log +import androidx.annotation.VisibleForTesting import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject import org.json.JSONArray @@ -41,8 +42,9 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { * Intended for testing only — pass a direct/synchronous executor to avoid * flaky `Thread.sleep()` calls in tests. */ + @VisibleForTesting @JvmStatic - fun setExecutorForTesting(testExecutor: Executor) { + internal fun setExecutorForTesting(testExecutor: Executor) { executor = testExecutor } } @@ -117,8 +119,7 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { val plugin = NotificationPlugin.instance if (plugin != null) { - plugin.getNotificationManager().schedule(notification) - NotificationPlugin.triggerNotification(notification, "unifiedpush") + plugin.getNotificationManager().schedule(notification, "unifiedpush") } else { Log.w(TAG, "NotificationPlugin not initialized, cannot show fallback notification") } diff --git a/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt b/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt index 1e7f063..e521913 100644 --- a/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt +++ b/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt @@ -395,9 +395,15 @@ class NotificationPluginUnifiedPushTest { @Test fun testSaveUnifiedPushDistributor_requiresNonNullDistributor() { - val distributor: String? = null + // Build a mock Invoke that returns a SaveUnifiedPushDistributorArgs with null distributor + val args = SaveUnifiedPushDistributorArgs() + // distributor defaults to null - // Verifies the null-check logic that the plugin uses - assertNull(distributor) + 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/TauriUnifiedPushMessagingServiceTest.kt b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt index 6b8e26e..c0a4905 100644 --- a/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt +++ b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt @@ -167,7 +167,7 @@ class TauriUnifiedPushMessagingServiceTest { // Verify that schedule was called on the manager (fallback notification) verify { mockManager.schedule(match { it.title == "Fallback Title" && it.body == "Fallback Body" - }) } + }, "unifiedpush") } } @Test @@ -184,7 +184,7 @@ class TauriUnifiedPushMessagingServiceTest { service.onMessage(mockContext, message, "default") // Verify that schedule was NOT called (no title or body) - verify(exactly = 0) { mockManager.schedule(any()) } + verify(exactly = 0) { mockManager.schedule(any(), any()) } } @Test @@ -202,7 +202,7 @@ class TauriUnifiedPushMessagingServiceTest { verify { mockManager.schedule(match { it.title == "" && it.body == "Body Only" - }) } + }, "unifiedpush") } } // --- Custom message handler tests --- @@ -227,7 +227,7 @@ class TauriUnifiedPushMessagingServiceTest { verify { handler.onMessage(mockContext, any(), "default") } // Fallback should NOT be called since handler returned true - verify(exactly = 0) { mockManager.schedule(any()) } + verify(exactly = 0) { mockManager.schedule(any(), any()) } } @Test @@ -252,7 +252,7 @@ class TauriUnifiedPushMessagingServiceTest { // Fallback SHOULD be called since handler returned false verify { mockManager.schedule(match { it.title == "Not Handled" && it.body == "Show Fallback" - }) } + }, "unifiedpush") } } @Test @@ -276,7 +276,7 @@ class TauriUnifiedPushMessagingServiceTest { // Fallback SHOULD be called since handler threw exception verify { mockManager.schedule(match { it.title == "Error" && it.body == "Fallback on error" - }) } + }, "unifiedpush") } } // --- onNewEndpoint tests --- @@ -332,7 +332,7 @@ class TauriUnifiedPushMessagingServiceTest { // handler should not be called verify(exactly = 0) { handler.onMessage(any(), any(), any()) } // fallback should be shown - verify { mockManager.schedule(any()) } + verify { mockManager.schedule(any(), eq("unifiedpush")) } } // --- Plugin not initialized tests --- diff --git a/src/mobile.rs b/src/mobile.rs index f6078ae..db5ef80 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -101,7 +101,7 @@ impl Notifications { .map(|r| serde_json::json!({ "endpoint": r.endpoint, "instance": r.instance })) .map_err(Into::into) } - #[cfg(all(feature = "unified-push", target_os = "ios"))] + #[cfg(all(feature = "unified-push", not(target_os = "android")))] { Err(crate::Error::Io(std::io::Error::other( "UnifiedPush is only supported on Android", @@ -122,7 +122,7 @@ impl Notifications { .run_mobile_plugin::<()>("unregisterFromUnifiedPush", ()) .map_err(Into::into) } - #[cfg(all(feature = "unified-push", target_os = "ios"))] + #[cfg(all(feature = "unified-push", not(target_os = "android")))] { Err(crate::Error::Io(std::io::Error::other( "UnifiedPush is only supported on Android", @@ -147,7 +147,7 @@ impl Notifications { .map(|r| serde_json::json!({ "distributors": r.distributors })) .map_err(Into::into) } - #[cfg(all(feature = "unified-push", target_os = "ios"))] + #[cfg(all(feature = "unified-push", not(target_os = "android")))] { Err(crate::Error::Io(std::io::Error::other( "UnifiedPush is only supported on Android", @@ -170,7 +170,7 @@ impl Notifications { .run_mobile_plugin::<()>("saveUnifiedPushDistributor", args) .map_err(Into::into) } - #[cfg(all(feature = "unified-push", target_os = "ios"))] + #[cfg(all(feature = "unified-push", not(target_os = "android")))] { let _ = distributor; Err(crate::Error::Io(std::io::Error::other( @@ -197,7 +197,7 @@ impl Notifications { .map(|r| serde_json::json!({ "distributor": r.distributor })) .map_err(Into::into) } - #[cfg(all(feature = "unified-push", target_os = "ios"))] + #[cfg(all(feature = "unified-push", not(target_os = "android")))] { Err(crate::Error::Io(std::io::Error::other( "UnifiedPush is only supported on Android", From cd69721a49573248e9d07d38cf22c5d1650ca8df Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 10 Mar 2026 01:13:23 +0100 Subject: [PATCH 15/24] Add support for UnifiedPush temporary unavailability and VAPID key handling --- .../app/tauri/notification/JSObjectUtils.kt | 96 +++++ .../tauri/notification/NotificationPlugin.kt | 99 ++--- .../TauriUnifiedPushMessagingService.kt | 93 +--- .../tauri/notification/JSObjectUtilsTest.kt | 187 +++++++++ .../NotificationPluginUnifiedPushTest.kt | 54 +++ .../TauriUnifiedPushMessagingServiceTest.kt | 397 ++++++++++++++++++ guest-js/index.test.ts | 67 ++- guest-js/index.ts | 46 +- src/mobile.rs | 14 +- src/models.rs | 9 + 10 files changed, 921 insertions(+), 141 deletions(-) create mode 100644 android/src/main/java/app/tauri/notification/JSObjectUtils.kt create mode 100644 android/src/test/java/app/tauri/notification/JSObjectUtilsTest.kt 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/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 1584ba1..d1de4bc 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -626,7 +626,12 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { UnifiedPush.register(activity, unifiedPushInstance) } - fun handleNewUnifiedPushEndpoint(endpoint: String, instance: String) { + fun handleNewUnifiedPushEndpoint( + endpoint: String, + instance: String, + pubKey: String? = null, + auth: String? = null, + ) { if (!BuildConfig.ENABLE_UNIFIED_PUSH) return val pendingInvoke: Invoke? @@ -637,16 +642,19 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { pendingUnifiedPushInvoke = null } - val result = JSObject() - result.put("endpoint", endpoint) - result.put("instance", instance) - - pendingInvoke?.resolve(result) + 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) + } + } - val data = JSObject() - data.put("endpoint", endpoint) - data.put("instance", instance) - trigger("unifiedpush-endpoint", data) + pendingInvoke?.resolve(buildResult()) + trigger("unifiedpush-endpoint", buildResult()) } // Called by TauriUnifiedPushMessagingService when unregistered @@ -662,6 +670,20 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { 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 @@ -698,60 +720,17 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { /** * 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]. - * Handles String, Int, Long, Double, Boolean, Map, and List types. + * Delegates to [JSObjectUtils.putValueToJSObject]. */ - private 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()) - } - } + private fun putValueToJSObject(target: JSObject, key: String, value: Any) = + JSObjectUtils.putValueToJSObject(target, key, value) /** - * Recursively converts a [List] (from JSON array parsing) into a [JSArray], - * properly handling nested maps, lists, and primitive types. + * Recursively converts a [List] (from JSON array parsing) into a [JSArray]. + * Delegates to [JSObjectUtils.convertListToJSArray]. */ - private 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(org.json.JSONObject.NULL) - else -> arr.put(item.toString()) - } - } - return arr - } + private fun convertListToJSArray(list: List<*>): JSArray = + JSObjectUtils.convertListToJSArray(list) fun getNotificationManager(): TauriNotificationManager { return manager diff --git a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt index 7b22444..8147951 100644 --- a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -3,9 +3,7 @@ package app.tauri.notification import android.content.Context import android.util.Log import androidx.annotation.VisibleForTesting -import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject -import org.json.JSONArray import org.json.JSONObject import org.unifiedpush.android.connector.FailedReason import org.unifiedpush.android.connector.MessagingReceiver @@ -51,7 +49,13 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { Log.d(TAG, "New endpoint registered: ${endpoint.url}") - NotificationPlugin.instance?.handleNewUnifiedPushEndpoint(endpoint.url, instance) + val pubKeySet = endpoint.pubKeySet + NotificationPlugin.instance?.handleNewUnifiedPushEndpoint( + endpoint.url, + instance, + pubKeySet?.pubKey, + pubKeySet?.auth, + ) } override fun onUnregistered(context: Context, instance: String) { @@ -59,6 +63,16 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { 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") @@ -69,7 +83,7 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { try { val json = JSONObject(messageString) for (key in json.keys()) { - pushData[key] = jsonValueToNative(json.get(key)) + pushData[key] = JSObjectUtils.jsonValueToNative(json.get(key)) } } catch (e: Exception) { Log.w(TAG, "Message is not valid JSON, forwarding as raw text") @@ -107,7 +121,7 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { val extraData = JSObject() for ((key, value) in pushData) { - putValueToJSObject(extraData, key, value) + JSObjectUtils.putValueToJSObject(extraData, key, value) } val notification = Notification().apply { id = (System.nanoTime() % Int.MAX_VALUE).toInt() @@ -129,73 +143,4 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { Log.e(TAG, "Registration failed for instance: $instance (reason: $reason)") NotificationPlugin.instance?.handleUnifiedPushRegistrationFailed(instance, reason.toString()) } - - private 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 - } - } - - private 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()) - } - } - - private 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(org.json.JSONObject.NULL) - else -> arr.put(item.toString()) - } - } - return arr - } } 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 index e521913..b741629 100644 --- a/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt +++ b/android/src/test/java/app/tauri/notification/NotificationPluginUnifiedPushTest.kt @@ -8,6 +8,7 @@ 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 @@ -32,6 +33,8 @@ class NotificationPluginUnifiedPushTest { @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 @@ -98,6 +101,38 @@ class NotificationPluginUnifiedPushTest { 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) @@ -118,6 +153,25 @@ class NotificationPluginUnifiedPushTest { 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 diff --git a/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt index c0a4905..295900d 100644 --- a/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt +++ b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt @@ -10,6 +10,7 @@ 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 @@ -281,6 +282,402 @@ class TauriUnifiedPushMessagingServiceTest { // --- 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") + } +} + + +@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() { val endpoint = mockk() diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index d002fb2..ddaf327 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -28,6 +28,7 @@ import { onUnifiedPushMessage, onUnifiedPushUnregistered, onUnifiedPushError, + onUnifiedPushTempUnavailable, registerActionTypes, pending, cancel, @@ -498,7 +499,7 @@ describe("Notification Functions", () => { describe("unregisterForPushNotifications", () => { it("should call invoke with correct plugin command", async () => { - mockInvoke.mockResolvedValue(""); + mockInvoke.mockResolvedValue(undefined); await unregisterForPushNotifications(); @@ -507,13 +508,12 @@ 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(); }); }); @@ -644,6 +644,31 @@ describe("Notification Functions", () => { 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", () => { @@ -749,6 +774,38 @@ describe("Notification Functions", () => { }); }); + 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" }); + }); + }); + describe("sendNotification", () => { it("should send notification with string title", async () => { mockInvoke.mockResolvedValue(undefined); diff --git a/guest-js/index.ts b/guest-js/index.ts index b5d70dc..0886356 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -473,15 +473,29 @@ 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; } /** @@ -650,6 +664,34 @@ async function onUnifiedPushError( 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. * @@ -951,6 +993,7 @@ export type { Channel, ScheduleInterval, NotificationClickedData, + UnifiedPushPublicKeySet, UnifiedPushEndpoint, }; @@ -971,6 +1014,7 @@ export { onUnifiedPushMessage, onUnifiedPushUnregistered, onUnifiedPushError, + onUnifiedPushTempUnavailable, registerActionTypes, pending, cancel, diff --git a/src/mobile.rs b/src/mobile.rs index db5ef80..eb37e8d 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -98,7 +98,19 @@ impl Notifications { (), ) .await - .map(|r| serde_json::json!({ "endpoint": r.endpoint, "instance": r.instance })) + .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")))] diff --git a/src/models.rs b/src/models.rs index 62dfd27..fc5d1cb 100644 --- a/src/models.rs +++ b/src/models.rs @@ -18,12 +18,21 @@ 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")] From 9c0c1e01d4fa6db2cc3c5a271a4c07d479f88f4a Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 10 Mar 2026 02:57:38 +0100 Subject: [PATCH 16/24] Added MessageStyling and other features that were TODO that I need for Sable --- .../app/tauri/notification/Notification.kt | 33 +++ .../tauri/notification/NotificationPlugin.kt | 1 + .../tauri/notification/NotificationStorage.kt | 7 +- .../notification/TauriNotificationManager.kt | 93 ++++++-- guest-js/index.test.ts | 213 +++++++++++++++++- guest-js/index.ts | 79 +++++++ macos/Sources/Notification.swift | 3 +- src/models.rs | 45 ++++ 8 files changed, 451 insertions(+), 23 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/Notification.kt b/android/src/main/java/app/tauri/notification/Notification.kt index 6ce340f..fb79830 100644 --- a/android/src/main/java/app/tauri/notification/Notification.kt +++ b/android/src/main/java/app/tauri/notification/Notification.kt @@ -10,6 +10,28 @@ import androidx.annotation.RequiresApi import app.tauri.annotation.InvokeArg import app.tauri.plugin.JSObject +@InvokeArg +class MessagingStylePerson { + var name: String = "" + var icon: 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() +} + @InvokeArg class Notification { var id: Int = 0 @@ -36,6 +58,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 d1de4bc..e7546e6 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -46,6 +46,7 @@ class NotificationAction { lateinit var id: String var title: String? = null var input: Boolean? = null + var icon: String? = null } @InvokeArg 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 e7d3156..c72039e 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -18,7 +18,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 @@ -138,11 +140,20 @@ 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): Person { + val builder = Person.Builder().setName(person.name) + if (person.key != null) { + builder.setKey(person.key) + } + 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() + } + @SuppressLint("MissingPermission") private fun buildNotification( notificationManager: NotificationManagerCompat, @@ -159,7 +170,41 @@ 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!! + val userPerson = buildPerson(msgStyle.user) + 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) } + messagingStyle.addMessage(msg.text, msg.timestamp, senderPerson) + } + mBuilder.setStyle(messagingStyle) + } else if (notification.largeBody != null) { // support multiline text mBuilder.setStyle( NotificationCompat.BigTextStyle() @@ -242,8 +287,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(), @@ -251,7 +302,7 @@ class TauriNotificationManager( flags ) val actionBuilder: NotificationCompat.Action.Builder = NotificationCompat.Action.Builder( - R.drawable.ic_transparent, + actionIconResId, notificationAction.title, actionPendingIntent ) @@ -309,7 +360,6 @@ class TauriNotificationManager( * 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 @SuppressLint("SimpleDateFormat") private fun triggerScheduledNotification(notification: android.app.Notification, request: Notification) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager @@ -327,6 +377,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) { @@ -335,9 +389,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 -> { @@ -345,7 +399,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), @@ -355,7 +409,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 -> {} } @@ -366,7 +420,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()) @@ -374,15 +429,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) } } } diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index ddaf327..f471884 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -659,7 +659,8 @@ describe("Notification Functions", () => { endpoint: "https://nextpush.example.com/push/xyz", instance: "default", pubKeySet: { - pubKey: "BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF", + pubKey: + "BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF", auth: "8eDyX_uCN0XRhSbY5hs7Hg", }, }; @@ -1328,4 +1329,214 @@ 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 0886356..158ee4c 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -131,6 +131,76 @@ 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; + /** 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[]; } /** @@ -294,6 +364,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; } /** @@ -995,6 +1071,9 @@ export type { NotificationClickedData, UnifiedPushPublicKeySet, UnifiedPushEndpoint, + MessagingStylePerson, + MessagingStyleMessage, + MessagingStyleConfig, }; export { diff --git a/macos/Sources/Notification.swift b/macos/Sources/Notification.swift index c12179b..91f78ea 100644 --- a/macos/Sources/Notification.swift +++ b/macos/Sources/Notification.swift @@ -126,8 +126,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/src/models.rs b/src/models.rs index fc5d1cb..5316efc 100644 --- a/src/models.rs +++ b/src/models.rs @@ -217,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 { @@ -246,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, } } } @@ -379,6 +395,35 @@ 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 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, } pub use android::*; From bcfe75008e5f15b8ca2f9e690cc6e65e72458b9b Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 10 Mar 2026 03:01:34 +0100 Subject: [PATCH 17/24] Added MessageStyling and other features that were TODO that I need for Sable --- macos/Sources/Notification.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Notification.swift b/macos/Sources/Notification.swift index 91f78ea..c12179b 100644 --- a/macos/Sources/Notification.swift +++ b/macos/Sources/Notification.swift @@ -126,7 +126,8 @@ func handleScheduledNotification(_ schedule: NotificationSchedule) throws let dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: at) if dateInfo.date! < Date() { - Logger.debug("Scheduled time is in the past: \(dateInfo.date!) < \(Date())") + // TODO: + //Logger.debug("Scheduled time is in the past: \(dateInfo.date!) < \(Date())") throw NotificationError.pastScheduledTime } From 59fe15c0a622891500964d51ea851c53b5cbd8eb Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 10 Mar 2026 13:15:28 +0100 Subject: [PATCH 18/24] Add support for avatar images and authentication in messaging style notifications and adress reviews --- README.md | 13 + .../app/tauri/notification/Notification.kt | 2 + .../tauri/notification/NotificationPlugin.kt | 26 +- .../notification/TauriNotificationManager.kt | 65 +++- .../TauriUnifiedPushMessagingServiceTest.kt | 349 ------------------ guest-js/index.ts | 145 ++++---- src/models.rs | 2 + 7 files changed, 158 insertions(+), 444 deletions(-) diff --git a/README.md b/README.md index b3ce5c9..841df01 100644 --- a/README.md +++ b/README.md @@ -554,12 +554,20 @@ 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). @@ -604,6 +612,11 @@ Listens for UnifiedPush error events. This event is triggered when there's an er **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/src/main/java/app/tauri/notification/Notification.kt b/android/src/main/java/app/tauri/notification/Notification.kt index fb79830..67ea159 100644 --- a/android/src/main/java/app/tauri/notification/Notification.kt +++ b/android/src/main/java/app/tauri/notification/Notification.kt @@ -14,6 +14,7 @@ import app.tauri.plugin.JSObject class MessagingStylePerson { var name: String = "" var icon: String? = null + var iconUrl: String? = null var key: String? = null } @@ -30,6 +31,7 @@ class MessagingStyleConfig { var conversationTitle: String? = null var isGroupConversation: Boolean = false var messages: List = listOf() + var authToken: String? = null } @InvokeArg diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index e7546e6..b4531d9 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -96,11 +96,10 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private var pendingTokenInvoke: Invoke? = null private var cachedToken: String? = null - @Volatile private var pendingUnifiedPushInvoke: Invoke? = null - @Volatile private var cachedUnifiedPushEndpoint: String? = null - @Volatile + 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 @@ -538,6 +537,12 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { 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 } @@ -560,6 +565,8 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { pendingUnifiedPushInvoke?.reject("Unregistration requested while registration was in progress") pendingUnifiedPushInvoke = null cachedUnifiedPushEndpoint = null + cachedPubKey = null + cachedAuth = null } UnifiedPush.unregister(activity, unifiedPushInstance) @@ -615,12 +622,12 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @PermissionCallback private fun unifiedPushPermissionsCallback(invoke: Invoke) { if (!manager.areNotificationsEnabled()) { - invoke.reject("Notification permissions denied") synchronized(unifiedPushLock) { if (pendingUnifiedPushInvoke === invoke) { pendingUnifiedPushInvoke = null } } + invoke.reject("Notification permissions denied") return } @@ -638,6 +645,8 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { val pendingInvoke: Invoke? synchronized(unifiedPushLock) { cachedUnifiedPushEndpoint = endpoint + cachedPubKey = pubKey + cachedAuth = auth unifiedPushInstance = instance pendingInvoke = pendingUnifiedPushInvoke pendingUnifiedPushInvoke = null @@ -664,6 +673,8 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { synchronized(unifiedPushLock) { cachedUnifiedPushEndpoint = null + cachedPubKey = null + cachedAuth = null } val data = JSObject() @@ -726,13 +737,6 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private fun putValueToJSObject(target: JSObject, key: String, value: Any) = JSObjectUtils.putValueToJSObject(target, key, value) - /** - * Recursively converts a [List] (from JSON array parsing) into a [JSArray]. - * Delegates to [JSObjectUtils.convertListToJSArray]. - */ - private fun convertListToJSArray(list: List<*>): JSArray = - JSObjectUtils.convertListToJSArray(list) - fun getNotificationManager(): TauriNotificationManager { return manager } diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index c72039e..85af730 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 @@ -140,12 +142,18 @@ class TauriNotificationManager( return ids } - private fun buildPerson(person: MessagingStylePerson): Person { + private fun buildPerson(person: MessagingStylePerson, authToken: String? = null): Person { val builder = Person.Builder().setName(person.name) if (person.key != null) { builder.setKey(person.key) } - if (person.icon != null) { + // Prefer iconUrl (remote avatar) over icon (drawable resource) + if (person.iconUrl != null) { + val bitmap = downloadAvatarBitmap(person.iconUrl!!, authToken) + 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)) @@ -154,6 +162,54 @@ class TauriNotificationManager( return builder.build() } + /** + * Downloads a remote avatar image and returns it as a circular-cropped bitmap. + * When an auth token is provided it is sent as a Bearer Authorization header, + * which is useful for endpoints that require authentication. + * Returns null on any failure (network error, invalid image, etc.). + */ + private fun downloadAvatarBitmap(url: String, authToken: String?): Bitmap? { + return try { + val connection = java.net.URL(url).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 null + } + val raw = BitmapFactory.decodeStream(connection.inputStream) + connection.disconnect() + if (raw == null) return null + cropCircle(raw) + } catch (e: Exception) { + Logger.error(Logger.tags(TAG), "Failed to download avatar: ${e.message}", e) + null + } + } + + /** + * Crops a bitmap into a circle (for round notification avatars). + */ + 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, @@ -191,7 +247,8 @@ class TauriNotificationManager( // Style selection (mutually exclusive: messagingStyle > largeBody > inboxLines) if (notification.messagingStyle != null) { val msgStyle = notification.messagingStyle!! - val userPerson = buildPerson(msgStyle.user) + val authToken = msgStyle.authToken + val userPerson = buildPerson(msgStyle.user, authToken) val messagingStyle = NotificationCompat.MessagingStyle(userPerson) if (msgStyle.conversationTitle != null) { @@ -200,7 +257,7 @@ class TauriNotificationManager( messagingStyle.isGroupConversation = msgStyle.isGroupConversation for (msg in msgStyle.messages) { - val senderPerson = msg.sender?.let { buildPerson(it) } + val senderPerson = msg.sender?.let { buildPerson(it, authToken) } messagingStyle.addMessage(msg.text, msg.timestamp, senderPerson) } mBuilder.setStyle(messagingStyle) diff --git a/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt index 295900d..cce1d5e 100644 --- a/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt +++ b/android/src/test/java/app/tauri/notification/TauriUnifiedPushMessagingServiceTest.kt @@ -3,7 +3,6 @@ package app.tauri.notification import android.content.Context import app.tauri.plugin.JSObject import io.mockk.* -import org.json.JSONObject import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -410,351 +409,3 @@ class TauriUnifiedPushMessagingServiceTest { } } - -@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() { - val endpoint = mockk() - every { endpoint.url } returns "https://push.example.com/endpoint/abc123" - - service.onNewEndpoint(mockContext, endpoint, "test-instance") - - verify { mockPlugin.handleNewUnifiedPushEndpoint("https://push.example.com/endpoint/abc123", "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" - - // Should not throw - service.onNewEndpoint(mockContext, endpoint, "test-instance") - } -} - diff --git a/guest-js/index.ts b/guest-js/index.ts index 158ee4c..91687fc 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -5,13 +5,9 @@ * @module */ -import { - invoke, - type PluginListener, - addPluginListener, -} from "@tauri-apps/api/core"; +import { invoke, type PluginListener, addPluginListener } from '@tauri-apps/api/core'; -export type { PermissionState } from "@tauri-apps/api/core"; +export type { PermissionState } from '@tauri-apps/api/core'; /** * Options to send a notification. @@ -122,7 +118,7 @@ interface Options { * - `"unifiedpush"` — notification received from a UnifiedPush distributor. * - `"local"` — notification created locally (immediate or scheduled). */ - source?: "push" | "unifiedpush" | "local"; + source?: 'push' | 'unifiedpush' | 'local'; /** * Notification visibility. */ @@ -172,6 +168,13 @@ interface MessagingStylePerson { * 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; } @@ -201,6 +204,11 @@ interface MessagingStyleConfig { 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; } /** @@ -236,17 +244,17 @@ interface ScheduleInterval { * Predefined intervals for repeating notifications. */ enum ScheduleEvery { - Year = "year", - Month = "month", - TwoWeeks = "twoWeeks", - Week = "week", - Day = "day", - Hour = "hour", - Minute = "minute", + Year = 'year', + Month = 'month', + TwoWeeks = 'twoWeeks', + Week = 'week', + Day = 'day', + Hour = 'hour', + Minute = 'minute', /** * Not supported on iOS. */ - Second = "second", + Second = 'second', } /** @@ -302,10 +310,7 @@ class Schedule { * @param allowWhileIdle - On Android, allows notification to fire even when the device is in idle mode. * @returns A new Schedule instance. */ - static interval( - interval: ScheduleInterval, - allowWhileIdle = false, - ): Schedule { + static interval(interval: ScheduleInterval, allowWhileIdle = false): Schedule { return { at: undefined, interval: { interval, allowWhileIdle }, @@ -321,11 +326,7 @@ class Schedule { * @param allowWhileIdle - On Android, allows notification to fire even when the device is in idle mode. * @returns A new Schedule instance. */ - static every( - kind: ScheduleEvery, - count: number, - allowWhileIdle = false, - ): Schedule { + static every(kind: ScheduleEvery, count: number, allowWhileIdle = false): Schedule { return { at: undefined, interval: undefined, @@ -497,7 +498,7 @@ interface Channel { * ``` */ async function isPermissionGranted(): Promise { - return await invoke("plugin:notifications|is_permission_granted"); + return await invoke('plugin:notifications|is_permission_granted'); } /** @@ -515,7 +516,7 @@ async function isPermissionGranted(): Promise { * @returns A promise resolving to whether the user granted the permission or not. */ async function requestPermission(): Promise { - return await invoke("plugin:notifications|request_permission"); + return await invoke('plugin:notifications|request_permission'); } /** @@ -531,7 +532,7 @@ async function requestPermission(): Promise { * @returns A promise resolving to the device push token. */ async function registerForPushNotifications(): Promise { - return await invoke("plugin:notifications|register_for_push_notifications"); + return await invoke('plugin:notifications|register_for_push_notifications'); } /** @@ -550,7 +551,7 @@ async function registerForPushNotifications(): Promise { * @returns A promise resolving when unregistration is complete. */ async function unregisterForPushNotifications(): Promise { - return await invoke("plugin:notifications|unregister_for_push_notifications"); + return await invoke('plugin:notifications|unregister_for_push_notifications'); } /** VAPID / Web Push public key set provided by the distributor for encrypted push. */ @@ -587,7 +588,7 @@ interface UnifiedPushEndpoint { * @returns A promise resolving to the UnifiedPush endpoint information. */ async function registerForUnifiedPush(): Promise { - return await invoke("plugin:notifications|register_for_unified_push"); + return await invoke('plugin:notifications|register_for_unified_push'); } /** @@ -602,7 +603,7 @@ async function registerForUnifiedPush(): Promise { * @returns A promise resolving when unregistration is complete. */ async function unregisterFromUnifiedPush(): Promise { - return await invoke("plugin:notifications|unregister_from_unified_push"); + return await invoke('plugin:notifications|unregister_from_unified_push'); } /** @@ -620,7 +621,7 @@ async function unregisterFromUnifiedPush(): Promise { async function getUnifiedPushDistributors(): Promise<{ distributors: string[]; }> { - return await invoke("plugin:notifications|get_unified_push_distributors"); + return await invoke('plugin:notifications|get_unified_push_distributors'); } /** @@ -636,7 +637,7 @@ async function getUnifiedPushDistributors(): Promise<{ * @returns A promise resolving when the distributor is saved. */ async function saveUnifiedPushDistributor(distributor: string): Promise { - return await invoke("plugin:notifications|save_unified_push_distributor", { + return await invoke('plugin:notifications|save_unified_push_distributor', { distributor, }); } @@ -653,7 +654,7 @@ async function saveUnifiedPushDistributor(distributor: string): Promise { * @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"); + return await invoke('plugin:notifications|get_unified_push_distributor'); } /** @@ -671,9 +672,9 @@ async function getUnifiedPushDistributor(): Promise<{ distributor: string }> { * @returns A promise resolving to a function that removes the listener. */ async function onUnifiedPushEndpoint( - cb: (data: UnifiedPushEndpoint) => void, + cb: (data: UnifiedPushEndpoint) => void ): Promise { - return await addPluginListener("notifications", "unifiedpush-endpoint", cb); + return await addPluginListener('notifications', 'unifiedpush-endpoint', cb); } /** @@ -691,9 +692,9 @@ async function onUnifiedPushEndpoint( * @returns A promise resolving to a function that removes the listener. */ async function onUnifiedPushMessage( - cb: (data: Record) => void, + cb: (data: Record) => void ): Promise { - return await addPluginListener("notifications", "unifiedpush-message", cb); + return await addPluginListener('notifications', 'unifiedpush-message', cb); } /** @@ -711,13 +712,9 @@ async function onUnifiedPushMessage( * @returns A promise resolving to a function that removes the listener. */ async function onUnifiedPushUnregistered( - cb: (data: { instance: string }) => void, + cb: (data: { instance: string }) => void ): Promise { - return await addPluginListener( - "notifications", - "unifiedpush-unregistered", - cb, - ); + return await addPluginListener('notifications', 'unifiedpush-unregistered', cb); } /** @@ -735,9 +732,9 @@ async function onUnifiedPushUnregistered( * @returns A promise resolving to a function that removes the listener. */ async function onUnifiedPushError( - cb: (data: { message: string; instance?: string }) => void, + cb: (data: { message: string; instance?: string }) => void ): Promise { - return await addPluginListener("notifications", "unifiedpush-error", cb); + return await addPluginListener('notifications', 'unifiedpush-error', cb); } /** @@ -759,13 +756,9 @@ async function onUnifiedPushError( * @returns A promise resolving to a function that removes the listener. */ async function onUnifiedPushTempUnavailable( - cb: (data: { instance: string }) => void, + cb: (data: { instance: string }) => void ): Promise { - return await addPluginListener( - "notifications", - "unifiedpush-temp-unavailable", - cb, - ); + return await addPluginListener('notifications', 'unifiedpush-temp-unavailable', cb); } /** @@ -786,9 +779,9 @@ async function onUnifiedPushTempUnavailable( * ``` */ async function sendNotification(options: Options | string): Promise { - await invoke("plugin:notifications|notify", { + await invoke('plugin:notifications|notify', { options: - typeof options === "string" + typeof options === 'string' ? { title: options, } @@ -814,7 +807,7 @@ async function sendNotification(options: Options | string): Promise { * @returns A promise indicating the success or failure of the operation. */ async function registerActionTypes(types: ActionType[]): Promise { - await invoke("plugin:notifications|register_action_types", { types }); + await invoke('plugin:notifications|register_action_types', { types }); } /** @@ -829,7 +822,7 @@ async function registerActionTypes(types: ActionType[]): Promise { * @returns A promise resolving to the list of pending notifications. */ async function pending(): Promise { - return await invoke("plugin:notifications|get_pending"); + return await invoke('plugin:notifications|get_pending'); } /** @@ -844,7 +837,7 @@ async function pending(): Promise { * @returns A promise indicating the success or failure of the operation. */ async function cancel(notifications: number[]): Promise { - await invoke("plugin:notifications|cancel", { notifications }); + await invoke('plugin:notifications|cancel', { notifications }); } /** @@ -859,7 +852,7 @@ async function cancel(notifications: number[]): Promise { * @returns A promise indicating the success or failure of the operation. */ async function cancelAll(): Promise { - await invoke("plugin:notifications|cancel_all"); + await invoke('plugin:notifications|cancel_all'); } /** @@ -874,7 +867,7 @@ async function cancelAll(): Promise { * @returns A promise resolving to the list of active notifications. */ async function active(): Promise { - return await invoke("plugin:notifications|get_active"); + return await invoke('plugin:notifications|get_active'); } /** @@ -888,10 +881,8 @@ async function active(): Promise { * * @returns A promise indicating the success or failure of the operation. */ -async function removeActive( - notifications: Array<{ id: number; tag?: string }>, -): Promise { - await invoke("plugin:notifications|remove_active", { notifications }); +async function removeActive(notifications: Array<{ id: number; tag?: string }>): Promise { + await invoke('plugin:notifications|remove_active', { notifications }); } /** @@ -906,7 +897,7 @@ async function removeActive( * @returns A promise indicating the success or failure of the operation. */ async function removeAllActive(): Promise { - await invoke("plugin:notifications|remove_active"); + await invoke('plugin:notifications|remove_active'); } /** @@ -928,7 +919,7 @@ async function removeAllActive(): Promise { * @returns A promise indicating the success or failure of the operation. */ async function createChannel(channel: Channel): Promise { - await invoke("plugin:notifications|create_channel", { channel }); + await invoke('plugin:notifications|create_channel', { channel }); } /** @@ -943,7 +934,7 @@ async function createChannel(channel: Channel): Promise { * @returns A promise indicating the success or failure of the operation. */ async function removeChannel(id: string): Promise { - await invoke("plugin:notifications|delete_channel", { id }); + await invoke('plugin:notifications|delete_channel', { id }); } /** @@ -958,7 +949,7 @@ async function removeChannel(id: string): Promise { * @returns A promise resolving to the list of notification channels. */ async function channels(): Promise { - return await invoke("plugin:notifications|list_channels"); + return await invoke('plugin:notifications|list_channels'); } /** @@ -978,9 +969,9 @@ async function channels(): Promise { * @returns A promise resolving to a function that removes the listener. */ async function onNotificationReceived( - cb: (notification: Options) => void, + cb: (notification: Options) => void ): Promise { - return await addPluginListener("notifications", "notification", cb); + return await addPluginListener('notifications', 'notification', cb); } /** @@ -999,10 +990,8 @@ async function onNotificationReceived( * @param cb - Callback function to handle notification actions. * @returns A promise resolving to a function that removes the listener. */ -async function onAction( - cb: (notification: Options) => void, -): Promise { - return await addPluginListener("notifications", "actionPerformed", cb); +async function onAction(cb: (notification: Options) => void): Promise { + return await addPluginListener('notifications', 'actionPerformed', cb); } /** @@ -1036,22 +1025,18 @@ interface NotificationClickedData { * @returns A promise resolving to a function that removes the listener. */ async function onNotificationClicked( - cb: (data: NotificationClickedData) => void, + cb: (data: NotificationClickedData) => void ): Promise { - const listener = await addPluginListener( - "notifications", - "notificationClicked", - cb, - ); + const listener = await addPluginListener('notifications', 'notificationClicked', cb); // Notify native side so pending cold-start clicks are delivered - await invoke("plugin:notifications|set_click_listener_active", { + await invoke('plugin:notifications|set_click_listener_active', { active: true, }); return { unregister: async () => { - await invoke("plugin:notifications|set_click_listener_active", { + await invoke('plugin:notifications|set_click_listener_active', { active: false, }); return listener.unregister(); diff --git a/src/models.rs b/src/models.rs index 5316efc..8f2200d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -404,6 +404,7 @@ pub struct Action { pub struct MessagingStylePerson { pub name: String, pub icon: Option, + pub icon_url: Option, pub key: Option, } @@ -424,6 +425,7 @@ pub struct MessagingStyleConfig { pub is_group_conversation: bool, #[serde(default)] pub messages: Vec, + pub auth_token: Option, } pub use android::*; From 150e5b6516ea69e27ff84c96316d6cec13589abb Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 10 Mar 2026 13:47:41 +0100 Subject: [PATCH 19/24] Add avatar image downloading on a background thread and update notification storage tests for icon handling --- .../notification/TauriNotificationManager.kt | 67 +++++++++---------- .../notification/NotificationStorageTest.kt | 3 + 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index 85af730..b99d63c 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -50,6 +50,7 @@ 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.newCachedThreadPool() fun handleNotificationActionPerformed( data: Intent, @@ -88,9 +89,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 @@ -162,38 +160,42 @@ class TauriNotificationManager( return builder.build() } - /** - * Downloads a remote avatar image and returns it as a circular-cropped bitmap. - * When an auth token is provided it is sent as a Bearer Authorization header, - * which is useful for endpoints that require authentication. - * Returns null on any failure (network error, invalid image, etc.). - */ + // Downloads a remote avatar image as a circular-cropped bitmap. + // Runs on a background thread to avoid NetworkOnMainThreadException. + // Returns null on any failure (network error, invalid image, timeout, etc.). private fun downloadAvatarBitmap(url: String, authToken: String?): Bitmap? { return try { - val connection = java.net.URL(url).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 null - } - val raw = BitmapFactory.decodeStream(connection.inputStream) - connection.disconnect() - if (raw == null) return null - cropCircle(raw) + val future = avatarExecutor.submit( + java.util.concurrent.Callable { + try { + val connection = java.net.URL(url).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 + } + } + ) + future.get(10, java.util.concurrent.TimeUnit.SECONDS) } catch (e: Exception) { - Logger.error(Logger.tags(TAG), "Failed to download avatar: ${e.message}", e) + Logger.error(Logger.tags(TAG), "Avatar download timed out or failed: ${e.message}", e) null } } - /** - * Crops a bitmap into a circle (for round notification avatars). - */ private fun cropCircle(src: Bitmap): Bitmap { val size = minOf(src.width, src.height) val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) @@ -413,10 +415,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) - */ + // 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 @@ -586,9 +586,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/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") From e48ecc57e85e6d00b616833688f9de86be6be29d Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 10 Mar 2026 13:48:34 +0100 Subject: [PATCH 20/24] Fixed prettier issues --- examples/notifications-demo/svelte.config.js | 6 +- examples/notifications-demo/vite.config.js | 8 +- guest-js/index.test.ts | 794 +++++++++---------- rollup.config.js | 16 +- vitest.config.ts | 14 +- 5 files changed, 392 insertions(+), 446 deletions(-) diff --git a/examples/notifications-demo/svelte.config.js b/examples/notifications-demo/svelte.config.js index a7830ea..9093d92 100644 --- a/examples/notifications-demo/svelte.config.js +++ b/examples/notifications-demo/svelte.config.js @@ -2,15 +2,15 @@ // so we use adapter-static with a fallback to index.html to put the site in SPA mode // See: https://svelte.dev/docs/kit/single-page-apps // See: https://v2.tauri.app/start/frontend/sveltekit/ for more info -import adapter from "@sveltejs/adapter-static"; -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: vitePreprocess(), kit: { adapter: adapter({ - fallback: "index.html", + fallback: 'index.html', }), }, }; diff --git a/examples/notifications-demo/vite.config.js b/examples/notifications-demo/vite.config.js index 3ecfa0a..ba5d503 100644 --- a/examples/notifications-demo/vite.config.js +++ b/examples/notifications-demo/vite.config.js @@ -1,5 +1,5 @@ -import { defineConfig } from "vite"; -import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from 'vite'; +import { sveltekit } from '@sveltejs/kit/vite'; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; @@ -19,14 +19,14 @@ export default defineConfig(async () => ({ host: host || false, hmr: host ? { - protocol: "ws", + protocol: 'ws', host, port: 1421, } : undefined, watch: { // 3. tell Vite to ignore watching `src-tauri` - ignored: ["**/src-tauri/**"], + ignored: ['**/src-tauri/**'], }, }, })); diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index f471884..eddfef9 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock the Tauri API before imports const mockInvoke = vi.fn(); const mockAddPluginListener = vi.fn(); -vi.mock("@tauri-apps/api/core", () => ({ +vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args: any[]) => mockInvoke(...args), addPluginListener: (...args: any[]) => mockAddPluginListener(...args), })); @@ -42,12 +42,12 @@ import { onNotificationReceived, onAction, onNotificationClicked, -} from "./index"; +} from './index'; -describe("Schedule", () => { - describe("Schedule.at", () => { - it("should create a schedule with date and default values", () => { - const date = new Date("2024-12-25T10:00:00"); +describe('Schedule', () => { + describe('Schedule.at', () => { + it('should create a schedule with date and default values', () => { + const date = new Date('2024-12-25T10:00:00'); const schedule = Schedule.at(date); expect(schedule.at).toBeDefined(); @@ -58,8 +58,8 @@ describe("Schedule", () => { expect(schedule.every).toBeUndefined(); }); - it("should create a repeating schedule", () => { - const date = new Date("2024-12-25T10:00:00"); + it('should create a repeating schedule', () => { + const date = new Date('2024-12-25T10:00:00'); const schedule = Schedule.at(date, true); expect(schedule.at).toBeDefined(); @@ -67,8 +67,8 @@ describe("Schedule", () => { expect(schedule.at?.allowWhileIdle).toBe(false); }); - it("should create a schedule with allowWhileIdle", () => { - const date = new Date("2024-12-25T10:00:00"); + it('should create a schedule with allowWhileIdle', () => { + const date = new Date('2024-12-25T10:00:00'); const schedule = Schedule.at(date, false, true); expect(schedule.at).toBeDefined(); @@ -76,8 +76,8 @@ describe("Schedule", () => { expect(schedule.at?.allowWhileIdle).toBe(true); }); - it("should create a repeating schedule with allowWhileIdle", () => { - const date = new Date("2024-12-25T10:00:00"); + it('should create a repeating schedule with allowWhileIdle', () => { + const date = new Date('2024-12-25T10:00:00'); const schedule = Schedule.at(date, true, true); expect(schedule.at).toBeDefined(); @@ -88,16 +88,16 @@ describe("Schedule", () => { expect(schedule.every).toBeUndefined(); }); - it("should preserve exact date object reference", () => { - const date = new Date("2024-01-01T00:00:00"); + it('should preserve exact date object reference', () => { + const date = new Date('2024-01-01T00:00:00'); const schedule = Schedule.at(date); expect(schedule.at?.date).toBe(date); }); }); - describe("Schedule.interval", () => { - it("should create an interval schedule with default allowWhileIdle", () => { + describe('Schedule.interval', () => { + it('should create an interval schedule with default allowWhileIdle', () => { const interval = { hour: 10, minute: 30 }; const schedule = Schedule.interval(interval); @@ -108,7 +108,7 @@ describe("Schedule", () => { expect(schedule.every).toBeUndefined(); }); - it("should create an interval schedule with allowWhileIdle", () => { + it('should create an interval schedule with allowWhileIdle', () => { const interval = { hour: 10, minute: 30 }; const schedule = Schedule.interval(interval, true); @@ -117,7 +117,7 @@ describe("Schedule", () => { expect(schedule.interval?.allowWhileIdle).toBe(true); }); - it("should handle complex interval with all time components", () => { + it('should handle complex interval with all time components', () => { const interval = { year: 2024, month: 11, @@ -139,14 +139,14 @@ describe("Schedule", () => { expect(schedule.interval?.interval.second).toBe(15); }); - it("should handle partial interval with only hour", () => { + it('should handle partial interval with only hour', () => { const interval = { hour: 15 }; const schedule = Schedule.interval(interval); expect(schedule.interval?.interval).toEqual({ hour: 15 }); }); - it("should preserve interval object reference", () => { + it('should preserve interval object reference', () => { const interval = { minute: 45 }; const schedule = Schedule.interval(interval); @@ -154,8 +154,8 @@ describe("Schedule", () => { }); }); - describe("Schedule.every", () => { - it("should create an every schedule with default allowWhileIdle", () => { + describe('Schedule.every', () => { + it('should create an every schedule with default allowWhileIdle', () => { const schedule = Schedule.every(ScheduleEvery.Day, 1); expect(schedule.every).toBeDefined(); @@ -166,7 +166,7 @@ describe("Schedule", () => { expect(schedule.interval).toBeUndefined(); }); - it("should create an every schedule with allowWhileIdle", () => { + it('should create an every schedule with allowWhileIdle', () => { const schedule = Schedule.every(ScheduleEvery.Hour, 2, true); expect(schedule.every).toBeDefined(); @@ -175,7 +175,7 @@ describe("Schedule", () => { expect(schedule.every?.allowWhileIdle).toBe(true); }); - it("should handle all ScheduleEvery enum values", () => { + it('should handle all ScheduleEvery enum values', () => { const intervals = [ ScheduleEvery.Year, ScheduleEvery.Month, @@ -193,7 +193,7 @@ describe("Schedule", () => { }); }); - it("should handle different count values", () => { + it('should handle different count values', () => { const counts = [1, 2, 5, 10, 100]; counts.forEach((count) => { @@ -202,14 +202,14 @@ describe("Schedule", () => { }); }); - it("should create schedule for every second", () => { + it('should create schedule for every second', () => { const schedule = Schedule.every(ScheduleEvery.Second, 30); expect(schedule.every?.interval).toBe(ScheduleEvery.Second); expect(schedule.every?.count).toBe(30); }); - it("should create schedule for every year", () => { + it('should create schedule for every year', () => { const schedule = Schedule.every(ScheduleEvery.Year, 1); expect(schedule.every?.interval).toBe(ScheduleEvery.Year); @@ -217,8 +217,8 @@ describe("Schedule", () => { }); }); - describe("Schedule mutual exclusivity", () => { - it("should have only at field when using Schedule.at", () => { + describe('Schedule mutual exclusivity', () => { + it('should have only at field when using Schedule.at', () => { const schedule = Schedule.at(new Date()); expect(schedule.at).toBeDefined(); @@ -226,7 +226,7 @@ describe("Schedule", () => { expect(schedule.every).toBeUndefined(); }); - it("should have only interval field when using Schedule.interval", () => { + it('should have only interval field when using Schedule.interval', () => { const schedule = Schedule.interval({ hour: 10 }); expect(schedule.interval).toBeDefined(); @@ -234,7 +234,7 @@ describe("Schedule", () => { expect(schedule.every).toBeUndefined(); }); - it("should have only every field when using Schedule.every", () => { + it('should have only every field when using Schedule.every', () => { const schedule = Schedule.every(ScheduleEvery.Day, 1); expect(schedule.every).toBeDefined(); @@ -244,38 +244,38 @@ describe("Schedule", () => { }); }); -describe("ScheduleEvery", () => { - it("should have correct enum values", () => { - expect(ScheduleEvery.Year).toBe("year"); - expect(ScheduleEvery.Month).toBe("month"); - expect(ScheduleEvery.TwoWeeks).toBe("twoWeeks"); - expect(ScheduleEvery.Week).toBe("week"); - expect(ScheduleEvery.Day).toBe("day"); - expect(ScheduleEvery.Hour).toBe("hour"); - expect(ScheduleEvery.Minute).toBe("minute"); - expect(ScheduleEvery.Second).toBe("second"); +describe('ScheduleEvery', () => { + it('should have correct enum values', () => { + expect(ScheduleEvery.Year).toBe('year'); + expect(ScheduleEvery.Month).toBe('month'); + expect(ScheduleEvery.TwoWeeks).toBe('twoWeeks'); + expect(ScheduleEvery.Week).toBe('week'); + expect(ScheduleEvery.Day).toBe('day'); + expect(ScheduleEvery.Hour).toBe('hour'); + expect(ScheduleEvery.Minute).toBe('minute'); + expect(ScheduleEvery.Second).toBe('second'); }); - it("should have exactly 8 enum values", () => { + it('should have exactly 8 enum values', () => { const values = Object.values(ScheduleEvery); expect(values).toHaveLength(8); }); - it("should contain all expected values", () => { + it('should contain all expected values', () => { const values = Object.values(ScheduleEvery); - expect(values).toContain("year"); - expect(values).toContain("month"); - expect(values).toContain("twoWeeks"); - expect(values).toContain("week"); - expect(values).toContain("day"); - expect(values).toContain("hour"); - expect(values).toContain("minute"); - expect(values).toContain("second"); + expect(values).toContain('year'); + expect(values).toContain('month'); + expect(values).toContain('twoWeeks'); + expect(values).toContain('week'); + expect(values).toContain('day'); + expect(values).toContain('hour'); + expect(values).toContain('minute'); + expect(values).toContain('second'); }); }); -describe("Importance", () => { - it("should have correct enum values", () => { +describe('Importance', () => { + it('should have correct enum values', () => { expect(Importance.None).toBe(0); expect(Importance.Min).toBe(1); expect(Importance.Low).toBe(2); @@ -283,14 +283,14 @@ describe("Importance", () => { expect(Importance.High).toBe(4); }); - it("should have sequential numeric values", () => { + it('should have sequential numeric values', () => { expect(Importance.Min).toBe(Importance.None + 1); expect(Importance.Low).toBe(Importance.Min + 1); expect(Importance.Default).toBe(Importance.Low + 1); expect(Importance.High).toBe(Importance.Default + 1); }); - it("should have exactly 5 importance levels", () => { + it('should have exactly 5 importance levels', () => { const values = [ Importance.None, Importance.Min, @@ -301,62 +301,62 @@ describe("Importance", () => { expect(values).toHaveLength(5); }); - it("should start at 0", () => { + it('should start at 0', () => { expect(Importance.None).toBe(0); }); - it("should end at 4", () => { + it('should end at 4', () => { expect(Importance.High).toBe(4); }); }); -describe("Visibility", () => { - it("should have correct enum values", () => { +describe('Visibility', () => { + it('should have correct enum values', () => { expect(Visibility.Secret).toBe(-1); expect(Visibility.Private).toBe(0); expect(Visibility.Public).toBe(1); }); - it("should have exactly 3 visibility levels", () => { + it('should have exactly 3 visibility levels', () => { const values = [Visibility.Secret, Visibility.Private, Visibility.Public]; expect(values).toHaveLength(3); }); - it("should have sequential values from -1 to 1", () => { + it('should have sequential values from -1 to 1', () => { expect(Visibility.Secret).toBe(-1); expect(Visibility.Private).toBe(0); expect(Visibility.Public).toBe(1); }); - it("should have Private as middle value", () => { + it('should have Private as middle value', () => { expect(Visibility.Private).toBe(0); expect(Visibility.Secret).toBeLessThan(Visibility.Private); expect(Visibility.Public).toBeGreaterThan(Visibility.Private); }); }); -describe("Schedule edge cases", () => { - it("should handle date with zero milliseconds", () => { - const date = new Date("2024-01-01T00:00:00.000Z"); +describe('Schedule edge cases', () => { + it('should handle date with zero milliseconds', () => { + const date = new Date('2024-01-01T00:00:00.000Z'); const schedule = Schedule.at(date); expect(schedule.at?.date.getMilliseconds()).toBe(0); }); - it("should handle interval with zero values", () => { + it('should handle interval with zero values', () => { const interval = { hour: 0, minute: 0, second: 0 }; const schedule = Schedule.interval(interval); expect(schedule.interval?.interval).toEqual(interval); }); - it("should handle every with count of zero", () => { + it('should handle every with count of zero', () => { const schedule = Schedule.every(ScheduleEvery.Day, 0); expect(schedule.every?.count).toBe(0); }); - it("should handle weekday boundary values (1-7)", () => { + it('should handle weekday boundary values (1-7)', () => { const interval1 = { weekday: 1 }; const interval7 = { weekday: 7 }; @@ -367,7 +367,7 @@ describe("Schedule edge cases", () => { expect(schedule7.interval?.interval.weekday).toBe(7); }); - it("should handle maximum time values", () => { + it('should handle maximum time values', () => { const interval = { year: 9999, month: 11, @@ -381,23 +381,23 @@ describe("Schedule edge cases", () => { expect(schedule.interval?.interval).toEqual(interval); }); - it("should handle empty interval object", () => { + it('should handle empty interval object', () => { const interval = {}; const schedule = Schedule.interval(interval); expect(schedule.interval?.interval).toEqual({}); }); - it("should handle future date", () => { - const futureDate = new Date("2050-01-01T00:00:00"); + it('should handle future date', () => { + const futureDate = new Date('2050-01-01T00:00:00'); const schedule = Schedule.at(futureDate); expect(schedule.at?.date).toBe(futureDate); expect(schedule.at?.date.getFullYear()).toBe(2050); }); - it("should handle past date", () => { - const pastDate = new Date("2000-01-01T00:00:00"); + it('should handle past date', () => { + const pastDate = new Date('2000-01-01T00:00:00'); const schedule = Schedule.at(pastDate); expect(schedule.at?.date).toBe(pastDate); @@ -405,8 +405,8 @@ describe("Schedule edge cases", () => { }); }); -describe("Schedule type safety", () => { - it("should have mutually exclusive schedule types", () => { +describe('Schedule type safety', () => { + it('should have mutually exclusive schedule types', () => { const atSchedule = Schedule.at(new Date()); const intervalSchedule = Schedule.interval({ hour: 10 }); const everySchedule = Schedule.every(ScheduleEvery.Day, 1); @@ -424,7 +424,7 @@ describe("Schedule type safety", () => { expect(everySchedule.interval).toBeFalsy(); }); - it("should return Schedule type from all factory methods", () => { + it('should return Schedule type from all factory methods', () => { const atSchedule = Schedule.at(new Date()); const intervalSchedule = Schedule.interval({ hour: 10 }); const everySchedule = Schedule.every(ScheduleEvery.Day, 1); @@ -435,25 +435,23 @@ describe("Schedule type safety", () => { }); }); -describe("Notification Functions", () => { +describe('Notification Functions', () => { beforeEach(() => { mockInvoke.mockClear(); mockAddPluginListener.mockClear(); }); - describe("isPermissionGranted", () => { - it("should call invoke with correct plugin command", async () => { + describe('isPermissionGranted', () => { + it('should call invoke with correct plugin command', async () => { mockInvoke.mockResolvedValue(true); const result = await isPermissionGranted(); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|is_permission_granted", - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|is_permission_granted'); expect(result).toBe(true); }); - it("should return false when permission not granted", async () => { + it('should return false when permission not granted', async () => { mockInvoke.mockResolvedValue(false); const result = await isPermissionGranted(); @@ -462,53 +460,51 @@ describe("Notification Functions", () => { }); }); - describe("requestPermission", () => { - it("should call invoke with correct plugin command", async () => { - mockInvoke.mockResolvedValue("granted"); + describe('requestPermission', () => { + it('should call invoke with correct plugin command', async () => { + mockInvoke.mockResolvedValue('granted'); const result = await requestPermission(); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|request_permission", - ); - expect(result).toBe("granted"); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|request_permission'); + expect(result).toBe('granted'); }); - it("should return denied when permission denied", async () => { - mockInvoke.mockResolvedValue("denied"); + it('should return denied when permission denied', async () => { + mockInvoke.mockResolvedValue('denied'); const result = await requestPermission(); - expect(result).toBe("denied"); + expect(result).toBe('denied'); }); }); - describe("registerForPushNotifications", () => { - it("should call invoke and return push token", async () => { - const mockToken = "abc123token"; + describe('registerForPushNotifications', () => { + it('should call invoke and return push token', async () => { + const mockToken = 'abc123token'; mockInvoke.mockResolvedValue(mockToken); const result = await registerForPushNotifications(); expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|register_for_push_notifications", + 'plugin:notifications|register_for_push_notifications' ); expect(result).toBe(mockToken); }); }); - describe("unregisterForPushNotifications", () => { - it("should call invoke with correct plugin command", async () => { + describe('unregisterForPushNotifications', () => { + it('should call invoke with correct plugin command', async () => { mockInvoke.mockResolvedValue(undefined); await unregisterForPushNotifications(); expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|unregister_for_push_notifications", + 'plugin:notifications|unregister_for_push_notifications' ); }); - it("should resolve without a return value", async () => { + it('should resolve without a return value', async () => { mockInvoke.mockResolvedValue(undefined); const result = await unregisterForPushNotifications(); @@ -517,54 +513,45 @@ describe("Notification Functions", () => { }); }); - describe("registerForUnifiedPush", () => { - it("should call invoke with correct plugin command", async () => { + describe('registerForUnifiedPush', () => { + it('should call invoke with correct plugin command', async () => { const mockEndpoint = { - endpoint: "https://example.com/push", - instance: "default", + endpoint: 'https://example.com/push', + instance: 'default', }; mockInvoke.mockResolvedValue(mockEndpoint); const result = await registerForUnifiedPush(); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|register_for_unified_push", - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|register_for_unified_push'); expect(result).toEqual(mockEndpoint); }); }); - describe("unregisterFromUnifiedPush", () => { - it("should call invoke with correct plugin command", async () => { + 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", - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|unregister_from_unified_push'); }); }); - describe("getUnifiedPushDistributors", () => { - it("should return the list of distributors", async () => { + describe('getUnifiedPushDistributors', () => { + it('should return the list of distributors', async () => { const mockDistributors = { - distributors: [ - "org.unifiedpush.distributor.nextpush", - "io.heckel.ntfy", - ], + 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(mockInvoke).toHaveBeenCalledWith('plugin:notifications|get_unified_push_distributors'); expect(result).toEqual(mockDistributors); }); - it("should handle empty distributors list", async () => { + it('should handle empty distributors list', async () => { mockInvoke.mockResolvedValue({ distributors: [] }); const result = await getUnifiedPushDistributors(); @@ -573,45 +560,43 @@ describe("Notification Functions", () => { }); }); - describe("saveUnifiedPushDistributor", () => { - it("should call invoke with distributor parameter", async () => { + describe('saveUnifiedPushDistributor', () => { + it('should call invoke with distributor parameter', async () => { mockInvoke.mockResolvedValue(undefined); - await saveUnifiedPushDistributor("org.unifiedpush.distributor.nextpush"); + await saveUnifiedPushDistributor('org.unifiedpush.distributor.nextpush'); expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|save_unified_push_distributor", - { distributor: "org.unifiedpush.distributor.nextpush" }, + 'plugin:notifications|save_unified_push_distributor', + { distributor: 'org.unifiedpush.distributor.nextpush' } ); }); }); - describe("getUnifiedPushDistributor", () => { - it("should return the current distributor", async () => { + describe('getUnifiedPushDistributor', () => { + it('should return the current distributor', async () => { const mockDistributor = { - distributor: "org.unifiedpush.distributor.nextpush", + distributor: 'org.unifiedpush.distributor.nextpush', }; mockInvoke.mockResolvedValue(mockDistributor); const result = await getUnifiedPushDistributor(); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|get_unified_push_distributor", - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|get_unified_push_distributor'); expect(result).toEqual(mockDistributor); }); - it("should handle empty distributor", async () => { - mockInvoke.mockResolvedValue({ distributor: "" }); + it('should handle empty distributor', async () => { + mockInvoke.mockResolvedValue({ distributor: '' }); const result = await getUnifiedPushDistributor(); - expect(result.distributor).toBe(""); + expect(result.distributor).toBe(''); }); }); - describe("onUnifiedPushEndpoint", () => { - it("should register endpoint listener", async () => { + describe('onUnifiedPushEndpoint', () => { + it('should register endpoint listener', async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -619,14 +604,14 @@ describe("Notification Functions", () => { const unlisten = await onUnifiedPushEndpoint(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - "notifications", - "unifiedpush-endpoint", - callback, + 'notifications', + 'unifiedpush-endpoint', + callback ); expect(unlisten).toBe(mockUnlisten); }); - it("should call callback with endpoint data", async () => { + it('should call callback with endpoint data', async () => { let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; @@ -637,15 +622,15 @@ describe("Notification Functions", () => { await onUnifiedPushEndpoint(callback); const endpointData = { - endpoint: "https://example.com/push", - instance: "default", + 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 () => { + 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; @@ -656,12 +641,11 @@ describe("Notification Functions", () => { await onUnifiedPushEndpoint(callback); const endpointData = { - endpoint: "https://nextpush.example.com/push/xyz", - instance: "default", + endpoint: 'https://nextpush.example.com/push/xyz', + instance: 'default', pubKeySet: { - pubKey: - "BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF", - auth: "8eDyX_uCN0XRhSbY5hs7Hg", + pubKey: 'BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF', + auth: '8eDyX_uCN0XRhSbY5hs7Hg', }, }; capturedCallback?.(endpointData); @@ -672,8 +656,8 @@ describe("Notification Functions", () => { }); }); - describe("onUnifiedPushMessage", () => { - it("should register message listener", async () => { + describe('onUnifiedPushMessage', () => { + it('should register message listener', async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -681,14 +665,14 @@ describe("Notification Functions", () => { const unlisten = await onUnifiedPushMessage(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - "notifications", - "unifiedpush-message", - callback, + 'notifications', + 'unifiedpush-message', + callback ); expect(unlisten).toBe(mockUnlisten); }); - it("should call callback with message data", async () => { + it('should call callback with message data', async () => { let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; @@ -699,10 +683,10 @@ describe("Notification Functions", () => { await onUnifiedPushMessage(callback); const messageData = { - title: "Hello", - body: "World", - instance: "default", - source: "unifiedpush", + title: 'Hello', + body: 'World', + instance: 'default', + source: 'unifiedpush', }; capturedCallback?.(messageData); @@ -710,8 +694,8 @@ describe("Notification Functions", () => { }); }); - describe("onUnifiedPushUnregistered", () => { - it("should register unregistered listener", async () => { + describe('onUnifiedPushUnregistered', () => { + it('should register unregistered listener', async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -719,14 +703,14 @@ describe("Notification Functions", () => { const unlisten = await onUnifiedPushUnregistered(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - "notifications", - "unifiedpush-unregistered", - callback, + 'notifications', + 'unifiedpush-unregistered', + callback ); expect(unlisten).toBe(mockUnlisten); }); - it("should call callback with instance data", async () => { + it('should call callback with instance data', async () => { let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; @@ -736,14 +720,14 @@ describe("Notification Functions", () => { const callback = vi.fn(); await onUnifiedPushUnregistered(callback); - capturedCallback?.({ instance: "default" }); + capturedCallback?.({ instance: 'default' }); - expect(callback).toHaveBeenCalledWith({ instance: "default" }); + expect(callback).toHaveBeenCalledWith({ instance: 'default' }); }); }); - describe("onUnifiedPushError", () => { - it("should register error listener", async () => { + describe('onUnifiedPushError', () => { + it('should register error listener', async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -751,14 +735,14 @@ describe("Notification Functions", () => { const unlisten = await onUnifiedPushError(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - "notifications", - "unifiedpush-error", - callback, + 'notifications', + 'unifiedpush-error', + callback ); expect(unlisten).toBe(mockUnlisten); }); - it("should call callback with error data", async () => { + it('should call callback with error data', async () => { let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; @@ -768,15 +752,15 @@ describe("Notification Functions", () => { const callback = vi.fn(); await onUnifiedPushError(callback); - const errorData = { message: "Registration failed", instance: "default" }; + const errorData = { message: 'Registration failed', instance: 'default' }; capturedCallback?.(errorData); expect(callback).toHaveBeenCalledWith(errorData); }); }); - describe("onUnifiedPushTempUnavailable", () => { - it("should register temp-unavailable listener", async () => { + describe('onUnifiedPushTempUnavailable', () => { + it('should register temp-unavailable listener', async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -784,14 +768,14 @@ describe("Notification Functions", () => { const unlisten = await onUnifiedPushTempUnavailable(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - "notifications", - "unifiedpush-temp-unavailable", - callback, + 'notifications', + 'unifiedpush-temp-unavailable', + callback ); expect(unlisten).toBe(mockUnlisten); }); - it("should call callback with instance data", async () => { + it('should call callback with instance data', async () => { let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; @@ -801,73 +785,73 @@ describe("Notification Functions", () => { const callback = vi.fn(); await onUnifiedPushTempUnavailable(callback); - capturedCallback?.({ instance: "default" }); + capturedCallback?.({ instance: 'default' }); - expect(callback).toHaveBeenCalledWith({ instance: "default" }); + expect(callback).toHaveBeenCalledWith({ instance: 'default' }); }); }); - describe("sendNotification", () => { - it("should send notification with string title", async () => { + describe('sendNotification', () => { + it('should send notification with string title', async () => { mockInvoke.mockResolvedValue(undefined); - await sendNotification("Test Title"); + await sendNotification('Test Title'); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { - options: { title: "Test Title" }, + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + options: { title: 'Test Title' }, }); }); - it("should send notification with full options object", async () => { + it('should send notification with full options object', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "Test", - body: "Test body", + title: 'Test', + body: 'Test body', id: 123, - channelId: "test-channel", + channelId: 'test-channel', }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); - it("should send notification with schedule", async () => { + it('should send notification with schedule', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "Scheduled", - schedule: Schedule.at(new Date("2024-12-25T10:00:00")), + title: 'Scheduled', + schedule: Schedule.at(new Date('2024-12-25T10:00:00')), }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); - it("should send notification with all optional fields", async () => { + it('should send notification with all optional fields', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "Full notification", - body: "Body text", - largeBody: "Large body", - summary: "Summary", - actionTypeId: "action-1", - group: "group-1", + title: 'Full notification', + body: 'Body text', + largeBody: 'Large body', + summary: 'Summary', + actionTypeId: 'action-1', + group: 'group-1', groupSummary: true, - sound: "notification.mp3", - inboxLines: ["Line 1", "Line 2"], - icon: "ic_notification", - largeIcon: "ic_large", - iconColor: "#FF0000", - attachments: [{ id: "att1", url: "file://image.jpg" }], - extra: { key: "value" }, + sound: 'notification.mp3', + inboxLines: ['Line 1', 'Line 2'], + icon: 'ic_notification', + largeIcon: 'ic_large', + iconColor: '#FF0000', + attachments: [{ id: 'att1', url: 'file://image.jpg' }], + extra: { key: 'value' }, ongoing: true, autoCancel: false, silent: true, @@ -877,53 +861,52 @@ describe("Notification Functions", () => { await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); }); - describe("registerActionTypes", () => { - it("should register action types", async () => { + describe('registerActionTypes', () => { + it('should register action types', async () => { mockInvoke.mockResolvedValue(undefined); const types = [ { - id: "message-actions", + id: 'message-actions', actions: [ - { id: "reply", title: "Reply", input: true }, - { id: "delete", title: "Delete", destructive: true }, + { id: 'reply', title: 'Reply', input: true }, + { id: 'delete', title: 'Delete', destructive: true }, ], }, ]; await registerActionTypes(types); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|register_action_types", - { types }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|register_action_types', { + types, + }); }); - it("should register action types with all optional properties", async () => { + it('should register action types with all optional properties', async () => { mockInvoke.mockResolvedValue(undefined); const types = [ { - id: "full-actions", + id: 'full-actions', actions: [ { - id: "action1", - title: "Action 1", + id: 'action1', + title: 'Action 1', requiresAuthentication: true, foreground: true, destructive: false, input: true, - inputButtonTitle: "Send", - inputPlaceholder: "Type here...", + inputButtonTitle: 'Send', + inputPlaceholder: 'Type here...', }, ], - hiddenPreviewsBodyPlaceholder: "Hidden", + hiddenPreviewsBodyPlaceholder: 'Hidden', customDismissAction: true, allowInCarPlay: false, hiddenPreviewsShowTitle: true, @@ -933,20 +916,19 @@ describe("Notification Functions", () => { await registerActionTypes(types); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|register_action_types", - { types }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|register_action_types', { + types, + }); }); }); - describe("pending", () => { - it("should retrieve pending notifications", async () => { + describe('pending', () => { + it('should retrieve pending notifications', async () => { const mockPending = [ { id: 1, - title: "Pending 1", - body: "Body 1", + title: 'Pending 1', + body: 'Body 1', schedule: Schedule.at(new Date()), }, ]; @@ -954,13 +936,11 @@ describe("Notification Functions", () => { const result = await pending(); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|get_pending", - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|get_pending'); expect(result).toEqual(mockPending); }); - it("should return empty array when no pending notifications", async () => { + it('should return empty array when no pending notifications', async () => { mockInvoke.mockResolvedValue([]); const result = await pending(); @@ -969,57 +949,55 @@ describe("Notification Functions", () => { }); }); - describe("cancel", () => { - it("should cancel notifications by IDs", async () => { + describe('cancel', () => { + it('should cancel notifications by IDs', async () => { mockInvoke.mockResolvedValue(undefined); await cancel([1, 2, 3]); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|cancel", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|cancel', { notifications: [1, 2, 3], }); }); - it("should cancel single notification", async () => { + it('should cancel single notification', async () => { mockInvoke.mockResolvedValue(undefined); await cancel([42]); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|cancel", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|cancel', { notifications: [42], }); }); - it("should handle empty array", async () => { + it('should handle empty array', async () => { mockInvoke.mockResolvedValue(undefined); await cancel([]); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|cancel", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|cancel', { notifications: [], }); }); }); - describe("cancelAll", () => { - it("should cancel all pending notifications", async () => { + describe('cancelAll', () => { + it('should cancel all pending notifications', async () => { mockInvoke.mockResolvedValue(undefined); await cancelAll(); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|cancel_all", - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|cancel_all'); }); }); - describe("active", () => { - it("should retrieve active notifications", async () => { + describe('active', () => { + it('should retrieve active notifications', async () => { const mockActive = [ { id: 1, - title: "Active 1", - body: "Body 1", + title: 'Active 1', + body: 'Body 1', groupSummary: false, data: {}, extra: {}, @@ -1030,13 +1008,11 @@ describe("Notification Functions", () => { const result = await active(); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|get_active", - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|get_active'); expect(result).toEqual(mockActive); }); - it("should return empty array when no active notifications", async () => { + it('should return empty array when no active notifications', async () => { mockInvoke.mockResolvedValue([]); const result = await active(); @@ -1045,86 +1021,72 @@ describe("Notification Functions", () => { }); }); - describe("removeActive", () => { - it("should remove active notifications by ID", async () => { + describe('removeActive', () => { + it('should remove active notifications by ID', async () => { mockInvoke.mockResolvedValue(undefined); await removeActive([{ id: 1 }, { id: 2 }]); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|remove_active", - { - notifications: [{ id: 1 }, { id: 2 }], - }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|remove_active', { + notifications: [{ id: 1 }, { id: 2 }], + }); }); - it("should remove active notification with tag", async () => { + it('should remove active notification with tag', async () => { mockInvoke.mockResolvedValue(undefined); - await removeActive([{ id: 1, tag: "news" }]); + await removeActive([{ id: 1, tag: 'news' }]); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|remove_active", - { - notifications: [{ id: 1, tag: "news" }], - }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|remove_active', { + notifications: [{ id: 1, tag: 'news' }], + }); }); - it("should handle empty array", async () => { + it('should handle empty array', async () => { mockInvoke.mockResolvedValue(undefined); await removeActive([]); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|remove_active", - { - notifications: [], - }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|remove_active', { + notifications: [], + }); }); }); - describe("removeAllActive", () => { - it("should remove all active notifications", async () => { + describe('removeAllActive', () => { + it('should remove all active notifications', async () => { mockInvoke.mockResolvedValue(undefined); await removeAllActive(); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|remove_active", - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|remove_active'); }); }); - describe("createChannel", () => { - it("should create notification channel with minimal properties", async () => { + describe('createChannel', () => { + it('should create notification channel with minimal properties', async () => { mockInvoke.mockResolvedValue(undefined); const channel = { - id: "test-channel", - name: "Test Channel", + id: 'test-channel', + name: 'Test Channel', }; await createChannel(channel); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|create_channel", - { channel }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|create_channel', { channel }); }); - it("should create channel with all properties", async () => { + it('should create channel with all properties', async () => { mockInvoke.mockResolvedValue(undefined); const channel = { - id: "full-channel", - name: "Full Channel", - description: "Channel description", - sound: "notification.mp3", + id: 'full-channel', + name: 'Full Channel', + description: 'Channel description', + sound: 'notification.mp3', lights: true, - lightColor: "#FF0000", + lightColor: '#FF0000', vibration: true, importance: Importance.High, visibility: Visibility.Public, @@ -1132,39 +1094,33 @@ describe("Notification Functions", () => { await createChannel(channel); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|create_channel", - { channel }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|create_channel', { channel }); }); }); - describe("removeChannel", () => { - it("should delete notification channel", async () => { + describe('removeChannel', () => { + it('should delete notification channel', async () => { mockInvoke.mockResolvedValue(undefined); - await removeChannel("test-channel"); + await removeChannel('test-channel'); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|delete_channel", - { - id: "test-channel", - }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|delete_channel', { + id: 'test-channel', + }); }); }); - describe("channels", () => { - it("should retrieve all notification channels", async () => { + describe('channels', () => { + it('should retrieve all notification channels', async () => { const mockChannels = [ { - id: "channel1", - name: "Channel 1", + id: 'channel1', + name: 'Channel 1', importance: Importance.Default, }, { - id: "channel2", - name: "Channel 2", + id: 'channel2', + name: 'Channel 2', importance: Importance.High, }, ]; @@ -1172,13 +1128,11 @@ describe("Notification Functions", () => { const result = await channels(); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|list_channels", - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|list_channels'); expect(result).toEqual(mockChannels); }); - it("should return empty array when no channels", async () => { + it('should return empty array when no channels', async () => { mockInvoke.mockResolvedValue([]); const result = await channels(); @@ -1187,24 +1141,20 @@ describe("Notification Functions", () => { }); }); - describe("onNotificationReceived", () => { - it("should register notification received listener", async () => { + describe('onNotificationReceived', () => { + it('should register notification received listener', async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); const callback = vi.fn(); const unlisten = await onNotificationReceived(callback); - expect(mockAddPluginListener).toHaveBeenCalledWith( - "notifications", - "notification", - callback, - ); + expect(mockAddPluginListener).toHaveBeenCalledWith('notifications', 'notification', callback); expect(unlisten).toBe(mockUnlisten); }); - it("should call callback when notification received", async () => { - const mockNotification = { title: "Test", body: "Body" }; + it('should call callback when notification received', async () => { + const mockNotification = { title: 'Test', body: 'Body' }; let capturedCallback: ((notification: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { @@ -1221,8 +1171,8 @@ describe("Notification Functions", () => { }); }); - describe("onAction", () => { - it("should register action performed listener", async () => { + describe('onAction', () => { + it('should register action performed listener', async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -1230,15 +1180,15 @@ describe("Notification Functions", () => { const unlisten = await onAction(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - "notifications", - "actionPerformed", - callback, + 'notifications', + 'actionPerformed', + callback ); expect(unlisten).toBe(mockUnlisten); }); - it("should call callback when action performed", async () => { - const mockNotification = { title: "Test", actionTypeId: "action-1" }; + it('should call callback when action performed', async () => { + const mockNotification = { title: 'Test', actionTypeId: 'action-1' }; let capturedCallback: ((notification: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { @@ -1255,8 +1205,8 @@ describe("Notification Functions", () => { }); }); - describe("onNotificationClicked", () => { - it("should register notification clicked listener", async () => { + describe('onNotificationClicked', () => { + it('should register notification clicked listener', async () => { const mockUnregister = vi.fn().mockResolvedValue(undefined); mockAddPluginListener.mockResolvedValue({ unregister: mockUnregister }); mockInvoke.mockResolvedValue(undefined); @@ -1265,18 +1215,17 @@ describe("Notification Functions", () => { const listener = await onNotificationClicked(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - "notifications", - "notificationClicked", - callback, + 'notifications', + 'notificationClicked', + callback ); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|set_click_listener_active", - { active: true }, - ); - expect(listener).toHaveProperty("unregister"); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|set_click_listener_active', { + active: true, + }); + expect(listener).toHaveProperty('unregister'); }); - it("should notify native side on unregister", async () => { + it('should notify native side on unregister', async () => { const mockUnregister = vi.fn().mockResolvedValue(undefined); mockAddPluginListener.mockResolvedValue({ unregister: mockUnregister }); mockInvoke.mockResolvedValue(undefined); @@ -1287,15 +1236,14 @@ describe("Notification Functions", () => { mockInvoke.mockClear(); await listener.unregister(); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|set_click_listener_active", - { active: false }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|set_click_listener_active', { + active: false, + }); expect(mockUnregister).toHaveBeenCalled(); }); - it("should call callback when notification clicked", async () => { - const mockClickedData = { id: 123, data: { key: "value" } }; + it('should call callback when notification clicked', async () => { + const mockClickedData = { id: 123, data: { key: 'value' } }; let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { @@ -1311,7 +1259,7 @@ describe("Notification Functions", () => { expect(callback).toHaveBeenCalledWith(mockClickedData); }); - it("should handle notification click without data", async () => { + it('should handle notification click without data', async () => { const mockClickedData = { id: 456 }; let capturedCallback: ((data: any) => void) | undefined; @@ -1330,44 +1278,44 @@ describe("Notification Functions", () => { }); }); - describe("sendNotification with progress bar", () => { - it("should send notification with determinate progress", async () => { + describe('sendNotification with progress bar', () => { + it('should send notification with determinate progress', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "Downloading...", + title: 'Downloading...', progress: 45, progressMax: 100, }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); - it("should send notification with indeterminate progress", async () => { + it('should send notification with indeterminate progress', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "Loading...", + title: 'Loading...', progressIndeterminate: true, }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); - it("should send notification with progress and body", async () => { + it('should send notification with progress and body', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "Upload", - body: "Uploading file.txt", + title: 'Upload', + body: 'Uploading file.txt', progress: 75, progressMax: 100, ongoing: true, @@ -1375,59 +1323,59 @@ describe("Notification Functions", () => { await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); }); - describe("sendNotification with category", () => { - it("should send notification with message category", async () => { + describe('sendNotification with category', () => { + it('should send notification with message category', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "New Message", - body: "Hello!", - category: "msg", + title: 'New Message', + body: 'Hello!', + category: 'msg', }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); - it("should send notification with alarm category", async () => { + it('should send notification with alarm category', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "Alarm", - category: "alarm", + title: 'Alarm', + category: 'alarm', }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); }); - describe("sendNotification with messagingStyle", () => { - it("should send notification with simple messaging style", async () => { + describe('sendNotification with messagingStyle', () => { + it('should send notification with simple messaging style', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "Chat", + title: 'Chat', messagingStyle: { - user: { name: "Me" }, + user: { name: 'Me' }, messages: [ - { text: "Hello!", timestamp: 1700000000000 }, + { text: 'Hello!', timestamp: 1700000000000 }, { - text: "Hi there!", + text: 'Hi there!', timestamp: 1700000060000, - sender: { name: "Alice" }, + sender: { name: 'Alice' }, }, ], }, @@ -1435,30 +1383,30 @@ describe("Notification Functions", () => { await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); - it("should send notification with group conversation", async () => { + it('should send notification with group conversation', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "Group Chat", + title: 'Group Chat', messagingStyle: { - user: { name: "Me", key: "user-1" }, - conversationTitle: "Project Team", + user: { name: 'Me', key: 'user-1' }, + conversationTitle: 'Project Team', isGroupConversation: true, messages: [ { - text: "Meeting at 3pm", + text: 'Meeting at 3pm', timestamp: 1700000000000, - sender: { name: "Bob", key: "user-2", icon: "ic_bob" }, + sender: { name: 'Bob', key: 'user-2', icon: 'ic_bob' }, }, { - text: "Sounds good!", + text: 'Sounds good!', timestamp: 1700000060000, - sender: { name: "Carol", key: "user-3" }, + sender: { name: 'Carol', key: 'user-3' }, }, { text: "I'll be there", timestamp: 1700000120000 }, ], @@ -1467,44 +1415,44 @@ describe("Notification Functions", () => { await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); - it("should send notification with user icon in messaging style", async () => { + it('should send notification with user icon in messaging style', async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: "Chat", + title: 'Chat', messagingStyle: { - user: { name: "Me", icon: "ic_me", key: "self" }, - messages: [{ text: "Hey!", timestamp: 1700000000000 }], + user: { name: 'Me', icon: 'ic_me', key: 'self' }, + messages: [{ text: 'Hey!', timestamp: 1700000000000 }], }, }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { options, }); }); }); - describe("registerActionTypes with icon", () => { - it("should register action types with custom icons", async () => { + describe('registerActionTypes with icon', () => { + it('should register action types with custom icons', async () => { mockInvoke.mockResolvedValue(undefined); const types = [ { - id: "message-actions", + id: 'message-actions', actions: [ - { id: "reply", title: "Reply", input: true, icon: "ic_reply" }, + { id: 'reply', title: 'Reply', input: true, icon: 'ic_reply' }, { - id: "delete", - title: "Delete", + id: 'delete', + title: 'Delete', destructive: true, - icon: "ic_delete", + icon: 'ic_delete', }, ], }, @@ -1512,31 +1460,29 @@ describe("Notification Functions", () => { await registerActionTypes(types); - expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|register_action_types", - { types }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|register_action_types', { + types, + }); }); - it("should register action types mixing icons and no icons", async () => { + it('should register action types mixing icons and no icons', async () => { mockInvoke.mockResolvedValue(undefined); const types = [ { - id: "mixed-actions", + id: 'mixed-actions', actions: [ - { id: "action-with-icon", title: "With Icon", icon: "ic_star" }, - { id: "action-without-icon", title: "Without Icon" }, + { 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 }, - ); + expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|register_action_types', { + types, + }); }); }); }); diff --git a/rollup.config.js b/rollup.config.js index 8701747..d7dacf3 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,20 +1,20 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { cwd } from "node:process"; -import typescript from "@rollup/plugin-typescript"; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { cwd } from 'node:process'; +import typescript from '@rollup/plugin-typescript'; -const pkg = JSON.parse(readFileSync(join(cwd(), "package.json"), "utf8")); +const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8')); export default { - input: "guest-js/index.ts", + input: 'guest-js/index.ts', output: [ { file: pkg.exports.import, - format: "esm", + format: 'esm', }, { file: pkg.exports.require, - format: "cjs", + format: 'cjs', }, ], plugins: [ diff --git a/vitest.config.ts b/vitest.config.ts index 35c613c..6cd78e2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,15 +1,15 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, - environment: "node", - include: ["**/*.test.ts"], + environment: 'node', + include: ['**/*.test.ts'], coverage: { - provider: "v8", - reporter: ["text", "json", "html"], - include: ["guest-js/**/*.ts"], - exclude: ["guest-js/**/*.test.ts"], + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['guest-js/**/*.ts'], + exclude: ['guest-js/**/*.test.ts'], }, }, }); From ea3f3d8d05619dd2c0e3537538fb91269ecf2723 Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 10 Mar 2026 14:39:41 +0100 Subject: [PATCH 21/24] Refactor unified push permission handling and improve avatar image downloading with enhanced security checks --- .../app/tauri/notification/NotificationPlugin.kt | 16 +++++++++++----- .../notification/TauriNotificationManager.kt | 9 +++++++-- .../TauriUnifiedPushMessagingService.kt | 1 + 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index b4531d9..8990481 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -621,12 +621,18 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @PermissionCallback private fun unifiedPushPermissionsCallback(invoke: Invoke) { - if (!manager.areNotificationsEnabled()) { - synchronized(unifiedPushLock) { - if (pendingUnifiedPushInvoke === invoke) { - pendingUnifiedPushInvoke = null - } + val isCurrent: Boolean + synchronized(unifiedPushLock) { + isCurrent = pendingUnifiedPushInvoke === invoke + if (isCurrent && !manager.areNotificationsEnabled()) { + pendingUnifiedPushInvoke = null } + } + + // Stale callback — a newer registerForUnifiedPush already rejected this invoke + if (!isCurrent) return + + if (!manager.areNotificationsEnabled()) { invoke.reject("Notification permissions denied") return } diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index b99d63c..bd8e6c6 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -50,7 +50,7 @@ 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.newCachedThreadPool() + private val avatarExecutor: java.util.concurrent.ExecutorService = java.util.concurrent.Executors.newFixedThreadPool(4) fun handleNotificationActionPerformed( data: Intent, @@ -168,7 +168,12 @@ class TauriNotificationManager( val future = avatarExecutor.submit( java.util.concurrent.Callable { try { - val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection + 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) { diff --git a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt index 8147951..ab1ed96 100644 --- a/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt +++ b/android/src/main/java/app/tauri/notification/TauriUnifiedPushMessagingService.kt @@ -20,6 +20,7 @@ open class TauriUnifiedPushMessagingService : MessagingReceiver() { companion object { private const val TAG = "TauriUnifiedPush" + @Volatile private var executor: Executor = Executors.newSingleThreadExecutor() @Volatile From daa413dd05bfd353a9560f8f312dfbb0f73ca931 Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 10 Mar 2026 14:53:50 +0100 Subject: [PATCH 22/24] Adjust codestyle to follow prettier to make CI happy --- examples/notifications-demo/svelte.config.js | 6 +- examples/notifications-demo/vite.config.js | 8 +- guest-js/index.test.ts | 806 ++++++++++--------- guest-js/index.ts | 133 +-- rollup.config.js | 16 +- vitest.config.ts | 14 +- 6 files changed, 538 insertions(+), 445 deletions(-) diff --git a/examples/notifications-demo/svelte.config.js b/examples/notifications-demo/svelte.config.js index 9093d92..a7830ea 100644 --- a/examples/notifications-demo/svelte.config.js +++ b/examples/notifications-demo/svelte.config.js @@ -2,15 +2,15 @@ // so we use adapter-static with a fallback to index.html to put the site in SPA mode // See: https://svelte.dev/docs/kit/single-page-apps // See: https://v2.tauri.app/start/frontend/sveltekit/ for more info -import adapter from '@sveltejs/adapter-static'; -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; +import adapter from "@sveltejs/adapter-static"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: vitePreprocess(), kit: { adapter: adapter({ - fallback: 'index.html', + fallback: "index.html", }), }, }; diff --git a/examples/notifications-demo/vite.config.js b/examples/notifications-demo/vite.config.js index ba5d503..3ecfa0a 100644 --- a/examples/notifications-demo/vite.config.js +++ b/examples/notifications-demo/vite.config.js @@ -1,5 +1,5 @@ -import { defineConfig } from 'vite'; -import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from "vite"; +import { sveltekit } from "@sveltejs/kit/vite"; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; @@ -19,14 +19,14 @@ export default defineConfig(async () => ({ host: host || false, hmr: host ? { - protocol: 'ws', + protocol: "ws", host, port: 1421, } : undefined, watch: { // 3. tell Vite to ignore watching `src-tauri` - ignored: ['**/src-tauri/**'], + ignored: ["**/src-tauri/**"], }, }, })); diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index eddfef9..3ffb646 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock the Tauri API before imports const mockInvoke = vi.fn(); const mockAddPluginListener = vi.fn(); -vi.mock('@tauri-apps/api/core', () => ({ +vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: any[]) => mockInvoke(...args), addPluginListener: (...args: any[]) => mockAddPluginListener(...args), })); @@ -42,12 +42,12 @@ import { onNotificationReceived, onAction, onNotificationClicked, -} from './index'; +} from "./index"; -describe('Schedule', () => { - describe('Schedule.at', () => { - it('should create a schedule with date and default values', () => { - const date = new Date('2024-12-25T10:00:00'); +describe("Schedule", () => { + describe("Schedule.at", () => { + it("should create a schedule with date and default values", () => { + const date = new Date("2024-12-25T10:00:00"); const schedule = Schedule.at(date); expect(schedule.at).toBeDefined(); @@ -58,8 +58,8 @@ describe('Schedule', () => { expect(schedule.every).toBeUndefined(); }); - it('should create a repeating schedule', () => { - const date = new Date('2024-12-25T10:00:00'); + it("should create a repeating schedule", () => { + const date = new Date("2024-12-25T10:00:00"); const schedule = Schedule.at(date, true); expect(schedule.at).toBeDefined(); @@ -67,8 +67,8 @@ describe('Schedule', () => { expect(schedule.at?.allowWhileIdle).toBe(false); }); - it('should create a schedule with allowWhileIdle', () => { - const date = new Date('2024-12-25T10:00:00'); + it("should create a schedule with allowWhileIdle", () => { + const date = new Date("2024-12-25T10:00:00"); const schedule = Schedule.at(date, false, true); expect(schedule.at).toBeDefined(); @@ -76,8 +76,8 @@ describe('Schedule', () => { expect(schedule.at?.allowWhileIdle).toBe(true); }); - it('should create a repeating schedule with allowWhileIdle', () => { - const date = new Date('2024-12-25T10:00:00'); + it("should create a repeating schedule with allowWhileIdle", () => { + const date = new Date("2024-12-25T10:00:00"); const schedule = Schedule.at(date, true, true); expect(schedule.at).toBeDefined(); @@ -88,16 +88,16 @@ describe('Schedule', () => { expect(schedule.every).toBeUndefined(); }); - it('should preserve exact date object reference', () => { - const date = new Date('2024-01-01T00:00:00'); + it("should preserve exact date object reference", () => { + const date = new Date("2024-01-01T00:00:00"); const schedule = Schedule.at(date); expect(schedule.at?.date).toBe(date); }); }); - describe('Schedule.interval', () => { - it('should create an interval schedule with default allowWhileIdle', () => { + describe("Schedule.interval", () => { + it("should create an interval schedule with default allowWhileIdle", () => { const interval = { hour: 10, minute: 30 }; const schedule = Schedule.interval(interval); @@ -108,7 +108,7 @@ describe('Schedule', () => { expect(schedule.every).toBeUndefined(); }); - it('should create an interval schedule with allowWhileIdle', () => { + it("should create an interval schedule with allowWhileIdle", () => { const interval = { hour: 10, minute: 30 }; const schedule = Schedule.interval(interval, true); @@ -117,7 +117,7 @@ describe('Schedule', () => { expect(schedule.interval?.allowWhileIdle).toBe(true); }); - it('should handle complex interval with all time components', () => { + it("should handle complex interval with all time components", () => { const interval = { year: 2024, month: 11, @@ -139,14 +139,14 @@ describe('Schedule', () => { expect(schedule.interval?.interval.second).toBe(15); }); - it('should handle partial interval with only hour', () => { + it("should handle partial interval with only hour", () => { const interval = { hour: 15 }; const schedule = Schedule.interval(interval); expect(schedule.interval?.interval).toEqual({ hour: 15 }); }); - it('should preserve interval object reference', () => { + it("should preserve interval object reference", () => { const interval = { minute: 45 }; const schedule = Schedule.interval(interval); @@ -154,8 +154,8 @@ describe('Schedule', () => { }); }); - describe('Schedule.every', () => { - it('should create an every schedule with default allowWhileIdle', () => { + describe("Schedule.every", () => { + it("should create an every schedule with default allowWhileIdle", () => { const schedule = Schedule.every(ScheduleEvery.Day, 1); expect(schedule.every).toBeDefined(); @@ -166,7 +166,7 @@ describe('Schedule', () => { expect(schedule.interval).toBeUndefined(); }); - it('should create an every schedule with allowWhileIdle', () => { + it("should create an every schedule with allowWhileIdle", () => { const schedule = Schedule.every(ScheduleEvery.Hour, 2, true); expect(schedule.every).toBeDefined(); @@ -175,7 +175,7 @@ describe('Schedule', () => { expect(schedule.every?.allowWhileIdle).toBe(true); }); - it('should handle all ScheduleEvery enum values', () => { + it("should handle all ScheduleEvery enum values", () => { const intervals = [ ScheduleEvery.Year, ScheduleEvery.Month, @@ -193,7 +193,7 @@ describe('Schedule', () => { }); }); - it('should handle different count values', () => { + it("should handle different count values", () => { const counts = [1, 2, 5, 10, 100]; counts.forEach((count) => { @@ -202,14 +202,14 @@ describe('Schedule', () => { }); }); - it('should create schedule for every second', () => { + it("should create schedule for every second", () => { const schedule = Schedule.every(ScheduleEvery.Second, 30); expect(schedule.every?.interval).toBe(ScheduleEvery.Second); expect(schedule.every?.count).toBe(30); }); - it('should create schedule for every year', () => { + it("should create schedule for every year", () => { const schedule = Schedule.every(ScheduleEvery.Year, 1); expect(schedule.every?.interval).toBe(ScheduleEvery.Year); @@ -217,8 +217,8 @@ describe('Schedule', () => { }); }); - describe('Schedule mutual exclusivity', () => { - it('should have only at field when using Schedule.at', () => { + describe("Schedule mutual exclusivity", () => { + it("should have only at field when using Schedule.at", () => { const schedule = Schedule.at(new Date()); expect(schedule.at).toBeDefined(); @@ -226,7 +226,7 @@ describe('Schedule', () => { expect(schedule.every).toBeUndefined(); }); - it('should have only interval field when using Schedule.interval', () => { + it("should have only interval field when using Schedule.interval", () => { const schedule = Schedule.interval({ hour: 10 }); expect(schedule.interval).toBeDefined(); @@ -234,7 +234,7 @@ describe('Schedule', () => { expect(schedule.every).toBeUndefined(); }); - it('should have only every field when using Schedule.every', () => { + it("should have only every field when using Schedule.every", () => { const schedule = Schedule.every(ScheduleEvery.Day, 1); expect(schedule.every).toBeDefined(); @@ -244,38 +244,38 @@ describe('Schedule', () => { }); }); -describe('ScheduleEvery', () => { - it('should have correct enum values', () => { - expect(ScheduleEvery.Year).toBe('year'); - expect(ScheduleEvery.Month).toBe('month'); - expect(ScheduleEvery.TwoWeeks).toBe('twoWeeks'); - expect(ScheduleEvery.Week).toBe('week'); - expect(ScheduleEvery.Day).toBe('day'); - expect(ScheduleEvery.Hour).toBe('hour'); - expect(ScheduleEvery.Minute).toBe('minute'); - expect(ScheduleEvery.Second).toBe('second'); +describe("ScheduleEvery", () => { + it("should have correct enum values", () => { + expect(ScheduleEvery.Year).toBe("year"); + expect(ScheduleEvery.Month).toBe("month"); + expect(ScheduleEvery.TwoWeeks).toBe("twoWeeks"); + expect(ScheduleEvery.Week).toBe("week"); + expect(ScheduleEvery.Day).toBe("day"); + expect(ScheduleEvery.Hour).toBe("hour"); + expect(ScheduleEvery.Minute).toBe("minute"); + expect(ScheduleEvery.Second).toBe("second"); }); - it('should have exactly 8 enum values', () => { + it("should have exactly 8 enum values", () => { const values = Object.values(ScheduleEvery); expect(values).toHaveLength(8); }); - it('should contain all expected values', () => { + it("should contain all expected values", () => { const values = Object.values(ScheduleEvery); - expect(values).toContain('year'); - expect(values).toContain('month'); - expect(values).toContain('twoWeeks'); - expect(values).toContain('week'); - expect(values).toContain('day'); - expect(values).toContain('hour'); - expect(values).toContain('minute'); - expect(values).toContain('second'); + expect(values).toContain("year"); + expect(values).toContain("month"); + expect(values).toContain("twoWeeks"); + expect(values).toContain("week"); + expect(values).toContain("day"); + expect(values).toContain("hour"); + expect(values).toContain("minute"); + expect(values).toContain("second"); }); }); -describe('Importance', () => { - it('should have correct enum values', () => { +describe("Importance", () => { + it("should have correct enum values", () => { expect(Importance.None).toBe(0); expect(Importance.Min).toBe(1); expect(Importance.Low).toBe(2); @@ -283,14 +283,14 @@ describe('Importance', () => { expect(Importance.High).toBe(4); }); - it('should have sequential numeric values', () => { + it("should have sequential numeric values", () => { expect(Importance.Min).toBe(Importance.None + 1); expect(Importance.Low).toBe(Importance.Min + 1); expect(Importance.Default).toBe(Importance.Low + 1); expect(Importance.High).toBe(Importance.Default + 1); }); - it('should have exactly 5 importance levels', () => { + it("should have exactly 5 importance levels", () => { const values = [ Importance.None, Importance.Min, @@ -301,62 +301,62 @@ describe('Importance', () => { expect(values).toHaveLength(5); }); - it('should start at 0', () => { + it("should start at 0", () => { expect(Importance.None).toBe(0); }); - it('should end at 4', () => { + it("should end at 4", () => { expect(Importance.High).toBe(4); }); }); -describe('Visibility', () => { - it('should have correct enum values', () => { +describe("Visibility", () => { + it("should have correct enum values", () => { expect(Visibility.Secret).toBe(-1); expect(Visibility.Private).toBe(0); expect(Visibility.Public).toBe(1); }); - it('should have exactly 3 visibility levels', () => { + it("should have exactly 3 visibility levels", () => { const values = [Visibility.Secret, Visibility.Private, Visibility.Public]; expect(values).toHaveLength(3); }); - it('should have sequential values from -1 to 1', () => { + it("should have sequential values from -1 to 1", () => { expect(Visibility.Secret).toBe(-1); expect(Visibility.Private).toBe(0); expect(Visibility.Public).toBe(1); }); - it('should have Private as middle value', () => { + it("should have Private as middle value", () => { expect(Visibility.Private).toBe(0); expect(Visibility.Secret).toBeLessThan(Visibility.Private); expect(Visibility.Public).toBeGreaterThan(Visibility.Private); }); }); -describe('Schedule edge cases', () => { - it('should handle date with zero milliseconds', () => { - const date = new Date('2024-01-01T00:00:00.000Z'); +describe("Schedule edge cases", () => { + it("should handle date with zero milliseconds", () => { + const date = new Date("2024-01-01T00:00:00.000Z"); const schedule = Schedule.at(date); expect(schedule.at?.date.getMilliseconds()).toBe(0); }); - it('should handle interval with zero values', () => { + it("should handle interval with zero values", () => { const interval = { hour: 0, minute: 0, second: 0 }; const schedule = Schedule.interval(interval); expect(schedule.interval?.interval).toEqual(interval); }); - it('should handle every with count of zero', () => { + it("should handle every with count of zero", () => { const schedule = Schedule.every(ScheduleEvery.Day, 0); expect(schedule.every?.count).toBe(0); }); - it('should handle weekday boundary values (1-7)', () => { + it("should handle weekday boundary values (1-7)", () => { const interval1 = { weekday: 1 }; const interval7 = { weekday: 7 }; @@ -367,7 +367,7 @@ describe('Schedule edge cases', () => { expect(schedule7.interval?.interval.weekday).toBe(7); }); - it('should handle maximum time values', () => { + it("should handle maximum time values", () => { const interval = { year: 9999, month: 11, @@ -381,23 +381,23 @@ describe('Schedule edge cases', () => { expect(schedule.interval?.interval).toEqual(interval); }); - it('should handle empty interval object', () => { + it("should handle empty interval object", () => { const interval = {}; const schedule = Schedule.interval(interval); expect(schedule.interval?.interval).toEqual({}); }); - it('should handle future date', () => { - const futureDate = new Date('2050-01-01T00:00:00'); + it("should handle future date", () => { + const futureDate = new Date("2050-01-01T00:00:00"); const schedule = Schedule.at(futureDate); expect(schedule.at?.date).toBe(futureDate); expect(schedule.at?.date.getFullYear()).toBe(2050); }); - it('should handle past date', () => { - const pastDate = new Date('2000-01-01T00:00:00'); + it("should handle past date", () => { + const pastDate = new Date("2000-01-01T00:00:00"); const schedule = Schedule.at(pastDate); expect(schedule.at?.date).toBe(pastDate); @@ -405,8 +405,8 @@ describe('Schedule edge cases', () => { }); }); -describe('Schedule type safety', () => { - it('should have mutually exclusive schedule types', () => { +describe("Schedule type safety", () => { + it("should have mutually exclusive schedule types", () => { const atSchedule = Schedule.at(new Date()); const intervalSchedule = Schedule.interval({ hour: 10 }); const everySchedule = Schedule.every(ScheduleEvery.Day, 1); @@ -424,7 +424,7 @@ describe('Schedule type safety', () => { expect(everySchedule.interval).toBeFalsy(); }); - it('should return Schedule type from all factory methods', () => { + it("should return Schedule type from all factory methods", () => { const atSchedule = Schedule.at(new Date()); const intervalSchedule = Schedule.interval({ hour: 10 }); const everySchedule = Schedule.every(ScheduleEvery.Day, 1); @@ -435,23 +435,25 @@ describe('Schedule type safety', () => { }); }); -describe('Notification Functions', () => { +describe("Notification Functions", () => { beforeEach(() => { mockInvoke.mockClear(); mockAddPluginListener.mockClear(); }); - describe('isPermissionGranted', () => { - it('should call invoke with correct plugin command', async () => { + describe("isPermissionGranted", () => { + it("should call invoke with correct plugin command", async () => { mockInvoke.mockResolvedValue(true); const result = await isPermissionGranted(); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|is_permission_granted'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|is_permission_granted", + ); expect(result).toBe(true); }); - it('should return false when permission not granted', async () => { + it("should return false when permission not granted", async () => { mockInvoke.mockResolvedValue(false); const result = await isPermissionGranted(); @@ -460,51 +462,53 @@ describe('Notification Functions', () => { }); }); - describe('requestPermission', () => { - it('should call invoke with correct plugin command', async () => { - mockInvoke.mockResolvedValue('granted'); + describe("requestPermission", () => { + it("should call invoke with correct plugin command", async () => { + mockInvoke.mockResolvedValue("granted"); const result = await requestPermission(); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|request_permission'); - expect(result).toBe('granted'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|request_permission", + ); + expect(result).toBe("granted"); }); - it('should return denied when permission denied', async () => { - mockInvoke.mockResolvedValue('denied'); + it("should return denied when permission denied", async () => { + mockInvoke.mockResolvedValue("denied"); const result = await requestPermission(); - expect(result).toBe('denied'); + expect(result).toBe("denied"); }); }); - describe('registerForPushNotifications', () => { - it('should call invoke and return push token', async () => { - const mockToken = 'abc123token'; + describe("registerForPushNotifications", () => { + it("should call invoke and return push token", async () => { + const mockToken = "abc123token"; mockInvoke.mockResolvedValue(mockToken); const result = await registerForPushNotifications(); expect(mockInvoke).toHaveBeenCalledWith( - 'plugin:notifications|register_for_push_notifications' + "plugin:notifications|register_for_push_notifications", ); expect(result).toBe(mockToken); }); }); - describe('unregisterForPushNotifications', () => { - it('should call invoke with correct plugin command', async () => { + describe("unregisterForPushNotifications", () => { + it("should call invoke with correct plugin command", async () => { mockInvoke.mockResolvedValue(undefined); await unregisterForPushNotifications(); expect(mockInvoke).toHaveBeenCalledWith( - 'plugin:notifications|unregister_for_push_notifications' + "plugin:notifications|unregister_for_push_notifications", ); }); - it('should resolve without a return value', async () => { + it("should resolve without a return value", async () => { mockInvoke.mockResolvedValue(undefined); const result = await unregisterForPushNotifications(); @@ -513,45 +517,54 @@ describe('Notification Functions', () => { }); }); - describe('registerForUnifiedPush', () => { - it('should call invoke with correct plugin command', async () => { + describe("registerForUnifiedPush", () => { + it("should call invoke with correct plugin command", async () => { const mockEndpoint = { - endpoint: 'https://example.com/push', - instance: 'default', + endpoint: "https://example.com/push", + instance: "default", }; mockInvoke.mockResolvedValue(mockEndpoint); const result = await registerForUnifiedPush(); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|register_for_unified_push'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|register_for_unified_push", + ); expect(result).toEqual(mockEndpoint); }); }); - describe('unregisterFromUnifiedPush', () => { - it('should call invoke with correct plugin command', async () => { + 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'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|unregister_from_unified_push", + ); }); }); - describe('getUnifiedPushDistributors', () => { - it('should return the list of distributors', async () => { + describe("getUnifiedPushDistributors", () => { + it("should return the list of distributors", async () => { const mockDistributors = { - distributors: ['org.unifiedpush.distributor.nextpush', 'io.heckel.ntfy'], + 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(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|get_unified_push_distributors", + ); expect(result).toEqual(mockDistributors); }); - it('should handle empty distributors list', async () => { + it("should handle empty distributors list", async () => { mockInvoke.mockResolvedValue({ distributors: [] }); const result = await getUnifiedPushDistributors(); @@ -560,43 +573,45 @@ describe('Notification Functions', () => { }); }); - describe('saveUnifiedPushDistributor', () => { - it('should call invoke with distributor parameter', async () => { + describe("saveUnifiedPushDistributor", () => { + it("should call invoke with distributor parameter", async () => { mockInvoke.mockResolvedValue(undefined); - await saveUnifiedPushDistributor('org.unifiedpush.distributor.nextpush'); + await saveUnifiedPushDistributor("org.unifiedpush.distributor.nextpush"); expect(mockInvoke).toHaveBeenCalledWith( - 'plugin:notifications|save_unified_push_distributor', - { distributor: 'org.unifiedpush.distributor.nextpush' } + "plugin:notifications|save_unified_push_distributor", + { distributor: "org.unifiedpush.distributor.nextpush" }, ); }); }); - describe('getUnifiedPushDistributor', () => { - it('should return the current distributor', async () => { + describe("getUnifiedPushDistributor", () => { + it("should return the current distributor", async () => { const mockDistributor = { - distributor: 'org.unifiedpush.distributor.nextpush', + distributor: "org.unifiedpush.distributor.nextpush", }; mockInvoke.mockResolvedValue(mockDistributor); const result = await getUnifiedPushDistributor(); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|get_unified_push_distributor'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|get_unified_push_distributor", + ); expect(result).toEqual(mockDistributor); }); - it('should handle empty distributor', async () => { - mockInvoke.mockResolvedValue({ distributor: '' }); + it("should handle empty distributor", async () => { + mockInvoke.mockResolvedValue({ distributor: "" }); const result = await getUnifiedPushDistributor(); - expect(result.distributor).toBe(''); + expect(result.distributor).toBe(""); }); }); - describe('onUnifiedPushEndpoint', () => { - it('should register endpoint listener', async () => { + describe("onUnifiedPushEndpoint", () => { + it("should register endpoint listener", async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -604,14 +619,14 @@ describe('Notification Functions', () => { const unlisten = await onUnifiedPushEndpoint(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - 'notifications', - 'unifiedpush-endpoint', - callback + "notifications", + "unifiedpush-endpoint", + callback, ); expect(unlisten).toBe(mockUnlisten); }); - it('should call callback with endpoint data', async () => { + it("should call callback with endpoint data", async () => { let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; @@ -622,15 +637,15 @@ describe('Notification Functions', () => { await onUnifiedPushEndpoint(callback); const endpointData = { - endpoint: 'https://example.com/push', - instance: 'default', + 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 () => { + 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; @@ -641,11 +656,12 @@ describe('Notification Functions', () => { await onUnifiedPushEndpoint(callback); const endpointData = { - endpoint: 'https://nextpush.example.com/push/xyz', - instance: 'default', + endpoint: "https://nextpush.example.com/push/xyz", + instance: "default", pubKeySet: { - pubKey: 'BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF', - auth: '8eDyX_uCN0XRhSbY5hs7Hg', + pubKey: + "BNcRdreALRFXTkOOUHK1EtK2wtZ5ZIILHY0CRbISTuErp8KS0DLjFCMDxEPPW4ECPF", + auth: "8eDyX_uCN0XRhSbY5hs7Hg", }, }; capturedCallback?.(endpointData); @@ -656,8 +672,8 @@ describe('Notification Functions', () => { }); }); - describe('onUnifiedPushMessage', () => { - it('should register message listener', async () => { + describe("onUnifiedPushMessage", () => { + it("should register message listener", async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -665,14 +681,14 @@ describe('Notification Functions', () => { const unlisten = await onUnifiedPushMessage(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - 'notifications', - 'unifiedpush-message', - callback + "notifications", + "unifiedpush-message", + callback, ); expect(unlisten).toBe(mockUnlisten); }); - it('should call callback with message data', async () => { + it("should call callback with message data", async () => { let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; @@ -683,10 +699,10 @@ describe('Notification Functions', () => { await onUnifiedPushMessage(callback); const messageData = { - title: 'Hello', - body: 'World', - instance: 'default', - source: 'unifiedpush', + title: "Hello", + body: "World", + instance: "default", + source: "unifiedpush", }; capturedCallback?.(messageData); @@ -694,8 +710,8 @@ describe('Notification Functions', () => { }); }); - describe('onUnifiedPushUnregistered', () => { - it('should register unregistered listener', async () => { + describe("onUnifiedPushUnregistered", () => { + it("should register unregistered listener", async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -703,14 +719,14 @@ describe('Notification Functions', () => { const unlisten = await onUnifiedPushUnregistered(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - 'notifications', - 'unifiedpush-unregistered', - callback + "notifications", + "unifiedpush-unregistered", + callback, ); expect(unlisten).toBe(mockUnlisten); }); - it('should call callback with instance data', async () => { + it("should call callback with instance data", async () => { let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; @@ -720,14 +736,14 @@ describe('Notification Functions', () => { const callback = vi.fn(); await onUnifiedPushUnregistered(callback); - capturedCallback?.({ instance: 'default' }); + capturedCallback?.({ instance: "default" }); - expect(callback).toHaveBeenCalledWith({ instance: 'default' }); + expect(callback).toHaveBeenCalledWith({ instance: "default" }); }); }); - describe('onUnifiedPushError', () => { - it('should register error listener', async () => { + describe("onUnifiedPushError", () => { + it("should register error listener", async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -735,14 +751,14 @@ describe('Notification Functions', () => { const unlisten = await onUnifiedPushError(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - 'notifications', - 'unifiedpush-error', - callback + "notifications", + "unifiedpush-error", + callback, ); expect(unlisten).toBe(mockUnlisten); }); - it('should call callback with error data', async () => { + it("should call callback with error data", async () => { let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; @@ -752,15 +768,15 @@ describe('Notification Functions', () => { const callback = vi.fn(); await onUnifiedPushError(callback); - const errorData = { message: 'Registration failed', instance: 'default' }; + const errorData = { message: "Registration failed", instance: "default" }; capturedCallback?.(errorData); expect(callback).toHaveBeenCalledWith(errorData); }); }); - describe('onUnifiedPushTempUnavailable', () => { - it('should register temp-unavailable listener', async () => { + describe("onUnifiedPushTempUnavailable", () => { + it("should register temp-unavailable listener", async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -768,14 +784,14 @@ describe('Notification Functions', () => { const unlisten = await onUnifiedPushTempUnavailable(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - 'notifications', - 'unifiedpush-temp-unavailable', - callback + "notifications", + "unifiedpush-temp-unavailable", + callback, ); expect(unlisten).toBe(mockUnlisten); }); - it('should call callback with instance data', async () => { + it("should call callback with instance data", async () => { let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { capturedCallback = cb; @@ -785,73 +801,73 @@ describe('Notification Functions', () => { const callback = vi.fn(); await onUnifiedPushTempUnavailable(callback); - capturedCallback?.({ instance: 'default' }); + capturedCallback?.({ instance: "default" }); - expect(callback).toHaveBeenCalledWith({ instance: 'default' }); + expect(callback).toHaveBeenCalledWith({ instance: "default" }); }); }); - describe('sendNotification', () => { - it('should send notification with string title', async () => { + describe("sendNotification", () => { + it("should send notification with string title", async () => { mockInvoke.mockResolvedValue(undefined); - await sendNotification('Test Title'); + await sendNotification("Test Title"); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { - options: { title: 'Test Title' }, + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { + options: { title: "Test Title" }, }); }); - it('should send notification with full options object', async () => { + it("should send notification with full options object", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'Test', - body: 'Test body', + title: "Test", + body: "Test body", id: 123, - channelId: 'test-channel', + channelId: "test-channel", }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); - it('should send notification with schedule', async () => { + it("should send notification with schedule", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'Scheduled', - schedule: Schedule.at(new Date('2024-12-25T10:00:00')), + title: "Scheduled", + schedule: Schedule.at(new Date("2024-12-25T10:00:00")), }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); - it('should send notification with all optional fields', async () => { + it("should send notification with all optional fields", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'Full notification', - body: 'Body text', - largeBody: 'Large body', - summary: 'Summary', - actionTypeId: 'action-1', - group: 'group-1', + title: "Full notification", + body: "Body text", + largeBody: "Large body", + summary: "Summary", + actionTypeId: "action-1", + group: "group-1", groupSummary: true, - sound: 'notification.mp3', - inboxLines: ['Line 1', 'Line 2'], - icon: 'ic_notification', - largeIcon: 'ic_large', - iconColor: '#FF0000', - attachments: [{ id: 'att1', url: 'file://image.jpg' }], - extra: { key: 'value' }, + sound: "notification.mp3", + inboxLines: ["Line 1", "Line 2"], + icon: "ic_notification", + largeIcon: "ic_large", + iconColor: "#FF0000", + attachments: [{ id: "att1", url: "file://image.jpg" }], + extra: { key: "value" }, ongoing: true, autoCancel: false, silent: true, @@ -861,52 +877,55 @@ describe('Notification Functions', () => { await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); }); - describe('registerActionTypes', () => { - it('should register action types', async () => { + describe("registerActionTypes", () => { + it("should register action types", async () => { mockInvoke.mockResolvedValue(undefined); const types = [ { - id: 'message-actions', + id: "message-actions", actions: [ - { id: 'reply', title: 'Reply', input: true }, - { id: 'delete', title: 'Delete', destructive: true }, + { id: "reply", title: "Reply", input: true }, + { id: "delete", title: "Delete", destructive: true }, ], }, ]; await registerActionTypes(types); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|register_action_types', { - types, - }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|register_action_types", + { + types, + }, + ); }); - it('should register action types with all optional properties', async () => { + it("should register action types with all optional properties", async () => { mockInvoke.mockResolvedValue(undefined); const types = [ { - id: 'full-actions', + id: "full-actions", actions: [ { - id: 'action1', - title: 'Action 1', + id: "action1", + title: "Action 1", requiresAuthentication: true, foreground: true, destructive: false, input: true, - inputButtonTitle: 'Send', - inputPlaceholder: 'Type here...', + inputButtonTitle: "Send", + inputPlaceholder: "Type here...", }, ], - hiddenPreviewsBodyPlaceholder: 'Hidden', + hiddenPreviewsBodyPlaceholder: "Hidden", customDismissAction: true, allowInCarPlay: false, hiddenPreviewsShowTitle: true, @@ -916,19 +935,22 @@ describe('Notification Functions', () => { await registerActionTypes(types); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|register_action_types', { - types, - }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|register_action_types", + { + types, + }, + ); }); }); - describe('pending', () => { - it('should retrieve pending notifications', async () => { + describe("pending", () => { + it("should retrieve pending notifications", async () => { const mockPending = [ { id: 1, - title: 'Pending 1', - body: 'Body 1', + title: "Pending 1", + body: "Body 1", schedule: Schedule.at(new Date()), }, ]; @@ -936,11 +958,13 @@ describe('Notification Functions', () => { const result = await pending(); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|get_pending'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|get_pending", + ); expect(result).toEqual(mockPending); }); - it('should return empty array when no pending notifications', async () => { + it("should return empty array when no pending notifications", async () => { mockInvoke.mockResolvedValue([]); const result = await pending(); @@ -949,55 +973,57 @@ describe('Notification Functions', () => { }); }); - describe('cancel', () => { - it('should cancel notifications by IDs', async () => { + describe("cancel", () => { + it("should cancel notifications by IDs", async () => { mockInvoke.mockResolvedValue(undefined); await cancel([1, 2, 3]); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|cancel', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|cancel", { notifications: [1, 2, 3], }); }); - it('should cancel single notification', async () => { + it("should cancel single notification", async () => { mockInvoke.mockResolvedValue(undefined); await cancel([42]); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|cancel', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|cancel", { notifications: [42], }); }); - it('should handle empty array', async () => { + it("should handle empty array", async () => { mockInvoke.mockResolvedValue(undefined); await cancel([]); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|cancel', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|cancel", { notifications: [], }); }); }); - describe('cancelAll', () => { - it('should cancel all pending notifications', async () => { + describe("cancelAll", () => { + it("should cancel all pending notifications", async () => { mockInvoke.mockResolvedValue(undefined); await cancelAll(); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|cancel_all'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|cancel_all", + ); }); }); - describe('active', () => { - it('should retrieve active notifications', async () => { + describe("active", () => { + it("should retrieve active notifications", async () => { const mockActive = [ { id: 1, - title: 'Active 1', - body: 'Body 1', + title: "Active 1", + body: "Body 1", groupSummary: false, data: {}, extra: {}, @@ -1008,11 +1034,13 @@ describe('Notification Functions', () => { const result = await active(); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|get_active'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|get_active", + ); expect(result).toEqual(mockActive); }); - it('should return empty array when no active notifications', async () => { + it("should return empty array when no active notifications", async () => { mockInvoke.mockResolvedValue([]); const result = await active(); @@ -1021,72 +1049,86 @@ describe('Notification Functions', () => { }); }); - describe('removeActive', () => { - it('should remove active notifications by ID', async () => { + describe("removeActive", () => { + it("should remove active notifications by ID", async () => { mockInvoke.mockResolvedValue(undefined); await removeActive([{ id: 1 }, { id: 2 }]); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|remove_active', { - notifications: [{ id: 1 }, { id: 2 }], - }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|remove_active", + { + notifications: [{ id: 1 }, { id: 2 }], + }, + ); }); - it('should remove active notification with tag', async () => { + it("should remove active notification with tag", async () => { mockInvoke.mockResolvedValue(undefined); - await removeActive([{ id: 1, tag: 'news' }]); + await removeActive([{ id: 1, tag: "news" }]); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|remove_active', { - notifications: [{ id: 1, tag: 'news' }], - }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|remove_active", + { + notifications: [{ id: 1, tag: "news" }], + }, + ); }); - it('should handle empty array', async () => { + it("should handle empty array", async () => { mockInvoke.mockResolvedValue(undefined); await removeActive([]); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|remove_active', { - notifications: [], - }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|remove_active", + { + notifications: [], + }, + ); }); }); - describe('removeAllActive', () => { - it('should remove all active notifications', async () => { + describe("removeAllActive", () => { + it("should remove all active notifications", async () => { mockInvoke.mockResolvedValue(undefined); await removeAllActive(); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|remove_active'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|remove_active", + ); }); }); - describe('createChannel', () => { - it('should create notification channel with minimal properties', async () => { + describe("createChannel", () => { + it("should create notification channel with minimal properties", async () => { mockInvoke.mockResolvedValue(undefined); const channel = { - id: 'test-channel', - name: 'Test Channel', + id: "test-channel", + name: "Test Channel", }; await createChannel(channel); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|create_channel', { channel }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|create_channel", + { channel }, + ); }); - it('should create channel with all properties', async () => { + it("should create channel with all properties", async () => { mockInvoke.mockResolvedValue(undefined); const channel = { - id: 'full-channel', - name: 'Full Channel', - description: 'Channel description', - sound: 'notification.mp3', + id: "full-channel", + name: "Full Channel", + description: "Channel description", + sound: "notification.mp3", lights: true, - lightColor: '#FF0000', + lightColor: "#FF0000", vibration: true, importance: Importance.High, visibility: Visibility.Public, @@ -1094,33 +1136,39 @@ describe('Notification Functions', () => { await createChannel(channel); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|create_channel', { channel }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|create_channel", + { channel }, + ); }); }); - describe('removeChannel', () => { - it('should delete notification channel', async () => { + describe("removeChannel", () => { + it("should delete notification channel", async () => { mockInvoke.mockResolvedValue(undefined); - await removeChannel('test-channel'); + await removeChannel("test-channel"); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|delete_channel', { - id: 'test-channel', - }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|delete_channel", + { + id: "test-channel", + }, + ); }); }); - describe('channels', () => { - it('should retrieve all notification channels', async () => { + describe("channels", () => { + it("should retrieve all notification channels", async () => { const mockChannels = [ { - id: 'channel1', - name: 'Channel 1', + id: "channel1", + name: "Channel 1", importance: Importance.Default, }, { - id: 'channel2', - name: 'Channel 2', + id: "channel2", + name: "Channel 2", importance: Importance.High, }, ]; @@ -1128,11 +1176,13 @@ describe('Notification Functions', () => { const result = await channels(); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|list_channels'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|list_channels", + ); expect(result).toEqual(mockChannels); }); - it('should return empty array when no channels', async () => { + it("should return empty array when no channels", async () => { mockInvoke.mockResolvedValue([]); const result = await channels(); @@ -1141,20 +1191,24 @@ describe('Notification Functions', () => { }); }); - describe('onNotificationReceived', () => { - it('should register notification received listener', async () => { + describe("onNotificationReceived", () => { + it("should register notification received listener", async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); const callback = vi.fn(); const unlisten = await onNotificationReceived(callback); - expect(mockAddPluginListener).toHaveBeenCalledWith('notifications', 'notification', callback); + expect(mockAddPluginListener).toHaveBeenCalledWith( + "notifications", + "notification", + callback, + ); expect(unlisten).toBe(mockUnlisten); }); - it('should call callback when notification received', async () => { - const mockNotification = { title: 'Test', body: 'Body' }; + it("should call callback when notification received", async () => { + const mockNotification = { title: "Test", body: "Body" }; let capturedCallback: ((notification: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { @@ -1171,8 +1225,8 @@ describe('Notification Functions', () => { }); }); - describe('onAction', () => { - it('should register action performed listener', async () => { + describe("onAction", () => { + it("should register action performed listener", async () => { const mockUnlisten = vi.fn(); mockAddPluginListener.mockResolvedValue(mockUnlisten); @@ -1180,15 +1234,15 @@ describe('Notification Functions', () => { const unlisten = await onAction(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - 'notifications', - 'actionPerformed', - callback + "notifications", + "actionPerformed", + callback, ); expect(unlisten).toBe(mockUnlisten); }); - it('should call callback when action performed', async () => { - const mockNotification = { title: 'Test', actionTypeId: 'action-1' }; + it("should call callback when action performed", async () => { + const mockNotification = { title: "Test", actionTypeId: "action-1" }; let capturedCallback: ((notification: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { @@ -1205,8 +1259,8 @@ describe('Notification Functions', () => { }); }); - describe('onNotificationClicked', () => { - it('should register notification clicked listener', async () => { + describe("onNotificationClicked", () => { + it("should register notification clicked listener", async () => { const mockUnregister = vi.fn().mockResolvedValue(undefined); mockAddPluginListener.mockResolvedValue({ unregister: mockUnregister }); mockInvoke.mockResolvedValue(undefined); @@ -1215,17 +1269,20 @@ describe('Notification Functions', () => { const listener = await onNotificationClicked(callback); expect(mockAddPluginListener).toHaveBeenCalledWith( - 'notifications', - 'notificationClicked', - callback + "notifications", + "notificationClicked", + callback, ); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|set_click_listener_active', { - active: true, - }); - expect(listener).toHaveProperty('unregister'); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|set_click_listener_active", + { + active: true, + }, + ); + expect(listener).toHaveProperty("unregister"); }); - it('should notify native side on unregister', async () => { + it("should notify native side on unregister", async () => { const mockUnregister = vi.fn().mockResolvedValue(undefined); mockAddPluginListener.mockResolvedValue({ unregister: mockUnregister }); mockInvoke.mockResolvedValue(undefined); @@ -1236,14 +1293,17 @@ describe('Notification Functions', () => { mockInvoke.mockClear(); await listener.unregister(); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|set_click_listener_active', { - active: false, - }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|set_click_listener_active", + { + active: false, + }, + ); expect(mockUnregister).toHaveBeenCalled(); }); - it('should call callback when notification clicked', async () => { - const mockClickedData = { id: 123, data: { key: 'value' } }; + it("should call callback when notification clicked", async () => { + const mockClickedData = { id: 123, data: { key: "value" } }; let capturedCallback: ((data: any) => void) | undefined; mockAddPluginListener.mockImplementation((_plugin, _event, cb) => { @@ -1259,7 +1319,7 @@ describe('Notification Functions', () => { expect(callback).toHaveBeenCalledWith(mockClickedData); }); - it('should handle notification click without data', async () => { + it("should handle notification click without data", async () => { const mockClickedData = { id: 456 }; let capturedCallback: ((data: any) => void) | undefined; @@ -1278,44 +1338,44 @@ describe('Notification Functions', () => { }); }); - describe('sendNotification with progress bar', () => { - it('should send notification with determinate progress', async () => { + describe("sendNotification with progress bar", () => { + it("should send notification with determinate progress", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'Downloading...', + title: "Downloading...", progress: 45, progressMax: 100, }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); - it('should send notification with indeterminate progress', async () => { + it("should send notification with indeterminate progress", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'Loading...', + title: "Loading...", progressIndeterminate: true, }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); - it('should send notification with progress and body', async () => { + it("should send notification with progress and body", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'Upload', - body: 'Uploading file.txt', + title: "Upload", + body: "Uploading file.txt", progress: 75, progressMax: 100, ongoing: true, @@ -1323,59 +1383,59 @@ describe('Notification Functions', () => { await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); }); - describe('sendNotification with category', () => { - it('should send notification with message category', async () => { + describe("sendNotification with category", () => { + it("should send notification with message category", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'New Message', - body: 'Hello!', - category: 'msg', + title: "New Message", + body: "Hello!", + category: "msg", }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); - it('should send notification with alarm category', async () => { + it("should send notification with alarm category", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'Alarm', - category: 'alarm', + title: "Alarm", + category: "alarm", }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); }); - describe('sendNotification with messagingStyle', () => { - it('should send notification with simple messaging style', async () => { + describe("sendNotification with messagingStyle", () => { + it("should send notification with simple messaging style", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'Chat', + title: "Chat", messagingStyle: { - user: { name: 'Me' }, + user: { name: "Me" }, messages: [ - { text: 'Hello!', timestamp: 1700000000000 }, + { text: "Hello!", timestamp: 1700000000000 }, { - text: 'Hi there!', + text: "Hi there!", timestamp: 1700000060000, - sender: { name: 'Alice' }, + sender: { name: "Alice" }, }, ], }, @@ -1383,30 +1443,30 @@ describe('Notification Functions', () => { await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); - it('should send notification with group conversation', async () => { + it("should send notification with group conversation", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'Group Chat', + title: "Group Chat", messagingStyle: { - user: { name: 'Me', key: 'user-1' }, - conversationTitle: 'Project Team', + user: { name: "Me", key: "user-1" }, + conversationTitle: "Project Team", isGroupConversation: true, messages: [ { - text: 'Meeting at 3pm', + text: "Meeting at 3pm", timestamp: 1700000000000, - sender: { name: 'Bob', key: 'user-2', icon: 'ic_bob' }, + sender: { name: "Bob", key: "user-2", icon: "ic_bob" }, }, { - text: 'Sounds good!', + text: "Sounds good!", timestamp: 1700000060000, - sender: { name: 'Carol', key: 'user-3' }, + sender: { name: "Carol", key: "user-3" }, }, { text: "I'll be there", timestamp: 1700000120000 }, ], @@ -1415,44 +1475,44 @@ describe('Notification Functions', () => { await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); - it('should send notification with user icon in messaging style', async () => { + it("should send notification with user icon in messaging style", async () => { mockInvoke.mockResolvedValue(undefined); const options = { - title: 'Chat', + title: "Chat", messagingStyle: { - user: { name: 'Me', icon: 'ic_me', key: 'self' }, - messages: [{ text: 'Hey!', timestamp: 1700000000000 }], + user: { name: "Me", icon: "ic_me", key: "self" }, + messages: [{ text: "Hey!", timestamp: 1700000000000 }], }, }; await sendNotification(options); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|notify', { + expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|notify", { options, }); }); }); - describe('registerActionTypes with icon', () => { - it('should register action types with custom icons', async () => { + describe("registerActionTypes with icon", () => { + it("should register action types with custom icons", async () => { mockInvoke.mockResolvedValue(undefined); const types = [ { - id: 'message-actions', + id: "message-actions", actions: [ - { id: 'reply', title: 'Reply', input: true, icon: 'ic_reply' }, + { id: "reply", title: "Reply", input: true, icon: "ic_reply" }, { - id: 'delete', - title: 'Delete', + id: "delete", + title: "Delete", destructive: true, - icon: 'ic_delete', + icon: "ic_delete", }, ], }, @@ -1460,29 +1520,35 @@ describe('Notification Functions', () => { await registerActionTypes(types); - expect(mockInvoke).toHaveBeenCalledWith('plugin:notifications|register_action_types', { - types, - }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|register_action_types", + { + types, + }, + ); }); - it('should register action types mixing icons and no icons', async () => { + it("should register action types mixing icons and no icons", async () => { mockInvoke.mockResolvedValue(undefined); const types = [ { - id: 'mixed-actions', + id: "mixed-actions", actions: [ - { id: 'action-with-icon', title: 'With Icon', icon: 'ic_star' }, - { id: 'action-without-icon', title: 'Without Icon' }, + { 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, - }); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|register_action_types", + { + types, + }, + ); }); }); }); diff --git a/guest-js/index.ts b/guest-js/index.ts index 91687fc..997f549 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -5,9 +5,13 @@ * @module */ -import { invoke, type PluginListener, addPluginListener } from '@tauri-apps/api/core'; +import { + invoke, + type PluginListener, + addPluginListener, +} from "@tauri-apps/api/core"; -export type { PermissionState } from '@tauri-apps/api/core'; +export type { PermissionState } from "@tauri-apps/api/core"; /** * Options to send a notification. @@ -118,7 +122,7 @@ interface Options { * - `"unifiedpush"` — notification received from a UnifiedPush distributor. * - `"local"` — notification created locally (immediate or scheduled). */ - source?: 'push' | 'unifiedpush' | 'local'; + source?: "push" | "unifiedpush" | "local"; /** * Notification visibility. */ @@ -244,17 +248,17 @@ interface ScheduleInterval { * Predefined intervals for repeating notifications. */ enum ScheduleEvery { - Year = 'year', - Month = 'month', - TwoWeeks = 'twoWeeks', - Week = 'week', - Day = 'day', - Hour = 'hour', - Minute = 'minute', + Year = "year", + Month = "month", + TwoWeeks = "twoWeeks", + Week = "week", + Day = "day", + Hour = "hour", + Minute = "minute", /** * Not supported on iOS. */ - Second = 'second', + Second = "second", } /** @@ -310,7 +314,10 @@ class Schedule { * @param allowWhileIdle - On Android, allows notification to fire even when the device is in idle mode. * @returns A new Schedule instance. */ - static interval(interval: ScheduleInterval, allowWhileIdle = false): Schedule { + static interval( + interval: ScheduleInterval, + allowWhileIdle = false, + ): Schedule { return { at: undefined, interval: { interval, allowWhileIdle }, @@ -326,7 +333,11 @@ class Schedule { * @param allowWhileIdle - On Android, allows notification to fire even when the device is in idle mode. * @returns A new Schedule instance. */ - static every(kind: ScheduleEvery, count: number, allowWhileIdle = false): Schedule { + static every( + kind: ScheduleEvery, + count: number, + allowWhileIdle = false, + ): Schedule { return { at: undefined, interval: undefined, @@ -498,7 +509,7 @@ interface Channel { * ``` */ async function isPermissionGranted(): Promise { - return await invoke('plugin:notifications|is_permission_granted'); + return await invoke("plugin:notifications|is_permission_granted"); } /** @@ -516,7 +527,7 @@ async function isPermissionGranted(): Promise { * @returns A promise resolving to whether the user granted the permission or not. */ async function requestPermission(): Promise { - return await invoke('plugin:notifications|request_permission'); + return await invoke("plugin:notifications|request_permission"); } /** @@ -532,7 +543,7 @@ async function requestPermission(): Promise { * @returns A promise resolving to the device push token. */ async function registerForPushNotifications(): Promise { - return await invoke('plugin:notifications|register_for_push_notifications'); + return await invoke("plugin:notifications|register_for_push_notifications"); } /** @@ -551,7 +562,7 @@ async function registerForPushNotifications(): Promise { * @returns A promise resolving when unregistration is complete. */ async function unregisterForPushNotifications(): Promise { - return await invoke('plugin:notifications|unregister_for_push_notifications'); + return await invoke("plugin:notifications|unregister_for_push_notifications"); } /** VAPID / Web Push public key set provided by the distributor for encrypted push. */ @@ -588,7 +599,7 @@ interface UnifiedPushEndpoint { * @returns A promise resolving to the UnifiedPush endpoint information. */ async function registerForUnifiedPush(): Promise { - return await invoke('plugin:notifications|register_for_unified_push'); + return await invoke("plugin:notifications|register_for_unified_push"); } /** @@ -603,7 +614,7 @@ async function registerForUnifiedPush(): Promise { * @returns A promise resolving when unregistration is complete. */ async function unregisterFromUnifiedPush(): Promise { - return await invoke('plugin:notifications|unregister_from_unified_push'); + return await invoke("plugin:notifications|unregister_from_unified_push"); } /** @@ -621,7 +632,7 @@ async function unregisterFromUnifiedPush(): Promise { async function getUnifiedPushDistributors(): Promise<{ distributors: string[]; }> { - return await invoke('plugin:notifications|get_unified_push_distributors'); + return await invoke("plugin:notifications|get_unified_push_distributors"); } /** @@ -637,7 +648,7 @@ async function getUnifiedPushDistributors(): Promise<{ * @returns A promise resolving when the distributor is saved. */ async function saveUnifiedPushDistributor(distributor: string): Promise { - return await invoke('plugin:notifications|save_unified_push_distributor', { + return await invoke("plugin:notifications|save_unified_push_distributor", { distributor, }); } @@ -654,7 +665,7 @@ async function saveUnifiedPushDistributor(distributor: string): Promise { * @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'); + return await invoke("plugin:notifications|get_unified_push_distributor"); } /** @@ -672,9 +683,9 @@ async function getUnifiedPushDistributor(): Promise<{ distributor: string }> { * @returns A promise resolving to a function that removes the listener. */ async function onUnifiedPushEndpoint( - cb: (data: UnifiedPushEndpoint) => void + cb: (data: UnifiedPushEndpoint) => void, ): Promise { - return await addPluginListener('notifications', 'unifiedpush-endpoint', cb); + return await addPluginListener("notifications", "unifiedpush-endpoint", cb); } /** @@ -692,9 +703,9 @@ async function onUnifiedPushEndpoint( * @returns A promise resolving to a function that removes the listener. */ async function onUnifiedPushMessage( - cb: (data: Record) => void + cb: (data: Record) => void, ): Promise { - return await addPluginListener('notifications', 'unifiedpush-message', cb); + return await addPluginListener("notifications", "unifiedpush-message", cb); } /** @@ -712,9 +723,13 @@ async function onUnifiedPushMessage( * @returns A promise resolving to a function that removes the listener. */ async function onUnifiedPushUnregistered( - cb: (data: { instance: string }) => void + cb: (data: { instance: string }) => void, ): Promise { - return await addPluginListener('notifications', 'unifiedpush-unregistered', cb); + return await addPluginListener( + "notifications", + "unifiedpush-unregistered", + cb, + ); } /** @@ -732,9 +747,9 @@ async function onUnifiedPushUnregistered( * @returns A promise resolving to a function that removes the listener. */ async function onUnifiedPushError( - cb: (data: { message: string; instance?: string }) => void + cb: (data: { message: string; instance?: string }) => void, ): Promise { - return await addPluginListener('notifications', 'unifiedpush-error', cb); + return await addPluginListener("notifications", "unifiedpush-error", cb); } /** @@ -756,9 +771,13 @@ async function onUnifiedPushError( * @returns A promise resolving to a function that removes the listener. */ async function onUnifiedPushTempUnavailable( - cb: (data: { instance: string }) => void + cb: (data: { instance: string }) => void, ): Promise { - return await addPluginListener('notifications', 'unifiedpush-temp-unavailable', cb); + return await addPluginListener( + "notifications", + "unifiedpush-temp-unavailable", + cb, + ); } /** @@ -779,9 +798,9 @@ async function onUnifiedPushTempUnavailable( * ``` */ async function sendNotification(options: Options | string): Promise { - await invoke('plugin:notifications|notify', { + await invoke("plugin:notifications|notify", { options: - typeof options === 'string' + typeof options === "string" ? { title: options, } @@ -807,7 +826,7 @@ async function sendNotification(options: Options | string): Promise { * @returns A promise indicating the success or failure of the operation. */ async function registerActionTypes(types: ActionType[]): Promise { - await invoke('plugin:notifications|register_action_types', { types }); + await invoke("plugin:notifications|register_action_types", { types }); } /** @@ -822,7 +841,7 @@ async function registerActionTypes(types: ActionType[]): Promise { * @returns A promise resolving to the list of pending notifications. */ async function pending(): Promise { - return await invoke('plugin:notifications|get_pending'); + return await invoke("plugin:notifications|get_pending"); } /** @@ -837,7 +856,7 @@ async function pending(): Promise { * @returns A promise indicating the success or failure of the operation. */ async function cancel(notifications: number[]): Promise { - await invoke('plugin:notifications|cancel', { notifications }); + await invoke("plugin:notifications|cancel", { notifications }); } /** @@ -852,7 +871,7 @@ async function cancel(notifications: number[]): Promise { * @returns A promise indicating the success or failure of the operation. */ async function cancelAll(): Promise { - await invoke('plugin:notifications|cancel_all'); + await invoke("plugin:notifications|cancel_all"); } /** @@ -867,7 +886,7 @@ async function cancelAll(): Promise { * @returns A promise resolving to the list of active notifications. */ async function active(): Promise { - return await invoke('plugin:notifications|get_active'); + return await invoke("plugin:notifications|get_active"); } /** @@ -881,8 +900,10 @@ async function active(): Promise { * * @returns A promise indicating the success or failure of the operation. */ -async function removeActive(notifications: Array<{ id: number; tag?: string }>): Promise { - await invoke('plugin:notifications|remove_active', { notifications }); +async function removeActive( + notifications: Array<{ id: number; tag?: string }>, +): Promise { + await invoke("plugin:notifications|remove_active", { notifications }); } /** @@ -897,7 +918,7 @@ async function removeActive(notifications: Array<{ id: number; tag?: string }>): * @returns A promise indicating the success or failure of the operation. */ async function removeAllActive(): Promise { - await invoke('plugin:notifications|remove_active'); + await invoke("plugin:notifications|remove_active"); } /** @@ -919,7 +940,7 @@ async function removeAllActive(): Promise { * @returns A promise indicating the success or failure of the operation. */ async function createChannel(channel: Channel): Promise { - await invoke('plugin:notifications|create_channel', { channel }); + await invoke("plugin:notifications|create_channel", { channel }); } /** @@ -934,7 +955,7 @@ async function createChannel(channel: Channel): Promise { * @returns A promise indicating the success or failure of the operation. */ async function removeChannel(id: string): Promise { - await invoke('plugin:notifications|delete_channel', { id }); + await invoke("plugin:notifications|delete_channel", { id }); } /** @@ -949,7 +970,7 @@ async function removeChannel(id: string): Promise { * @returns A promise resolving to the list of notification channels. */ async function channels(): Promise { - return await invoke('plugin:notifications|list_channels'); + return await invoke("plugin:notifications|list_channels"); } /** @@ -969,9 +990,9 @@ async function channels(): Promise { * @returns A promise resolving to a function that removes the listener. */ async function onNotificationReceived( - cb: (notification: Options) => void + cb: (notification: Options) => void, ): Promise { - return await addPluginListener('notifications', 'notification', cb); + return await addPluginListener("notifications", "notification", cb); } /** @@ -990,8 +1011,10 @@ async function onNotificationReceived( * @param cb - Callback function to handle notification actions. * @returns A promise resolving to a function that removes the listener. */ -async function onAction(cb: (notification: Options) => void): Promise { - return await addPluginListener('notifications', 'actionPerformed', cb); +async function onAction( + cb: (notification: Options) => void, +): Promise { + return await addPluginListener("notifications", "actionPerformed", cb); } /** @@ -1025,18 +1048,22 @@ interface NotificationClickedData { * @returns A promise resolving to a function that removes the listener. */ async function onNotificationClicked( - cb: (data: NotificationClickedData) => void + cb: (data: NotificationClickedData) => void, ): Promise { - const listener = await addPluginListener('notifications', 'notificationClicked', cb); + const listener = await addPluginListener( + "notifications", + "notificationClicked", + cb, + ); // Notify native side so pending cold-start clicks are delivered - await invoke('plugin:notifications|set_click_listener_active', { + await invoke("plugin:notifications|set_click_listener_active", { active: true, }); return { unregister: async () => { - await invoke('plugin:notifications|set_click_listener_active', { + await invoke("plugin:notifications|set_click_listener_active", { active: false, }); return listener.unregister(); diff --git a/rollup.config.js b/rollup.config.js index d7dacf3..8701747 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,20 +1,20 @@ -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { cwd } from 'node:process'; -import typescript from '@rollup/plugin-typescript'; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { cwd } from "node:process"; +import typescript from "@rollup/plugin-typescript"; -const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8')); +const pkg = JSON.parse(readFileSync(join(cwd(), "package.json"), "utf8")); export default { - input: 'guest-js/index.ts', + input: "guest-js/index.ts", output: [ { file: pkg.exports.import, - format: 'esm', + format: "esm", }, { file: pkg.exports.require, - format: 'cjs', + format: "cjs", }, ], plugins: [ diff --git a/vitest.config.ts b/vitest.config.ts index 6cd78e2..35c613c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,15 +1,15 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - environment: 'node', - include: ['**/*.test.ts'], + environment: "node", + include: ["**/*.test.ts"], coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - include: ['guest-js/**/*.ts'], - exclude: ['guest-js/**/*.test.ts'], + provider: "v8", + reporter: ["text", "json", "html"], + include: ["guest-js/**/*.ts"], + exclude: ["guest-js/**/*.test.ts"], }, }, }); From c77ca0e59e4f8605280de9abafa9a0a114e498dc Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 10 Mar 2026 14:56:01 +0100 Subject: [PATCH 23/24] Add logging for past scheduled notification time checks --- macos/Sources/Notification.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 } From 90e5d49991664458eab3df3b2d5a56c0bc74e367 Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 10 Mar 2026 15:15:33 +0100 Subject: [PATCH 24/24] Refactor avatar image downloading to prefetch avatars in parallel and enhance security by stripping auth tokens from JSON --- .../app/tauri/notification/Notification.kt | 2 + .../tauri/notification/NotificationPlugin.kt | 57 ++++++++-- .../notification/TauriNotificationManager.kt | 106 ++++++++++++------ src/models.rs | 1 + 4 files changed, 121 insertions(+), 45 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/Notification.kt b/android/src/main/java/app/tauri/notification/Notification.kt index 67ea159..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,7 @@ 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 { @@ -31,6 +32,7 @@ class MessagingStyleConfig { var conversationTitle: String? = null var isGroupConversation: Boolean = false var messages: List = listOf() + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) var authToken: String? = null } diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 8990481..8781aa9 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -248,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) { @@ -621,23 +621,43 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @PermissionCallback private fun unifiedPushPermissionsCallback(invoke: Invoke) { - val isCurrent: Boolean + val shouldRegister: Boolean + val instanceToRegister: String synchronized(unifiedPushLock) { - isCurrent = pendingUnifiedPushInvoke === invoke - if (isCurrent && !manager.areNotificationsEnabled()) { + 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 } } - // Stale callback — a newer registerForUnifiedPush already rejected this invoke - if (!isCurrent) return - - if (!manager.areNotificationsEnabled()) { + if (!shouldRegister) { invoke.reject("Notification permissions denied") return } - UnifiedPush.register(activity, unifiedPushInstance) + // 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( @@ -743,6 +763,25 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { 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 } diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index bd8e6c6..63c785a 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -50,7 +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) + 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, @@ -140,14 +142,14 @@ class TauriNotificationManager( return ids } - private fun buildPerson(person: MessagingStylePerson, authToken: String? = null): Person { + 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 = downloadAvatarBitmap(person.iconUrl!!, authToken) + val bitmap = prefetchedAvatars[person.iconUrl] if (bitmap != null) { builder.setIcon(IconCompat.createWithBitmap(bitmap)) } @@ -161,39 +163,43 @@ class TauriNotificationManager( } // Downloads a remote avatar image as a circular-cropped bitmap. - // Runs on a background thread to avoid NetworkOnMainThreadException. - // Returns null on any failure (network error, invalid image, timeout, etc.). - private fun downloadAvatarBitmap(url: String, authToken: String?): Bitmap? { - return try { - val future = 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) + // 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() - if (raw == null) return@Callable null - cropCircle(raw) - } catch (e: Exception) { - Logger.error(Logger.tags(TAG), "Failed to download avatar: ${e.message}", e) - null + 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) @@ -201,6 +207,33 @@ class TauriNotificationManager( } } + // 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) @@ -254,8 +287,9 @@ class TauriNotificationManager( // Style selection (mutually exclusive: messagingStyle > largeBody > inboxLines) if (notification.messagingStyle != null) { val msgStyle = notification.messagingStyle!! - val authToken = msgStyle.authToken - val userPerson = buildPerson(msgStyle.user, authToken) + // 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) { @@ -264,7 +298,7 @@ class TauriNotificationManager( messagingStyle.isGroupConversation = msgStyle.isGroupConversation for (msg in msgStyle.messages) { - val senderPerson = msg.sender?.let { buildPerson(it, authToken) } + val senderPerson = msg.sender?.let { buildPerson(it, avatars) } messagingStyle.addMessage(msg.text, msg.timestamp, senderPerson) } mBuilder.setStyle(messagingStyle) diff --git a/src/models.rs b/src/models.rs index 8f2200d..84a9132 100644 --- a/src/models.rs +++ b/src/models.rs @@ -425,6 +425,7 @@ pub struct MessagingStyleConfig { pub is_group_conversation: bool, #[serde(default)] pub messages: Vec, + #[serde(skip_serializing)] pub auth_token: Option, }