From 8a80194fe55b41033c533a459fe1008a02856501 Mon Sep 17 00:00:00 2001 From: heaven Date: Sun, 17 May 2026 02:22:20 +0300 Subject: [PATCH] fix(push): dedup re-rings within one call session via composite (roomId, callSessionId) key so a participant rejoin doesn't re-alert --- .../app/VojoFirebaseMessagingService.java | 67 +++++++++++++++++++ .../java/chat/vojo/app/VojoPollWorker.java | 61 +++++++++++++++-- 2 files changed, 122 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java index 7147d1ad..f6f7fffc 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -92,6 +92,44 @@ public class VojoFirebaseMessagingService extends MessagingService { return RTC_NOTIFICATION_TYPE.equals(type) || RTC_NOTIFICATION_TYPE_STABLE.equals(type); } + /** + * Composite dedup key for a single call session (a ring + its retries). + * Element-web uses the same shape (`call_{callId}_{roomId}` in + * `getIncomingCallToastKey`). We mark this key in NotificationDedup the + * moment we post the first CallStyle for the session; later + * m.rtc.notification events that share the parent — re-ring for + * participant rejoin, FCM retry-with-fresh-event-id — see the mark and + * silently skip the post. + */ + private static String compositeCallDedupKey(String roomId, String callSessionId) { + return "call:" + roomId + ":" + callSessionId; + } + + /** + * Pull the parent-call event_id (the "call session" identifier) out of + * a flattened Sygnal payload. The standard MSC4075 m.rtc.notification + * references the parent via `content.m.relates_to.event_id`; Sygnal + * flattens nested content with `_` separator but keeps `m.relates_to` + * literal, so the observed FCM keys land as either + * `content_m.relates_to_event_id` (literal dot) or + * `content_m_relates_to_event_id` (escaped to underscore) depending on + * deployment config. We probe both shapes — and `content_call_id` as + * a third candidate for legacy MSC2746 calls — so the dedup works + * regardless of which encoding the homeserver's Sygnal happens to use. + */ + private static String extractCallSessionId(Map data) { + String[] candidates = new String[] { + "content_m.relates_to_event_id", + "content_m_relates_to_event_id", + "content_call_id", + }; + for (String key : candidates) { + String v = data.get(key); + if (v != null && !v.isEmpty()) return v; + } + return null; + } + private static final long RTC_DEFAULT_LIFETIME_MS = 30_000L; private static final long RTC_LIFETIME_GRACE_MS = 2_000L; @@ -204,6 +242,19 @@ public class VojoFirebaseMessagingService extends MessagingService { Log.w(TAG, "route: call missing eventId/roomId, drop"); return; } + // Composite session dedup — silently drop re-rings (different + // event_id, same parent call session) that would otherwise + // re-alert the user for a call they already saw the first + // ring for. The legacy per-eventId dedup misses this because + // each re-ring has a fresh m.rtc.notification event_id. + String callSessionId = extractCallSessionId(data); + if (callSessionId != null) { + String compositeKey = compositeCallDedupKey(roomId, callSessionId); + if (NotificationDedup.wasNotified(this, compositeKey)) { + dlog("route: call re-ring suppressed session=" + callSessionId); + return; + } + } // Snapshot the payload — FCM internals may recycle the map reference. Map snapshot = new HashMap<>(data); boolean seeded = upsertIncomingRing(snapshot, remoteMessage.getMessageId()); @@ -219,6 +270,15 @@ public class VojoFirebaseMessagingService extends MessagingService { // event as a "Missed call" notification even though the user // already saw the live JS strip and chose to ignore it. NotificationDedup.markNotified(this, eventId); + // Mark the session composite so a re-ring of the same call + // session (different event_id) doesn't re-alert. Marked + // unconditionally — fg path's "JS strip owns UX" means the + // user has already been alerted in-app even though no native + // notification was posted. + if (callSessionId != null) { + NotificationDedup.markNotified(this, + compositeCallDedupKey(roomId, callSessionId)); + } if (MainActivity.isInForeground) { dlog("route: call seeded (foreground, JS strip owns UX) event=" + eventId); // Race guard: MainActivity.onPause may have run its render @@ -1259,6 +1319,13 @@ public class VojoFirebaseMessagingService extends MessagingService { if (ringEventId != null && !ringEventId.isEmpty()) { NotificationDedup.markNotified(ctx, ringEventId); } + // Session-level mark for re-ring suppression. Same key shape as + // onMessageReceived above so the two entry points are consistent. + String sessionId = extractCallSessionId(data); + if (sessionId != null) { + NotificationDedup.markNotified(ctx, + compositeCallDedupKey(roomId, sessionId)); + } try { scheduleCallNotificationExpiry(ctx, data, tag, notifId, fallbackBaseTs); diff --git a/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java index 8de9e4e8..953cf88c 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java +++ b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java @@ -285,12 +285,40 @@ public class VojoPollWorker extends Worker { boolean isRing = "ring".equals(flattened.get("content_notification_type")); if (isRtcType && isRing) { - // Stale ring (call lifetime is 30 seconds; we poll - // every 15 minutes). Show "Missed call" so the user - // knows somebody tried, without phantom-ringing a - // long-dead call via CallStyle. - posted = VojoFirebaseMessagingService - .renderMissedCallNotification(ctx, flattened); + // Composite session dedup: if FCM already alerted + // for this call session (different ring event, + // same parent), skip posting a duplicate + // missed-call. Without this, a session with one + // FCM live-alert ring + one re-ring through + // polling would surface as both a CallStyle and + // a missed-call card. + String roomIdField = flattened.get("room_id"); + String sessionId = flattened.get("content_m.relates_to_event_id"); + if (sessionId == null) sessionId = flattened.get("content_call_id"); + if (roomIdField != null && sessionId != null) { + String composite = + "call:" + roomIdField + ":" + sessionId; + if (NotificationDedup.wasNotified(ctx, composite)) { + if (ts > highestTsSeen) highestTsSeen = ts; + treatAsNotRenderable = true; + } + } + if (!treatAsNotRenderable) { + // Stale ring (call lifetime is 30 seconds; we + // poll every 15 minutes). Show "Missed call" + // so the user knows somebody tried, without + // phantom-ringing a long-dead call via + // CallStyle. + posted = VojoFirebaseMessagingService + .renderMissedCallNotification(ctx, flattened); + if (posted && roomIdField != null && sessionId != null) { + // Mark the composite so the next polling + // cycle observing a re-ring for the same + // session doesn't double-post. + NotificationDedup.markNotified(ctx, + "call:" + roomIdField + ":" + sessionId); + } + } } else if (isRtcType) { // Non-ring RTC sub-type. MSC4075 defines at least // "ring" and "notification" — the latter is the @@ -546,6 +574,27 @@ public class VojoPollWorker extends Worker { if (content.has("lifetime")) { out.put("content_lifetime", String.valueOf(content.optLong("lifetime"))); } + // Parent call event_id for session-level dedup. The shared + // FCM renderer reads this from the flattened key + // `content_m.relates_to_event_id` (mirroring one of Sygnal's + // flatten shapes); writing the literal-dot variant here keeps + // FCM and polling on the same key. + JSONObject relates = content.optJSONObject("m.relates_to"); + if (relates != null) { + String parentEventId = relates.optString("event_id", null); + if (parentEventId != null && !parentEventId.isEmpty()) { + out.put("content_m.relates_to_event_id", parentEventId); + } + } + // Legacy MSC2746 call_id fallback. Modern MSC4075 sessions + // surface via m.relates_to above; this branch is a no-op for + // them but keeps the shape symmetric for older deployments. + if (content.has("call_id")) { + String callId = content.optString("call_id", null); + if (callId != null && !callId.isEmpty()) { + out.put("content_call_id", callId); + } + } } }