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:
parent
38d24e5527
commit
8a80194fe5
2 changed files with 122 additions and 6 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue