From f3cc222a26ed942d7b1af647997e3a6e105dbe46 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 12:33:26 +0200 Subject: [PATCH] fix(android): attach room payload to cold-start push notification intent UnifiedPushNotifier.showFromPush() built a bare launch PendingIntent with no room_id/event_id/user_id, so cold-start notification taps opened the app to the home screen with zero room context. Attach the push payload to the intent so NotificationPlugin.onIntent() can extract and emit it to JS via triggerNotificationClicked(), matching the warm-path behavior. --- .../tauri/notification/UnifiedPushNotifier.kt | 83 ++++++++++++++++--- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt index c573549..3093d17 100644 --- a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt +++ b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt @@ -4,6 +4,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -13,43 +14,99 @@ object UnifiedPushNotifier { private const val CHANNEL_ID = "messages" fun showFromPush(context: Context, rawMessage: String) { - val notification = try { - JSONObject(rawMessage).optJSONObject("notification") + val rootJson = try { + JSONObject(rawMessage) } catch (e: Exception) { null } ?: return + val notification = rootJson.optJSONObject("notification") ?: return + val roomId = notification.optString("room_id") + val eventId = notification.optString("event_id") val sender = notification.optString("sender_display_name") val title = notification.optString("room_name").ifEmpty { "New message" } val body = buildBody(notification, sender) + // Extract user_id from top-level or notification sub-object + val userId = rootJson.optString("user_id").ifEmpty { + notification.optString("user_id") + } + ensureChannel(context) val iconId = context.resources .getIdentifier("notification_icon", "drawable", context.packageName) .takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info + val notifId = (roomId.ifEmpty { eventId }).hashCode() + + // Build intent mimicking TauriNotificationManager.buildIntent() so + // that NotificationPlugin.onIntent() -> handleNotificationActionPerformed() + // -> extractLocalNotificationData() extracts room_id/event_id/user_id + // from NOTIFICATION_OBJ_INTENT_KEY, matching the warm-path behavior. + val intent = buildPushIntent(context, notifId, roomId, eventId, userId) + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_CANCEL_CURRENT + } + 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 - ) + .setContentIntent( + PendingIntent.getActivity(context, notifId, intent, flags) ) + + NotificationManagerCompat.from(context).notify(notifId, builder.build()) + } + + /** + * Builds an intent carrying the push payload so that + * [NotificationPlugin.onIntent] can extract it via + * [TauriNotificationManager.handleNotificationActionPerformed] and + * [NotificationPlugin.extractLocalNotificationData]. + * + * Mirrors the structure set by [TauriNotificationManager.buildIntent] in the + * warm path (JS-triggered sendNotification). Because [UnifiedPushNotifier] + * runs when [NotificationPlugin.instance] is null (cold start), we replicate + * the intent extras rather than calling into the not-yet-initialized plugin. + */ + private fun buildPushIntent( + context: Context, + notifId: Int, + roomId: String, + eventId: String, + userId: String + ): Intent { + val intent = context.packageManager + .getLaunchIntentForPackage(context.packageName)!! + intent.action = Intent.ACTION_MAIN + intent.addCategory(Intent.CATEGORY_LAUNCHER) + intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + intent.putExtra(NOTIFICATION_INTENT_KEY, notifId) + intent.putExtra(ACTION_INTENT_KEY, DEFAULT_PRESS_ACTION) + intent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, true) + + // Build the sourceJson that extractLocalNotificationData expects: + // a JSON object with "id" and "extra" containing room context. + val extraJson = JSONObject().apply { + put("room_id", roomId) + put("event_id", eventId) + if (userId.isNotEmpty()) put("user_id", userId) } + val sourceJson = JSONObject().apply { + put("id", notifId) + put("extra", extraJson) + }.toString() + intent.putExtra(NOTIFICATION_OBJ_INTENT_KEY, sourceJson) - val id = (roomId.ifEmpty { notification.optString("event_id") }).hashCode() - NotificationManagerCompat.from(context).notify(id, builder.build()) + return intent } private fun buildBody(notification: JSONObject, sender: String): String {