Replace FCM foreground-skip cache with Java ring registry to fix silent ring on mid-ring backgrounding and eliminate dual dismiss plane

This commit is contained in:
heaven 2026-04-24 01:47:03 +03:00
parent a35dfb1a5b
commit cf0bf56541
20 changed files with 959 additions and 269 deletions

View file

@ -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 {

View file

@ -103,8 +103,7 @@
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- DM call lock-screen retention: CallForegroundService keeps the call
process foregrounded under lock so AppOps doesn't revoke RECORD_AUDIO
and netd doesn't block background network. Context in
docs/plans/dm_calls_techdebt.md §2.2. -->
and netd doesn't block background network. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
</manifest>

View file

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

View file

@ -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.

View file

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

View file

@ -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

View file

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

View file

@ -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_<roomId>,
// 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<String, IncomingRing> 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<String, Long> 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<String, String> 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 bgfgbg toggles otherwise cancel+repost the
// CallStyle and fresh-alert on each cycle. Preserved across cancel
// so the cooldown spans the bgfgbg window; setOnlyAlertOnce alone
// doesn't help because cancel+post is a fresh notification, not an update.
volatile long lastAlertedAt;
IncomingRing(Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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_<roomId>), 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<String, String> 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<Map.Entry<String, IncomingRing>> it = ringRegistry.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, IncomingRing> 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<String, String> 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<IncomingRing> 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<IncomingRing> snapshot;
java.util.Set<String> 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 (bgfgbg
// 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 bgfgbg 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<Map.Entry<String, Long>> 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<Map.Entry<String, IncomingRing>> it = ringRegistry.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, IncomingRing> 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<String, String> 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<String, String> 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 {

View file

@ -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 (
<EncryptedContent mEvent={mEvent}>
{() => {
// §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;

View file

@ -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';

View file

@ -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

View file

@ -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<void> {
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<Set<string>>(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<string>();
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]);
};

View file

@ -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<typeof setTimeout>;
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<IRTCNotificationContent>();
// 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));

View file

@ -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

View file

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

View file

@ -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,

View file

@ -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 <audio> on top would
// double-ring during the grace window after backgrounding while the WebView
// still processes /sync.
// Ring audio mirrors foreground policy on Android — gated on `appActive`
// so the native CallStyle ringtone owns UX in background and the JS
// <audio> doesn't double-ring during the grace window after backgrounding
// while the WebView still processes /sync. On web / iOS there is no
// native ring surface, so audio plays regardless of visibility.
//
// KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no
// user gesture yet), the ring is silent — strip is still visible but user may
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) is
// Phase 3 polish.
// Known gap: if the browser blocks `audio.play()` (cold page load, no user
// gesture yet), the ring is silent — strip is still visible but user may
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications)
// is Phase 3 polish.
import React, { useEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
@ -93,7 +93,16 @@ export function IncomingCallStripRenderer() {
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (hasIncoming && appActive) {
// Platform split on the audio gate:
// - Android: gate on appActive. When backgrounded the native CallStyle
// ringtone (via vojo_calls_v2 channel) takes over, so JS audio must
// stop to avoid double-ring.
// - web / iOS: no native fallback exists. Gating on visibility here
// silenced the only ring source whenever the user switched tabs —
// user-reported regression. Keep audio playing regardless of
// visibility on non-Android platforms.
const platformGatedActive = isAndroidPlatform() ? appActive : true;
if (hasIncoming && platformGatedActive) {
audio.currentTime = 0;
audio.play().catch(() => {
// autoplay blocked — strip UI still visible

View file

@ -81,7 +81,6 @@ import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
import { useDismissNativeCallNotifications } from '../hooks/useDismissNativeCallNotifications';
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
@ -89,7 +88,12 @@ function IncomingCallsFeature() {
useIncomingRtcNotifications();
useCallerAutoHangup();
usePendingCallActionConsumer();
useDismissNativeCallNotifications();
// Native CallStyle dismissal is owned by the Android ring registry:
// VojoFirebaseMessagingService.removeIncomingRing (ownership-checked cancel)
// fires on atom REMOVE via bridge, and MainActivity.onResume calls
// cancelRenderedIncomingRings for the background→foreground handoff.
// A JS-side dismiss hook here is redundant and risks a blind tag/id cancel
// hitting a foreign ring in the same room slot.
useAppUrlOpen();
return null;
}

View file

@ -173,7 +173,7 @@ export function ClientRoot({ children }: ClientRootProps) {
// Mirror {accessToken, baseUrl, userId} into native SharedPreferences so
// CallDeclineReceiver can send m.call.decline without booting the WebView.
// No-op on web. See docs/plans/dm_calls_techdebt.md §5.35.
// No-op on web.
useEffect(() => {
if (!mx) return;
writeSessionBridge(mx);

View file

@ -6,14 +6,32 @@
// mic AppOp and applies background data firewall when the app goes to
// background (e.g. screen lock), killing the call.
//
// Context: docs/plans/dm_calls_techdebt.md §2.2.
//
import { registerPlugin } from '@capacitor/core';
import { isAndroidPlatform } from '../../utils/capacitor';
export interface IncomingRingUpsert {
eventId: string;
roomId: string;
callerName?: string;
// Milliseconds since epoch of the sender's origin timestamp (as reported by
// content.sender_ts). Native uses it to compute the expiry alarm so a
// late-rendered entry doesn't live past true lifetime.
senderTs?: number;
// Ring lifetime in milliseconds (content.lifetime, falls back to 30s native-side).
lifetime?: number;
// Optional — used as google.message_id in the PendingIntent extras so
// Capacitor's pushNotificationActionPerformed fires on Answer tap. Left
// blank for JS-originated upserts; Java merges richer FCM data on seed.
messageId?: string;
}
interface CallForegroundServicePlugin {
start(options?: { title?: string; body?: string }): Promise<void>;
stop(): Promise<void>;
upsertIncomingRing(options: IncomingRingUpsert): Promise<void>;
removeIncomingRing(options: { eventId: string }): Promise<void>;
}
const plugin = registerPlugin<CallForegroundServicePlugin>('CallForegroundService');
@ -30,4 +48,19 @@ export const callForegroundService = {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.stop();
},
// Add/refresh a live incoming ring in the native registry. Called from the
// incomingCallsAtom sync effect whenever a key lands in the atom (happy
// path). Idempotent with any prior FCM seed for the same eventId —
// (atom-sync cleanup + native registry bridge).
upsertIncomingRing(entry: IncomingRingUpsert): Promise<void> {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.upsertIncomingRing(entry);
},
// Drop a ring from the native registry and tombstone its eventId so a late
// FCM/sync re-seed within ring lifetime cannot resurrect it. Called from
// atom REMOVE, suppress paths, and the decline-first branch.
removeIncomingRing(eventId: string): Promise<void> {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.removeIncomingRing({ eventId });
},
};