fix(calls): harden DM signaling — Doze ring ceiling, FGS onCreate crash, logout teardown, role-aware grace, web ring withdrawal, caller-cancel

This commit is contained in:
heaven 2026-06-24 02:44:52 +03:00
parent 5d908bc04a
commit fe32bed081
16 changed files with 567 additions and 83 deletions

View file

@ -151,4 +151,12 @@ public class CallForegroundPlugin extends Plugin {
VojoFirebaseMessagingService.removeIncomingRing(getContext(), eventId);
call.resolve();
}
// Logout / session-loss sweep: drop all rings + Telecom session + FGS so they
// don't outlive the session across the JS window.location.reload() (RED-3).
@PluginMethod
public void clearAllIncomingRings(PluginCall call) {
VojoFirebaseMessagingService.clearAllIncomingRings(getContext());
call.resolve();
}
}

View file

@ -1,6 +1,7 @@
package chat.vojo.app;
import android.Manifest;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
@ -69,6 +70,41 @@ public class CallForegroundService extends Service {
private static final String TAG = "CallFgs";
// Set once startForeground has actually promoted this instance. Gates the
// "nothing valid to start" / error paths from calling stopSelf on an instance
// that onCreate already promoted (which would tear down a live call's FGS).
private boolean started = false;
@Override
public void onCreate() {
super.onCreate();
// Discharge the startForegroundService() promise ATOMICALLY here, before
// any onStartCommand work or a racing stopService can destroy this instance
// with the promise unfulfilled that throws
// ForegroundServiceDidNotStartInTimeException and crashes the process
// (RED-4, reachable on cold-start answer-from-killed where a start and a
// no-embed stop race on the looper). phoneCall is always a valid FGS type
// for us (runtime prerequisite is the manifest MANAGE_OWN_CALLS, auto-granted;
// not a while-in-use type) so this placeholder promotion never throws the
// ungranted-type error. onStartCommand then refines the title and
// narrows/uprates the type from the actual request.
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (nm != null) ensureOngoingChannel(nm);
try {
Notification placeholder = buildNotification("Активный звонок", "");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
startForeground(NOTIFICATION_ID, placeholder,
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL);
} else {
startForeground(NOTIFICATION_ID, placeholder);
}
started = true;
Log.d(TAG, "onCreate: FGS promoted (phoneCall placeholder)");
} catch (Throwable t) {
Log.e(TAG, "onCreate: startForeground threw", t);
}
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String title = intent != null ? intent.getStringExtra(EXTRA_TITLE) : null;
@ -78,13 +114,52 @@ public class CallForegroundService extends Service {
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null) {
Log.w(TAG, "onStartCommand: NotificationManager null, cannot start FGS");
stopSelf(startId);
Log.w(TAG, "onStartCommand: NotificationManager null");
if (!started) stopSelf(startId);
return START_NOT_STICKY;
}
ensureOngoingChannel(nm);
try {
Notification n = buildNotification(title, body);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// API 30+: 3-arg startForeground with an explicit type bitmask
// computed from the requested EXTRA_TYPE_* flags the grants held,
// always a subset of the manifest's "microphone|phoneCall".
int type = computeForegroundType(intent);
if (type == 0) {
// Nothing valid requested (e.g. microphone w/o RECORD_AUDIO and
// phoneCall not asked). A type-0 typed FGS is invalid on API 34+,
// so KEEP the onCreate phoneCall placeholder rather than crash.
// If onCreate somehow never promoted, there is nothing to keep.
if (!started) {
Log.w(TAG, "onStartCommand: no valid type and not started, stopping");
stopSelf(startId);
}
return START_NOT_STICKY;
}
startForeground(NOTIFICATION_ID, n, type);
started = true;
Log.d(TAG, "startForeground ok type=" + type);
} else {
startForeground(NOTIFICATION_ID, n);
started = true;
Log.d(TAG, "startForeground ok (pre-R, manifest-driven type)");
}
} catch (Throwable t) {
// Promotion already happened in onCreate (started=true) for the common
// path, so a refine failure here is non-fatal keep the placeholder
// FGS rather than tear down a live call. Only stop if we were never
// promoted at all.
Log.e(TAG, "onStartCommand: startForeground threw", t);
if (!started) stopSelf(startId);
}
return START_NOT_STICKY;
}
private Notification buildNotification(String title, String body) {
Intent launchIntent = new Intent(this, MainActivity.class)
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
@ -92,8 +167,7 @@ public class CallForegroundService extends Service {
int piFlags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
PendingIntent launchPI = PendingIntent.getActivity(this, 0, launchIntent, piFlags);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
@ -102,46 +176,8 @@ public class CallForegroundService extends Service {
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(launchPI);
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// API 30+: 3-arg startForeground with an explicit type bitmask.
// The type is computed from the requested EXTRA_TYPE_* flags
// the grants held, and is always a subset of the manifest's
// "microphone|phoneCall". API 34+ REQUIRES the runtime type to be
// a non-empty subset of the manifest declaration.
int type = computeForegroundType(intent);
if (type == 0) {
// Nothing valid to start (e.g. microphone requested but
// RECORD_AUDIO not granted, and phoneCall not requested). A
// typed FGS with type 0 is invalid on API 34+; do NOT fall
// back to TYPE_NONE (not a subset of the manifest). Mirror the
// historical "skip without retention" behavior.
Log.w(TAG, "onStartCommand: no valid FGS type for request, skipping");
stopSelf(startId);
return START_NOT_STICKY;
}
startForeground(NOTIFICATION_ID, builder.build(), type);
Log.d(TAG, "startForeground ok type=" + type);
} else {
// API 24-29: 2-arg form; manifest foregroundServiceType attribute
// is enough for the OS to classify the service correctly.
startForeground(NOTIFICATION_ID, builder.build());
Log.d(TAG, "startForeground ok (pre-R, manifest-driven type)");
}
} catch (Throwable t) {
// If startForeground throws despite our precondition checks
// (unexpected OEM behavior, race, manifest drift), we intentionally
// do NOT retry with TYPE_NONE invalid on API 34+ when the manifest
// declares typed services. Surface the failure and let the call
// proceed without retention rather than crash with
// ForegroundServiceTypeException.
Log.e(TAG, "startForeground threw, stopping service without retry", t);
stopSelf(startId);
}
return START_NOT_STICKY;
.setContentIntent(launchPI)
.build();
}
// Build the foregroundServiceType bitmask from the request flags grants

View file

@ -181,6 +181,18 @@ class VojoCallsManager private constructor(context: Context) {
/** True while any Telecom session (ringing or active) is live. */
fun hasSession(): Boolean = controlScope != null
/**
* True while a session is live OR still FORMING. `currentRoomId` is set
* synchronously under [lock] in [startCall] before the `addCall` binder
* round-trip publishes [controlScope], so this is the authoritative
* "is the single call slot taken?" predicate. [hasSession] (controlScope
* != null) reads false during that forming window using it to decide
* "free" let a concurrently-arriving FCM ring claim the slot and tear down
* a call that was mid-formation (B1/RED-8). Callers deciding whether to
* start a competing session MUST consult this, not [hasSession].
*/
fun isBusy(): Boolean = synchronized(lock) { currentRoomId != null }
/** True once the call left RINGING (answered locally or by a remote surface). */
fun isAnswered(): Boolean = answered

View file

@ -17,6 +17,7 @@ import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.PowerManager;
import android.service.notification.StatusBarNotification;
import android.util.Log;
@ -157,6 +158,39 @@ 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;
// Upper bound on a ring's lifetime. `content_lifetime` is attacker-controlled
// raw peer content; without a cap a crafted value (e.g. ~23 days, still under
// the AlarmManager/long ceiling) would keep the CallStyle + Telecom session +
// phoneCall FGS alive far past any real ring. Mirrors the JS receive-side cap
// (RTC_NOTIFICATION_MAX_LIFETIME in utils/rtcNotification.ts) and the send-side
// cap (CallWidgetDriver RTC_RING_LIFETIME_MAX_MS) so all three agree.
private static final long RTC_MAX_LIFETIME_MS = 5 * 60 * 1000L;
// Max trusted skew between a sender-reported origin ts and our local receipt
// time before we distrust it. Mirrors getNotificationEventSendTs on the JS
// side (which clamps against origin_server_ts); the native side has no
// origin_server_ts (Sygnal V1-flatten drops it, and the numeric
// content_sender_ts too on the FCM-direct path), so the skew-immune anchor is
// the local receipt wall-clock (seededAt / now).
private static final long RTC_SENDER_TS_SKEW_MS = 15_000L;
// Clamp an attacker-controlled lifetime to [.., RTC_MAX_LIFETIME_MS]; a
// non-positive value falls back to the default. Mirror of the JS clamp.
private static long clampLifetime(long lifetime) {
if (lifetime <= 0) return RTC_DEFAULT_LIFETIME_MS;
return Math.min(lifetime, RTC_MAX_LIFETIME_MS);
}
// Skew-immune expiry baseline: trust the sender-reported origin ts only when
// it lands within RTC_SENDER_TS_SKEW_MS of our local receipt anchor; otherwise
// fall back to the receipt anchor. Keeps native expiry in agreement with JS
// (getNotificationEventSendTs) so a clock-skewed peer can neither hang the
// ring open for minutes nor expire it prematurely.
private static long expiryBaseTs(long senderTs, long receiptAnchor) {
if (senderTs > 0 && Math.abs(senderTs - receiptAnchor) < RTC_SENDER_TS_SKEW_MS) {
return senderTs;
}
return receiptAnchor;
}
// Extra attached to every CallStyle notification posted from the registry;
// onResume ownership check matches this against the currently-rendered
@ -220,6 +254,19 @@ public class VojoFirebaseMessagingService extends MessagingService {
private static volatile String telecomRingEventId = null;
private static final Object telecomLock = new Object();
// RED-1: Doze-proof in-process ring ceiling
// The phoneCall FGS keeps the PROCESS alive across the ring, but a foreground
// service does NOT hold a CPU wakelock under deep Doze the CPU suspends and
// the AlarmManager expiry (setAndAllowWhileIdle) can slip minutes, so an
// un-answered ring (its Telecom session + phoneCall FGS) would outlive its
// lifetime by minutes. We mirror element-x: for the bounded ring window hold a
// PARTIAL_WAKE_LOCK and post an in-process teardown, both tied to the ring that
// owns the slot. Guarded by telecomLock. The AlarmManager alarm stays as the
// backstop for the process-death case (this handler dies with the process).
private static final Handler ringCeilingHandler = new Handler(Looper.getMainLooper());
private static PowerManager.WakeLock ringWakeLock = null;
private static Runnable ringCeilingRunnable = null;
private static final class IncomingRing {
final Map<String, String> data;
// Not final a JS-first upsert seeds a null messageId; when FCM
@ -1403,8 +1450,8 @@ public class VojoFirebaseMessagingService extends MessagingService {
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);
long newLifetime = clampLifetime(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
@ -1414,8 +1461,8 @@ public class VojoFirebaseMessagingService extends MessagingService {
}
// 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);
long otherLifetime = clampLifetime(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="
@ -1593,7 +1640,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
purgeExpiredTombstones();
removed = ringRegistry.remove(eventId);
long lifetime = removed != null
? parseLong(removed.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS)
? clampLifetime(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);
@ -1706,8 +1753,9 @@ public class VojoFirebaseMessagingService extends MessagingService {
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;
long lifetime = clampLifetime(parseLong(entry.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS));
// seededAt is the skew-immune receipt anchor; trust senderTs only if close.
long baseTs = expiryBaseTs(senderTs, entry.seededAt);
return baseTs + lifetime + RTC_LIFETIME_GRACE_MS < now;
}
@ -1739,9 +1787,15 @@ public class VojoFirebaseMessagingService extends MessagingService {
VojoCallsManager mgr = VojoCallsManager.getInstance(appCtx);
synchronized (telecomLock) {
if (eventId.equals(telecomRingEventId)) return; // already started for this ring
// Single session: don't disturb an active call or an existing ring
// session. A second simultaneous ring stays notification-only.
if (telecomRingEventId != null || mgr.hasSession()) {
// Single session: don't disturb an active call, a FORMING call, or an
// existing ring session. A second simultaneous ring stays
// notification-only. Must use isBusy() (synchronous currentRoomId),
// NOT hasSession(): an outgoing/join startCall sets currentRoomId
// under lock but publishes controlScope only after a binder
// round-trip, so hasSession() is briefly false while the call is
// forming reading it here let this ring claim the slot and
// teardownLocked the forming call (RED-8).
if (telecomRingEventId != null || mgr.isBusy()) {
dlog("telecom: session busy, ring stays notification-only event=" + eventId);
return;
}
@ -1763,6 +1817,9 @@ public class VojoFirebaseMessagingService extends MessagingService {
// stop the wrong call's service). Now the abort path simply returns
// nothing was started.
final VojoCallsManager fmgr = mgr;
final long ringCeilingMs = clampLifetime(
parseLong(entry.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS))
+ RTC_LIFETIME_GRACE_MS;
new Handler(Looper.getMainLooper()).post(() -> {
// Re-check the slot: a decline/expiry/supersede could have run
// maybeTeardownTelecomRing in the post window (clearing the slot).
@ -1788,10 +1845,15 @@ public class VojoFirebaseMessagingService extends MessagingService {
fmgr.setFallbackListener(nativeRingListener(appCtx, eventId, roomId, messageId));
fmgr.startCall(roomId, callerName, false /* video */, true /* incoming */);
dlog("telecom: incoming session started event=" + eventId);
// Doze-proof in-process ceiling: hold a bounded wakelock + post a
// teardown so this un-answered ring can't outlive its lifetime even
// under deep Doze (RED-1). Cancelled on answer/decline/supersede.
acquireRingCeiling(appCtx, eventId, ringCeilingMs);
} catch (Throwable t) {
Log.w(TAG, "telecom: startCall(incoming) failed", t);
synchronized (telecomLock) {
if (eventId.equals(telecomRingEventId)) telecomRingEventId = null;
releaseRingCeilingLocked();
}
stopRingForegroundService(appCtx);
}
@ -1805,15 +1867,26 @@ public class VojoFirebaseMessagingService extends MessagingService {
synchronized (telecomLock) {
if (!eventId.equals(telecomRingEventId)) return;
telecomRingEventId = null;
// Cancel the in-process ceiling + release the ring wakelock (RED-1).
releaseRingCeilingLocked();
}
VojoCallsManager mgr = VojoCallsManager.getInstance(ctx.getApplicationContext());
// Drop THIS ring's native fallback listener unconditionally the ring no
// longer owns the slot. On the answered path the JS plugin listener is now
// the source of truth; leaving the fallback installed would let a later,
// unrelated call's remote onAnswer (BT/Auto/Wear) fire this ring's stale
// closure and boot an Answer intent for the WRONG (now-dead) room (ORANGE-6).
try {
mgr.setFallbackListener(null);
} catch (Throwable t) {
Log.w(TAG, "telecom: clearing fallback listener threw", t);
}
if (mgr.isAnswered()) {
dlog("telecom: ring removed but answered — active call keeps session");
return;
}
// Un-answered ring removed (decline / expiry / suppress) end Telecom + FGS.
try {
mgr.setFallbackListener(null);
mgr.endCall();
} catch (Throwable t) {
Log.w(TAG, "telecom: endCall on ring teardown threw", t);
@ -1829,6 +1902,92 @@ public class VojoFirebaseMessagingService extends MessagingService {
}
}
// Acquire the bounded ring wakelock + post the in-process teardown for the
// ring that currently owns the slot (RED-1). No-op if the slot moved during
// dispatch. Both are released together by releaseRingCeilingLocked.
private static void acquireRingCeiling(Context ctx, String eventId, long ceilingMs) {
final Context appCtx = ctx.getApplicationContext();
synchronized (telecomLock) {
if (!eventId.equals(telecomRingEventId)) return;
releaseRingCeilingLocked();
try {
PowerManager pm = (PowerManager) appCtx.getSystemService(Context.POWER_SERVICE);
if (pm != null) {
ringWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "vojo:ring");
ringWakeLock.setReferenceCounted(false);
// Hard timeout on the wakelock itself, belt to the runnable's
// suspenders: the CPU is released after the ring window even if
// teardown somehow never runs.
ringWakeLock.acquire(ceilingMs);
}
} catch (Throwable t) {
Log.w(TAG, "telecom: ring wakelock acquire threw", t);
}
ringCeilingRunnable = () -> {
// Un-answered ring outlived its lifetime deterministic teardown.
// Idempotent with the AlarmManager backstop and any decline/answer.
dlog("telecom: in-process ring ceiling fired event=" + eventId);
removeIncomingRing(appCtx, eventId);
};
ringCeilingHandler.postDelayed(ringCeilingRunnable, ceilingMs);
}
}
// Caller MUST hold telecomLock.
private static void releaseRingCeilingLocked() {
if (ringCeilingRunnable != null) {
ringCeilingHandler.removeCallbacks(ringCeilingRunnable);
ringCeilingRunnable = null;
}
if (ringWakeLock != null) {
try {
if (ringWakeLock.isHeld()) ringWakeLock.release();
} catch (Throwable ignored) {
// already released / never held
}
ringWakeLock = null;
}
}
/**
* Tear down EVERY live ring + the Telecom session + the phoneCall/mic FGS.
* Called on logout / server-driven session loss (RED-3): the JS path does
* window.location.reload(), which runs no React effect cleanup, and a
* cold-start ring may not even be in incomingCallsAtom so without an
* explicit sweep the process-static ringRegistry / telecomRingEventId /
* Telecom session / FGS would outlive the session (zombie CallStyle the
* logged-out user can neither answer nor decline). Idempotent.
*/
static void clearAllIncomingRings(Context ctx) {
java.util.List<String> eventIds;
synchronized (registryLock) {
eventIds = new java.util.ArrayList<>(ringRegistry.keySet());
}
// removeIncomingRing per entry tombstones + tears down its Telecom ring +
// FGS + dismisses the full-screen Activity.
for (String eventId : eventIds) {
removeIncomingRing(ctx, eventId);
}
// Defensive belt for state NOT represented by a registry entry: an already
// ANSWERED active call (telecomRingEventId cleared on answer, but the
// Telecom session + mic|phoneCall FGS are live) must die on logout too.
String stuck;
synchronized (telecomLock) {
stuck = telecomRingEventId;
telecomRingEventId = null;
releaseRingCeilingLocked();
}
try {
VojoCallsManager mgr = VojoCallsManager.getInstance(ctx.getApplicationContext());
mgr.setFallbackListener(null);
mgr.endCall();
} catch (Throwable t) {
Log.w(TAG, "clearAll: telecom endCall threw", t);
}
stopRingForegroundService(ctx);
if (stuck != null) IncomingCallActivity.finishFor(stuck);
}
// Native handler for a ring's Telecom session created from a process with no
// JS attached. VojoCallsManager uses it only while its primary (JS plugin)
// listener is null; once the WebView loads, the JS listener takes precedence.
@ -1845,6 +2004,18 @@ public class VojoFirebaseMessagingService extends MessagingService {
// primary answer-from-killed path is the CallStyle tap (a user
// notification interaction, always allowed). Full BT/Auto
// answer-from-killed is completed in Phase D.
//
// Stale-ring guard: only the ring that currently owns the slot
// may boot Answer. A fallback closure that outlived its ring
// (defense-in-depth with the unconditional clear in
// maybeTeardownTelecomRing) must not launch Answer for a room
// that is no longer ringing (ORANGE-6).
synchronized (telecomLock) {
if (!eventId.equals(telecomRingEventId)) {
dlog("telecom: native onAnswer for stale ring, ignoring event=" + eventId);
return;
}
}
try {
buildActionPI(appCtx, ("ans_" + eventId).hashCode(),
"answer", roomId, eventId, messageId).send();
@ -2236,10 +2407,11 @@ public class VojoFirebaseMessagingService extends MessagingService {
long fallbackBaseTs
) {
long senderTs = parseLong(data.get("content_sender_ts"), -1L);
long lifetime = parseLong(data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
long lifetime = clampLifetime(parseLong(data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS));
// 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;
// entry.seededAt for registry-render (ring was seeded earlier) both
// are skew-immune receipt anchors. Trust senderTs only when close to it.
long baseTs = expiryBaseTs(senderTs, fallbackBaseTs);
long triggerAt = baseTs + lifetime + RTC_LIFETIME_GRACE_MS;
AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE);

View file

@ -477,3 +477,38 @@ Telecom — чисто Android-нативный слой; web/Electron/iOS ег
ограничение Android 12+, как Phase-D; чистка/disconnect надёжны, бут аппа — нет); B1 — реализован, нужна проверка
под реальным переплетением потоков на устройстве; GSM-hold — мьют мика + OS audio-focus ducking входящего (widget
API не даёт полноценный «hold» медиа); реальный аватар на over-lock и «свернуть»=bubble/PiP — полиш, не баг.
- **2026-06-24 — Внешнее multi-agent ревью сигнального слоя (12 линз → adversarial-verify → second pass) + фиксы RED/ORANGE.**
Полный отчёт: [calls_server_review_2026-06-24.md](calls_server_review_2026-06-24.md) (+ raw findings). Корневой вывод
**подтверждён первоисточником** (Sygnal `gcmpushkin.py:710-717`): V1-flatten форвардит только **строковые** поля `content`
на один уровень → `content.sender_ts`/`content.lifetime` (числа) и `content.m.relates_to` (dict) **дропаются** на FCM-direct,
т.е. нативные latest-wins/expiry/composite-dedup были мертвы на killed-пути. Вердикт ревью: «в прод на устройствах нельзя».
**Реализовано и собрано (tsc + eslint --max-warnings 0 + prettier + `:app:assembleDebug` — всё зелёное; на железе НЕ гонялось):**
- **RED-1 — Doze-устойчивый потолок ринга.** `VojoFirebaseMessagingService`: на старте ring-time Telecom-сессии берём
bounded `PARTIAL_WAKE_LOCK("vojo:ring")` + `Handler.postDelayed(removeIncomingRing, lifetime+grace)`, привязанные к
`telecomRingEventId`, отменяемые на answer/decline/supersede (`releaseRingCeilingLocked`). FGS держит процесс, но не CPU —
неточный `setAndAllowWhileIdle` под Doze слипал на минуты; AlarmManager остаётся backstop'ом на смерть процесса. (Зеркало element-x.)
- **RED-2 — own-2nd-device disarm.** `useCallerAutoHangup`: peer теперь **user-scoped** (`m.userId !== selfId`), no-answer-дедлайн
привязан к реальному `lifetime` ринга (guard от stale own-ring). ORANGE-12 закрыт тем же.
- **RED-3 — logout-teardown.** Новый bridge `callForegroundService.clearAllIncomingRings()` (нативный sweep: все ринги +
Telecom-сессия + FGS + ceiling) вызывается перед `reload()` в `handleLogout`/`logoutClient`/`clearLocalSessionAndReload`.
- **RED-4 — FGS-краш.** `CallForegroundService`: промоция в `onCreate` (phoneCall-placeholder, атомарно гасит
startForegroundService-обещание до возможного `stopService`), `started`-флаг, `onStartCommand` лишь уточняет тип/тайтл.
- **RED-5 — web SW не снимал ринг.** `sw.ts`: self-dismiss баннера по `lifetime`/`sender_ts` (waitUntil-bounded) + `closeCall`
message-handler; `useIncomingRtcNotifications` шлёт `closeCall` в SW на каждом удалении ринга. (Web — не провалидировано без HS+WebPush.)
- **RED-6 (online) — caller-cancel.** `subscribeMemberships` детектит peer-empty (вызывающий ушёл) и снимает ринг с 8-сек
grace (flap-absorb, recall внутри окна не убивается). **Offline-часть** (push-правило на membership-leave/`m.rtc.decline`)
— серверная, **отложена** (см. ниже).
- **RED-7 — мёртвый звонок у отвечающего.** Прокинут флаг `incoming` через `CallEmbed`/`createCallEmbed`/`switchOrStartDmCall`;
отвечающий, чей пир не появился, рвёт за `PEER_LEAVE_GRACE` (8s), а не ждёт 40s с живым миком.
- **RED-8 — second-ring рвёт forming-звонок.** `VojoCallsManager.isBusy()` (синхронный `currentRoomId` под lock) заменил
`hasSession()` в `ensureTelecomIncoming`.
- **ORANGE-1** нативный clamp `lifetime`(≤5мин) + skew-clamp `sender_ts` против receipt-anchor (`isExpired`, expiry-alarm, tombstones).
- **ORANGE-6** `fallbackListener` чистится безусловно на teardown ринга + stale-ring guard в `nativeRingListener.onAnswer`.
- **ORANGE-10** `waitLeave` диспозит prev в `finally` (fail-open switch). **YELLOW-2/3** decline-гейт `rel_type==='m.reference'`+sender.
- **Опровергнут самим ревьюером:** YELLOW-5 (нет await между key-check и set → лика нет).
- **ОТЛОЖЕНО (серверное / большой объём, документировано):** (a) **push-правило для caller-cancel offline**
override-rule на membership-leave или cleartext cancel-маркер → нативный `removeIncomingRing` (без него killed-callee
ждёт ceiling, ~lifetime); (b) **UnifiedPush** для de-Googled/FCM-blocked (сейчас только 15-мин polling → missed-card);
(c) F15 same-room eviction по parent `callSessionId` (упирается в тот же Sygnal-starvation — нужен string-mirror полей в
cleartext-ринге); (d) decline-id из send-response вместо скрейпа таймлайна. **Все RED требуют прогона на Samsung
(Doze, answer-from-killed, logout-во-время-звонка, 2 устройства).**

View file

@ -70,7 +70,7 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
}
setIncoming({ type: 'REMOVE', key: callKey });
};
switchOrStartDmCall(call.roomId)
switchOrStartDmCall(call.roomId, true /* answering an incoming ring */)
.then(dropRing)
.catch((err: unknown) => {
// eslint-disable-next-line no-console

View file

@ -41,7 +41,8 @@ export const createCallEmbed = (
themeKind: ElementCallThemeKind,
container: HTMLElement,
pref?: CallPreferences,
voiceOnly = false
voiceOnly = false,
incoming = false
): CallEmbed => {
const rtcSession = mx.matrixRTC.getRoomSession(room);
const ongoing = rtcSession.memberships.length > 0;
@ -51,7 +52,7 @@ export const createCallEmbed = (
const controlState =
pref && new CallControlState(pref.microphone, voiceOnly ? false : pref.video);
const embed = new CallEmbed(mx, room, widget, container, controlState, voiceOnly);
const embed = new CallEmbed(mx, room, widget, container, controlState, voiceOnly, incoming);
return embed;
};

View file

@ -26,6 +26,7 @@ import {
MatrixEvent,
MatrixEventEvent,
MatrixEventHandlerMap,
RelationType,
RoomEvent,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
@ -35,7 +36,11 @@ import { IRTCNotificationContent } from 'matrix-js-sdk/lib/matrixrtc/types';
import { useMatrixClient } from './useMatrixClient';
import { callEmbedAtom } from '../state/callEmbed';
import { mDirectAtom } from '../state/mDirectList';
import { RTC_NOTIFICATION_DEFAULT_LIFETIME } from '../utils/rtcNotification';
import {
getNotificationEventSendTs,
getRtcNotificationLifetime,
RTC_NOTIFICATION_DEFAULT_LIFETIME,
} from '../utils/rtcNotification';
// Grace beyond the ring lifetime before giving up on no-answer. Covers /sync
// latency between B joining and A seeing the membership state event.
@ -74,15 +79,19 @@ export const useCallerAutoHangup = (): void => {
const selfId = mx.getUserId();
if (!selfId) return undefined;
const selfDeviceId = mx.getDeviceId();
const isSelf = (m: CallMembership): boolean =>
m.userId === selfId && (!selfDeviceId || m.deviceId === selfDeviceId);
// Peer = the OTHER user in the DM, identified by user id only (NOT device).
// A device-scoped check counted the caller's *own* second device as a peer,
// which set peerSeen=true at start and disarmed the no-answer teardown
// entirely — leaving the caller stuck in "connecting" with a live mic FGS
// forever. "Did the callee answer?" is a per-user question; our own other
// devices are irrelevant. Mirrors element-call's `userId !== localUser`.
const isPeer = (m: CallMembership): boolean => m.userId !== selfId;
const session = mx.matrixRTC.getRoomSession(callEmbed.room);
let disposed = false;
let peerSeen = session.memberships.some((m) => !isSelf(m));
let peerSeen = session.memberships.some(isPeer);
let peerLeaveTimer: ReturnType<typeof setTimeout> | undefined;
let ownRingNotifEvent = findLatestOwnRingNotifEvent(
selfId,
@ -107,12 +116,39 @@ export const useCallerAutoHangup = (): void => {
}
};
// Anchor the no-answer deadline to the ring we actually sent (sender_ts +
// its lifetime) rather than a hardcoded 30s. A ring may legitimately carry
// a longer lifetime; the old constant tore the caller down at 40s while the
// callee's strip was still live, so a late answer landed in a dead call.
// Guard: only trust the ring's deadline when it is still in the future — a
// stale own-ring left in the timeline from a previous call must not fire
// immediately and kill a freshly-answered call (fall back to the default).
const computeNoAnswerDelay = (): number => {
if (ownRingNotifEvent) {
const expireAt =
getNotificationEventSendTs(ownRingNotifEvent) +
getRtcNotificationLifetime(ownRingNotifEvent);
const remaining = expireAt + NO_ANSWER_GRACE_MS - Date.now();
if (remaining > 0) return remaining;
}
return RTC_NOTIFICATION_DEFAULT_LIFETIME + NO_ANSWER_GRACE_MS;
};
// Caller (we started the call): wait the ring lifetime for the callee.
// Answerer (we just answered an incoming ring): the peer was a member since
// the ring was sent, so if it is absent now the caller cancelled in the same
// instant — bail in a few seconds instead of sitting in a dead call for ~40s
// with a live mic (RED-7). A late /sync membership still cancels this timer
// via onMemberships before it fires.
const noAnswerTimer: ReturnType<typeof setTimeout> | undefined = peerSeen
? undefined
: setTimeout(performHangup, RTC_NOTIFICATION_DEFAULT_LIFETIME + NO_ANSWER_GRACE_MS);
: setTimeout(
performHangup,
callEmbed.incoming ? PEER_LEAVE_GRACE_MS : computeNoAnswerDelay()
);
const onMemberships = (_prev: CallMembership[], next: CallMembership[]) => {
const peerPresent = next.some((m) => !isSelf(m));
const peerPresent = next.some(isPeer);
if (peerPresent) {
clearPeerLeaveTimer();
if (!peerSeen) {
@ -144,7 +180,12 @@ export const useCallerAutoHangup = (): void => {
if (ev.getRoomId() !== roomId) return;
if (ev.getType() !== EventType.RTCDecline) return;
if (ev.getSender() === selfId) return;
const declinedNotifEventId = ev.getRelation()?.event_id;
// MSC4310 declines reference the ring via an `m.reference` relation. Only
// honor that shape — a decline carrying a different rel_type (thread reply,
// annotation) pointing at our ring eventId must not tear the call down.
const rel = ev.getRelation();
if (rel?.rel_type !== RelationType.Reference) return;
const declinedNotifEventId = rel.event_id;
if (!declinedNotifEventId) return;
// Same-room retries can leave multiple historical ring events behind.

View file

@ -44,6 +44,24 @@ import {
} from '../utils/rtcNotification';
import { callForegroundService } from '../plugins/call/callForegroundService';
// Grace before withdrawing a ring when the calling peer's RTC membership goes
// empty (the online "caller cancelled" path). Absorbs brief LiveKit-reconnect
// membership flaps so a hiccup doesn't drop a still-live ring.
const PEER_LEAVE_GRACE_MS = 8_000;
// Web only: ask the service worker to close the OS-level ring banner for a room.
// On Android the native registry owns the CallStyle (callForegroundService.*);
// on web the SW is the only context that can close a notification it posted, so
// every ring removal must bridge to it or the banner lingers (RED-5). No-op on
// native / when no SW controls the page.
const closeWebRing = (roomId: string): void => {
try {
navigator.serviceWorker?.controller?.postMessage({ type: 'closeCall', roomId });
} catch {
/* SW not controlling this page — banner self-dismisses at expiry */
}
};
// Extract the room-scoped call slot id from a raw membership state event,
// covering both the legacy `m.call.member` shape (SessionMembershipData with
// `call_id` string) and the `m.rtc.member` shape (RtcMembershipData with
@ -201,6 +219,7 @@ export const useIncomingRtcNotifications = (): void => {
callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
/* best-effort — registry tombstones the eventId regardless */
});
closeWebRing(entry.roomId);
registry.delete(key);
}
});
@ -209,6 +228,15 @@ export const useIncomingRtcNotifications = (): void => {
useEffect(() => {
const registry = registryRef.current;
const declinedTimers = declinedTimersRef.current;
// Per-room pending caller-cancel teardown timers (peer membership went empty).
const peerLeaveTimers = new Map<string, ReturnType<typeof setTimeout>>();
const clearPeerLeaveTimer = (roomId: string) => {
const t = peerLeaveTimers.get(roomId);
if (t) {
clearTimeout(t);
peerLeaveTimers.delete(roomId);
}
};
const removeByKey = (key: string) => {
const entry = registry.get(key);
@ -226,6 +254,7 @@ export const useIncomingRtcNotifications = (): void => {
callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
/* best-effort — registry tombstones the eventId regardless */
});
closeWebRing(entry.roomId);
registry.delete(key);
setIncoming({ type: 'REMOVE', key });
};
@ -244,9 +273,35 @@ export const useIncomingRtcNotifications = (): void => {
const subscribeMemberships = (room: Room): (() => void) => {
const session = mx.matrixRTC.getRoomSession(room);
const handler = (_old: CallMembership[], next: CallMembership[]) => {
if (next.some((m) => m.userId === mx.getUserId())) {
const handler = (prev: CallMembership[], next: CallMembership[]) => {
const selfId = mx.getUserId();
// Self joined the call (answered here or on another device) → drop toast.
if (next.some((m) => m.userId === selfId)) {
clearPeerLeaveTimer(room.roomId);
removeByRoom(room.roomId);
return;
}
const hasPeer = next.some((m) => m.userId !== selfId);
if (hasPeer) {
// Peer (re)appeared — cancel any pending caller-cancel teardown. Covers
// the genuine ring AND a brief reconnect flap that emptied memberships.
clearPeerLeaveTimer(room.roomId);
return;
}
const hadPeer = prev.some((m) => m.userId !== selfId);
if (hadPeer && !peerLeaveTimers.has(room.roomId)) {
// Caller cancelled: the ringing peer's membership went empty. Withdraw
// the ring after a short grace (flap absorption). This is the ONLINE
// caller-cancel path; an offline/killed callee relies on the native
// expiry ceiling (RED-1) plus a server push rule for membership-leave
// (not yet provisioned — see docs/plans/calls_server_review).
peerLeaveTimers.set(
room.roomId,
setTimeout(() => {
peerLeaveTimers.delete(room.roomId);
removeByRoom(room.roomId);
}, PEER_LEAVE_GRACE_MS)
);
}
};
session.on(MatrixRTCSessionEvent.MembershipsChanged, handler);
@ -271,8 +326,14 @@ export const useIncomingRtcNotifications = (): void => {
const processEvent = async (ev: MatrixEvent, room: Room): Promise<void> => {
if (ev.getType() === EventType.RTCDecline) {
// Only our own decline (this or another of our devices) should withdraw
// our ring, and only via the MSC4310 `m.reference` relation. Without
// these gates any room member could send a decline-shaped event with an
// arbitrary rel_type to suppress a legitimate ring. The ring branch
// below already enforces `rel_type === Reference`; mirror it here.
if (ev.getSender() !== mx.getSafeUserId()) return;
const rel = ev.getRelation();
if (rel?.event_id) {
if (rel?.rel_type === RelationType.Reference && rel.event_id) {
rememberDeclined(rel.event_id);
removeByNotifId(rel.event_id);
// Explicit forget for the decline-first race: if the notification
@ -428,6 +489,10 @@ export const useIncomingRtcNotifications = (): void => {
/* best-effort — FCM seed likely already populated it anyway */
});
// A fresh ring for this room cancels any pending caller-cancel teardown
// from a prior ring (e.g. close-then-immediate-recall inside the grace).
clearPeerLeaveTimer(room.roomId);
setIncoming({
type: 'ADD',
key,
@ -518,6 +583,8 @@ export const useIncomingRtcNotifications = (): void => {
registry.clear();
declinedTimers.forEach((timer) => clearTimeout(timer));
declinedTimers.clear();
peerLeaveTimers.forEach((t) => clearTimeout(t));
peerLeaveTimers.clear();
};
}, [mx, setIncoming]);
};

View file

@ -84,7 +84,7 @@ export const usePendingCallActionConsumer = (): void => {
}
setIncoming({ type: 'REMOVE_BY_ROOM', roomId });
};
switchOrStartDmCall(roomId)
switchOrStartDmCall(roomId, true /* answering an incoming ring */)
.then(() => {
dropRing();
// switchOrStartDmCall resolves (not rejects) when the room isn't in the

View file

@ -39,7 +39,10 @@ const SWITCH_LEAVE_TIMEOUT_MS = 3000;
const createSwitchTimeoutError = (roomId: string): Error =>
new Error(`[dm-call] switch timed out waiting for clean leave in ${roomId}`);
export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) => {
export const useSwitchOrStartDmCall = (): ((
roomId: string,
incoming?: boolean
) => Promise<void>) => {
const mx = useMatrixClient();
const theme = useTheme();
const store = useStore();
@ -102,7 +105,7 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) =>
);
const doSwitchOrStart = useCallback(
async (roomId: string): Promise<void> => {
async (roomId: string, incoming: boolean): Promise<void> => {
const room = mx.getRoom(roomId);
if (!room) {
// eslint-disable-next-line no-console
@ -141,24 +144,46 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) =>
}
prev.dispose();
} else {
await waitLeave(prev);
prev.dispose();
// Wait for a clean leave, but NEVER let the 3s barrier timeout strand
// the previous embed. The SDK's delayed-leave (~8s) reconciles the
// server-side membership regardless, so on timeout we dispose and
// switch anyway instead of throwing — which previously left `prev` in
// callEmbedAtom, never started the new call, and made every retry
// re-arm waitLeave on the now-unresponsive widget (switch wedged until
// reload). Fail-open beats fail-stuck.
try {
await waitLeave(prev);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[dm-call] waitLeave timed out; disposing prev and switching anyway', err);
} finally {
prev.dispose();
}
}
}
const embed = createCallEmbed(mx, room, true, theme.kind, container, callPref, true);
const embed = createCallEmbed(
mx,
room,
true,
theme.kind,
container,
callPref,
true,
incoming
);
store.set(callEmbedAtom, embed);
},
[mx, store, callEmbedRef, callPref, theme, waitLeave]
);
return useCallback(
(roomId: string): Promise<void> => {
(roomId: string, incoming = false): Promise<void> => {
const enter = (): Promise<void> => {
if (inFlightRef.current) {
return inFlightRef.current.then(enter, enter);
}
const task = doSwitchOrStart(roomId);
const task = doSwitchOrStart(roomId, incoming);
const tracked: Promise<void> = task.finally(() => {
if (inFlightRef.current === tracked) inFlightRef.current = undefined;
});

View file

@ -20,6 +20,7 @@ import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridg
import { polling } from '../../plugins/polling';
import { proxy } from '../../plugins/proxy';
import { useProxy } from '../../hooks/useProxy';
import { callForegroundService } from '../../plugins/call/callForegroundService';
function ClientRootLoading() {
return <AuthSplashScreen />;
@ -28,6 +29,11 @@ function ClientRootLoading() {
const useLogoutListener = (mx?: MatrixClient) => {
useEffect(() => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
// Kill any live native call surface FIRST: a ringing/active call (CallStyle
// + Telecom session + phoneCall FGS) is process-static and would survive the
// window.location.reload() below (which runs no React cleanup) as a zombie
// the logged-out user can neither answer nor decline (RED-3).
await callForegroundService.clearAllIncomingRings().catch(() => undefined);
// Wipe the native session bridge before the reload — otherwise the dead
// access_token lingers in shared_prefs and CallDeclineReceiver spends
// the next login cycle posting 401s until writeSessionBridge overwrites.

View file

@ -41,6 +41,14 @@ export class CallEmbed {
public readonly voiceOnly: boolean;
// True when this embed was created by ANSWERING an incoming ring (callee side),
// false when WE started the call (caller side). Lets useCallerAutoHangup pick the
// right no-answer grace: a caller waits the full ring lifetime for the callee;
// an answerer whose peer never appears (caller cancelled in the same instant)
// must bail in a few seconds, not sit in a dead call for ~40s with a live mic
// (RED-7).
public readonly incoming: boolean;
public readonly control: CallControl;
private readonly container: HTMLElement;
@ -151,7 +159,8 @@ export class CallEmbed {
widget: Widget,
container: HTMLElement,
initialControlState?: CallControlState,
voiceOnly = false
voiceOnly = false,
incoming = false
) {
const iframe = CallEmbed.getIframe(
widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() })
@ -167,6 +176,7 @@ export class CallEmbed {
this.iframe = iframe;
this.container = container;
this.voiceOnly = voiceOnly;
this.incoming = incoming;
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);

View file

@ -35,6 +35,7 @@ interface CallForegroundServicePlugin {
stop(): Promise<void>;
upsertIncomingRing(options: IncomingRingUpsert): Promise<void>;
removeIncomingRing(options: { eventId: string }): Promise<void>;
clearAllIncomingRings(): Promise<void>;
}
const plugin = registerPlugin<CallForegroundServicePlugin>('CallForegroundService');
@ -66,4 +67,14 @@ export const callForegroundService = {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.removeIncomingRing({ eventId });
},
// Logout / session-loss sweep: tear down every live ring + the Telecom session
// + the foreground service. The JS logout path does window.location.reload(),
// which runs no React effect cleanup, and a cold-start ring may never have
// entered incomingCallsAtom — so the process-static native ring/Telecom/FGS
// state must be cleared explicitly before the reload or it outlives the
// session as an unactionable zombie call (RED-3).
clearAllIncomingRings(): Promise<void> {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.clearAllIncomingRings();
},
};

View file

@ -8,6 +8,7 @@ import { isNativePlatform } from '../app/utils/capacitor';
import { clearSessionBridge } from '../app/utils/sessionBridge';
import { polling } from '../app/plugins/polling';
import { proxy } from '../app/plugins/proxy';
import { callForegroundService } from '../app/plugins/call/callForegroundService';
type Session = {
baseUrl: string;
@ -71,6 +72,11 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
};
export const logoutClient = async (mx: MatrixClient) => {
// 0. Kill any live native call surface first — a ringing/active call (CallStyle
// + Telecom session + phoneCall FGS) is process-static and would survive the
// reload as a zombie the logged-out user can't answer or decline (RED-3).
await callForegroundService.clearAllIncomingRings().catch(() => undefined);
// 1. Deactivate pusher on the homeserver while we still have a valid token.
// Run before pushSessionToSW() so that if a push arrives mid-logout, the
// SW still has a working session to resolve event details with.
@ -189,6 +195,8 @@ export const clearLoginData = async () => {
// IndexedDB + localStorage. Server-side logout is skipped — the homeserver
// will time out the session naturally.
export const clearLocalSessionAndReload = async () => {
// Kill any live native call surface before the reload (RED-3) — see logoutClient.
await callForegroundService.clearAllIncomingRings().catch(() => undefined);
await clearSessionBridge();
// Wipe the Tink-encrypted proxy creds + clear the override + tear down the
// relay on this local-only logout too (proxy_support.md §15, locked).

View file

@ -203,6 +203,19 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
const lang = normalizeLang(data.lang);
swLanguage = lang;
event.waitUntil(writeStoredLang(lang));
return;
}
if (type === 'closeCall') {
// Client observed the ring end (decline / answer / caller-cancel / expiry)
// and asks us to dismiss the OS-level ring banner it can't close itself (RED-5).
const { roomId } = data;
if (typeof roomId === 'string') {
event.waitUntil(
self.registration.getNotifications({ tag: `call_${roomId}` }).then((ns) => {
ns.forEach((n) => n.close());
})
);
}
}
});
@ -575,7 +588,14 @@ async function fetchEventDetails(
session: SessionInfo,
roomId: string,
eventId: string
): Promise<{ title: string; body: string; isCall: boolean; isInvite: boolean }> {
): Promise<{
title: string;
body: string;
isCall: boolean;
isInvite: boolean;
lifetime?: number;
senderTs?: number;
}> {
const headers = { Authorization: `Bearer ${session.accessToken}` };
// /event/{id} and /state/... share the same "joined-or-former-joined"
// access gate — for invitees both return 404/403 respectively. For
@ -599,6 +619,8 @@ async function fetchEventDetails(
let body = fb.newMessage;
let isCall = false;
let isInvite = false;
let lifetime: number | undefined;
let senderTs: number | undefined;
let roomName: string | undefined;
let inviterDisplay: string | undefined;
let inviterMxid: string | undefined;
@ -621,6 +643,11 @@ async function fetchEventDetails(
isCall = true;
title = fb.incomingCall;
body = fb.openToAnswer;
// Surface the ring's own lifetime/sender_ts so the push handler can
// self-dismiss the banner at expiry (RED-5). Cleartext ring content (the
// CallWidgetDriver bypass keeps these readable even in E2EE DMs).
if (typeof event?.content?.lifetime === 'number') lifetime = event.content.lifetime;
if (typeof event?.content?.sender_ts === 'number') senderTs = event.content.sender_ts;
} else if (event?.type === 'm.room.member' && event?.content?.membership === 'invite') {
// Invite path for an already-joined user (rare — re-invite after
// leave, or multi-device where another session joined). The 404
@ -677,7 +704,7 @@ async function fetchEventDetails(
title = roomName;
}
return { title, body, isCall, isInvite };
return { title, body, isCall, isInvite, lifetime, senderTs };
}
self.addEventListener('push', (event: PushEvent) => {
@ -707,6 +734,8 @@ self.addEventListener('push', (event: PushEvent) => {
let body = notif?.content?.body ?? fb.newMessage;
let isCall = false;
let isInvite = false;
let callLifetime: number | undefined;
let callSenderTs: number | undefined;
// Fetch event details BEFORE the visible-client gate, because an
// incoming-call ring must surface even when Vojo is already open in
@ -721,6 +750,8 @@ self.addEventListener('push', (event: PushEvent) => {
body = details.body;
isCall = details.isCall;
isInvite = details.isInvite;
callLifetime = details.lifetime;
callSenderTs = details.senderTs;
} catch {
// fall back to defaults; isCall/isInvite stay false and we show a
// generic message notification. Cold-start (no live session → no
@ -751,6 +782,27 @@ self.addEventListener('push', (event: PushEvent) => {
requireInteraction: true,
renotify: true,
} as NotificationOptions & { renotify?: boolean });
// Expiry self-dismiss (RED-5): requireInteraction disables the browser's
// auto-dismiss, so without this a ring banner would linger forever after
// the caller cancels or it times out. Hold the SW alive for the bounded
// ring window and close the banner at expiry. A decline / answer /
// caller-cancel seen by a live client closes it sooner via 'closeCall'.
const DEFAULT_RING_MS = 30_000;
const MAX_RING_MS = 95_000;
const life =
typeof callLifetime === 'number' && callLifetime > 0
? Math.min(callLifetime, MAX_RING_MS)
: DEFAULT_RING_MS;
const base =
typeof callSenderTs === 'number' && Math.abs(callSenderTs - Date.now()) < 15_000
? callSenderTs
: Date.now();
const delay = Math.min(MAX_RING_MS, Math.max(0, base + life + 2_000 - Date.now()));
await new Promise<void>((resolve) => {
setTimeout(resolve, delay);
});
const openRings = await self.registration.getNotifications({ tag: `call_${roomId}` });
openRings.forEach((n) => n.close());
return;
}