diff --git a/android/app/build.gradle b/android/app/build.gradle
index 80808050..846c89f8 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -26,6 +26,13 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
+ // AGP 8+ requires explicit opt-in for BuildConfig generation. We rely on
+ // BuildConfig.DEBUG to gate Log.d calls that dump privacy-sensitive
+ // identifiers (roomId, eventId) so release builds don't leak them through
+ // logcat / crash-reporter buffers. See dlog() in VojoFirebaseMessagingService.
+ buildFeatures {
+ buildConfig = true
+ }
}
repositories {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0a112bca..5bcf9e68 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -103,8 +103,7 @@
+ and netd doesn't block background network. -->
diff --git a/android/app/src/main/java/chat/vojo/app/CallCancelReceiver.java b/android/app/src/main/java/chat/vojo/app/CallCancelReceiver.java
index aa9d0bda..99c133ba 100644
--- a/android/app/src/main/java/chat/vojo/app/CallCancelReceiver.java
+++ b/android/app/src/main/java/chat/vojo/app/CallCancelReceiver.java
@@ -22,16 +22,23 @@ public class CallCancelReceiver extends BroadcastReceiver {
public static final String ACTION_CANCEL_CALL = "chat.vojo.app.CANCEL_CALL";
public static final String EXTRA_NOTIF_TAG = "notif_tag";
public static final String EXTRA_NOTIF_ID = "notif_id";
+ // Carried so the receiver can tombstone and drop the matching registry
+ // entry — otherwise a same-eventId re-delivery after expiry would seed the
+ // registry again and render a stale ring on next backgrounding.
+ public static final String EXTRA_NOTIF_EVENT_ID = "notif_event_id";
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null || !ACTION_CANCEL_CALL.equals(intent.getAction())) return;
String tag = intent.getStringExtra(EXTRA_NOTIF_TAG);
int id = intent.getIntExtra(EXTRA_NOTIF_ID, -1);
+ String notifEventId = intent.getStringExtra(EXTRA_NOTIF_EVENT_ID);
if (tag == null || id == -1) return;
NotificationManager nm =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
- if (nm == null) return;
- nm.cancel(tag, id);
+ if (nm != null) nm.cancel(tag, id);
+ if (notifEventId != null) {
+ VojoFirebaseMessagingService.removeIncomingRing(context, notifEventId);
+ }
}
}
diff --git a/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java b/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java
index be34fe46..a3f23891 100644
--- a/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java
+++ b/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java
@@ -40,14 +40,13 @@ import java.util.concurrent.Executors;
* JS-path can't cover us either (a logged-out client has no Matrix session
* to call sendRtcDecline against). Cancelling the notification is the only
* feedback we can give; leaving the ring would trap the user on a call they
- * can't accept or decline until the A-side times out. See techdebt §5.35.
+ * can't accept or decline until the A-side times out.
*
* Note on idempotency: the flusher's retry generates a new txnId, so on a
* split-success-fail sequence (receiver HTTP timed out, flusher succeeds)
* we may land two {@code m.call.decline} events in the timeline with the
* same {@code rel.event_id}. This is cosmetic — the caller's auto-hangup
- * hook is idempotent and fires on the first decline. See techdebt §5.35
- * "txnId unification" note.
+ * hook is idempotent and fires on the first decline.
*/
public class CallDeclineReceiver extends BroadcastReceiver {
@@ -98,6 +97,7 @@ public class CallDeclineReceiver extends BroadcastReceiver {
if (notifTag != null && notifId != -1) {
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
}
+ VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
return;
}
@@ -111,10 +111,21 @@ public class CallDeclineReceiver extends BroadcastReceiver {
// Do NOT pass the Throwable — JSONException.getMessage() embeds
// the malformed input, which here contains the access token.
Log.e(TAG, "onReceive: prefs JSON parse failed: " + t.getClass().getSimpleName());
+ // Still drop the native and the registry entry — user tapped Decline,
+ // the ring should not re-surface on next backgrounding just because
+ // we can't send the decline over HTTP.
+ if (notifTag != null && notifId != -1) {
+ NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
+ }
+ VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
return;
}
if (accessToken == null || accessToken.isEmpty() || baseUrl == null || baseUrl.isEmpty()) {
- Log.w(TAG, "onReceive: empty accessToken/baseUrl in session, leaving ring intact");
+ Log.w(TAG, "onReceive: empty accessToken/baseUrl in session, cancelling ring locally");
+ if (notifTag != null && notifId != -1) {
+ NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
+ }
+ VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
return;
}
@@ -124,6 +135,9 @@ public class CallDeclineReceiver extends BroadcastReceiver {
if (notifTag != null && notifId != -1) {
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
}
+ // Drop the registry entry and tombstone the eventId so the next
+ // onPause renderRegistry can't resurrect the declined ring.
+ VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
// 2. Write tombstone BEFORE HTTP so the flusher sees work to do
// if we fail/die. Remove only on confirmed 2xx.
diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java
index 9297d0a0..c2d87264 100644
--- a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java
+++ b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java
@@ -14,6 +14,9 @@ import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
+import java.util.HashMap;
+import java.util.Map;
+
/**
* JS → Android bridge for CallForegroundService lifecycle.
*
@@ -82,4 +85,57 @@ public class CallForegroundPlugin extends Plugin {
call.reject("stop_failed: " + t.getClass().getSimpleName() + ": " + t.getMessage());
}
}
+
+ // JS upserts a live incoming ring into the native registry (atom ADD
+ // happy-path). Idempotent with any prior FCM seed for the same eventId —
+ // Java merges metadata fields append-only. See VojoFirebaseMessagingService
+ // for the registry operations contract.
+ @PluginMethod
+ public void upsertIncomingRing(PluginCall call) {
+ String eventId = call.getString("eventId");
+ String roomId = call.getString("roomId");
+ if (eventId == null || eventId.isEmpty() || roomId == null || roomId.isEmpty()) {
+ call.reject("missing_eventId_or_roomId");
+ return;
+ }
+ Map data = new HashMap<>();
+ data.put("event_id", eventId);
+ data.put("room_id", roomId);
+ String callerName = call.getString("callerName");
+ if (callerName != null && !callerName.isEmpty()) {
+ data.put("sender_display_name", callerName);
+ }
+ // Pass through senderTs/lifetime as strings — registry stores the same
+ // Map shape that FCM delivers, and downstream consumers
+ // (scheduleCallNotificationExpiry, isExpired) parseLong them.
+ Long senderTs = call.getLong("senderTs");
+ if (senderTs != null && senderTs > 0) {
+ data.put("content_sender_ts", Long.toString(senderTs));
+ }
+ Long lifetime = call.getLong("lifetime");
+ if (lifetime != null && lifetime > 0) {
+ data.put("content_lifetime", Long.toString(lifetime));
+ }
+ String messageId = call.getString("messageId");
+ // messageId is used as google.message_id in the Answer/Launch PendingIntent
+ // extras — Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
+ // on containsKey. Empty string also satisfies the gate; we pass the
+ // caller's value through verbatim.
+ VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
+ call.resolve();
+ }
+
+ // JS removes a ring from the native registry (atom REMOVE / suppress path /
+ // native action receiver path). Tombstones the eventId to reject late
+ // FCM or /sync re-seeds within the ring lifetime.
+ @PluginMethod
+ public void removeIncomingRing(PluginCall call) {
+ String eventId = call.getString("eventId");
+ if (eventId == null || eventId.isEmpty()) {
+ call.reject("missing_event_id");
+ return;
+ }
+ VojoFirebaseMessagingService.removeIncomingRing(getContext(), eventId);
+ call.resolve();
+ }
}
diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundService.java b/android/app/src/main/java/chat/vojo/app/CallForegroundService.java
index e75a2651..03fde957 100644
--- a/android/app/src/main/java/chat/vojo/app/CallForegroundService.java
+++ b/android/app/src/main/java/chat/vojo/app/CallForegroundService.java
@@ -23,7 +23,7 @@ import androidx.core.app.NotificationCompat;
* Both revocations were observed in Phase 0 capture on Samsung OneUI API 36:
* mic went Active=false ~5s after screen-off, netd isBlocked=true ~13s after,
* causing Element Call inside the hidden WebView to tear down the LiveKit
- * session and the call to drop. Full context in docs/plans/dm_calls_techdebt.md §2.2.
+ * session and the call to drop.
*
* Preconditions enforced by callers:
* - RECORD_AUDIO runtime permission granted (plugin-side check in
diff --git a/android/app/src/main/java/chat/vojo/app/MainActivity.java b/android/app/src/main/java/chat/vojo/app/MainActivity.java
index 7bc28b3d..de7811be 100644
--- a/android/app/src/main/java/chat/vojo/app/MainActivity.java
+++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java
@@ -1,12 +1,36 @@
package chat.vojo.app;
import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
import androidx.activity.EdgeToEdge;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
public static volatile boolean isInForeground = false;
+ // Short debounce on the onPause→renderRegistry edge so an in-flight JS
+ // removeIncomingRing bridge call (e.g. user accepted/declined, then
+ // immediately pressed Home) has a chance to land before we post native
+ // CallStyle for a ring that's about to be removed. 150ms covers the
+ // strip-accept chain (Capacitor roundtrip + switchOrStartDmCall
+ // resolve + sync-effect → bridge) on mid-range Android; imperceptible
+ // as a silent-ring delay.
+ private static final long RENDER_DEBOUNCE_MS = 150L;
+ // Modest debounce on the onResume→cancelRenderedIncomingRings edge so JS
+ // has a moment to hydrate incomingCallsAtom before the native surface
+ // goes away. Covers warm resume (hook alive, /sync delivers an ADD
+ // within ~100-200ms) cleanly. Cold resume (killed process, Matrix
+ // client rehydration takes 1-3s) still has a "no surface" window —
+ // acceptable tradeoff since tap-native flows carry call_action and
+ // are handled by pendingCallActionConsumer regardless of atom state.
+ private static final long CANCEL_DEBOUNCE_MS = 300L;
+ private final Handler lifecycleHandler = new Handler(Looper.getMainLooper());
+ private final Runnable renderRunnable = () ->
+ VojoFirebaseMessagingService.renderRegistry(this);
+ private final Runnable cancelRunnable = () ->
+ VojoFirebaseMessagingService.cancelRenderedIncomingRings(this);
+
@Override
protected void onCreate(Bundle savedInstanceState) {
// Custom plugins must be registered before super.onCreate so BridgeActivity
@@ -22,11 +46,37 @@ public class MainActivity extends BridgeActivity {
public void onResume() {
super.onResume();
isInForeground = true;
+ // Cancel any pending render: user came back before the debounce fired,
+ // JS strip will own UX, no need to surface native.
+ lifecycleHandler.removeCallbacks(renderRunnable);
+ // Defer the native cancel so JS strip has a moment to hydrate from
+ // incomingCallsAtom. Registry entries persist — they still represent
+ // live rings, just the native surfaces go.
+ lifecycleHandler.removeCallbacks(cancelRunnable);
+ lifecycleHandler.postDelayed(cancelRunnable, CANCEL_DEBOUNCE_MS);
}
@Override
public void onPause() {
super.onPause();
isInForeground = false;
+ // Re-backgrounding: don't cancel a native the user will still need
+ // visible. The render runnable below will re-render if needed.
+ lifecycleHandler.removeCallbacks(cancelRunnable);
+ // Schedule render — user is backgrounding, JS audio gate is about to
+ // close, native CallStyle must surface or the ring goes silent. Debounce
+ // absorbs the bridge-call race: if onResume fires within RENDER_DEBOUNCE_MS
+ // (user bounce), the render is cancelled.
+ lifecycleHandler.removeCallbacks(renderRunnable);
+ lifecycleHandler.postDelayed(renderRunnable, RENDER_DEBOUNCE_MS);
+ }
+
+ @Override
+ public void onDestroy() {
+ // Drop any pending render/cancel so runnables — which capture `this` —
+ // can't fire post-destroy and land an nm.notify / nm.cancel against a
+ // dead Activity context on config change (rotation) or process teardown.
+ lifecycleHandler.removeCallbacksAndMessages(null);
+ super.onDestroy();
}
}
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 19dc9e40..678e2aaf 100644
--- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java
+++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java
@@ -10,6 +10,8 @@ import android.media.AudioAttributes;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
+import android.os.Bundle;
+import android.service.notification.StatusBarNotification;
import android.util.Log;
import androidx.core.app.NotificationCompat;
@@ -18,7 +20,10 @@ import androidx.core.app.Person;
import com.capacitorjs.plugins.pushnotifications.MessagingService;
import com.google.firebase.messaging.RemoteMessage;
+import java.util.HashMap;
+import java.util.Iterator;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
/**
* Sygnal delivers Matrix pushes as data-only FCM messages (no `notification`
@@ -29,14 +34,12 @@ import java.util.Map;
* Message branch: builds a system notification when the activity is NOT in
* the foreground — covering both "backgrounded" and "killed" cases.
*
- * Call branch: when the app is backgrounded we show a CallStyle incoming-call
- * notification with Answer/Decline actions + full-screen intent that wakes the
- * device and launches MainActivity over the lockscreen. When the app is already
- * foregrounded, JS owns the UX via the in-app incoming-call strip, so we must
- * NOT also surface a system banner. The FSI is also what satisfies AOSP
- * NotificationManagerService.checkDisqualifyingFeatures on API 31+; without it
- * CallStyle throws IAE and the notification is silently dropped. See
- * docs/plans/dm_calls.md ADR 2.5-fsi.
+ * Call branch: funnels every observed DM ring through the native ring
+ * registry (see below). FCM arrival either seeds the registry (foreground,
+ * JS strip will own UX) or seeds + renders immediately (background). The
+ * registry is authoritative for "which rings are currently live"; onPause
+ * renders whatever the registry still holds, onResume cancels native
+ * surfaces we raised.
*/
public class VojoFirebaseMessagingService extends MessagingService {
@@ -58,13 +61,102 @@ public class VojoFirebaseMessagingService extends MessagingService {
private static final long RTC_DEFAULT_LIFETIME_MS = 30_000L;
private static final long RTC_LIFETIME_GRACE_MS = 2_000L;
+ // Extra attached to every CallStyle notification posted from the registry;
+ // onResume ownership check matches this against the currently-rendered
+ // StatusBarNotification so we don't cancel a foreign ring that happens to
+ // share the same room-scoped tag/id slot.
+ private static final String EXTRA_RING_EVENT_ID = "vojo_ring_event_id";
+
private static final String TAG = "VojoFCM";
+ // Debug-only log helper. Release builds strip these calls at compile time
+ // (BuildConfig.DEBUG is a compile-time constant; the javac dead-code
+ // eliminator drops the whole statement). Use dlog() for anything that
+ // includes privacy-sensitive identifiers (roomId, eventId, sender);
+ // use Log.w / Log.e directly for error paths that must surface in release.
+ private static void dlog(String msg) {
+ if (BuildConfig.DEBUG) Log.d(TAG, msg);
+ }
+
+ // ────────────────────────────────────────────────────────────────────
+ // Native Ring Registry
+ //
+ // Single source of truth on the Java side for "which rings are currently
+ // live". Populated by FCM arrival (fg or bg) and by JS bridge upsert on
+ // incomingCallsAtom ADD. Removed by JS bridge on atom REMOVE and suppress
+ // paths, by native decline/cancel receivers, and by expiry sweep.
+ //
+ // Key: notifEventId (MSC4075 m.rtc.notification event_id). Same eventId
+ // observed by both FCM and /sync → idempotent upsert. Remove-wins
+ // consistency is enforced via tombstones: a late FCM seed arriving after
+ // JS suppressed the ring is rejected by a tombstone for the ring lifetime.
+ //
+ // Native notification identity remains room-scoped (tag=call_,
+ // id=hash(tag)). If a same-room second ring arrives before the first was
+ // dismissed, both live in the registry by distinct eventIds; renderRegistry
+ // posts each but nm.notify replaces in the one room-scoped slot. Only
+ // one of them is user-visible at a time — same-room latest-wins matches
+ // the UX contract for a single active ring per DM.
+ // ────────────────────────────────────────────────────────────────────
+
+ private static final ConcurrentHashMap ringRegistry =
+ new ConcurrentHashMap<>();
+
+ // eventId → absolute-ms when tombstone expires. Block upserts during this
+ // window so a late FCM re-delivery or late /sync cannot resurrect a ring
+ // the user already accepted/declined/ignored.
+ private static final ConcurrentHashMap ringTombstones =
+ new ConcurrentHashMap<>();
+
+ // Single lock covering every registry+tombstone mutation so remove-wins and
+ // latest-wins-per-room stay atomic. ConcurrentHashMap would linearize each
+ // call individually, but the invariants ("don't upsert if tombstoned",
+ // "evict older same-room on upsert") span both maps and must see a
+ // consistent snapshot. Critical sections stay short; no Android Binder
+ // calls (NotificationManager, AlarmManager) run under the lock.
+ private static final Object registryLock = new Object();
+
+ private static final class IncomingRing {
+ final Map data;
+ // Not final — a JS-first upsert seeds a null messageId; when FCM
+ // arrives later with a real messageId we want to adopt it so the
+ // Answer PendingIntent carries a non-empty google.message_id. Merge
+ // rule in upsertIncomingRing: overwrite only when existing is null.
+ volatile String messageId;
+ // Wall-clock at seed time. Fallback baseline for expiry checks when the
+ // payload lacks `content_sender_ts`.
+ final long seededAt;
+ // 0 if not currently posted as a native CallStyle. Set by renderOne on
+ // successful post; reset by cancelRenderedIncomingRings. Used by
+ // renderRegistry to avoid double-posting while the native is live.
+ volatile long renderedAt;
+ // Wall-clock of the most recent ALERTING post (non-silent). Used by
+ // renderOne to decide whether a re-render within the cooldown should
+ // be silent — rapid bg↔fg↔bg toggles otherwise cancel+repost the
+ // CallStyle and fresh-alert on each cycle. Preserved across cancel
+ // so the cooldown spans the bg→fg→bg window; setOnlyAlertOnce alone
+ // doesn't help because cancel+post is a fresh notification, not an update.
+ volatile long lastAlertedAt;
+
+ IncomingRing(Map data, String messageId, long seededAt) {
+ this.data = data;
+ this.messageId = messageId;
+ this.seededAt = seededAt;
+ this.renderedAt = 0L;
+ this.lastAlertedAt = 0L;
+ }
+ }
+
+ // Re-alert cooldown. Within this many ms of the last alerting post, a
+ // render goes out silently (setSilent(true)) — visual only, no
+ // ringtone/vibration. After the window, next render alerts fresh.
+ private static final long RE_ALERT_COOLDOWN_MS = 3_000L;
+
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
super.onMessageReceived(remoteMessage);
Map data = remoteMessage.getData();
- Log.d(TAG, "recv: type=" + data.get("type")
+ dlog("recv: type=" + data.get("type")
+ " cn_type=" + data.get("content_notification_type")
+ " room=" + data.get("room_id")
+ " event=" + data.get("event_id")
@@ -72,19 +164,49 @@ public class VojoFirebaseMessagingService extends MessagingService {
try {
if (RTC_NOTIFICATION_TYPE.equals(data.get("type"))
&& "ring".equals(data.get("content_notification_type"))) {
- if (MainActivity.isInForeground) {
- Log.d(TAG, "route: skip call notif (foreground, JS strip owns UX)");
+ String eventId = data.get("event_id");
+ String roomId = data.get("room_id");
+ if (eventId == null || roomId == null) {
+ Log.w(TAG, "route: call missing eventId/roomId, drop");
return;
}
- Log.d(TAG, "route: call-branch (background)");
- showIncomingCallNotification(remoteMessage);
+ // Snapshot the payload — FCM internals may recycle the map reference.
+ Map snapshot = new HashMap<>(data);
+ boolean seeded = upsertIncomingRing(snapshot, remoteMessage.getMessageId());
+ if (!seeded) {
+ dlog("route: call tombstoned, skipping native (event=" + eventId + ")");
+ return;
+ }
+ if (MainActivity.isInForeground) {
+ dlog("route: call seeded (foreground, JS strip owns UX) event=" + eventId);
+ // Race guard: MainActivity.onPause may have run its render
+ // between our fg check and the upsert above. If fg flipped
+ // to false meanwhile, render now so the seeded entry isn't
+ // left unrendered until the next backgrounding cycle.
+ if (!MainActivity.isInForeground) {
+ dlog("route: race-detected (fg→bg during seed), render now");
+ renderRegistry(this);
+ }
+ } else {
+ dlog("route: call seeded + render (background) event=" + eventId);
+ IncomingRing entry = ringRegistry.get(eventId);
+ // Guard against late-delivered FCM (doze / retry past lifetime):
+ // renderRegistry filters expired entries, but the bg direct-render
+ // path skipped that check. Without this guard a dead ring would
+ // briefly surface and then be dismissed by the expiry alarm.
+ if (entry != null && !isExpired(entry, System.currentTimeMillis())) {
+ renderOne(this, entry);
+ } else if (entry != null) {
+ dlog("route: call bg drop expired event=" + eventId);
+ }
+ }
return;
}
if (!MainActivity.isInForeground) {
- Log.d(TAG, "route: message-branch (background)");
+ dlog("route: message-branch (background)");
showSystemNotification(remoteMessage);
} else {
- Log.d(TAG, "route: skip (foreground, non-call)");
+ dlog("route: skip (foreground, non-call)");
}
} catch (Throwable t) {
// Don't let any notification-construction bug crash the FCM service — if we
@@ -151,7 +273,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
// Guard against the (rare) hashCode collision with the reserved summary id.
int notifId = uniqueKey.hashCode();
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
- Log.d(TAG, "msg: posting notif id=" + notifId + " channel=" + CHANNEL_ID
+ dlog("msg: posting notif id=" + notifId + " channel=" + CHANNEL_ID
+ " notifsEnabled=" + nm.areNotificationsEnabled());
try {
nm.notify(notifId, builder.build());
@@ -170,22 +292,397 @@ public class VojoFirebaseMessagingService extends MessagingService {
nm.notify(SUMMARY_NOTIFICATION_ID, summary.build());
}
- private void showIncomingCallNotification(RemoteMessage message) {
- Map data = message.getData();
+ // ────────────────────────────────────────────────────────────────────
+ // Registry operations (public to package)
+ // ────────────────────────────────────────────────────────────────────
+
+ /**
+ * Seed or refresh a registry entry. Returns true if the entry was stored,
+ * false if rejected by a live tombstone (remove-wins consistency).
+ *
+ * Idempotent when the eventId is already present. Metadata merge is
+ * append-only: FCM payload typically carries richer fields
+ * (sender_display_name, content_sender_ts, content_lifetime) than a JS
+ * MatrixEvent-derived upsert, so a later upsert for the same eventId
+ * preserves fields the first-observed payload already had.
+ *
+ * Latest-wins per room: native notification identity is room-scoped
+ * (tag=call_), so a second upsert for a different eventId in
+ * the same DM evicts the prior entry and tombstones it — the newer
+ * ring owns the single slot and its Answer/Decline PendingIntents bind
+ * to its eventId, not to the stale one's.
+ *
+ * All reads and mutations of ringRegistry + ringTombstones run under
+ * registryLock so tombstone-check → eviction → put stays a single atomic
+ * section relative to concurrent removeIncomingRing callers.
+ */
+ static boolean upsertIncomingRing(Map data, String messageId) {
+ String eventId = data.get("event_id");
+ if (eventId == null || eventId.isEmpty()) {
+ Log.w(TAG, "upsert: missing event_id, drop");
+ return false;
+ }
+ String roomId = data.get("room_id");
+ synchronized (registryLock) {
+ purgeExpiredTombstones();
+ Long tombstoneExpiry = ringTombstones.get(eventId);
+ if (tombstoneExpiry != null && tombstoneExpiry > System.currentTimeMillis()) {
+ dlog("upsert: tombstoned event=" + eventId + " until=" + tombstoneExpiry);
+ return false;
+ }
+
+ // Latest-wins per room, compared by content_sender_ts. A late
+ // FCM retry / reordered /sync / decrypt lag can deliver an older
+ // ring AFTER a newer same-room ring already settled in registry;
+ // pure last-observed-wins would let the stale event evict the
+ // newer one. We compare sender_ts: newer always wins; if the
+ // incoming event is older than an existing same-room entry we
+ // drop the incoming and tombstone its eventId so re-deliveries
+ // stay rejected. When either side has no sender_ts (malformed
+ // push payload) we fall back to last-observed (evict existing),
+ // matching the prior behavior for that corner.
+ if (roomId != null) {
+ long newSenderTs = parseLong(data.get("content_sender_ts"), -1L);
+ Iterator> it = ringRegistry.entrySet().iterator();
+ while (it.hasNext()) {
+ Map.Entry e = it.next();
+ if (e.getKey().equals(eventId)) continue;
+ IncomingRing other = e.getValue();
+ if (!roomId.equals(other.data.get("room_id"))) continue;
+ long otherSenderTs = parseLong(other.data.get("content_sender_ts"), -1L);
+ if (newSenderTs > 0 && otherSenderTs > 0 && newSenderTs < otherSenderTs) {
+ // Incoming is strictly older. Drop + tombstone it;
+ // keep existing newer ring intact.
+ long newLifetime = parseLong(
+ data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
+ ringTombstones.put(eventId,
+ System.currentTimeMillis() + 2 * newLifetime + RTC_LIFETIME_GRACE_MS);
+ dlog("upsert: drop stale same-room event=" + eventId
+ + " room=" + roomId + " incomingTs=" + newSenderTs
+ + " existingTs=" + otherSenderTs);
+ return false;
+ }
+ // Incoming is newer (or ts unknown on either side) — evict
+ // existing and tombstone its eventId.
+ long otherLifetime = parseLong(
+ other.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
+ ringTombstones.put(e.getKey(),
+ System.currentTimeMillis() + 2 * otherLifetime + RTC_LIFETIME_GRACE_MS);
+ dlog("upsert: evict older same-room entry event="
+ + e.getKey() + " room=" + roomId + " supersededBy=" + eventId);
+ it.remove();
+ }
+ }
+
+ IncomingRing existing = ringRegistry.get(eventId);
+ if (existing != null) {
+ boolean mergedAny = false;
+ for (Map.Entry e : data.entrySet()) {
+ if (!existing.data.containsKey(e.getKey()) && e.getValue() != null) {
+ existing.data.put(e.getKey(), e.getValue());
+ mergedAny = true;
+ }
+ }
+ // messageId sits on the IncomingRing, not in data. JS seeds
+ // with null; when FCM arrives later with a real messageId we
+ // want to adopt it so the Answer PendingIntent's
+ // google.message_id is non-empty. Only overwrite when
+ // existing is absent — the first-observed FCM messageId wins.
+ if (existing.messageId == null && messageId != null) {
+ existing.messageId = messageId;
+ mergedAny = true;
+ }
+ if (mergedAny) {
+ dlog("upsert: merged fields event=" + eventId);
+ }
+ return true;
+ }
+ ringRegistry.put(eventId, new IncomingRing(data, messageId, System.currentTimeMillis()));
+ dlog("upsert: seed event=" + eventId + " room=" + roomId);
+ return true;
+ }
+ }
+
+ /**
+ * Render every registry entry that is still live and not yet rendered.
+ * Called from MainActivity.onPause. Idempotent: entries with renderedAt>0
+ * are skipped.
+ */
+ static void renderRegistry(Context ctx) {
+ java.util.List snapshot;
+ long now;
+ // Lock window: purge + snapshot only. renderOne posts Binder-heavy
+ // notifications, which must never run under registryLock or a slow
+ // NotificationManager callback could stall upserts.
+ synchronized (registryLock) {
+ if (ringRegistry.isEmpty()) return;
+ now = System.currentTimeMillis();
+ purgeExpiredTombstones();
+ purgeExpiredEntries(now);
+ snapshot = new java.util.ArrayList<>(ringRegistry.values());
+ }
+ dlog("render: walk registry size=" + snapshot.size());
+ for (IncomingRing entry : snapshot) {
+ // Skip entries already posted — renderOne only sets renderedAt on
+ // a successful nm.notify, and cancelRenderedIncomingRings resets
+ // it on onResume. Cooldown-driven "silent" re-alert is handled
+ // inside renderOne via lastAlertedAt so we still post visual
+ // (just silent) instead of dropping the surface entirely.
+ if (entry.renderedAt > 0) continue;
+ if (isExpired(entry, now)) continue;
+ renderOne(ctx, entry);
+ }
+ }
+
+ /**
+ * Cancel native surfaces for all rendered entries and clear their
+ * renderedAt flag. Registry entries persist — they represent live ring
+ * state, not render history. Called from MainActivity.onResume. Uses an
+ * extras-based ownership check to avoid cancelling a same-slot native
+ * raised by a foreign path (different eventId in the same DM).
+ */
+ static void cancelRenderedIncomingRings(Context ctx) {
+ java.util.List snapshot;
+ java.util.Set liveEventIds;
+ synchronized (registryLock) {
+ snapshot = new java.util.ArrayList<>(ringRegistry.values());
+ liveEventIds = new java.util.HashSet<>(ringRegistry.keySet());
+ }
+ NotificationManager nm =
+ (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (nm == null) return;
+ StatusBarNotification[] active;
+ try {
+ active = nm.getActiveNotifications();
+ } catch (Throwable t) {
+ Log.w(TAG, "cancelRendered: getActiveNotifications threw", t);
+ active = new StatusBarNotification[0];
+ }
+ // 1) Cancel entries the registry knows it rendered and reset
+ // renderedAt so the next onPause re-renders the surface (bg→fg→bg
+ // toggle scenarios where the ring is still live). lastAlertedAt is
+ // preserved — renderOne uses it as a cooldown to suppress ringtone
+ // on a re-render within RE_ALERT_COOLDOWN_MS of the previous alert.
+ for (IncomingRing entry : snapshot) {
+ if (entry.renderedAt == 0) continue;
+ String eventId = entry.data.get("event_id");
+ String roomId = entry.data.get("room_id");
+ if (eventId == null || roomId == null) {
+ entry.renderedAt = 0;
+ continue;
+ }
+ String tag = "call_" + roomId;
+ int notifId = tag.hashCode();
+ if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
+ cancelWithOwnershipCheck(nm, active, tag, notifId, eventId);
+ entry.renderedAt = 0;
+ }
+ // 2) Orphan sweep: cancel any active CallStyle carrying our
+ // EXTRA_RING_EVENT_ID whose eventId is NOT in the current registry.
+ // Covers the process-kill case where the static registry was cleared
+ // but NotificationManager kept the notification visible, and the
+ // foreign-owner-leftover case where an evicted entry's native is still
+ // on screen. Foreign rings posted by other apps are ignored (no
+ // matching extra), and our own rings still live in registry are left
+ // for the owned-cancel loop above.
+ for (StatusBarNotification sbn : active) {
+ String tag = sbn.getTag();
+ if (tag == null || !tag.startsWith("call_")) continue;
+ Bundle extras = sbn.getNotification() != null
+ ? sbn.getNotification().extras : null;
+ String slotEventId = extras != null
+ ? extras.getString(EXTRA_RING_EVENT_ID) : null;
+ if (slotEventId == null) continue;
+ if (liveEventIds.contains(slotEventId)) continue;
+ try {
+ nm.cancel(tag, sbn.getId());
+ dlog("cancelRendered: orphan swept tag=" + tag
+ + " eventId=" + slotEventId);
+ } catch (Throwable t) {
+ Log.w(TAG, "cancelRendered: orphan cancel threw tag=" + tag, t);
+ }
+ }
+ }
+
+ /**
+ * Remove a registry entry, tombstone the eventId, and cancel any native
+ * surface we rendered for it. Called from JS bridge (atom REMOVE /
+ * suppress paths), CallDeclineReceiver (native Decline action), and
+ * CallCancelReceiver (expiry alarm).
+ *
+ * Tombstone window = 2 × entry lifetime + grace when the removed entry
+ * is known (covers per-event content_lifetime variation); falls back to
+ * 2 × RTC_DEFAULT_LIFETIME_MS + grace for remove-before-upsert calls
+ * that have no entry to read lifetime from. Covers FCM retry window and
+ * sender-clock skew.
+ *
+ * Registry+tombstone mutation runs under registryLock; Binder calls
+ * (NotificationManager.getActiveNotifications, nm.cancel) run outside
+ * the lock so a slow system callback cannot stall concurrent upserts.
+ */
+ static void removeIncomingRing(Context ctx, String eventId) {
+ if (eventId == null || eventId.isEmpty()) return;
+ IncomingRing removed;
+ long tombstoneWindow;
+ synchronized (registryLock) {
+ // Opportunistic purge on every remove — without this, tombstones
+ // grow unbounded if upserts stop flowing.
+ purgeExpiredTombstones();
+ removed = ringRegistry.remove(eventId);
+ long lifetime = removed != null
+ ? parseLong(removed.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS)
+ : RTC_DEFAULT_LIFETIME_MS;
+ tombstoneWindow = 2 * lifetime + RTC_LIFETIME_GRACE_MS;
+ ringTombstones.put(eventId, System.currentTimeMillis() + tombstoneWindow);
+ }
+ dlog("remove: event=" + eventId + " had=" + (removed != null)
+ + " tombstoneWindow=" + tombstoneWindow);
+ if (removed == null || removed.renderedAt == 0) return;
+ String roomId = removed.data.get("room_id");
+ if (roomId == null) return;
+ String tag = "call_" + roomId;
+ int notifId = tag.hashCode();
+ if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
+ NotificationManager nm =
+ (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (nm == null) return;
+ StatusBarNotification[] active;
+ try {
+ active = nm.getActiveNotifications();
+ } catch (Throwable t) {
+ Log.w(TAG, "remove: getActiveNotifications threw", t);
+ return;
+ }
+ cancelWithOwnershipCheck(nm, active, tag, notifId, eventId);
+ }
+
+ private static void cancelWithOwnershipCheck(
+ NotificationManager nm,
+ StatusBarNotification[] active,
+ String tag,
+ int notifId,
+ String expectedEventId
+ ) {
+ for (StatusBarNotification sbn : active) {
+ if (!tag.equals(sbn.getTag()) || sbn.getId() != notifId) continue;
+ Bundle extras = sbn.getNotification() != null
+ ? sbn.getNotification().extras : null;
+ String slotEventId = extras != null
+ ? extras.getString(EXTRA_RING_EVENT_ID) : null;
+ if (expectedEventId.equals(slotEventId)) {
+ try {
+ nm.cancel(tag, notifId);
+ } catch (Throwable t) {
+ Log.w(TAG, "cancel threw tag=" + tag, t);
+ }
+ } else {
+ dlog("cancel: foreign-owner slot tag=" + tag
+ + " slotEventId=" + slotEventId
+ + " expected=" + expectedEventId + " — leaving intact");
+ }
+ return;
+ }
+ }
+
+ private static void renderOne(Context ctx, IncomingRing entry) {
+ long now = System.currentTimeMillis();
+ // Silent re-render if an alerting post landed less than
+ // RE_ALERT_COOLDOWN_MS ago. lastAlertedAt persists across cancel so
+ // a bg→fg→bg toggle within the cooldown stays silent even though
+ // renderedAt is reset by cancelRenderedIncomingRings.
+ boolean silent = entry.lastAlertedAt > 0
+ && (now - entry.lastAlertedAt) < RE_ALERT_COOLDOWN_MS;
+ boolean posted = postIncomingCallNotification(
+ ctx, entry.data, entry.messageId,
+ entry.data.get("event_id"), entry.seededAt, silent
+ );
+ // Only mark rendered on a successful nm.notify so the next render
+ // cycle retries on transient Binder failure instead of leaving the
+ // entry permanently "rendered" with no actual native surface.
+ if (posted) {
+ entry.renderedAt = now;
+ // Only advance the cooldown baseline on actually-alerting posts.
+ // Silent posts don't re-trigger ringtone so they don't count
+ // toward the cooldown window; otherwise a chain of silent posts
+ // would indefinitely defer the next allowed alert.
+ if (!silent) entry.lastAlertedAt = now;
+ }
+ }
+
+ // Caller must hold registryLock.
+ private static void purgeExpiredTombstones() {
+ long now = System.currentTimeMillis();
+ Iterator> it = ringTombstones.entrySet().iterator();
+ while (it.hasNext()) {
+ if (it.next().getValue() < now) it.remove();
+ }
+ }
+
+ // Caller must hold registryLock.
+ private static void purgeExpiredEntries(long now) {
+ Iterator> it = ringRegistry.entrySet().iterator();
+ while (it.hasNext()) {
+ Map.Entry e = it.next();
+ if (isExpired(e.getValue(), now)) {
+ dlog("purge: expired event=" + e.getKey());
+ it.remove();
+ }
+ }
+ }
+
+ private static boolean isExpired(IncomingRing entry, long now) {
+ long senderTs = parseLong(entry.data.get("content_sender_ts"), -1L);
+ long lifetime = parseLong(entry.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
+ long baseTs = (senderTs > 0) ? senderTs : entry.seededAt;
+ return baseTs + lifetime + RTC_LIFETIME_GRACE_MS < now;
+ }
+
+ // ────────────────────────────────────────────────────────────────────
+ // Post path (shared between FCM-direct and registry-render)
+ // ────────────────────────────────────────────────────────────────────
+
+ /**
+ * Package-private, static — posts the CallStyle incoming-call notification.
+ * All entry points (FCM-direct bg, registry-render) funnel through here so
+ * channel creation, action intents, ownership extras, and expiry alarm
+ * stay in lock-step.
+ *
+ * Returns true iff nm.notify succeeded. Callers (renderOne) use this to
+ * gate setting renderedAt — a false return leaves the entry unrendered so
+ * the next onPause cycle retries.
+ *
+ * ringEventId: tagged onto the notification's extras for ownership check
+ * on cancel (same-DM foreign rings must not be killed by our cancels).
+ * fallbackBaseTs: baseline used for expiry alarm when the payload lacks
+ * `content_sender_ts`. Callers pass `now` for FCM-direct (ring just
+ * arrived) or the registry entry's seededAt (ring was seeded earlier,
+ * alarm must target true expiry).
+ * silent: when true, attaches builder.setSilent(true) so the post goes
+ * out without ringtone / vibration / heads-up, keeping the visual
+ * banner. Used by renderOne for the cooldown-gated re-render path.
+ */
+ static boolean postIncomingCallNotification(
+ Context ctx,
+ Map data,
+ String messageId,
+ String ringEventId,
+ long fallbackBaseTs,
+ boolean silent
+ ) {
String roomId = data.get("room_id");
String notifEventId = data.get("event_id");
if (roomId == null || notifEventId == null) {
Log.w(TAG, "call: missing roomId/eventId, abort");
- return;
+ return false;
}
- NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null) {
Log.w(TAG, "call: NotificationManager is null, abort");
- return;
+ return false;
}
- ensureCallChannel(nm);
+ ensureCallChannel(ctx, nm);
String callerName = firstNonEmpty(
data.get("sender_display_name"),
@@ -193,7 +690,6 @@ public class VojoFirebaseMessagingService extends MessagingService {
data.get("sender"),
"Vojo"
);
- String messageId = message.getMessageId();
String tag = "call_" + roomId;
int notifId = tag.hashCode();
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
@@ -205,40 +701,44 @@ public class VojoFirebaseMessagingService extends MessagingService {
int launchReq = ("open_" + notifEventId).hashCode();
PendingIntent answerPI = buildActionPI(
- answerReq, "answer", roomId, notifEventId, messageId
+ ctx, answerReq, "answer", roomId, notifEventId, messageId
);
- // Decline goes through a BroadcastReceiver so the WebView never boots
- // — m.call.decline is sent directly from Java via the stored access
- // token (see CallDeclineReceiver). MainActivity is only involved on
- // fresh-reinstall / null-session fallback, where the receiver leaves
- // the ring intact and the JS-path consumer takes over after unlock.
PendingIntent declinePI = buildDeclineBroadcastPI(
- declineReq, roomId, notifEventId, tag, notifId
+ ctx, declineReq, roomId, notifEventId, tag, notifId
);
PendingIntent launchPI = buildActionPI(
- launchReq, null, roomId, notifEventId, messageId
+ ctx, launchReq, null, roomId, notifEventId, messageId
);
Person caller = new Person.Builder().setName(callerName).build();
Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
- NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CALL_CHANNEL_ID)
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CALL_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(true)
.setAutoCancel(false)
+ // Suppress re-alert on same-(tag, id) updates (e.g., same-ring
+ // re-render without cancel between). Fresh alert still fires after
+ // a cancel + repost cycle — Android treats that as a new post.
+ .setOnlyAlertOnce(true)
.setContentIntent(launchPI)
- // Full-screen wakeup over lockscreen — the "real" incoming-call UX
- // (WhatsApp/Telegram-style). Also the only reliable way to satisfy
- // AOSP's checkDisqualifyingFeatures gate for CallStyle on API 31+;
- // Samsung OneUI specifically rejects CallStyle with `fgs=false` +
- // `FullScreenIntent=null` even when USE_FULL_SCREEN_INTENT is just
- // declared. Requires the permission in the manifest.
+ // Full-screen wakeup over lockscreen — the "real" incoming-call UX.
+ // Also the only reliable way to satisfy AOSP's checkDisqualifyingFeatures
+ // gate for CallStyle on API 31+; Samsung OneUI specifically rejects
+ // CallStyle with `fgs=false` + `FullScreenIntent=null`.
.setFullScreenIntent(launchPI, true)
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declinePI, answerPI));
+ if (ringEventId != null) {
+ Bundle extras = new Bundle();
+ extras.putString(EXTRA_RING_EVENT_ID, ringEventId);
+ builder.addExtras(extras);
+ }
+ if (silent) builder.setSilent(true);
+
// Builder-level sound/vibration are ignored on API 26+ once the
// channel has its own settings — we configure both on the channel in
// ensureCallChannel() and skip the redundant builder calls here.
@@ -247,31 +747,36 @@ public class VojoFirebaseMessagingService extends MessagingService {
builder.setVibrate(buildRingVibrationPattern());
}
- Log.d(TAG, "call: posting notif tag=" + tag + " id=" + notifId
- + " channel=" + CALL_CHANNEL_ID + " notifsEnabled=" + nm.areNotificationsEnabled());
+ dlog("call: posting notif tag=" + tag + " id=" + notifId
+ + " channel=" + CALL_CHANNEL_ID + " notifsEnabled=" + nm.areNotificationsEnabled()
+ + " ringEventId=" + ringEventId);
try {
nm.notify(tag, notifId, builder.build());
- Log.d(TAG, "call: nm.notify returned OK");
+ dlog("call: nm.notify returned OK");
} catch (Throwable t) {
Log.e(TAG, "call: nm.notify threw", t);
- return;
+ return false;
}
try {
- scheduleCallNotificationExpiry(data, tag, notifId);
+ scheduleCallNotificationExpiry(ctx, data, tag, notifId, fallbackBaseTs);
} catch (Throwable t) {
Log.e(TAG, "call: scheduleCallNotificationExpiry threw", t);
+ // Not a post failure — alarm is a separate concern, nm.notify
+ // already succeeded. Leave return=true so renderedAt is set.
}
+ return true;
}
- private PendingIntent buildActionPI(
+ private static PendingIntent buildActionPI(
+ Context ctx,
int requestCode,
String callAction,
String roomId,
String notifEventId,
String messageId
) {
- Intent intent = new Intent(this, MainActivity.class)
+ Intent intent = new Intent(ctx, MainActivity.class)
.setAction(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
// Capacitor PushNotificationsPlugin gates `pushNotificationActionPerformed`
@@ -283,17 +788,18 @@ public class VojoFirebaseMessagingService extends MessagingService {
if (callAction != null) intent.putExtra("call_action", callAction);
int flags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
- return PendingIntent.getActivity(this, requestCode, intent, flags);
+ return PendingIntent.getActivity(ctx, requestCode, intent, flags);
}
- private PendingIntent buildDeclineBroadcastPI(
+ private static PendingIntent buildDeclineBroadcastPI(
+ Context ctx,
int requestCode,
String roomId,
String notifEventId,
String notifTag,
int notifId
) {
- Intent intent = new Intent(this, CallDeclineReceiver.class)
+ Intent intent = new Intent(ctx, CallDeclineReceiver.class)
.setAction(CallDeclineReceiver.ACTION_DECLINE_CALL)
.putExtra(CallDeclineReceiver.EXTRA_ROOM_ID, roomId)
.putExtra(CallDeclineReceiver.EXTRA_NOTIF_EVENT_ID, notifEventId)
@@ -301,7 +807,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
.putExtra(CallDeclineReceiver.EXTRA_NOTIF_ID, notifId);
int flags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
- return PendingIntent.getBroadcast(this, requestCode, intent, flags);
+ return PendingIntent.getBroadcast(ctx, requestCode, intent, flags);
}
// Mirrors the JS-side createChannel in usePushNotifications.ts. Lazy creation
@@ -312,7 +818,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
private void ensureMessageChannel(NotificationManager nm) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
- Log.d(TAG, "msg: creating channel " + CHANNEL_ID);
+ dlog("msg: creating channel " + CHANNEL_ID);
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"Messages",
@@ -324,16 +830,16 @@ public class VojoFirebaseMessagingService extends MessagingService {
nm.createNotificationChannel(channel);
}
- private void ensureCallChannel(NotificationManager nm) {
+ private static void ensureCallChannel(Context ctx, NotificationManager nm) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
if (nm.getNotificationChannel(CALL_CHANNEL_ID) != null) return;
// Drop the pre-v2 channel on first creation of v2 so it doesn't linger
// in Settings → Notifications (user-visible cruft) after the bump.
if (nm.getNotificationChannel(LEGACY_CALL_CHANNEL_ID) != null) {
- Log.d(TAG, "call: deleting legacy channel " + LEGACY_CALL_CHANNEL_ID);
+ dlog("call: deleting legacy channel " + LEGACY_CALL_CHANNEL_ID);
nm.deleteNotificationChannel(LEGACY_CALL_CHANNEL_ID);
}
- Log.d(TAG, "call: creating channel " + CALL_CHANNEL_ID);
+ dlog("call: creating channel " + CALL_CHANNEL_ID);
NotificationChannel channel = new NotificationChannel(
CALL_CHANNEL_ID,
"Incoming calls",
@@ -360,31 +866,32 @@ public class VojoFirebaseMessagingService extends MessagingService {
nm.createNotificationChannel(channel);
}
- private void scheduleCallNotificationExpiry(
+ private static void scheduleCallNotificationExpiry(
+ Context ctx,
Map data,
String tag,
- int notifId
+ int notifId,
+ long fallbackBaseTs
) {
long senderTs = parseLong(data.get("content_sender_ts"), -1L);
long lifetime = parseLong(data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
- long baseTs = (senderTs > 0) ? senderTs : System.currentTimeMillis();
+ // Callers pass `now` for FCM-direct (ring just arrived) and
+ // entry.seededAt for registry-render (ring was seeded earlier).
+ long baseTs = (senderTs > 0) ? senderTs : fallbackBaseTs;
long triggerAt = baseTs + lifetime + RTC_LIFETIME_GRACE_MS;
- AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
+ AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE);
if (am == null) return;
- Intent cancelIntent = new Intent(this, CallCancelReceiver.class)
+ Intent cancelIntent = new Intent(ctx, CallCancelReceiver.class)
.setAction(CallCancelReceiver.ACTION_CANCEL_CALL)
.putExtra(CallCancelReceiver.EXTRA_NOTIF_TAG, tag)
- .putExtra(CallCancelReceiver.EXTRA_NOTIF_ID, notifId);
+ .putExtra(CallCancelReceiver.EXTRA_NOTIF_ID, notifId)
+ .putExtra(CallCancelReceiver.EXTRA_NOTIF_EVENT_ID, data.get("event_id"));
int flags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
- PendingIntent pi = PendingIntent.getBroadcast(this, notifId, cancelIntent, flags);
+ PendingIntent pi = PendingIntent.getBroadcast(ctx, notifId, cancelIntent, flags);
- // setAndAllowWhileIdle is enough for a 30s-ish dismiss: exactness is
- // nice-to-have but not worth the SCHEDULE_EXACT_ALARM user grant on API 34+.
- // Drift of a few minutes on doze-deep devices is acceptable — the JS layer
- // cancels proactively on decline / self-join anyway.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi);
} else {
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 444352ae..faee8340 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -1022,11 +1022,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[string, MatrixEvent, number, EventTimelineSet, boolean]
>(
{
- // Suppress DM-call service events from the timeline (§5.8). In encrypted
+ // Suppress DM-call service events from the timeline. In encrypted
// DMs this takes effect after per-event decryption re-render via
// EncryptedContent; the first render shows the "not decrypted" placeholder.
// Hardcoded strings — migrate to EventType.RTCNotification/RTCDecline
- // when MSC4075/MSC4310 stabilize (§5.19).
+ // when MSC4075/MSC4310 stabilize.
'org.matrix.msc4075.rtc.notification': () => null,
'org.matrix.msc4310.rtc.decline': () => null,
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
@@ -1129,11 +1129,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
return (
{() => {
- // §5.9: after decrypt, DM-call service events still route through
+ // After decrypt, DM-call service events still route through
// this branch (outer typeToRenderer dispatched on the pre-decrypt
// 'm.room.encrypted' type). Drop the whole row instead of falling
// through to MessageUnsupportedContent. Keys mirror the hardcoded
- // literals in the outer filter — migrate together (§5.19).
+ // literals in the outer filter — migrate together.
const decryptedType = mEvent.getType();
if (decryptedType === 'org.matrix.msc4075.rtc.notification') return null;
if (decryptedType === 'org.matrix.msc4310.rtc.decline') return null;
diff --git a/src/app/hooks/useAndroidCallForegroundSync.ts b/src/app/hooks/useAndroidCallForegroundSync.ts
index aac2cf66..5ff686dc 100644
--- a/src/app/hooks/useAndroidCallForegroundSync.ts
+++ b/src/app/hooks/useAndroidCallForegroundSync.ts
@@ -21,7 +21,7 @@
// retention. The call isn't actually live in that window — media hasn't
// started — so lock-screen there is a non-event; user can retry.
//
-// Android-only. Context: docs/plans/dm_calls_techdebt.md §2.2.
+// Android-only.
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
diff --git a/src/app/hooks/useCallerAutoHangup.ts b/src/app/hooks/useCallerAutoHangup.ts
index 0eef8dfc..9381dd20 100644
--- a/src/app/hooks/useCallerAutoHangup.ts
+++ b/src/app/hooks/useCallerAutoHangup.ts
@@ -2,17 +2,16 @@
//
// Scope despite the name: fires on any DM callEmbed, including when we're the
// B-side (answered a ring). Logic is symmetric: "leave if peer left or never
-// arrived". Rename pending — see dm_calls_techdebt.md §5.15.
+// arrived". Rename pending.
//
-// Covers four cases (all §5.5):
+// Covers four cases:
// 1. Peer declines — RTCDecline timeline event → hangup immediately.
// 2. Peer never joins — no-answer timer fires (lifetime + grace).
// 3. Peer joins then leaves — memberships go empty → hangup after grace.
// 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip.
//
-// KNOWN GAP (also in dm_calls_techdebt.md):
-// §5.18 Grace constants are empirically chosen, may need tuning with
-// real-world /sync + LiveKit reconnect metrics.
+// KNOWN GAP: grace constants are empirically chosen, may need tuning with
+// real-world /sync + LiveKit reconnect metrics.
//
// Encrypted DMs: RTCDecline arrives as m.room.encrypted first and Timeline
// does not re-emit post-decrypt (matrix-js-sdk 38.2). We mirror the
diff --git a/src/app/hooks/useDismissNativeCallNotifications.ts b/src/app/hooks/useDismissNativeCallNotifications.ts
deleted file mode 100644
index e61af658..00000000
--- a/src/app/hooks/useDismissNativeCallNotifications.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { useEffect, useRef, useState } from 'react';
-import { useAtomValue } from 'jotai';
-import { App } from '@capacitor/app';
-import { incomingCallsAtom } from '../state/incomingCalls';
-import { isAndroidPlatform, isNativePlatform } from '../utils/capacitor';
-
-const SUMMARY_NOTIFICATION_ID = -2147483648;
-
-// Reproduces java.lang.String#hashCode so JS-side ids match the tag ids the
-// Java service computed. Must stay in sync with CallCancelReceiver / the
-// tag.hashCode() call in VojoFirebaseMessagingService.
-function javaStringHashCode(s: string): number {
- let h = 0;
- for (let i = 0; i < s.length; i += 1) {
- // eslint-disable-next-line no-bitwise
- h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
- }
- return h;
-}
-
-async function dismissRooms(roomIds: string[]): Promise {
- if (roomIds.length === 0) return;
- if (!isNativePlatform()) return;
- const { PushNotifications } = await import('@capacitor/push-notifications');
- // Shape mirrors VojoFirebaseMessagingService.showIncomingCallNotification:
- // tag = "call_" + roomId, id = tag.hashCode(). The Android Capacitor
- // plugin reads id via JSObject.getInteger (PushNotificationsPlugin.java
- // line 166), so the id must go over the bridge as a number — the TS
- // type declares string, hence the cast.
- const notifications = roomIds.map((roomId) => {
- const tag = `call_${roomId}`;
- let id = javaStringHashCode(tag);
- if (id === SUMMARY_NOTIFICATION_ID) id += 1;
- return { id, tag };
- });
- await PushNotifications.removeDeliveredNotifications({
- notifications: notifications as unknown as Parameters<
- typeof PushNotifications.removeDeliveredNotifications
- >[0]['notifications'],
- }).catch(() => {
- /* best-effort — alarm fallback still dismisses at lifetime expiry */
- });
-}
-
-// Keep native CallStyle in sync with the JS-owned incoming-calls atom, enforcing
-// the "foreground → JS strip, background → platform surface" invariant on the
-// dismiss side (symmetric to the show-side skip in VojoFirebaseMessagingService
-// and the JS-audio gate in IncomingCallStripRenderer).
-// - REMOVE (ring ended: accept/decline/other-device/expiry) → dismiss always.
-// - ADD while foregrounded → JS strip owns UX → dismiss any stale native.
-// - ADD while backgrounded → native CallStyle owns UX → keep it, do NOT dismiss.
-// - background → foreground transition with a live ring → one-shot sweep hands
-// ownership back to the JS strip.
-// The AlarmManager fallback in the Java service still handles killed-process
-// dismiss on lifetime expiry.
-export const useDismissNativeCallNotifications = (): void => {
- const incoming = useAtomValue(incomingCallsAtom);
- const prevRoomsRef = useRef>(new Set());
- const [appActive, setAppActive] = useState(
- () => typeof document === 'undefined' || document.visibilityState === 'visible'
- );
-
- // App state tracking.
- // Android: `pause`/`resume` fire inside BridgeActivity.onPause/onResume via
- // AppPlugin.handleOnPause/Resume — the same Activity callbacks that flip
- // MainActivity.isInForeground — and reach JS within ms via the WebView
- // bridge. `appStateChange` on Android fires only in BridgeActivity.onStop,
- // tens of ms to ~1s after onPause; using it as the gate would leak incoming
- // rings into a "just backgrounded" window where Java sees background (FCM
- // shows native CallStyle) but JS still sees active (and the dismiss branch
- // below would clobber the freshly-raised native ring).
- // iOS/web keeps `appStateChange` because there it maps to
- // willResignActive/didBecomeActive (iOS) or document.visibilitychange (web)
- // — the correct edge. The Capacitor `pause`/`resume` events on iOS bind to
- // didEnterBackground/willEnterForeground, which is a different semantics.
- // Race-guard: a real lifecycle event arriving before getState() resolves
- // must win over the stale snapshot.
- useEffect(() => {
- let cancelled = false;
- let sawLifecycleEvent = false;
- const handles: Array<{ remove: () => void }> = [];
- const track = (h: { remove: () => void }) => {
- if (cancelled) h.remove();
- else handles.push(h);
- };
-
- const apply = (isActive: boolean) => {
- sawLifecycleEvent = true;
- if (!cancelled) setAppActive(isActive);
- };
-
- if (isAndroidPlatform()) {
- App.addListener('pause', () => apply(false)).then(track);
- App.addListener('resume', () => apply(true)).then(track);
- } else {
- App.addListener('appStateChange', ({ isActive }) => apply(isActive)).then(track);
- }
-
- App.getState()
- .then((state) => {
- if (!cancelled && !sawLifecycleEvent) setAppActive(state.isActive);
- })
- .catch(() => {
- /* web fallback handled by initial document.visibilityState */
- });
-
- return () => {
- cancelled = true;
- handles.forEach((h) => h.remove());
- };
- }, []);
-
- // Diff-driven dismiss. prevRoomsRef is the write-site for the current-rooms
- // snapshot; the handoff-effect below reads it and relies on this effect
- // running first on shared re-renders (React runs effects in declaration
- // order — do not reorder).
- useEffect(() => {
- const nextRooms = new Set();
- incoming.forEach((call) => nextRooms.add(call.roomId));
-
- const added: string[] = [];
- const removed: string[] = [];
- prevRoomsRef.current.forEach((roomId) => {
- if (!nextRooms.has(roomId)) removed.push(roomId);
- });
- nextRooms.forEach((roomId) => {
- if (!prevRoomsRef.current.has(roomId)) added.push(roomId);
- });
- prevRoomsRef.current = nextRooms;
-
- const toDismiss = appActive ? [...removed, ...added] : removed;
- dismissRooms(toDismiss).catch(() => {
- /* best-effort */
- });
- }, [incoming, appActive]);
-
- // Handoff sweep on background → foreground. Reads prevRoomsRef populated by
- // the diff-effect above. Also fires on initial mount when appActive=true
- // with a non-empty atom (Answer-from-killed cold boot where /sync populated
- // the atom before first render) — JS now owns ring UX so any native
- // CallStyle raised by the FCM path must go. Double-dismiss with the
- // diff-effect on a simultaneous transition+ADD tick is harmless (native
- // cancel is idempotent).
- useEffect(() => {
- if (!appActive) return;
- if (prevRoomsRef.current.size === 0) return;
- dismissRooms(Array.from(prevRoomsRef.current)).catch(() => {
- /* best-effort */
- });
- }, [appActive]);
-};
diff --git a/src/app/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts
index 1f825b12..68be1d4e 100644
--- a/src/app/hooks/useIncomingRtcNotifications.ts
+++ b/src/app/hooks/useIncomingRtcNotifications.ts
@@ -1,7 +1,7 @@
// Incoming DM ring: watches `m.rtc.notification` in the live timeline and
// populates `incomingCallsAtom` so the bottom strip can render.
//
-// Encrypted DMs (§5.9): `RoomEvent.Timeline` fires once with
+// Encrypted DMs: `RoomEvent.Timeline` fires once with
// `ev.getType() === 'm.room.encrypted'` and is never re-emitted after decrypt
// (matrix-js-sdk 38.2 event-timeline-set.js:563). We listen to both Timeline
// and `MatrixEventEvent.Decrypted` and kick decryption from the Timeline
@@ -9,7 +9,7 @@
// for RTCNotification and on idempotent `removeByNotifId` for RTCDecline, so
// double delivery (Timeline for cleartext + Decrypted for encrypted) is safe.
//
-// The `registryRef` sync-effect below handles an asymmetry §5.6: REMOVE on the
+// The `registryRef` sync-effect below handles an asymmetry: REMOVE on the
// atom can come from outside the hook (strip buttons), and we need to drop
// timers/listeners for those keys to avoid leaks and to let a fresh ring with
// the same dedup key re-trigger.
@@ -44,6 +44,7 @@ import {
isRtcNotificationExpired,
RTC_NOTIFICATION_DEFAULT_LIFETIME,
} from '../utils/rtcNotification';
+import { callForegroundService } from '../plugins/call/callForegroundService';
// Returns "" for room-scoped calls (MSC4143/MSC3401v2 — empty call_id means
// "the only call in this room"). Returns undefined when no membership is known.
@@ -119,6 +120,10 @@ const resolveCallId = async (
type RegistryEntry = {
roomId: string;
notifEventId: string;
+ // Sender-reported origin timestamp (ms since epoch). Used for
+ // latest-wins-by-senderTs on same-room second rings: a late-arriving older
+ // ring must not evict a newer one already seated. See processEvent below.
+ senderTs: number;
timer: ReturnType;
unsubMemberships?: () => void;
};
@@ -148,9 +153,18 @@ export const useIncomingRtcNotifications = (): void => {
}, [callEmbed, setIncoming]);
// Any key dropped from the atom (external REMOVE via strip accept/decline, etc.)
- // must also drop the matching registry entry — otherwise its expiry timer and
- // memberships listener leak, and a fresh ring for the same dedup key would be
- // swallowed by `registry.has(key)` until the timer finally fires.
+ // must also drop the matching hook-local registry entry — otherwise its
+ // expiry timer and memberships listener leak, and a fresh ring for the same
+ // dedup key would be swallowed by `registry.has(key)` until the timer
+ // finally fires.
+ //
+ // Also catches the external-remove paths for the Android native ring
+ // registry: removals issued by strip buttons, pendingCallActionConsumer,
+ // and the callEmbed join effect go through setIncoming directly without
+ // touching the hook-local registry — this effect is the single place we can
+ // call removeIncomingRing on the bridge for those paths. Internal removals
+ // (removeByKey) call removeIncomingRing inline so they don't rely on this
+ // effect seeing the registry entry.
useEffect(() => {
const registry = registryRef.current;
Array.from(registry.keys()).forEach((key) => {
@@ -159,6 +173,9 @@ export const useIncomingRtcNotifications = (): void => {
if (!entry) return;
clearTimeout(entry.timer);
entry.unsubMemberships?.();
+ callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
+ /* best-effort — registry tombstones the eventId regardless */
+ });
registry.delete(key);
}
});
@@ -173,6 +190,17 @@ export const useIncomingRtcNotifications = (): void => {
if (!entry) return;
clearTimeout(entry.timer);
entry.unsubMemberships?.();
+ // Bottom of the internal-removal stack (removeByRoom / removeByNotifId
+ // funnel through here). Bridge the native ring registry remove before
+ // clearing the hook-local registry: the [incoming] sync-effect above
+ // only sees entries still present at atom-change time, so if we
+ // deleted here and then setIncoming, the effect would miss this removal
+ // and the native registry would keep the entry. External paths (strip
+ // buttons, pendingCallActionConsumer) keep registry intact across the
+ // atom change and are handled by the sync-effect.
+ callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
+ /* best-effort — registry tombstones the eventId regardless */
+ });
registry.delete(key);
setIncoming({ type: 'REMOVE', key });
};
@@ -226,6 +254,15 @@ export const useIncomingRtcNotifications = (): void => {
if (rel?.event_id) {
rememberDeclined(rel.event_id);
removeByNotifId(rel.event_id);
+ // Explicit forget for the decline-first race: if the notification
+ // processEvent hasn't run yet (or hasn't reached ADD because
+ // resolveCallId is mid-await), registry doesn't own the notifEventId
+ // and removeByNotifId is a no-op. The Java registry may still hold
+ // the FCM seed — a render on next backgrounding would resurrect
+ // an already-declined ring.
+ callForegroundService.removeIncomingRing(rel.event_id).catch(() => {
+ /* best-effort */
+ });
}
return;
}
@@ -233,15 +270,42 @@ export const useIncomingRtcNotifications = (): void => {
if (ev.getType() !== EventType.RTCNotification) return;
if (ev.getSender() === mx.getSafeUserId()) return;
+ const evId = ev.getId();
+
+ // The Java-side ring registry may already hold an entry for this
+ // eventId from an FCM seed that landed before /sync delivered the event
+ // to us. Every suppress-return below must remove+tombstone the entry so
+ // the next MainActivity.onPause renderRegistry doesn't surface native
+ // CallStyle for a ring JS decided not to ring. Paths we don't explicitly
+ // remove here (missing evId / malformed rel): registry lifetime check
+ // in isExpired remains the only safety net.
+ const removeFromRegistry = () => {
+ if (evId) {
+ callForegroundService.removeIncomingRing(evId).catch(() => {
+ /* best-effort */
+ });
+ }
+ };
+
const content = ev.getContent();
// Only DM ring — group call notifications use 'notification' type and are out of scope.
+ // FCM path only seeds registry for 'ring' content_notification_type, so no entry to remove.
if (content.notification_type !== 'ring') return;
- if (!mDirectRef.current.has(room.roomId)) return;
- if (isRtcNotificationExpired(ev)) return;
+ if (!mDirectRef.current.has(room.roomId)) {
+ removeFromRegistry();
+ return;
+ }
+ if (isRtcNotificationExpired(ev)) {
+ removeFromRegistry();
+ return;
+ }
// Already participating in the room session → suppress duplicate toast.
const session = mx.matrixRTC.getRoomSession(room);
- if (session.memberships.some((m) => m.sender === mx.getUserId())) return;
+ if (session.memberships.some((m) => m.sender === mx.getUserId())) {
+ removeFromRegistry();
+ return;
+ }
const rel = ev.getRelation();
if (rel?.rel_type !== RelationType.Reference || !rel.event_id) return;
@@ -249,21 +313,66 @@ export const useIncomingRtcNotifications = (): void => {
const sender = ev.getSender();
if (!sender) return;
- const evId = ev.getId();
if (!evId) return;
- if (declinedTimers.has(evId)) return;
+ if (declinedTimers.has(evId)) {
+ removeFromRegistry();
+ return;
+ }
const callId = await resolveCallId(mx, room, sender, rel.event_id);
- if (callId === undefined) return;
+ if (callId === undefined) {
+ removeFromRegistry();
+ return;
+ }
// Re-check anything that can change during the await. resolveCallId can
// yield for seconds (5s MembershipsChanged wait, then fetchRoomEvent) —
// a membership join or a matching decline can land meanwhile and must be
// observed before we commit the ADD.
- if (session.memberships.some((m) => m.sender === mx.getUserId())) return;
- if (declinedTimers.has(evId)) return;
+ if (session.memberships.some((m) => m.sender === mx.getUserId())) {
+ removeFromRegistry();
+ return;
+ }
+ if (declinedTimers.has(evId)) {
+ removeFromRegistry();
+ return;
+ }
const key = getIncomingCallKey(callId, room.roomId);
+ const senderTs = getNotificationEventSendTs(ev);
+
+ // Latest-wins per-room, compared by senderTs. A late FCM retry /
+ // reordered /sync / decrypt lag can deliver an older ring AFTER a
+ // newer same-room ring already seated in registry. Naive last-observed
+ // wins would let the stale event evict the newer one. Only evict when
+ // the incoming event's senderTs is newer than the existing same-room
+ // entry's; if incoming is older, drop it (return) so the newer ring
+ // keeps the slot. Native registry enforces the symmetric invariant in
+ // VojoFirebaseMessagingService.upsertIncomingRing.
+ const sameRoomStaler = Array.from(registry.values()).filter(
+ (e) => e.roomId === room.roomId && e.notifEventId !== evId
+ );
+ const incomingIsOlder = sameRoomStaler.some(
+ (e) => e.senderTs > 0 && senderTs > 0 && senderTs < e.senderTs
+ );
+ if (incomingIsOlder) {
+ // Stale same-room event — don't ADD, don't evict. Native side will
+ // tombstone via its own check when our upsert hits the bridge; here
+ // we also bridge the removeIncomingRing so any FCM seed for this
+ // stale eventId goes away.
+ callForegroundService.removeIncomingRing(evId).catch(() => {
+ /* best-effort */
+ });
+ return;
+ }
+ Array.from(registry.entries())
+ .filter(([, entry]) => entry.roomId === room.roomId && entry.notifEventId !== evId)
+ .forEach(([supersededKey]) => removeByKey(supersededKey));
+
+ // Duplicate processEvent invocation (e.g., timeline + decrypted path for
+ // the same event). The earlier one owns the ring lifecycle — do NOT
+ // remove here or we'd tombstone an eventId the first invocation is
+ // about to ADD.
if (registry.has(key)) return;
const timer = scheduleExpiry(key, ev);
@@ -272,10 +381,30 @@ export const useIncomingRtcNotifications = (): void => {
registry.set(key, {
roomId: room.roomId,
notifEventId: evId,
+ senderTs,
timer,
unsubMemberships,
});
+ // Happy-path upsert into the native ring registry. Idempotent
+ // with any prior FCM seed for the same eventId — Java merges metadata
+ // append-only, so we pass only the fields JS has reliable access to.
+ const senderMember = room.getMember(sender);
+ const callerName =
+ senderMember?.rawDisplayName || room.name || sender || 'Vojo';
+ const lifetime = content.lifetime ?? RTC_NOTIFICATION_DEFAULT_LIFETIME;
+ callForegroundService
+ .upsertIncomingRing({
+ eventId: evId,
+ roomId: room.roomId,
+ callerName,
+ senderTs,
+ lifetime,
+ })
+ .catch(() => {
+ /* best-effort — FCM seed likely already populated it anyway */
+ });
+
setIncoming({
type: 'ADD',
key,
@@ -314,8 +443,26 @@ export const useIncomingRtcNotifications = (): void => {
};
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (ev) => {
- const redacted = ev.event.redacts;
- if (redacted) removeByNotifId(redacted);
+ // v11+ rooms moved `redacts` into content; matrix-js-sdk mirrors the
+ // older top-level field when possible but the content fallback is
+ // the safety net.
+ const redacted =
+ ev.event.redacts ??
+ (ev.getContent() as { redacts?: string } | undefined)?.redacts;
+ if (!redacted) return;
+ // Symmetric with RTCDecline: if the redaction arrives before
+ // the notification's processEvent reaches ADD (race via
+ // handleTimeline / handleDecrypted), registry has no entry →
+ // removeByNotifId is a no-op, but the Java ring registry may hold
+ // an FCM seed that would render on next onPause.
+ // - rememberDeclined blocks any late ADD attempt for this eventId.
+ // - bridge removeIncomingRing tombstones the native-side entry.
+ // - removeByNotifId covers the common case where atom already has it.
+ rememberDeclined(redacted);
+ removeByNotifId(redacted);
+ callForegroundService.removeIncomingRing(redacted).catch(() => {
+ /* best-effort */
+ });
};
const handleSessionEnded = (roomId: string) => {
@@ -332,9 +479,19 @@ export const useIncomingRtcNotifications = (): void => {
mx.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
mx.removeListener(RoomEvent.Redaction, handleRedaction);
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
+ // Sync the Java ring registry on teardown: process-global state
+ // outlives this hook, so any live entry left unremoved will be rendered
+ // by the next MainActivity.onPause for a ring no JS owner exists for.
+ // Reasons to hit this path: logout, client swap, provider remount,
+ // hot-reload in dev. Best-effort bridge — registry tombstones each
+ // eventId for the ring lifetime so a late FCM re-delivery can't
+ // resurrect the zombie.
registry.forEach((entry) => {
clearTimeout(entry.timer);
entry.unsubMemberships?.();
+ callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
+ /* best-effort */
+ });
});
registry.clear();
declinedTimers.forEach((timer) => clearTimeout(timer));
diff --git a/src/app/hooks/usePendingCallActionConsumer.ts b/src/app/hooks/usePendingCallActionConsumer.ts
index b8ec37cf..8ca818b2 100644
--- a/src/app/hooks/usePendingCallActionConsumer.ts
+++ b/src/app/hooks/usePendingCallActionConsumer.ts
@@ -39,8 +39,8 @@ export const usePendingCallActionConsumer = (): void => {
const { roomId, notifEventId } = pending;
setPending(undefined);
- // Unreachable in practice after techdebt §5.35 landed: every Decline
- // button press now fires CallDeclineReceiver via PendingIntent.getBroadcast,
+ // Unreachable in practice: every Decline button press fires
+ // CallDeclineReceiver via PendingIntent.getBroadcast,
// so MainActivity never boots and `pushNotificationActionPerformed` never
// fires with call_action='decline'. Nothing else queues a decline onto
// pendingCallActionAtom. Kept as a safety-net in case a future JS-path
diff --git a/src/app/hooks/usePendingDeclinesFlusher.ts b/src/app/hooks/usePendingDeclinesFlusher.ts
index 50794f9d..42704d4e 100644
--- a/src/app/hooks/usePendingDeclinesFlusher.ts
+++ b/src/app/hooks/usePendingDeclinesFlusher.ts
@@ -18,9 +18,8 @@ const PENDING_PREFIX = 'vojo.pendingDeclines.';
// via matrix-js-sdk's sendRtcDecline → cosmetic chance of two decline events
// in the timeline if the receiver actually succeeded but the process was
// killed before clearing the tombstone. A-side auto-hangup is idempotent on
-// the first decline, so this is timeline-clutter only. See techdebt §5.35
-// "txnId unification" note — fixing requires plumbing txnId through prefs,
-// skipped for MVP.
+// the first decline, so this is timeline-clutter only. Fixing requires
+// plumbing txnId through prefs, skipped for MVP.
export const usePendingDeclinesFlusher = (): void => {
const mx = useMatrixClient();
diff --git a/src/app/hooks/useSwitchOrStartDmCall.ts b/src/app/hooks/useSwitchOrStartDmCall.ts
index 3f51bc22..2c8382ea 100644
--- a/src/app/hooks/useSwitchOrStartDmCall.ts
+++ b/src/app/hooks/useSwitchOrStartDmCall.ts
@@ -1,6 +1,6 @@
// DM call entry point that unifies start, join and switch flows.
//
-// Contract (dm_calls_techdebt.md §5.36):
+// Contract:
// - no prev embed → start a new DM call
// - prev.roomId === arg → no-op (healthy same-room click)
// - prev.roomId !== arg → switch: hangup prev, wait for clean leave,
diff --git a/src/app/pages/IncomingCallStripRenderer.tsx b/src/app/pages/IncomingCallStripRenderer.tsx
index 5f177c58..accbbdbb 100644
--- a/src/app/pages/IncomingCallStripRenderer.tsx
+++ b/src/app/pages/IncomingCallStripRenderer.tsx
@@ -9,16 +9,16 @@
// app is foregrounded it suppresses that banner, so strip render itself does
// not need to mirror foreground policy in JS.
//
-// Ring audio DOES mirror foreground policy — gated on `appActive` (techdebt
-// §5.39): when the app/tab is hidden the platform surface (Android CallStyle /
-// web SW push) owns ringtone UX, and playing the in-app