From 55cbecd4e773525a2bafe8c3b45a8c6f723f9cbb Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Wed, 29 Apr 2026 13:45:13 +0300 Subject: [PATCH] fix(call): restore Android CallStyle banner for DM voice calls in encrypted rooms --- .../app/VojoFirebaseMessagingService.java | 12 +- docs/plans/dm_calls_techdebt.md | 19 +++ src/app/hooks/useIncomingRtcNotifications.ts | 5 +- src/app/plugins/call/CallWidgetDriver.ts | 143 ++++++++++++++++++ src/app/utils/push.ts | 94 ++++++++---- 5 files changed, 243 insertions(+), 30 deletions(-) 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 3c06eaea..e41d10c7 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -57,7 +57,17 @@ public class VojoFirebaseMessagingService extends MessagingService { // for the empty string and a handful of other inputs). private static final int SUMMARY_NOTIFICATION_ID = Integer.MIN_VALUE; + // Both the unstable MSC4075 prefix that current matrix-js-sdk emits and the + // stable name peers may upgrade to. Receive-side fallback only — send-side + // (CallWidgetDriver cleartext bypass) keeps emitting the unstable name until + // the SDK promotes the constant. private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification"; + private static final String RTC_NOTIFICATION_TYPE_STABLE = "m.rtc.notification"; + + private static boolean isRtcNotificationType(String type) { + return RTC_NOTIFICATION_TYPE.equals(type) || RTC_NOTIFICATION_TYPE_STABLE.equals(type); + } + private static final long RTC_DEFAULT_LIFETIME_MS = 30_000L; private static final long RTC_LIFETIME_GRACE_MS = 2_000L; @@ -162,7 +172,7 @@ public class VojoFirebaseMessagingService extends MessagingService { + " event=" + data.get("event_id") + " fg=" + MainActivity.isInForeground); try { - if (RTC_NOTIFICATION_TYPE.equals(data.get("type")) + if (isRtcNotificationType(data.get("type")) && "ring".equals(data.get("content_notification_type"))) { String eventId = data.get("event_id"); String roomId = data.get("room_id"); diff --git a/docs/plans/dm_calls_techdebt.md b/docs/plans/dm_calls_techdebt.md index e2c79180..4b93ac2a 100644 --- a/docs/plans/dm_calls_techdebt.md +++ b/docs/plans/dm_calls_techdebt.md @@ -439,6 +439,7 @@ Tradeoff: 1-3s окно между созданием embed и `JoinCall` без ### 5.43. 🟢 Stale ring в JS strip после killed-decline в DM — landed 2026-04-23 (commit 9e5fa6b) - **Landed fix:** в [useIncomingRtcNotifications.ts](../../src/app/hooks/useIncomingRtcNotifications.ts) заведён `declinedTimersRef: Map` с self-expiring 30s entries. `rememberDeclined()` helper в decline-branch записывает `rel.event_id` **перед** `removeByNotifId`. Notification-branch имеет **двойной** check `declinedTimers.has(evId)`: pre-await short-circuit (не гоняем `resolveCallId` для уже отклонённых) и post-await race-guard рядом с существующим membership re-check. Unmount cleanup clear'ит pending timers. Атом / native / receiver не тронуты. - **Изначальная гипотеза (§5.43 pre-fix) оказалась частично ошибочна:** live-логи показали что `m.rtc.notification` летит **cleartext** даже в encrypted DM (нужно для push-сервиса), а не как `m.room.encrypted`. Настоящий race — async внутри processEvent: `resolveCallId` yields до 5s (MembershipsChanged wait) + fetchRoomEvent. За этот yield Timeline decline проходит через handleTimeline → populates declinedTimers → но notification-branch уже миновал pre-await check и после await проверял только membership, не declined set. Fix-через-set всё равно правильный, просто check нужен **с обеих сторон** от await, а не только до. +- **Корректировка 2026-04-29 (см. §5.51):** утверждение «`m.rtc.notification` летит cleartext даже в encrypted DM» оказалось НЕВЕРНЫМ. Logcat 2026-04-29 показал FCM payload с `type=m.room.encrypted, cn_type=null` для encrypted-DM ring'а — то есть SDK всё-таки шифровал event. Скорее всего тест 2026-04-23 случайно был на unencrypted DM, либо сделан через другой path. Race-fix через `declinedTimersRef` сам по себе правильный (race в processEvent существует независимо от encryption), но вывод о cleartext был ошибочным. После §5.51 cleartext-bypass для ring уже **сделан явно** в `CallWidgetDriver`, так что текущее состояние согласовано: ring действительно cleartext, но только потому что мы сами переписали путь, а не потому что SDK так делал. - **Trace (confirmed by live logcat 2026-04-23):** 1. FCM доставляет notification → native CallStyle → user жмёт Decline → `CallDeclineReceiver` шлёт cleartext `m.call.decline` PUT (200 OK). 2. User открывает app (cold boot или resume). /sync подтягивает оба события. @@ -590,3 +591,21 @@ App foreground, incoming DM call. JS strip + audio начинают отраба 5. Ожидаемый результат после фикса: prompt остаётся loading до ответа, затем показывает localized error. - **Фикс-направление:** `await mx.leave(roomId);` в обоих prompt'ах. Не `return mx.leave(roomId)`, если сохраняем `useAsyncCallback` contract. - **Scope:** не связано с i18n-диффом; pre-existing bug, найден при ревью локализации leave prompt'а. + +### 5.51. ✅ Encrypted RTC ring FCM misclassifies as message on Android — RESOLVED 2026-04-29 + +- **Симптом (до фикса):** Android background/killed мог показать обычное message-уведомление вместо CallStyle для входящего звонка в encrypted DM. +- **Логкат 2026-04-29 (до фикса):** ring пришёл в `VojoFirebaseMessagingService` как `recv: type=m.room.encrypted cn_type=null ... event=`. JS затем расшифровал тот же event и сделал `CallForegroundService.upsertIncomingRing`, поэтому foreground in-app звонок прошёл. Background Java FCM path не мог классифицировать payload как ring. +- **Root cause:** matrix-js-sdk шифрует `m.rtc.notification` через `client.sendEvent` в e2ee комнате (`MatrixRTCSession.sendCallNotify` → `client.sendEvent`). Server-side push rule `EventMatch type=org.matrix.msc4075.rtc.notification` матчится только на outer cleartext type, который для encrypted envelope всегда `m.room.encrypted`. Sygnal падает в default message-rule. Java classifier тоже видит только outer type и не может распознать ring. Element Web обходит через SW fetch+decrypt; Element X — через matrix-rust-sdk native; Vojo Android FCM не имеет ни того, ни другого. +- **Что не является доказательством:** `adb shell am force-stop chat.vojo.app` переводит приложение в Android stopped state; FCM обычно не доставляется до ручного запуска. Для killed-process теста использовать swipe-away / обычное убийство процесса, не force-stop. +- **Применённый фикс:** cleartext bypass только для `m.rtc.notification` с `notification_type === 'ring'` через `CallWidgetDriver.sendEvent`. Server видит outer type = `org.matrix.msc4075.rtc.notification`, push rule матчит, Java classifier распознаёт ring, CallStyle появляется. Symmetрично существующему `CallDeclineReceiver.sendDecline`, который уже шлёт `m.rtc.decline` cleartext через прямой HTTP PUT. + - **Threat model.** Утечка: server и push gateway видят факт «в этой комнате был ring» + room_id + sender + sender_ts + lifetime + relation. НЕ утечка: message bodies, LiveKit token, media keys, SDP / session secrets, аудио-поток. По MSC4075 ring content и так не несёт secret payload. Эквивалент legacy `m.call.invite` cleartext signaling в WebRTC-over-Matrix. В коде и доках называем это «cleartext ring metadata for Android FCM classification», не «unencrypted calls». + - **Allowlist-only.** Чтобы upstream Element Call в будущем случайно не протащил secret в cleartext, форвардятся только: `notification_type, m.relates_to, sender_ts, lifetime, m.mentions, m.call.intent`. Всё неизвестное дропается на send-side. + - **Stable-name fallback — narrow scope.** Покрыты только Android push pipeline компоненты, которые блокировали encrypted-DM ring delivery: server-side override push rule + Java FCM classifier — оба теперь матчат ОБА `org.matrix.msc4075.rtc.notification` (unstable) и `m.rtc.notification` (stable). Send-side остаётся unstable: `CallWidgetDriver` шлёт `EventType.RTCNotification` (matrix-js-sdk константа), при promotion SDK значение переключится автоматически. + - **НЕ покрыто намеренно:** SW classification ([`src/sw.ts`](../../src/sw.ts)), JS hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup` через `EventType.RTCNotification` — auto-upgrade при SDK bump), widget capability declarations ([`src/app/plugins/call/utils.ts`](../../src/app/plugins/call/utils.ts)), и timeline event-type filters в `RoomTimeline.tsx` — все они на hardcoded unstable string. По решению в [`docs/ai/desired_features.md`](../ai/desired_features.md) #6 миграция всех MSC RTC префиксов делается единым диффом, когда стабилизация landed (MSC4075 + MSC4143 + MSC4195 + MSC4310 параллельно). Текущий фикс не делает преждевременную частичную миграцию. + - **Legacy `EventType.CallNotify` (`org.matrix.msc4075.call.notify`) defense.** matrix-js-sdk's `MatrixRTCSession.sendCallNotify` параллельно отправляет ОБА новый `m.rtc.notification` И legacy CallNotify через `Promise.all`. Защита двухслойная: + - **Primary:** capability denial — `getCallCapabilities` не грантит Send для `EventType.CallNotify`, widget capability check rejects request ещё внутри widget API до `CallWidgetDriver.sendEvent`. + - **Secondary:** silent no-op в `CallWidgetDriver.sendEvent` для `EventType.CallNotify` с sentinel event_id (`$vojo:suppressed-legacy-call-notify`). На случай если future widget release обходит capability check — событие на сервер не уходит. Resolve вместо throw сохраняет совместимость с upstream `Promise.all` umbrella в `sendCallNotify`: `.then` отрабатывает, `DidSendCallNotification` emit'ится (Vojo не consumer'ит, но keep upstream contract clean), нет «Unhandled promise rejection» в консоли. Push-rule layer **не** используется здесь намеренно: в encrypted DM HS видит только outer `m.room.encrypted`, `EventMatch` на inner type silently no-op'ит, suppress rule был бы misleading. + - **Shape validation.** `sanitizeRingContent` отвергает payloads с invalid `m.relates_to` (не `m.reference` / non-string event_id), non-finite или non-positive `sender_ts`, и `lifetime` вне (0, 5min]. На invalid — fallback на encrypted send-path: in-app strip всё равно получит event через /sync; теряется только Android background CallStyle. Это укрепляет инвариант «cleartext только корректная ring metadata» против будущих upstream Element Call изменений. + - **Что НЕ покрыто фиксом и почему:** (a) `m.rtc.member` — state event, не идёт через push pipeline; (b) `m.rtc.decline` — для killed-state уже cleartext через `CallDeclineReceiver`, для foreground шлётся через `mx.sendRtcDecline` и шифруется в e2ee, но decline path не показывает CallStyle banner — отдельная проблема, не блокирующая. Если decline cleartext bypass понадобится — добавить тем же allowlist-паттерном в `CallWidgetDriver`. +- **Deferred follow-up — strict threat model.** Если когда-нибудь нужно скрыть от сервера сам факт звонка (не только содержимое), потребуется native-decrypt path аналогично Element X: либо порт matrix-rust-sdk в Android shell, либо headless WebView crypto bridge (хрупко из-за cold-start time budget). Большой scope, отдельным планом. diff --git a/src/app/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts index 2d3e22e6..100164bc 100644 --- a/src/app/hooks/useIncomingRtcNotifications.ts +++ b/src/app/hooks/useIncomingRtcNotifications.ts @@ -286,7 +286,10 @@ export const useIncomingRtcNotifications = (): void => { // FCM path only seeds registry for 'ring' content_notification_type, so no entry to remove. if (content.notification_type !== 'ring') return; if (!mDirectRef.current.has(room.roomId)) { - removeFromRegistry(); + // `mDirectAtom` is hydrated from account-data in a React effect, so it + // can be empty during cold/warm startup even for a legitimate DM. Keep + // the native registry alive here: pre-registry behavior was "no JS + // strip", not "cancel the Android CallStyle". return; } if (isRtcNotificationExpired(ev)) { diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts index babe90ef..6e85fa45 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -14,11 +14,13 @@ import { IOpenIDUpdate, } from 'matrix-widget-api'; import { + ClientPrefix, EventType, type IContent, MatrixError, type MatrixEvent, Direction, + Method, type SendDelayedEventResponse, type StateEvents, type TimelineEvents, @@ -27,6 +29,99 @@ import { import { getCallCapabilities } from './utils'; import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix'; +// Cleartext ring metadata for Android FCM classification — NOT "unencrypted calls". +// +// Why this exists. matrix-js-sdk encrypts every room event in an e2ee room before +// PUT /send. Server stores it as `m.room.encrypted`; Sygnal/HS push rules can only +// match outer cleartext fields, so an `EventMatch type=org.matrix.msc4075.rtc.notification` +// override never fires for encrypted DMs. Push falls back to the default message rule, +// FCM payload arrives with `type=m.room.encrypted, cn_type=null`, the Java classifier +// in `VojoFirebaseMessagingService.onMessageReceived` cannot recognise it as a ring, +// and the user sees a generic message banner instead of CallStyle. Element Web mitigates +// this by fetching+decrypting in the SW; Element X does it via native matrix-rust-sdk. +// Vojo Android has neither path, so the only short fix is to send the ring signal cleartext. +// +// Threat model. The signaling layer "an incoming ring exists in this room at time T" +// becomes server- and push-gateway-visible. Message bodies, LiveKit tokens, media +// encryption keys, SDP/session secrets, and call audio remain end-to-end encrypted — +// MSC4075 keeps those out of `m.rtc.notification` content by design. Equivalent to +// legacy `m.call.invite` cleartext signaling in WebRTC-over-Matrix. Stricter threat +// models that require hiding even the call-existence fact need the native-decrypt path +// tracked in `docs/plans/dm_calls_techdebt.md` §5.51. +// +// Reconstruct the ring payload field-by-field from validated primitives. +// We do NOT use a top-level allowlist + shallow copy: nested objects like +// `m.relates_to`, `m.mentions`, and a future opaque `m.call.intent` would +// then leak any sub-fields Element Call upstream might add. Each accepted +// field below is rebuilt from primitive-typed inputs only, so unknown +// sub-fields cannot ride along. + +const RTC_RING_LIFETIME_MAX_MS = 5 * 60 * 1000; +const RTC_RING_MENTIONS_MAX_USERS = 64; + +const isObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +// Validate a widget-provided ring payload before we send it cleartext. Returns +// the sanitized object on a happy path or `null` if any required field is +// missing/malformed — caller then falls back to the encrypted send path so the +// in-app strip still gets the event via /sync, even if Android push misses. +// Prevents a hostile or buggy widget from leaking arbitrary cleartext under +// the guise of "ring metadata". +function sanitizeRingContent(content: IContent): IContent | null { + const relates = content['m.relates_to']; + if (!isObject(relates)) return null; + if (relates.rel_type !== 'm.reference') return null; + if (typeof relates.event_id !== 'string' || relates.event_id.length === 0) return null; + + const { sender_ts: senderTs, lifetime } = content; + if (typeof senderTs !== 'number' || !Number.isFinite(senderTs) || senderTs <= 0) return null; + + if ( + typeof lifetime !== 'number' || + !Number.isFinite(lifetime) || + lifetime <= 0 || + lifetime > RTC_RING_LIFETIME_MAX_MS + ) { + return null; + } + + const out: IContent = { + notification_type: 'ring', + 'm.relates_to': { + rel_type: 'm.reference', + event_id: relates.event_id, + }, + sender_ts: senderTs, + lifetime, + }; + + // m.mentions per Matrix spec: { user_ids?: string[]; room?: boolean }. + // Reconstruct from primitives so an upstream-added field can't leak. + const mentions = content['m.mentions']; + if (isObject(mentions)) { + const sanitizedMentions: { user_ids?: string[]; room?: boolean } = {}; + if (Array.isArray(mentions.user_ids)) { + sanitizedMentions.user_ids = mentions.user_ids + .filter((u): u is string => typeof u === 'string') + .slice(0, RTC_RING_MENTIONS_MAX_USERS); + } + if (typeof mentions.room === 'boolean') { + sanitizedMentions.room = mentions.room; + } + out['m.mentions'] = sanitizedMentions; + } + + // m.call.intent per MSC4310 is a free-form string hint ('audio' / 'video'). + // Forward only if it's a primitive string. + const intent = content['m.call.intent']; + if (typeof intent === 'string' && intent.length > 0 && intent.length < 64) { + out['m.call.intent'] = intent; + } + + return out; +} + export class CallWidgetDriver extends WidgetDriver { private allowedCapabilities: Set; @@ -59,6 +154,35 @@ export class CallWidgetDriver extends WidgetDriver { if (!client || !roomId) throw new Error('Not in a room or not attached to a client'); let r: { event_id: string } | null; + const sanitizedRing = + stateKey === null && + eventType === EventType.RTCNotification && + content.notification_type === 'ring' + ? sanitizeRingContent(content) + : null; + + // Defense-in-depth against the legacy `EventType.CallNotify` + // (`org.matrix.msc4075.call.notify`) sibling event that + // `MatrixRTCSession.sendCallNotify` upstream still emits in parallel + // with `m.rtc.notification`. `getCallCapabilities` already omits Send + // for it, so the widget capability check should reject the request + // before it reaches us — but if a future widget release bypasses + // capability validation, the encrypted send would surface as a + // default-rule message banner alongside the real ring (Vojo's ring + // listener watches RTCNotification, not CallNotify). A push-rule + // suppression cannot fix this: the homeserver only sees + // `type=m.room.encrypted` for encrypted DMs and `EventMatch` against + // the inner type silently no-ops. So we silently no-op the legacy send + // here, returning a sentinel event_id. Throwing is tempting but would + // poison `MatrixRTCSession.sendCallNotify`'s `Promise.all` umbrella — + // its `.then` doesn't fire on reject, `DidSendCallNotification` never + // emits, and an "Unhandled promise rejection" log flickers per ring. + // Vojo doesn't consume `DidSendCallNotification`, but a clean resolve + // keeps the sibling new-RTCNotification ack path identical to upstream. + if (stateKey === null && eventType === EventType.CallNotify) { + return { roomId, eventId: '$vojo:suppressed-legacy-call-notify' }; + } + if (typeof stateKey === 'string') { r = await client.sendStateEvent( roomId, @@ -69,6 +193,25 @@ export class CallWidgetDriver extends WidgetDriver { } else if (eventType === EventType.RoomRedaction) { // special case: extract the `redacts` property and call redact r = await client.redactEvent(roomId, content.redacts); + } else if (sanitizedRing) { + // Bypass the encryption pipeline for ring signaling. See the rationale block + // above the allowlist for full reasoning. Only `notification_type === 'ring'` + // is special-cased — group-call `notification_type === 'notification'` keeps + // the default encrypted path because it isn't routed through the Android + // CallStyle classifier. If the payload fails shape validation we fall through + // to the regular encrypted send below so the in-app strip still gets the + // event via /sync; only Android background CallStyle is lost on that branch. + const txnId = client.makeTxnId(); + const path = `/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent( + eventType + )}/${encodeURIComponent(txnId)}`; + r = await client.http.authedRequest<{ event_id: string }>( + Method.Put, + path, + undefined, + sanitizedRing, + { prefix: ClientPrefix.V3 } + ); } else { r = await client.sendEvent( roomId, diff --git a/src/app/utils/push.ts b/src/app/utils/push.ts index e715fc82..6636c9e7 100644 --- a/src/app/utils/push.ts +++ b/src/app/utils/push.ts @@ -1,6 +1,7 @@ import { MatrixClient, IPusherRequest, + IPushRule, PushRuleKind, ConditionKind, PushRuleActionName, @@ -74,45 +75,75 @@ export type PusherIds = { }; export const RTC_RING_PUSH_RULE_ID = 'chat.vojo.rtc.ring'; +export const RTC_RING_PUSH_RULE_ID_STABLE = 'chat.vojo.rtc.ring.stable'; -// Matrix default push rules don't cover `org.matrix.msc4075.rtc.notification`, -// so without an explicit Override rule the homeserver never dispatches RTC ring -// events to Sygnal — background/killed devices silently miss incoming DM calls. +// Matrix default push rules don't cover MSC4075 RTC ring events, so without +// an explicit Override rule the homeserver never dispatches RTC ring events +// to Sygnal — background/killed devices silently miss incoming DM calls. +// +// Two rules are registered in parallel: one against the unstable +// `org.matrix.msc4075.rtc.notification` event type that current matrix-js-sdk +// (and our send-side cleartext bypass in `CallWidgetDriver`) emits, and one +// against the stable `m.rtc.notification` name so peers running an upgraded +// SDK with the stable promotion don't silently miss us. EventMatch only +// matches the outer cleartext event type, which is why our send-side relies +// on the cleartext bypass — this rule only fires on cleartext ring events. // // Idempotent: Synapse returns 200 OK on PUT with an identical body, so calling -// this on every pusher (re)registration and on lifecycle startup is cheap. +// these on every pusher (re)registration and on lifecycle startup is cheap. // Failures are logged but not thrown — a transient /pushrules 5xx shouldn't // break pusher setup (foreground path via useIncomingRtcNotifications keeps -// working regardless of whether this rule made it to the server). +// working regardless of whether the rule made it to the server). +const ringRuleBody = ( + eventTypePattern: string +): Pick => ({ + conditions: [ + { + kind: ConditionKind.EventMatch, + key: 'type', + pattern: eventTypePattern, + }, + { + kind: ConditionKind.EventMatch, + key: 'content.notification_type', + pattern: 'ring', + }, + ], + actions: [ + PushRuleActionName.Notify, + { set_tweak: TweakName.Sound, value: 'ring' }, + { set_tweak: TweakName.Highlight, value: true }, + ], +}); + export async function ensureRtcRingPushRule(mx: MatrixClient): Promise { try { - await mx.addPushRule('global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID, { - conditions: [ - { - kind: ConditionKind.EventMatch, - key: 'type', - pattern: 'org.matrix.msc4075.rtc.notification', - }, - { - kind: ConditionKind.EventMatch, - key: 'content.notification_type', - pattern: 'ring', - }, - ], - actions: [ - PushRuleActionName.Notify, - { set_tweak: TweakName.Sound, value: 'ring' }, - { set_tweak: TweakName.Highlight, value: true }, - ], - }); + await mx.addPushRule( + 'global', + PushRuleKind.Override, + RTC_RING_PUSH_RULE_ID, + ringRuleBody('org.matrix.msc4075.rtc.notification') + ); } catch (err) { // eslint-disable-next-line no-console - console.warn('[push] ensureRtcRingPushRule failed:', err); + console.warn('[push] ensureRtcRingPushRule (unstable) failed:', err); + } + try { + await mx.addPushRule( + 'global', + PushRuleKind.Override, + RTC_RING_PUSH_RULE_ID_STABLE, + ringRuleBody('m.rtc.notification') + ); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[push] ensureRtcRingPushRule (stable) failed:', err); } } -// Symmetric cleanup for `useDisablePushNotifications`. The rule lives in -// account data so it would otherwise outlive the pusher — and other logged-in + +// Symmetric cleanup for `useDisablePushNotifications`. The rules live in +// account data so they would otherwise outlive the pusher — and other logged-in // clients (Element, a second Vojo session) would keep applying the ring // tweaks after the user explicitly turned push off here. 404 on delete of an // absent rule lands in the same warn path — that's the "already gone" case, @@ -122,10 +153,17 @@ export async function removeRtcRingPushRule(mx: MatrixClient): Promise { await mx.deletePushRule('global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID); } catch (err) { // eslint-disable-next-line no-console - console.warn('[push] removeRtcRingPushRule failed:', err); + console.warn('[push] removeRtcRingPushRule (unstable) failed:', err); + } + try { + await mx.deletePushRule('global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID_STABLE); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[push] removeRtcRingPushRule (stable) failed:', err); } } + export async function registerWebPusher( mx: MatrixClient, subscription: PushSubscription,