From fe32bed08119f9d3ef5a5f44e956127c0cde3a17 Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 24 Jun 2026 02:44:52 +0300 Subject: [PATCH] =?UTF-8?q?fix(calls):=20harden=20DM=20signaling=20?= =?UTF-8?q?=E2=80=94=20Doze=20ring=20ceiling,=20FGS=20onCreate=20crash,=20?= =?UTF-8?q?logout=20teardown,=20role-aware=20grace,=20web=20ring=20withdra?= =?UTF-8?q?wal,=20caller-cancel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/vojo/app/CallForegroundPlugin.java | 8 + .../chat/vojo/app/CallForegroundService.java | 124 +++++++---- .../java/chat/vojo/app/VojoCallsManager.kt | 12 ++ .../app/VojoFirebaseMessagingService.java | 200 ++++++++++++++++-- docs/plans/telecom_migration.md | 35 +++ .../call-status/IncomingCallStrip.tsx | 2 +- src/app/hooks/useCallEmbed.ts | 5 +- src/app/hooks/useCallerAutoHangup.ts | 57 ++++- src/app/hooks/useIncomingRtcNotifications.ts | 73 ++++++- src/app/hooks/usePendingCallActionConsumer.ts | 2 +- src/app/hooks/useSwitchOrStartDmCall.ts | 39 +++- src/app/pages/client/ClientRoot.tsx | 6 + src/app/plugins/call/CallEmbed.ts | 12 +- src/app/plugins/call/callForegroundService.ts | 11 + src/client/initMatrix.ts | 8 + src/sw.ts | 56 ++++- 16 files changed, 567 insertions(+), 83 deletions(-) diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java index 50dce3b5..e2456cbd 100644 --- a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java +++ b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java @@ -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(); + } } diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundService.java b/android/app/src/main/java/chat/vojo/app/CallForegroundService.java index c8d2f067..03e4c928 100644 --- a/android/app/src/main/java/chat/vojo/app/CallForegroundService.java +++ b/android/app/src/main/java/chat/vojo/app/CallForegroundService.java @@ -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 diff --git a/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt b/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt index 0c016138..c095b1eb 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt +++ b/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt @@ -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 diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java index e4f5551a..ade45b72 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -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 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 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); diff --git a/docs/plans/telecom_migration.md b/docs/plans/telecom_migration.md index 0ce70d0c..38d5460b 100644 --- a/docs/plans/telecom_migration.md +++ b/docs/plans/telecom_migration.md @@ -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 устройства).** diff --git a/src/app/features/call-status/IncomingCallStrip.tsx b/src/app/features/call-status/IncomingCallStrip.tsx index ddd2f3f8..d2c7b6e5 100644 --- a/src/app/features/call-status/IncomingCallStrip.tsx +++ b/src/app/features/call-status/IncomingCallStrip.tsx @@ -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 diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index 0b73cbc0..5ae10865 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -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; }; diff --git a/src/app/hooks/useCallerAutoHangup.ts b/src/app/hooks/useCallerAutoHangup.ts index 6bb8b8c4..f0f97414 100644 --- a/src/app/hooks/useCallerAutoHangup.ts +++ b/src/app/hooks/useCallerAutoHangup.ts @@ -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 | 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 | 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. diff --git a/src/app/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts index c78a3559..bc0a8e7c 100644 --- a/src/app/hooks/useIncomingRtcNotifications.ts +++ b/src/app/hooks/useIncomingRtcNotifications.ts @@ -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>(); + 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 => { 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]); }; diff --git a/src/app/hooks/usePendingCallActionConsumer.ts b/src/app/hooks/usePendingCallActionConsumer.ts index 3229943c..56c5a652 100644 --- a/src/app/hooks/usePendingCallActionConsumer.ts +++ b/src/app/hooks/usePendingCallActionConsumer.ts @@ -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 diff --git a/src/app/hooks/useSwitchOrStartDmCall.ts b/src/app/hooks/useSwitchOrStartDmCall.ts index de310ae8..29171b9d 100644 --- a/src/app/hooks/useSwitchOrStartDmCall.ts +++ b/src/app/hooks/useSwitchOrStartDmCall.ts @@ -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) => { +export const useSwitchOrStartDmCall = (): (( + roomId: string, + incoming?: boolean +) => Promise) => { const mx = useMatrixClient(); const theme = useTheme(); const store = useStore(); @@ -102,7 +105,7 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise) => ); const doSwitchOrStart = useCallback( - async (roomId: string): Promise => { + async (roomId: string, incoming: boolean): Promise => { const room = mx.getRoom(roomId); if (!room) { // eslint-disable-next-line no-console @@ -141,24 +144,46 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise) => } 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 => { + (roomId: string, incoming = false): Promise => { const enter = (): Promise => { if (inFlightRef.current) { return inFlightRef.current.then(enter, enter); } - const task = doSwitchOrStart(roomId); + const task = doSwitchOrStart(roomId, incoming); const tracked: Promise = task.finally(() => { if (inFlightRef.current === tracked) inFlightRef.current = undefined; }); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index d4e449e7..23303d25 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -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 ; @@ -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. diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 9b618298..a16db9a4 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -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); diff --git a/src/app/plugins/call/callForegroundService.ts b/src/app/plugins/call/callForegroundService.ts index cf1a06c1..6bd623d1 100644 --- a/src/app/plugins/call/callForegroundService.ts +++ b/src/app/plugins/call/callForegroundService.ts @@ -35,6 +35,7 @@ interface CallForegroundServicePlugin { stop(): Promise; upsertIncomingRing(options: IncomingRingUpsert): Promise; removeIncomingRing(options: { eventId: string }): Promise; + clearAllIncomingRings(): Promise; } const plugin = registerPlugin('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 { + if (!isAndroidPlatform()) return Promise.resolve(); + return plugin.clearAllIncomingRings(); + }, }; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index a02e530c..8c1a04c2 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -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). diff --git a/src/sw.ts b/src/sw.ts index a381ba28..f97a6310 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -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((resolve) => { + setTimeout(resolve, delay); + }); + const openRings = await self.registration.getNotifications({ tag: `call_${roomId}` }); + openRings.forEach((n) => n.close()); return; }