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