fix(call): restore Android CallStyle banner for DM voice calls in encrypted rooms

This commit is contained in:
heaven 2026-04-29 13:45:13 +03:00
parent d2c77496a7
commit 3cd1611ee2
5 changed files with 243 additions and 30 deletions

View file

@ -57,7 +57,17 @@ public class VojoFirebaseMessagingService extends MessagingService {
// for the empty string and a handful of other inputs). // for the empty string and a handful of other inputs).
private static final int SUMMARY_NOTIFICATION_ID = Integer.MIN_VALUE; 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 = "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_DEFAULT_LIFETIME_MS = 30_000L;
private static final long RTC_LIFETIME_GRACE_MS = 2_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") + " event=" + data.get("event_id")
+ " fg=" + MainActivity.isInForeground); + " fg=" + MainActivity.isInForeground);
try { try {
if (RTC_NOTIFICATION_TYPE.equals(data.get("type")) if (isRtcNotificationType(data.get("type"))
&& "ring".equals(data.get("content_notification_type"))) { && "ring".equals(data.get("content_notification_type"))) {
String eventId = data.get("event_id"); String eventId = data.get("event_id");
String roomId = data.get("room_id"); String roomId = data.get("room_id");

View file

@ -439,6 +439,7 @@ Tradeoff: 1-3s окно между созданием embed и `JoinCall` без
### 5.43. 🟢 Stale ring в JS strip после killed-decline в DM — landed 2026-04-23 (commit 9e5fa6b) ### 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<notifEventId, timeout>` с 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 не тронуты. - **Landed fix:** в [useIncomingRtcNotifications.ts](../../src/app/hooks/useIncomingRtcNotifications.ts) заведён `declinedTimersRef: Map<notifEventId, timeout>` с 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, а не только до. - **Изначальная гипотеза (§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):** - **Trace (confirmed by live logcat 2026-04-23):**
1. FCM доставляет notification → native CallStyle → user жмёт Decline → `CallDeclineReceiver` шлёт cleartext `m.call.decline` PUT (200 OK). 1. FCM доставляет notification → native CallStyle → user жмёт Decline → `CallDeclineReceiver` шлёт cleartext `m.call.decline` PUT (200 OK).
2. User открывает app (cold boot или resume). /sync подтягивает оба события. 2. User открывает app (cold boot или resume). /sync подтягивает оба события.
@ -590,3 +591,21 @@ App foreground, incoming DM call. JS strip + audio начинают отраба
5. Ожидаемый результат после фикса: prompt остаётся loading до ответа, затем показывает localized error. 5. Ожидаемый результат после фикса: prompt остаётся loading до ответа, затем показывает localized error.
- **Фикс-направление:** `await mx.leave(roomId);` в обоих prompt'ах. Не `return mx.leave(roomId)`, если сохраняем `useAsyncCallback<undefined, MatrixError, []>` contract. - **Фикс-направление:** `await mx.leave(roomId);` в обоих prompt'ах. Не `return mx.leave(roomId)`, если сохраняем `useAsyncCallback<undefined, MatrixError, []>` contract.
- **Scope:** не связано с i18n-диффом; pre-existing bug, найден при ревью локализации leave prompt'а. - **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=<rtc-notification-event-id>`. 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, отдельным планом.

View file

@ -286,7 +286,10 @@ export const useIncomingRtcNotifications = (): void => {
// FCM path only seeds registry for 'ring' content_notification_type, so no entry to remove. // FCM path only seeds registry for 'ring' content_notification_type, so no entry to remove.
if (content.notification_type !== 'ring') return; if (content.notification_type !== 'ring') return;
if (!mDirectRef.current.has(room.roomId)) { 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; return;
} }
if (isRtcNotificationExpired(ev)) { if (isRtcNotificationExpired(ev)) {

View file

@ -14,11 +14,13 @@ import {
IOpenIDUpdate, IOpenIDUpdate,
} from 'matrix-widget-api'; } from 'matrix-widget-api';
import { import {
ClientPrefix,
EventType, EventType,
type IContent, type IContent,
MatrixError, MatrixError,
type MatrixEvent, type MatrixEvent,
Direction, Direction,
Method,
type SendDelayedEventResponse, type SendDelayedEventResponse,
type StateEvents, type StateEvents,
type TimelineEvents, type TimelineEvents,
@ -27,6 +29,99 @@ import {
import { getCallCapabilities } from './utils'; import { getCallCapabilities } from './utils';
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix'; 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<string, unknown> =>
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 { export class CallWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set<Capability>; private allowedCapabilities: Set<Capability>;
@ -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'); if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
let r: { event_id: string } | null; 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') { if (typeof stateKey === 'string') {
r = await client.sendStateEvent( r = await client.sendStateEvent(
roomId, roomId,
@ -69,6 +193,25 @@ export class CallWidgetDriver extends WidgetDriver {
} else if (eventType === EventType.RoomRedaction) { } else if (eventType === EventType.RoomRedaction) {
// special case: extract the `redacts` property and call redact // special case: extract the `redacts` property and call redact
r = await client.redactEvent(roomId, content.redacts); 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 { } else {
r = await client.sendEvent( r = await client.sendEvent(
roomId, roomId,

View file

@ -1,6 +1,7 @@
import { import {
MatrixClient, MatrixClient,
IPusherRequest, IPusherRequest,
IPushRule,
PushRuleKind, PushRuleKind,
ConditionKind, ConditionKind,
PushRuleActionName, 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 = '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`, // Matrix default push rules don't cover MSC4075 RTC ring events, so without
// so without an explicit Override rule the homeserver never dispatches RTC ring // an explicit Override rule the homeserver never dispatches RTC ring events
// events to Sygnal — background/killed devices silently miss incoming DM calls. // 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 // 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 // Failures are logged but not thrown — a transient /pushrules 5xx shouldn't
// break pusher setup (foreground path via useIncomingRtcNotifications keeps // 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<IPushRule, 'actions' | 'conditions' | 'pattern'> => ({
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<void> { export async function ensureRtcRingPushRule(mx: MatrixClient): Promise<void> {
try { try {
await mx.addPushRule('global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID, { await mx.addPushRule(
conditions: [ 'global',
{ PushRuleKind.Override,
kind: ConditionKind.EventMatch, RTC_RING_PUSH_RULE_ID,
key: 'type', ringRuleBody('org.matrix.msc4075.rtc.notification')
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 },
],
});
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // 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 // 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 // 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, // 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<void> {
await mx.deletePushRule('global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID); await mx.deletePushRule('global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID);
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // 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( export async function registerWebPusher(
mx: MatrixClient, mx: MatrixClient,
subscription: PushSubscription, subscription: PushSubscription,