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:
parent
649aea7244
commit
49f7e7417f
20 changed files with 959 additions and 269 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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<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 (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<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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue