fix(push): dedup re-rings within one call session via composite (roomId, callSessionId) key so a participant rejoin doesn't re-alert

This commit is contained in:
heaven 2026-05-17 02:22:20 +03:00
parent 38d24e5527
commit 8a80194fe5
2 changed files with 122 additions and 6 deletions

View file

@ -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<String, String> 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<String, String> 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);

View file

@ -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);
}
}
}
}