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:
parent
5d908bc04a
commit
fe32bed081
16 changed files with 567 additions and 83 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 устройства).**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
56
src/sw.ts
56
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<void>((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
const openRings = await self.registration.getNotifications({ tag: `call_${roomId}` });
|
||||
openRings.forEach((n) => n.close());
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue