From 9902657bf36b5bbb0d18a07e03a252c36088d5ac Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 18 Jul 2026 18:35:44 +0200 Subject: [PATCH] feat(android): add UnifiedPush backend alongside FCM --- android/build.gradle.kts | 1 + android/src/main/AndroidManifest.xml | 18 ++++ .../tauri/notification/NotificationPlugin.kt | 86 +++++++++++++++++-- .../tauri/notification/UnifiedPushNotifier.kt | 75 ++++++++++++++++ .../tauri/notification/UnifiedPushReceiver.kt | 31 +++++++ src/commands.rs | 15 +++- src/lib.rs | 15 +++- src/mobile.rs | 29 +++++++ src/models.rs | 6 ++ 9 files changed, 264 insertions(+), 12 deletions(-) create mode 100644 android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt create mode 100644 android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 75196951..ae244e9a 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -73,6 +73,7 @@ dependencies { implementation(platform("com.google.firebase:firebase-bom:34.16.0")) implementation("com.google.firebase:firebase-messaging-ktx:24.1.2") + implementation("org.unifiedpush.android:connector:3.3.3") testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk-android:1.14.11") testImplementation("io.mockk:mockk-agent:1.14.11") diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index d5f3a81c..71b493df 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,5 +1,11 @@ + + + + + + @@ -22,6 +28,18 @@ + + + + + + + + + + diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index 061eb3dd..36c1b13e 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" @@ -63,6 +64,11 @@ class SetClickListenerActiveArgs { var active: Boolean = false } +@InvokeArg +class DistributorArgs { + var distributor: String? = null +} + @InvokeArg class ActiveNotification { var id: Int = 0 @@ -401,7 +407,16 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } } - // If we already have a cached token, return it immediately + proceedPushRegistration(invoke) + } + + private fun proceedPushRegistration(invoke: Invoke) { + if (UnifiedPush.getSavedDistributor(activity) != null) { + pendingTokenInvoke = invoke + UnifiedPush.register(activity, "default") + return + } + cachedToken?.let { val result = JSObject() result.put("deviceToken", it) @@ -409,10 +424,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } - // Store the invoke to respond later when we get the token pendingTokenInvoke = invoke - - // Request the FCM token getFirebaseToken() } @@ -423,6 +435,13 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } + if (UnifiedPush.getSavedDistributor(activity) != null) { + UnifiedPush.unregister(activity, "default") + cachedToken = null + invoke.resolve() + return + } + FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task -> if (!task.isSuccessful) { invoke.reject("Failed to delete FCM token: ${task.exception?.message}") @@ -441,8 +460,63 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } - // Permissions granted, now get the token - getFirebaseToken() + proceedPushRegistration(invoke) + } + + @Command + fun listDistributors(invoke: Invoke) { + val distributors = UnifiedPush.getDistributors(activity) + val result = JSObject() + val arr = JSArray() + distributors.forEach { arr.put(it) } + result.put("distributors", arr) + invoke.resolve(result) + } + + @Command + fun setDistributor(invoke: Invoke) { + val args = invoke.parseArgs(DistributorArgs::class.java) + val distributor = args.distributor + if (distributor == null) { + invoke.reject("Distributor parameter is required") + return + } + UnifiedPush.saveDistributor(activity, distributor) + invoke.resolve() + } + + @Command + fun setToken(invoke: Invoke) { + invoke.resolve() + } + + fun onUnifiedPushNewEndpoint(endpoint: String) { + cachedToken = endpoint + val result = JSObject() + result.put("deviceToken", endpoint) + pendingTokenInvoke?.resolve(result) + pendingTokenInvoke = null + + val data = JSObject() + data.put("token", endpoint) + trigger("push-token", data) + } + + fun onUnifiedPushRegistrationFailed(reason: String?) { + pendingTokenInvoke?.reject(reason ?: "UnifiedPush registration failed") + pendingTokenInvoke = null + } + + fun onUnifiedPushUnregistered() { + pendingTokenInvoke?.resolve(JSObject()) + pendingTokenInvoke = null + cachedToken = null + } + + fun onUnifiedPushMessage(content: String) { + val data = JSObject() + data.put("message", content) + trigger("push-message", data) } private fun getFirebaseToken() { diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt new file mode 100644 index 00000000..c573549d --- /dev/null +++ b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt @@ -0,0 +1,75 @@ +package app.tauri.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import org.json.JSONObject + +object UnifiedPushNotifier { + private const val CHANNEL_ID = "messages" + + fun showFromPush(context: Context, rawMessage: String) { + val notification = try { + JSONObject(rawMessage).optJSONObject("notification") + } catch (e: Exception) { + null + } ?: return + + val roomId = notification.optString("room_id") + val sender = notification.optString("sender_display_name") + val title = notification.optString("room_name").ifEmpty { "New message" } + val body = buildBody(notification, sender) + + ensureChannel(context) + + val iconId = context.resources + .getIdentifier("notification_icon", "drawable", context.packageName) + .takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(iconId) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + + context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { intent -> + builder.setContentIntent( + PendingIntent.getActivity( + context, + roomId.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + } + + val id = (roomId.ifEmpty { notification.optString("event_id") }).hashCode() + NotificationManagerCompat.from(context).notify(id, builder.build()) + } + + private fun buildBody(notification: JSONObject, sender: String): String { + val prefix = if (sender.isNotEmpty()) "$sender: " else "" + if (notification.optString("type") == "m.room.encrypted") { + return "${prefix}Encrypted message" + } + val text = notification.optJSONObject("content")?.optString("body")?.takeIf { it.isNotEmpty() } + return if (text != null) "$prefix$text" else "${prefix}New message" + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) ?: return + if (manager.getNotificationChannel(CHANNEL_ID) == null) { + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Messages", NotificationManager.IMPORTANCE_HIGH).apply { + description = "Matrix message and invite notifications" + } + ) + } + } +} diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt b/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt new file mode 100644 index 00000000..4ac37416 --- /dev/null +++ b/android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt @@ -0,0 +1,31 @@ +package app.tauri.notification + +import android.content.Context +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 + +class UnifiedPushReceiver : MessagingReceiver() { + override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { + NotificationPlugin.instance?.onUnifiedPushNewEndpoint(endpoint.url) + } + + override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { + NotificationPlugin.instance?.onUnifiedPushRegistrationFailed(reason.name) + } + + override fun onUnregistered(context: Context, instance: String) { + NotificationPlugin.instance?.onUnifiedPushUnregistered() + } + + override fun onMessage(context: Context, message: PushMessage, instance: String) { + val content = String(message.content, Charsets.UTF_8) + val plugin = NotificationPlugin.instance + if (plugin != null) { + plugin.onUnifiedPushMessage(content) + } else { + UnifiedPushNotifier.showFromPush(context, content) + } + } +} diff --git a/src/commands.rs b/src/commands.rs index f3255e49..8f082bd4 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -59,7 +59,10 @@ pub async fn unregister_for_push_notifications( } } -#[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))] +#[cfg(all( + feature = "push-notifications", + any(all(desktop, target_os = "linux"), target_os = "android") +))] #[command] pub async fn list_distributors( _app: AppHandle, @@ -68,7 +71,10 @@ pub async fn list_distributors( notification.list_distributors().await } -#[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))] +#[cfg(all( + feature = "push-notifications", + any(all(desktop, target_os = "linux"), target_os = "android") +))] #[command] pub async fn set_distributor( _app: AppHandle, @@ -78,7 +84,10 @@ pub async fn set_distributor( notification.set_distributor(name).await } -#[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))] +#[cfg(all( + feature = "push-notifications", + any(all(desktop, target_os = "linux"), target_os = "android") +))] #[command] pub async fn set_token( _app: AppHandle, diff --git a/src/lib.rs b/src/lib.rs index a4d3f7e2..14d4ff55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -316,11 +316,20 @@ pub fn init() -> TauriPlugin> { listeners::register_listener, #[cfg(desktop)] listeners::remove_listener, - #[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))] + #[cfg(all( + feature = "push-notifications", + any(all(desktop, target_os = "linux"), target_os = "android") + ))] commands::list_distributors, - #[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))] + #[cfg(all( + feature = "push-notifications", + any(all(desktop, target_os = "linux"), target_os = "android") + ))] commands::set_distributor, - #[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))] + #[cfg(all( + feature = "push-notifications", + any(all(desktop, target_os = "linux"), target_os = "android") + ))] commands::set_token, ]) .setup(|app, api| { diff --git a/src/mobile.rs b/src/mobile.rs index 6bd06703..04e46c99 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -95,6 +95,35 @@ impl Notifications { } } + #[cfg(all(target_os = "android", feature = "push-notifications"))] + pub async fn list_distributors(&self) -> crate::Result> { + self.0 + .run_mobile_plugin_async::("listDistributors", ()) + .await + .map(|r| r.distributors) + .map_err(Into::into) + } + + #[cfg(all(target_os = "android", feature = "push-notifications"))] + pub async fn set_distributor(&self, name: String) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("distributor", name); + self.0 + .run_mobile_plugin_async::<()>("setDistributor", args) + .await + .map_err(Into::into) + } + + #[cfg(all(target_os = "android", feature = "push-notifications"))] + pub async fn set_token(&self, token: String) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("token", token); + self.0 + .run_mobile_plugin_async::<()>("setToken", args) + .await + .map_err(Into::into) + } + pub async fn permission_state(&self) -> crate::Result { self.0 .run_mobile_plugin_async::("checkPermissions", ()) diff --git a/src/models.rs b/src/models.rs index 96ac97f1..0d94ba27 100644 --- a/src/models.rs +++ b/src/models.rs @@ -18,6 +18,12 @@ pub struct PushNotificationResponse { pub device_token: String, } +#[cfg(all(target_os = "android", feature = "push-notifications"))] +#[derive(Debug, Deserialize)] +pub struct DistributorsResponse { + pub distributors: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Attachment {