# DM-звонки — техдолг и замечания по реализации

Живой документ. Дополнение к [dm_calls.md](dm_calls.md). Здесь — **регистр** открытых багов, отложенных тестов и polish-задач. Имплементационные детали уходят в код / коммиты, сюда пишем только: симптом, влияние, направление фикса, когда возвращаться.

Принцип: каждый пункт имеет **статус** (🟢/🟡/🔴), краткое описание, и **где вернуться** (фаза/приоритет).

---

## Содержание

- [1. Phase 1 — статус готовности](#1-phase-1--статус-готовности)
- [2. Активные баги и наблюдения](#2-активные-баги-и-наблюдения)
- [3. Отложенные тесты](#3-отложенные-тесты)
- [4. Polish backlog](#4-polish-backlog)
- [5. Технический долг](#5-технический-долг)

---

## 1. Phase 1 — статус готовности

### 1.1. Проверено живьём ✅

| # | Сценарий | Платформа | Статус |
|---|----------|-----------|--------|
| 1 | Кнопка трубки в DM (Start/Join/disabled) | web | 🟢 |
| 2 | A звонит, аудио двухстороннее | web↔web | 🟢 |
| 3 | CallStatus pill в DM-комнате | web + Android | 🟢 |
| 4 | voiceOnly скрывает Video/ScreenShare | web | 🟢 |
| 5 | Hangup закрывает iframe, атом сбрасывается | web | 🟢 |
| 6 | Full lifecycle: A hangup → B hangup → обе кнопки в Start | web↔web | 🟢 |
| 7 | Mutex: занят в другой комнате → tooltip "Вы уже в другом звонке" | web | 🟢 |
| 8 | Firefox web — звонок проходит, autoplay не блокирует | web | 🟢 |

### 1.2. Проверено только чтением кода

| # | Что | Риск |
|---|-----|------|
| 1 | Privacy-фикс `video: false` при voiceOnly | низкий — Element Call уважает intent |
| 2 | `pointer-events: none` на скрытом iframe | низкий — клики проверены |
| 3 | Autodispose `callEmbedAtom` при logout / `beforeunload` | средний — leak возможен |

### 1.3. Phase 1 — критерии мержа

- [x] Живые сценарии 1.1
- [x] Code review 1.2 (risks accepted)
- [ ] Android mic — перенесено в Phase 3 (см. **2.3**)

→ **Phase 1 готова к коммиту в `vojo/dev`**.

---

## 2. Активные баги и наблюдения

### 2.1. 🟡 B видит "Start call" пока A уже звонит — race ongoing-резолва

- **Симптом:** у B кнопка показывает **Start**, не **Join**, хотя A уже звонит.
- **Корень:** `MatrixRTCSession.sessionMembershipsForRoom` возвращает 0 у B — state event ещё не обработан при рендере.
- **Когда чинить:** Phase 2 (там в любом случае добавляется RTCNotification listener).

### 2.2. 🟢 Android dropout при lock screen / сворачивании — закрыто 2026-04-22 — 2026-04-23

#### Симптом
Звонок рвался в ~21s после блокировки экрана на Samsung OneUI (и вероятно любом Android 14+). Peer видел нас зависшими в session ещё минутами (связано с §5.21 zombie membership).

#### Изначальный диагноз — неверный
Гипотеза была "Chromium тротлит JS-таймеры в hidden WebView → LiveKit keepalive starvation → peer connection рвётся". Phase 0 instrumentation (1-сек heartbeat с `deltaMs` логом) опроверг: JS event loop работает идеально под lock, 21 тик подряд с `deltaMs=999..1001`. WebView renderer не эвакуируется, процесс не freezed.

#### Реальный диагноз
Две Android platform-policy защиты против background-приложения без FGS:

1. **AppOps revocation of `android:record_audio`** (Android 12+ while-in-use gating). В ~T+5s после lock ОС переводит op в `Active: false`. Мик-track в WebRTC становится muted/ended — peer перестаёт нас слышать.
2. **Background network firewall** через `NetdEventListenerService`. В ~T+13s netd начинает возвращать `isBlocked=true` для DNS/TCP от UID приложения. LiveKit/ICE keepalive не проходят, session экспайрит на сервере.

Evidence из живого capture (Samsung OneUI API 36, один прогон 2026-04-22):

| T+ от lock | Событие | Источник в logcat |
|---|---|---|
| 0s | Lock | `PhoneWindowManager: Going to sleep OFF_BECAUSE_OF_USER` |
| ~5s | Мик отозван | `AiPrivacy::SaSensorRepository: record_audio ... Active: false` |
| ~13s | Сеть заблокирована | `NetdEventListenerService: ... isBlocked=true` |
| ~19s | Audio pipe EPIPE каскад | `chromium: services/audio/sync_reader.cc: Broken pipe` |
| ~21s | Widget teardown | `AS.AudioService: setMode(mode=0)` |
| ~22s (unlock) | Dispose | JS → `callEmbedAtom(undefined)` |

#### Альтернативы, которые рассмотрели и отклонили

- **`KEEP_SCREEN_ON` / `PARTIAL_WAKE_LOCK`** — fake-retention. Wake lock держит CPU, но не снимает AppOps mic revocation и не лифтит netd firewall. Не решает root cause.
- **`ROLE_DIALER` (стать default dialer)** — overkill. Конфликт с реальным dialer пользователя, Play-review риск, не нужны для нашего UX.
- **`ConnectionService` / `MANAGE_OWN_CALLS` / `core-telecom` self-managed VoIP** — правильный long-term путь для native ongoing-call UX (BT routing, ongoing CallStyle, audio focus arbitration), но weeks of work и Play-review re-entry. Для простого "не ронять звонок под lock" — избыточно. Оставлено как опциональный Phase 3 upgrade, если продукту понадобится full native ongoing UX. Ранее тот же tradeoff обсуждался в §5.34 для другой UX-задачи (lockscreen Answer/Decline buttons) и был отклонён по тем же причинам.
- **`foregroundServiceType="mediaPlayback"`** — лифтит netd firewall, но НЕ лифтит AppOps mic revocation. Покрывает половину проблемы.
- **Fallback `foregroundServiceType="none"` при отсутствии RECORD_AUDIO** — на API 34+ Android требует, чтобы тип при `startForeground()` был subset манифестных types. Манифест объявляет только `microphone` → `NONE` не subset → `SecurityException`/`ForegroundServiceTypeException`. Этот путь рассматривался и был явно забракован рецензером до мержа.

#### Выбор: `microphone` FGS
`foregroundServiceType="microphone"` закрывает обе revocation'а одним шагом:
- держит `android:record_audio` AppOps в `Active: true` всё время жизни сервиса → мик не отзывается;
- переводит процесс в `PROCESS_STATE_FOREGROUND_SERVICE` → netd background firewall не применяется.

Permissions: `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_MICROPHONE` (normal-level, manifest declaration достаточна) + уже существующий `RECORD_AUDIO`.

#### Ключевое design-решение: lifecycle keyed на widget `joined`, не на `!!callEmbed`

Сервис стартует не при создании embed, а когда widget внутри iframe реально отправил `JoinCall` action (через `useCallJoined` hook). Причина:

- К моменту `JoinCall` Element Call внутри iframe уже вызвал `getUserMedia` → OS-промпт `RECORD_AUDIO` обработан, grant в наличии. Это гарантирует что `startForeground(..., TYPE_MICROPHONE)` не упадёт в `SecurityException`.
- Манифест декларирует только `microphone`, TYPE_NONE не валидный subset на API 34+ → fallback-путь исключён архитектурно, никаких try/retry.
- Если widget вышел в `preparingError`, `joined` никогда не становится true → FGS не стартует → нет ghost-notification.

Tradeoff: 1-3s окно между созданием embed и `JoinCall` без retention. В этом окне media ещё не живой (mic не шлёт данных) → lock там — non-event. См. также §5.41 для answer-from-killed варианта этого окна.

#### Сопутствующие правки

- `isAndroidPlatform()` вместо `isNativePlatform()` — чтобы wrapper не пытался звать несуществующий plugin path на iOS (на будущее).
- `onPreparingError` → `setCallEmbed(undefined)` handler в `CallEmbedProvider` — для очистки zombie-embed в атоме при failure widget'а; не критично для FGS после joined-gating, но полезно само по себе.

#### Файлы

- Новые: [CallForegroundService.java](../../android/app/src/main/java/chat/vojo/app/CallForegroundService.java), [CallForegroundPlugin.java](../../android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java), [callForegroundService.ts](../../src/app/plugins/call/callForegroundService.ts), [useAndroidCallForegroundSync.ts](../../src/app/hooks/useAndroidCallForegroundSync.ts).
- Изменённые: [AndroidManifest.xml](../../android/app/src/main/AndroidManifest.xml) (permissions + service declaration), [MainActivity.java](../../android/app/src/main/java/chat/vojo/app/MainActivity.java) (plugin register), [CallEmbedProvider.tsx](../../src/app/components/CallEmbedProvider.tsx) (hook mount + preparingError), [capacitor.ts](../../src/app/utils/capacitor.ts) (`isAndroidPlatform`).
- Commit: `3a34a4d` на `vojo/dev`.

#### Валидация

- Samsung OneUI API 36 — подтверждён живьём, 1m42s под lock без revocation'ов и без teardown, hangup нормальный.
- Pixel / stock AOSP — pending, см. §3.7.
- Vendor aggressive battery savers — не тестировалось, см. §5.40.
- Answer-from-killed window 1-3s — известный gap, см. §5.41.
- Double ring UX в 5-sec grace после сворачивания — отдельный bug, см. §5.39.

#### Как потом даблчекнуть
Повторить Phase 0 T1-сценарий: debug APK → `adb logcat -c && adb logcat -v threadtime > ~/phase0-check.log` → звонок A→B, B отвечает, A блокирует 2-3 мин, unlock, hangup. Filter: `grep -E 'CallFgs|AiPrivacy.*record_audio|NetdEventListenerService|chat.vojo.app' ~/phase0-check.log`. Gate проходит если нет `record_audio ... Active: false` и нет `isBlocked=true` во время lock; есть `CallFgs: startForeground ok type=microphone` на JoinCall и `CallFgs: onDestroy` на hangup.

### 2.3. 🟢 Android mute — работает, misdiagnosed — проверено 2026-04-24

- Livetest подтвердил: mute-кнопка глушит mic на Android. Изначальный симптом был, вероятно, непониманием upstream deafen-логики: «Turn Off Sound» auto-мьютит mic и наоборот ([CallControl.ts:157-159, 207-209](../../src/app/plugins/call/CallControl.ts#L207-L209), upstream PR #2680/#2737 Discord-style). UI labels не отражают deafen semantics, отсюда confusion. Оставляем как есть — не наш код, DM-impact минимальный.

### 2.4. 🟡 Stale membership после hangup (~10-15с)

- Серверная задержка очистки membership. Кнопка показывает "Join" ещё ~15с.
- **Фикс:** `recentlyHungUpAtom` с TTL 30s → "Завершение…" локально.
- **Когда чинить:** Phase 3.

### 2.5. 🟡 i18n debt — английские tooltip'ы

- В [CallControl.tsx](../../src/app/features/call-status/CallControl.tsx) и iframe Element Call — зашитые английские строки (`End`, `Turn Off Microphone`, …).
- **Фикс:** `Call.*` в `locales/{en,ru}.json` + `useTranslation`. iframe-строки — апстрим, не наш scope.
- **Когда чинить:** отдельный i18n-тикет.

---

## 3. Отложенные тесты

### 3.1. E2EE-комнаты
- **Почему нельзя:** well-known форсит `force_disable=true`, E2EE-комнату взять негде.
- **Когда:** отдельный продуктовый эпик.

### 3.2. Network drop / iframe reconnect
- **Почему нельзя:** internals Element Call, нужна площадка с throttling + второй живой собеседник.
- **Когда:** Phase 3.

### 3.3. Multi-device
- **Почему нельзя:** зависит от RTCNotification + RTCDecline (Phase 2).

### 3.4. Killed-state push (FCM на закрытом Android)
- **Когда:** Phase 2.5 runbook.

### 3.5. Ringtone / Ringback / callend
- **Когда:** Phase 2 (ring) + Phase 3 (ringback, callend).

### 3.6. "Live" индикатор в списке DM
- **Когда:** отдельный UX-тикет после Phase 2.

### 3.7. Phase 1 FGS retention — multi-vendor validation
- **Статус:** Samsung OneUI API 36 — прошло живьём (см. §2.2). Pixel / stock AOSP — не тестировалось.
- **Почему нужно:** Phase 0 evidence base был single-vendor (Samsung-specific теги `AiPrivacy::SaSensorRepository`, AppOps revocation moment). Нужно подтвердить, что `microphone` FGS лифтит те же revocation'ы и на stock AOSP, иначе риск "починил Samsung-specific, не чинит baseline".
- **Как проверять:** повторить Phase 0 T1-сценарий на Pixel: `npm run build:android:debug` → APK → `adb logcat -c && adb logcat -v threadtime > ...ring.log` → звонок + lock 2-3 мин → проверить в логе отсутствие `record_audio ... Active: false` и `NetdEventListenerService: isBlocked=true` во время lock.
- **Когда:** при доступе к Pixel-классу устройству. Критерий acceptance — Phase 1 (§2.2) закрывается полностью только после multi-vendor подтверждения.

---

## 4. Polish backlog

- Таймер длительности в CallStatus
- Ringback / callend звуки
- Dispose callEmbed при logout / `beforeunload`
- AudioContext silent-catch на autoplay reject
- Android mic sanity (**2.3**)
- `recentlyHungUpAtom` (**2.4**)
- i18n (**2.5**)
- Multi-device self-ring suppression

---

## 5. Технический долг

### 5.1. 🟡 Iframe скрыт `visibility:hidden` — modal-prompts невидимы
- На iOS Safari Element Call может показать permission prompt — в скрытом iframe пользователь не увидит.
- На web Chrome/Firefox и Android — permission уже выдан, не блокер.

### 5.2. 🟡 `guessDmRoomUserId` edge cases
- Некорректно помеченная в `m.direct` DM → резолв партнёра может дать self. Шапка не жалуется.

### 5.3. 🟡 Phase 4 — beta voice-room cleanup
- Legacy CallView/CallChatView/`RoomType.Call` для beta voice-комнат. Удаление — отдельный PR после стабилизации DM.

### 5.7. 🟡 Нет реплея ring после полной потери сессии
- **Краевой:** если A звонила **до** того как B залогинился и `lifetime` истёк до первого sync — тост не появится. Также при реальной потере сети B.
- **Фикс:** при mount хука пройтись по live timeline DM за последние `lifetime` мс.
- **Когда чинить:** Phase 2.5 (вместе с push).

### 5.10. 🟡 В уже в звонке в другой комнате — молчаливый skip
- **Симптом:** А+В, В в звонке в комнате X → никакой индикации дозвона у В.
- **Корень:** ранний return на `inCallRef.current` в [useIncomingRtcNotifications.ts:193](../../src/app/hooks/useIncomingRtcNotifications.ts#L193).
- **Решается вместе с §5.36** (multi-call invariants).

### 5.11. 🟡 Multi-client (Element X одновременно) — не проверено
- Риски: двойной звук, рассинхрон при answer.
- **Когда чинить:** Phase 2.5 / 3.

### 5.14. 🟡 Ключ дедупа ring не поддержит group-ring
- `CallMembership.callId === ""` для всех Element Call звонков → ключ `call_${callId}_${roomId}` сводится к одному на комнату. Сейчас безопасно — group ring шлёт `'notification'`, фильтр отсекает.
- **Когда чинить:** если появится group ring — переделать ключ на `call_${roomId}_${sender}`.

### 5.15. 🟡 `useCallerAutoHangup` — имя не отражает scope
- Хук срабатывает на любой DM callEmbed, не только caller.
- **Фикс:** переименовать в `useDmCallAutoHangup`.
- **Когда чинить:** при следующем touch'е файла.

### 5.16. 🟡 Decline не сверяется с конкретным ring event_id
- **Риск:** два ring'a в одной DM подряд от одного peer'а → decline старого может положить наш текущий звонок. Теоретическая дыра, не воспроизведена.
- **Фикс:** матчить `rel.event_id` с `notifEventId` нашего исходящего ring'а.
- **Решается вместе с §5.36.**

### 5.17. 🟡 Ring audio — нет fallback при блокировке autoplay

- **Симптом (confirmed live 2026-04-24):** после reload страницы входящий звонок — визуал strip есть, звонок active, `ring.ogg` молчит. Chrome autoplay policy блокирует `audio.play()` без user gesture; promise reject'ится, catch silent.
- **Real-world impact:** юзер пропускает входящие после любого page reload пока не кликнет хоть раз по странице. Не corner case — типичный PWA flow.
- **Корень:** в [IncomingCallStripRenderer.tsx](../../src/app/pages/IncomingCallStripRenderer.tsx) `audio.play()` reject swallow'ится без user-visible fallback.
- **Фикс-направления:**
  - (a) детектить reject → visible affordance «Tap to enable ringtone» рядом со strip
  - (b) pre-unlock audio на первом любом user-gesture после mount (one-shot `click`/`keydown` listener → dummy `audio.play()` → реальный ring потом сможет играть)
  - (c) (a)+(b) combo — best-effort unlock + visible fallback если не удалось
- **Когда чинить:** real bug, high priority Phase 3 polish. (b) скорее всего самый низкий код и UX impact — пробовать первым.

### 5.18. 🟡 Magic numbers — grace timeouts подобраны на глаз
- `NO_ANSWER_GRACE_MS=10_000`, `PEER_LEAVE_GRACE_MS=8_000`. Эмпирика без метрик /sync latency и LiveKit reconnect.
- **Фикс:** собрать прод-метрики, вынести в config.

### 5.19. 🟡 Hardcoded event type strings в RoomTimeline filter
- `'org.matrix.msc4075.rtc.notification'` / `'org.matrix.msc4310.rtc.decline'` литералами. После stable-финализации MSC фильтр молча сломается.
- **Фикс:** computed property keys с `EventType.RTCNotification` / `RTCDecline`.
- **Когда чинить:** при следующем touch'е `typeToRenderer` или обновлении matrix-js-sdk.

### 5.20. 🟡 `ClientNonUIFeatures` / `roomToUnread` — не обрабатывают encrypted events
- Baseline cinny, не наш scope. В encrypted DM может теряться notification sound/popup пока событие не расшифровано.
- **Фикс:** `useTimelineAndDecryptedEvents()` helper, применить массово.

### 5.21. 🟠 LiveKit/network reconnect — mitigated, не fully closed — landed 2026-04-24 (commit 91afffc)

- **Симптом:** network blip 3-5с → LiveKit PC умирает, /sync восстанавливается, widget может не эмитить hangup наружу → pill "активный звонок" залипает, FGS остаётся ghost.
- **Корень:** MatrixRTC `DEFAULT_EXPIRE_DURATION=4h` + MembershipManager refresh на minutes-scale. Widget-api contract узкий (JoinCall/HangupCall/Close/DeviceMute) — LiveKit state наружу не exposed.
- **Что landed:**
  - `io.element.close` listener в `CallEmbedProvider.tsx` (mirror element-web `Call.ts:901,995-999`) — EC error-state fallback auto-clears atom, даже если widget не emit Hangup.
  - Hangup timeout 8с в `CallControl.tsx` (mirror element-web `performDisconnection` TIMEOUT_MS=16s, у нас 8s под mobile UX и matches `PEER_LEAVE_GRACE_MS`). Force-clear локально если widget не ack'ает.
  - `try/catch` вокруг `callEmbed.hangup()` — transport-reject (widget dead) не skip'ает fallback polling.
- **Residual gap:** если EC вообще не эмитит Close/Hangup И юзер не жмёт End → pill всё ещё может висеть. Live-tests показывают что EC обычно cleanup'ит за 11с (internal reconnect timeout), так что это narrow edge case.
- **Не взяли (scope):** не патчим minified EC bundle (Element-HQ sanctioned upgrade model важнее), не добавляем Force End button (element-web тоже не делает), не добавляем `StopMessaging` listener — в §5.48 follow-up.
- **Validation:** 2 live-test сценария с adb wifi off/on. Логи подтверждают Close/Hangup firing через 11с → cleanup → FGS stop.

### 5.22. 🟠 После zombie-сессии повторный звонок — mitigated — landed 2026-04-24 (commit 91afffc)

- **Симптом:** после §5.21 попытка позвонить в ту же DM — silent fail через early-return в `useSwitchOrStartDmCall.ts:111` на `prev?.roomId === roomId`.
- **Что landed:** same-room retap теперь разделён — healthy (`prev.joined && peerPresent`) → no-op как раньше, zombie (`!joined || !peerPresent`) → best-effort `prev.hangup()` + direct `prev.dispose()` (skip waitLeave на unresponsive widget) → fresh start.
- **Residual gap:** форма `prev.joined && peerPresent && dead_media` всё ещё возвращает no-op. Закрывается через §5.21 hangup timeout: user жмёт End → 8с → force-clear → retap работает.
- **Не full closure** — retry во время EC reconnect spinner требует сначала End + ожидание 8с. Acceptable UX tradeoff без EC bundle patching.

### 5.23. 🟢 Multi-device decline — works — verified 2026-04-24

- [useIncomingRtcNotifications.ts:220-232](../../src/app/hooks/useIncomingRtcNotifications.ts#L220-L232) subscribe'ится на `MatrixRTCSessionEvent.MembershipsChanged` → если наш `user_id` появился в session (answer на другом device'е) → `removeByRoom`.
- RTCDecline event'ы через /sync обрабатываются в `processEvent:251-267` → `removeByNotifId`. Decline с device-1 → event через /sync → device-2 drops ring.
- Landed скорее всего вместе с §5.35 MVP decline patch. Остаточной latency после /sync нет.

### 5.24. 🟠 Cold-start SW push — accepted out-of-MVP per [dm_calls.md §2.5.2](dm_calls.md)

- **Симптом:** вкладки закрыты → SW без access_token → `fetchEventDetails` не запускается → показывается "New message" вместо "Incoming call".
- **Почему не чиним сейчас:** [dm_calls.md:540-547](dm_calls.md#L540) явно декларирует auto-join из cold-start вне MVP scope. Требует token-recovery из IndexedDB + RTC session без полного client boot — большое усложнение ради одного corner case'а.
- **Current MVP behavior:** клик по generic push → open client → live session подхватит ring-event из lifetime-окна (30с). Acceptable.
- **Когда пересмотреть:** если real-world feedback покажет что cold-start calls частые (PWA power users).

### 5.26. 🟡 Chrome notification click → «Open Vojo?» промпт
- **Симптом:** на Windows Chrome клик по OS-нотификации показывает popup вместо прямого focus. Аналогично для ring.
- **Гипотезы:** PWA × Chrome, background tab focus policy, late preventDefault.
- **Когда чинить:** Phase 3, требует Windows для repro.

### 5.27. 🟢 Push-уведомления на приглашения в комнату — landed 2026-04-24

- **Симптом (pre-fix):** на Android нативе push приходил, но показывался как «Vojo / New message» (не локализовано, generic). На web push тоже приходил как «Vojo / Новое сообщение». Корректный текст — «X пригласил вас в Y».
- **Root (Android):** [VojoFirebaseMessagingService.showSystemNotification](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java#L220) делал `firstNonEmpty(data.get("content_body"), "New message")`. У invite event нет `content.body` → fallback. Локализации не было ни для invite, ни для fallback «New message». Локаль бралась через `Locale.getDefault()` (device), игнорируя in-app i18next язык — user на англоязычной OS с русским UI увидел бы англ текст.
- **Root (web):** [sw.ts `fetchEventDetails`](../../src/sw.ts#L250) звал `GET /_matrix/client/v3/rooms/{id}/event/{id}`. Spec: endpoint гейтится по history-visibility → **404 M_NOT_FOUND** для invitee (Synapse маскирует `403 M_FORBIDDEN` как 404, чтобы не утекал факт существования комнаты). Параллельный `/state/m.room.name/` тоже 403 для invitee (spec алгоритм state-endpoint'а требует `joined` или `former-joined`). Оба pre-join-эндпоинта бесполезны. Единственный documented путь для SW получить invite context — `GET /_matrix/client/v3/notifications` или `/sync?filter=...&timeout=0` (см. also: Element-web вообще не имеет SW push-path'а, строит уведомления in-tab через live `/sync`, т.е. reference client обходит проблему).
- **Landed architecture:** Push-текст стал DRY через три поверхности — i18next main app, Android FCM service, web SW. Source of truth — новый namespace `Push` в [public/locales/en.json](../../public/locales/en.json) и [public/locales/ru.json](../../public/locales/ru.json) (7 Android-shared keys + 3 SW-only: `encrypted_message`, `incoming_call`, `open_to_answer`). Copy на web-surface и Android-surface идентичен потому что тянется из одного файла; no hand-maintained mirror.
- **Landed fix (Android):**
  - Build-time emitter [scripts/gen-push-strings.mjs](../../scripts/gen-push-strings.mjs) читает Push namespace → конвертит `{{inviter}}`/`{{roomName}}` в positional `%1$s`/`%2$s` (stable name→position мапа) → пишет `values{,-ru}/push_strings.xml`. Resource-файлы помечены AUTO-GENERATED; редактируется JSON. Скрипт валидирует parity (keys + placeholder tokens) и падает с ненулевым кодом если в en.json/ru.json расходятся ключи или placeholders. С §5.49 скрипт вызывается Gradle task'ом `GeneratePushStringsTask` с `--out build/generated/res/push/<variant>/`; XMLs не коммитятся.
  - Почему build-time, не runtime JSON parse: `VojoFirebaseMessagingService.onMessageReceived` бежит до WebView/JS под killed-процесс; `JSONObject` parse на этом хот-path добавил бы новый класс «uncaught → missed push» failures. aapt валидация + Android Studio tools сохранены.
  - [PushStrings.java](../../android/app/src/main/java/chat/vojo/app/PushStrings.java) — тонкий helper, делает `getString(R.string.push_*)` на `createConfigurationContext` с локалью юзера. Локаль берётся из Capacitor SharedPreferences (`CapacitorStorage.xml`, ключ `vojo.appLanguage`), fallback на device locale когда pref отсутствует (killed-cold-push до boot'а JS). Оба источника clamp'ятся в `en|ru` (anything else → `en`, English как lingua franca для user'а с device locale вне этого набора; i18next `fallbackLng` переведён на `'en'` в эту же сессию для consistency между поверхностями). [pushLanguageBridge.ts](../../src/app/utils/pushLanguageBridge.ts) мирроит `i18n.on('languageChanged')` на ОБЕ поверхности: `@capacitor/preferences` для Java + `postMessage({type:'setLanguage', lang})` для SW. Монтируется в [App.tsx](../../src/app/pages/App.tsx#L29).
  - `showSystemNotification` в [VojoFirebaseMessagingService.java](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java) — invite-ветка (детект `type=m.room.member` + `content_membership=invite`): title = `PushStrings.inviteTitle(ctx)`, body = 1 из 4 `R.string.push_invite_body_*` вариантов (`invite_body` / `_no_room` / `_no_inviter` / `_generic`) по `(hasInviter × hasRoom)`. Inviter = `sender_display_name` → MXID local-part (strip `@alice:hs.tld` → `alice`) → пусто. Всегда передаём оба positional arg'а (даже unused), иначе Android's positional formatter throws на `%2$s` без 2-го аргумента.
- **Landed fix (web):**
  - [src/sw.ts](../../src/sw.ts) — runtime loader `loadBundle(lang)` делает `fetch(new URL('public/locales/{lang}.json', self.registration.scope))` (scope-relative на случай если Vite `base` станет не-root), кэширует parsed Push namespace в модульной `bundleCache`. Failures НЕ кэшируются — на transient 5xx следующий push попробует снова. `pushFallback()` теперь async, достаёт строки из bundle или из встроенного `HARDCODED` safety-net (8 коротких строк per lang) если bundle unavailable.
  - SW language bridge через postMessage `{type:'setLanguage', lang}` + IndexedDB (`vojo-sw-meta` db, `kv` store, key `language`). Main thread постит на каждом `languageChanged` + re-posts через `sw.ready` и `controllerchange` (first-install race: controller ещё null когда bridge инициализируется). SW кэширует в модульной `swLanguage` + персистит в IDB для cold-wake. Fallback chain: `swLanguage` → IDB → `navigator.language` (normalized) → `'en'`.
  - При `!evRes.ok` (404 для invitee) — `fetchInviteFromNotifications` → `GET /_matrix/client/v3/notifications?limit=30`: находит entry по `event_id`, извлекает inviter из `event.unsigned.invite_room_state` (m.room.member с `sender == event.sender`, fallback MXID local-part) и room name (m.room.name оттуда же). Одна retry на 400ms против Synapse-index лага. Fallback-of-fallback: если /notifications 5xx / offline / entry absent → generic брендовый notif.
  - Wording семантика (both surfaces): title = «Приглашение»/«Invitation» всегда (WhatsApp/Telegram convention — маркер категории в шейде), inviter + room в body, 4 варианта.
- **Что НЕ сделано (известные gap'ы):**
  - **`!isInForeground` гейт для invite path сохранён** (показываем native только в background). Architect предлагал снять для invite'ов (WhatsApp-like: баннер даже при открытом приложении). Отдельное product-решение, не часть text fix.
  - **Channel names («Messages», «Incoming calls») в Java захардкожены** и не локализованы — видны юзеру в Settings → Notifications → Channels. Мелкая косметика, отдельный тикет.
  - ~~**Drift detection для generated push_strings.xml.**~~ Закрыто §5.49 — XMLs теперь Gradle-generated, не коммитятся, drift невозможен by construction.
- **Diagnostic note:** сначала думали что Android push не приходит вообще — user'у показалось. Живой logcat показал что push доходит, просто текст неправильный (§5.27 investigation 2026-04-24).

### 5.28. 🔴 Edge: OS-уведомления о звонках не приходят
- **Симптом:** на Chrome call push работает, на Edge — Push Messaging пуст или баннер не показывается.
- **Корень:** Edge → WNS вместо FCM; известны нестабильности (410 Gone subscriptions, PWA install state).
- **Направление:** воспроизвести на Edge DevTools + Sygnal логи, re-subscribe retry на `pushsubscriptionchange`, тех-документ как снять проблему в Edge.
- **До фикса** — рекомендовать Chrome.

### 5.29. 🟡 In-tab звук уведомлений срабатывает через раз после 2.5.2-пивота
- **Симптом:** после «SW-only OS-нотификации» пивота звук в открытой вкладке играет неконсистентно.
- **Гипотезы:** autoplay policy gating, unreadCacheRef dedup, гонка SW push × navigation.
- **Направление:** `console.debug` по пути `handleTimelineEvent`, убрать dedup cache для sound path.
- **Когда чинить:** Phase 3.

### 5.30. 🟢 ADR 2.5 v1/v2 — CallStyle подавлялся AOSP — закрыто v3

- Закрыто 2026-04-19 добавлением `setFullScreenIntent(launchPI, true)` + `USE_FULL_SCREEN_INTENT` в манифест. Детали ADR — в истории коммитов.

### 5.31. 🟡 `setBypassDnd(true)` не работает без `ACCESS_NOTIFICATION_POLICY`
- Без permission Android тихо игнорит флаг. На устройствах с DND / Samsung Sleep Mode call заглушен.
- Permission «special» — требует user grant через Settings deep-link.
- **Когда чинить:** Phase 3, или раньше если жалобы на тишину.

### 5.32. 🟢 `vojo_messages` channel терялся на fresh install — закрыто

- Закрыто 2026-04-19 добавлением `ensureMessageChannel()` в Java FCM-сервис (зеркально `ensureCallChannel`).

### 5.33. 🟢 Decline из CallStyle foregrounding'ил app — закрыто через §5.35.

### 5.34. 🟠 Lockscreen Answer/Decline buttons на Samsung OneUI — abandoned

- **Симптом:** на locked screen Samsung OneUI Android 14 показывает CallStyle стабом без кнопок. Tap notification → unlock → MainActivity. Пользователь не может быстро среагировать не разлочив.
- **Попытки фикса:**
  - Full native `IncomingCallActivity` (first cut 2026-04-20) — отклонён: WebRTC всё равно в WebView → Answer требует unlock, UX не лучше.
  - FGS `foregroundServiceType="phoneCall"` (2026-04-21) — отклонён: на API 34+ требует permission `MANAGE_OWN_CALLS` или dialer role, иначе `startForeground(PHONE_CALL_TYPE)` бросает SecurityException. Мы не диалер, полноценная регистрация как ConnectionService — недели работы + Play-review риск.
- **Текущий компромисс:** живём с §5.35 (Decline работает без unlock через broadcast receiver, но кнопка видна только после unlock). Decline-на-локскрине остаётся UX-проблемой на Samsung.
- **Пути вперёд (не MVP):**
  - ConnectionService + TelecomManager регистрация → полноценный «диалерный» путь.
  - Попробовать `foregroundServiceType="mediaPlayback"` или `"shortService"` — не требуют спец-прав, но вероятно Samsung не триггерит lockscreen-кнопки на них.
- **Когда чинить:** не MVP. Ждём продуктового решения «становимся серьёзным звонилкой или нет».

### 5.35. 🟢 MVP Decline patch — landed 2026-04-20

- Decline тап из CallStyle уходит через `CallDeclineReceiver` (BroadcastReceiver) → Matrix PUT напрямую из Java по stored access_token → `nm.cancel`. MainActivity не бутится, ни флэша ни unlock-требования.
- Ключевые файлы: [CallDeclineReceiver.java](../../android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java), [sessionBridge.ts](../../src/app/utils/sessionBridge.ts), [usePendingDeclinesFlusher.ts](../../src/app/hooks/usePendingDeclinesFlusher.ts), [VojoFirebaseMessagingService.java](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java).
- Восстановление при network/процесс-фейлах: tombstone `vojo.pendingDeclines.{notifEventId}` в prefs, flusher на `App.resume` добирает.
- **Известный компромисс:** receiver и flusher генерят разные txnId → при split-success-fail в timeline может сесть два `m.call.decline` (cosmetic, caller-side auto-hangup идемпотентен).
- **Что НЕ закрыто:** lockscreen buttons visibility (§5.34), Answer без WebView (принципиально), full-screen ringing UI.

### 5.36. 🟢 Multi-call UX & call state invariants — landed 2026-04-22

- Enforce'ится **ровно один** активный call embed: новый outgoing / Answer при активном звонке идут через общий switch-flow.
- Несколько входящих ring'ов одновременно разрешены: `useIncomingRtcNotifications` больше не режет их по `inCallRef`.
- Switch ждёт clean handoff старого звонка; blind `dispose()` больше не используется как основной барьер.
- Закрыто параллельно: §5.10, §5.11, §5.16, §5.21-5.22, §5.23.
- Android incoming-call UX теперь split по state:
  - foreground → только in-app strip
  - background / killed → native `CallStyle`
  - если app вернулся foreground во время ещё живого ring, JS dismiss'ит старый `CallStyle`, как только берёт ring в `incomingCallsAtom`

### 5.37. 🟡 Native foreground cold-start window
- **Симптом:** MainActivity уже `foreground`, поэтому Java suppress'ит `CallStyle`, но Matrix client / timeline listener ещё не готовы и in-app strip поднимается только через 1-3с или не успевает до expiry.
- **Контекст:** это не обычный "app already open" case, а handoff между native startup и ещё не прогретым JS bridge.
- **Фикс-направление:** gate не только по `isInForeground`, а по "JS incoming-call UX ready" (bridge JS → Java, fallback на `CallStyle` пока JS ни разу не отрапортовал readiness).
- **Когда чинить:** если live-тесты покажут, что окно воспроизводится достаточно часто, чтобы мешать MVP.

### 5.38. 🟡 Foreground strip latency зависит от /sync
- **Симптом:** когда app уже foregrounded, incoming strip появляется не по FCM, а после прихода `m.rtc.notification` через timeline / decrypted path; на плохой сети это может быть заметно позже push-triggered `CallStyle`.
- **Контекст:** текущая архитектура сознательно избегает второго JS delivery path через `pushNotificationReceived`, чтобы не плодить dedup и race'ы с timeline.
- **Фикс-направление:** отдельный fast-path из foreground push listener в `incomingCallsAtom` с жёстким dedup по `notifEventId` / room.
- **Когда чинить:** post-MVP polish, только если живая задержка реально бьёт по UX.

### 5.39. 🟢 Double ring UX при быстром backgrounded state — landed 2026-04-23
- **Landed fix:** в [IncomingCallStripRenderer.tsx](../../src/app/pages/IncomingCallStripRenderer.tsx) добавлен `App.appStateChange` subscription, JS `audio.play()` гейтится на `hasIncoming && appActive`. Listener регистрируется первым, `sawAppStateEvent` флаг блокирует stale `App.getState()`-снапшот от затирания свежего event (race-guard). Initial state — `document.visibilityState === 'visible'` (синхронный корректный снапшот и на web, и в Capacitor WebView). Визуал / атом / native не тронуты.
- **Симптом (pre-fix):** открыть Vojo, свернуть (Home), в течение 5 секунд получить incoming call → одновременно играют **и** native `CallStyle` ringtone + vibration (через `vojo_calls_v2` channel), **и** JS ring.ogg (через WebView audio element в `IncomingCallStripRenderer`). Если app полностью убит — только native (корректно). Если app foregrounded — только JS (корректно). Окно "just backgrounded" — двойной surface.
- **Корень:** архитектурный инвариант "foreground → JS strip, background → native CallStyle" (см. [usePushNotifications.ts:449-456](../../src/app/hooks/usePushNotifications.ts#L449-L456)) enforced только на native стороне — `VojoFirebaseMessagingService` проверяет `MainActivity.isInForeground`. JS не проверяет. `IncomingCallStripRenderer` запускает `audio.play()` безусловно при любом добавлении в `incomingCallsAtom`, включая те, что прилетают через /sync пока WebView жив но app свёрнут.
- **Визуальный strip и так невидим** в backgrounded состоянии (WebView off-screen), так что "второй banner" — это native, а не JS. Реальная проблема — **два аудиоканала** и vibration от native.
- **Направление фикса:** в [IncomingCallStripRenderer.tsx:35-47](../../src/app/pages/IncomingCallStripRenderer.tsx#L35-L47) добавить `App.isActive` subscription через Capacitor App, гейтить `audio.play()` на `hasIncoming && appActive`. Pause при background. Визуал и так недоступен, `useDismissNativeCallNotifications` при возврате app в foreground продолжит работать как handoff.
- **Размер:** ~10 строк, один useEffect. Не трогает native.
- **Когда чинить:** отдельный commit/PR. Не блокирует Phase 1 retention.
- **После приземления §5.39 становится видим §5.42** — тот же "just backgrounded" window, но уже не double-ring, а **silent ring**: native dismiss'ится JS-side как только /sync донесёт событие, а JS-audio по §5.39 в background молчит.

### 5.40. 🟡 Vendor aggressive battery saver взаимодействие с `microphone` FGS
- **Статус:** не тестировалось. Samsung One UI с default battery settings — FGS живёт (§2.2 confirmed). При включении Samsung "Put app to sleep" / Device Care / Xiaomi MIUI autostart deny / EMUI app launch restrictions — поведение неизвестно.
- **Риск:** на некоторых OEM legitimate FGS убивается или не стартует без explicit user-level battery-optimization whitelist.
- **Митигация-направление:** runtime prompt через `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (standard Android intent для deep-link в Settings), показывать один раз после первой попытки звонка или с onboarding. Vendor-specific API (Samsung Knox whitelist, MIUI autostart) — отдельный эпик.
- **Как проверять:** тест на Samsung с включённым "Put app to sleep" для Vojo, тест на Xiaomi без autostart grant. Воспроизвести Phase 0 T1-сценарий, смотреть в logcat `CallFgs` таги + `ActivityManager killing` события.
- **Когда чинить:** если жалобы живьём или при попадании в vendor-lab testing.

### 5.41. 🟡 Answer-from-killed state retention gap
- **Симптом:** app killed → incoming FCM → CallStyle native → user tap Answer → MainActivity boots → WebView loads → widget `JoinCall` → FGS запускается. Если user блокирует экран между tap-Answer и `JoinCall` (~1-3s окно), FGS запуск из background триггерит `BackgroundServiceStartNotAllowedException` на API 34+.
- **Корень:** Phase 1 lifecycle gate (`joined`, см. [useAndroidCallForegroundSync.ts](../../src/app/hooks/useAndroidCallForegroundSync.ts)) требует foreground старт. Answer-из-killed идёт через foreground MainActivity, но окно от boot до JoinCall не защищено.
- **Текущее поведение:** если user успевает заблокировать до JoinCall — звонок рвётся как Phase 0 baseline.
- **Направление фикса:** либо (а) стартовать FGS из MainActivity.onCreate когда intent содержит `call_action=answer` (предрасположенный старт до WebView load), либо (б) Core-Telecom self-managed path который имеет exempt от background-start restrictions.
- **Когда чинить:** Phase 3 (Telecom UX upgrade) или раньше если жалобы живьём. В MVP явно вне scope.

### 5.42. 🟢 Preждевременный native `CallStyle` dismiss в background — silent ring — landed 2026-04-23
- **Landed fix:** в [useDismissNativeCallNotifications.ts](../../src/app/hooks/useDismissNativeCallNotifications.ts) diff разделён на `added` / `removed`; `removed` дисмиссится всегда, `added` — только при `appActive=true`. Добавлена `App.appStateChange` subscription с race-guard'ом (`sawAppStateEvent` блокирует stale `getState()` от затирания свежего event), initial — `document.visibilityState === 'visible'`. Отдельный handoff-effect: при `appActive: false→true` с непустым `prevRoomsRef` — one-shot sweep всех currently-incoming rooms (закрывает "native raised в background → user вернулся → JS owner → native must go"). Body дисмисса вынесен в `dismissRooms()` helper. Атом / native / FCM listener не тронуты. Инвариант "foreground → JS strip, background → platform surface" теперь enforced и на dismiss-стороне.
- **Residual gap:** см. §5.44 — `App.appStateChange` переключается по BridgeActivity.onStop, а `MainActivity.isInForeground` по onPause → narrow window где Java и JS видят lifecycle по-разному и §5.42 снова уязвим в "только что свернули" интервале. Закрывает главный сценарий (background >секунд), но не onPause→onStop edge.
- **Симптом:** app свёрнут (screen on/off, процесс жив), приходит DM ring → native `CallStyle` поднимается корректно + начинает звенеть через channel ringtone → через ~0.1-1s пропадает и ringtone обрывается. Phone молчит до повторного ring / expiry. После приземления §5.39 — ещё и JS-audio в background gated off, так что user остаётся полностью без сигнала.
- **Корень:** [useDismissNativeCallNotifications.ts:29-66](../../src/app/hooks/useDismissNativeCallNotifications.ts#L29-L66) dismiss'ит native `CallStyle` на **любом** изменении `incomingCallsAtom`, без различения `ADD` vs `REMOVE` и без gate на `App.isActive`. Инвариант "foreground → JS strip, background → platform surface" enforced для native show path ([VojoFirebaseMessagingService.java:71-82](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java#L71-L82)) и для JS-audio (после §5.39), но **не для dismiss path**. Комментарий хука ("OR newly takes ownership of one after the app returns foreground") описывает *желаемое* поведение — реализация шире.
- **Trace сценария:**
  1. MainActivity.onPause → `isInForeground=false`.
  2. FCM долетает в `VojoFirebaseMessagingService.onMessageReceived` → routed в call-branch → `showIncomingCallNotification` → native CallStyle + channel ringtone.
  3. /sync (long-poll в живом но backgrounded WebView) доставляет тот же `m.rtc.notification` → `useIncomingRtcNotifications.processEvent` → `setIncoming({type: 'ADD', ...})`.
  4. `useDismissNativeCallNotifications` effect fires на изменение атома → `PushNotifications.removeDeliveredNotifications({ tag: "call_<roomId>", id })` → native CallStyle cancelled, ringtone обрывается.
  5. §5.39 gate в `IncomingCallStripRenderer` не играет JS-audio (`appActive=false`) → полная тишина.
- **Почему раньше было менее заметно:** до §5.39 fix JS-audio играл безусловно → даже если native dismiss'ился, в ушах хотя бы оставался JS `ring.ogg`. Это маскировало dismiss-side зазор (и само порождало §5.39). После §5.39 маска снята, баг выходит на передний план в тот же window.
- **Влияет только на "WebView жив и /sync долетает" state:** app killed → WebView не запущен → атом не меняется → native звенит нормально до user action / expiry. App foreground → native вообще не показан (foreground skip в сервисе) → нечего dismiss'ить. Зазор именно в "паузнутый но живой" состоянии (обычно первые десятки секунд после Home до того как Android начнёт aggressive suspend'ить WebView).
- **Направление фикса:**
  1. В [useDismissNativeCallNotifications.ts](../../src/app/hooks/useDismissNativeCallNotifications.ts) разделить `addedRooms` и `removedRooms` (уже есть diff, только не разводятся по разным bucket'ам).
     - `removedRooms` → dismiss'ить всегда (ring ended by accept/decline/expiry/other-device — stale native должен уйти).
     - `addedRooms` → dismiss'ить только при `App.isActive === true` (foreground → JS взял ownership; background → native surface остаётся primary).
  2. Добавить подписку на `App.appStateChange` в том же хуке. На transition `isActive: false → true` с непустым атомом — one-shot dismiss sweep для всех rooms в атоме (закрывает handoff: "ADD случился в background → native остался → user вернулся в foreground → теперь JS владеет → пора убрать native").
  3. Race с initial `getState()` — применять тот же паттерн что в §5.39 fix ([IncomingCallStripRenderer.tsx](../../src/app/pages/IncomingCallStripRenderer.tsx)): listener регистрируется первым, `sawAppStateEvent` флаг блокирует stale `getState()`-снапшот от затирания свежего event.
- **Размер:** ~30-40 строк, один хук. Native не трогать. Атом не трогать.
- **Проверочные сценарии (после фикса):**
  1. App foreground → incoming → atom ADD → native не показывался (foreground skip) → dismiss no-op → JS strip + JS audio. **Как сейчас.**
  2. App уже > ~30s в background → incoming → native CallStyle + channel ringtone → /sync ADDs атом → dismiss НЕ срабатывает (`appActive=false`) → native звенит дальше. **Фикс: было silent, стало native-ring.**
  3. App в background → incoming → native звенит → user возвращает app в foreground → `appActive: false→true` → one-shot dismiss sweep → native уходит → JS strip visible + JS audio (§5.39) начинает играть. **Чистый handoff, не double-ring.**
  4. App foreground → user accepts/declines через strip → atom REMOVE → dismiss срабатывает безусловно. **Как сейчас.**
  5. App killed (процесс мёртв) → native CallStyle ringtone до answer/decline/expiry. Хук не задействован. **Как сейчас.**
- **Когда чинить:** следом за §5.39 коммитом, приоритет выше чем остаток §5.39-risks — это прямой user-visible regression (silent ring) в том же "just backgrounded" window, который §5.39 должен был починить.
- **Связь с §5.36:** bullet "если app вернулся foreground во время ещё живого ring, JS dismiss'ит старый CallStyle, как только берёт ring в `incomingCallsAtom`" описывает *intended* handoff. Текущая реализация fires этот dismiss и когда app НЕ возвращался — §5.42 чинит именно это расхождение.

### 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 не тронуты.
- **Изначальная гипотеза (§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 подтягивает оба события.
  3. handleTimeline notification → processEvent → notif-branch → pre-await `declinedTimers.has` === false → `await resolveCallId(...)` yields.
  4. Во время yield'а: handleTimeline decline → processEvent decline-branch → `rememberDeclined(rel.event_id)` → `declinedSizeAfter: 1`. `removeByNotifId` на этом моменте no-op (registry.set ещё не произошёл).
  5. resolveCallId возвращается → post-await re-check membership (clear) → **post-await re-check `declinedTimers.has(evId)` === true → return**. Atom НЕ получает ADD.
- **Симптом (pre-fix):** app killed/backgrounded, приходит DM-звонок → native `CallStyle` → user жмёт Decline на native-баннере → у звонящего отклоняется, но user открывает app и видит тот же звонок в in-app strip. Если нажать Accept — initiates outgoing call обратно caller'у (на session, которой уже нет).
- **Связь с §5.35:** receiver-path через cleartext raw HTTP — часть §5.35 design. Race возникал из-за async yield в resolveCallId, не из-за encrypt/cleartext asymmetry (обе события cleartext). Fix не меняет receiver design.
- **Не закрывает:** §5.44 (onPause→onStop lifecycle drift), §5.37 (foreground cold-start window).

### 5.44. 🟢 onPause→onStop lifecycle drift — landed 2026-04-23 (commit 649aea7)
- **Landed fix:** в обоих [useDismissNativeCallNotifications.ts](../../src/app/hooks/useDismissNativeCallNotifications.ts) и [IncomingCallStripRenderer.tsx](../../src/app/pages/IncomingCallStripRenderer.tsx) подписка на `App.appStateChange` заменена на пару `pause`+`resume` под `isAndroidPlatform()` branch'ем; iOS/web остался на `appStateChange`. `pause`/`resume` Capacitor App плагина fires в `BridgeActivity.onPause`/`onResume` через `AppPlugin.handleOnPause/Resume` — тот же Activity callback, что переключает `MainActivity.isInForeground`, — и доходит до JS через WebView bridge с латентностью ms. Race-guard переименован `sawAppStateEvent` → `sawLifecycleEvent`, остальная логика (initial `document.visibilityState`, `App.getState()` race-guarded fallback, handoff sweep на foreground) не тронута.
- **Live-измерение drift'а (test 2026-04-23):** на test-девайсе `onPause → onStop` 27-51ms (меньше предсказанных 100-1000ms, но реальный). `pause` event в JS приходит за 1-2ms после Java `onPause` — практически на том же edge'е.
- **iOS/web semantics intentionally preserved:** `pause`/`resume` на iOS bind to `didEnterBackground`/`willEnterForeground`, а `appStateChange` — to `willResignActive`/`didBecomeActive`. Для iOS `appStateChange` это правильный edge, не меняем.
- **Не закрывает:** §5.45 (FCM-foreground-skip race для уже живого ring'а — exposed test'ом §5.44, но это другой code path и другая природа).

- **Симптом (pre-fix):** в узком окне "app только что свернули" (Home нажат, onPause прошёл, onStop ещё не пришёл) баги §5.39 (double-ring) и §5.42 (silent-ring) снова частично живы, несмотря на landed fix'ы.
- **Корень:** lifecycle-asymmetry между Java-сторой и JS-сторой:
  - Java [MainActivity.java:28](../../android/app/src/main/java/chat/vojo/app/MainActivity.java#L28) — `isInForeground=false` ставится в `onPause()`.
  - Capacitor App plugin [BridgeActivity.java:113-123](../../node_modules/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java#L113-L123) — `fireStatusChange(false)` вызывается в **onStop**, не onPause. `appStateChange` event и `getState().isActive` сэмплируют именно этот edge.
  - Между onPause и onStop — десятки мс до секунд (зависит от девайса / анимаций / системной загрузки). В этом окне Java считает app backgrounded (FCM → native CallStyle), JS считает appActive=true (dismiss-ветка в useDismissNativeCallNotifications, audio-play в IncomingCallStripRenderer).
- **Trace (§5.42 residual):**
  1. User нажал Home → `onPause` → `isInForeground=false`, WebView ещё видимый для Capacitor.
  2. FCM ring → service видит `isInForeground=false` → native CallStyle.
  3. /sync в живом WebView → атом ADD.
  4. `useDismissNativeCallNotifications` видит `appActive=true` (onStop ещё не пришёл) → dismiss'ит native. Silent ring.
- **Trace (§5.39 residual):** тот же gap — audio.play() в IncomingCallStripRenderer на `hasIncoming && appActive=true`, играет double-ring вместе с native ringtone пока onStop не переключит.
- **Направление фикса:** варианты:
  1. **DOM `visibilitychange`:** в обоих хуках дополнительно слушать `document.addEventListener('visibilitychange', ...)` и считать `active = App.isActive && !document.hidden`. WebView обычно fires visibility hidden ближе к onPause (через `onWindowFocusChanged` / `onPause` → `WebView.onWindowVisibilityChanged`). Earliest edge выигрывает. Дёшево, но полагается на WebView behavior — требует верификации на реальном девайсе (Samsung / Xiaomi может отличаться).
  2. **Custom Capacitor plugin:** свой plugin с `@PluginMethod` + lifecycle hooks (`handleOnPause`, `handleOnResume`) шлёт JS event. Авторитетно, но overhead и требует aar build.
  3. **SharedPreferences bridge:** MainActivity пишет `vojo.isInForeground` в CapacitorStorage.xml, JS читает на каждом atom-change через Preferences plugin. Polling-heavy, плохой fit.
- **Приоритет (1)** — дёшево проверить. Если на test devices visibilitychange fires ближе к onPause → закрывает residual одной строкой.
- **Размер:** 5-10 строк в каждом из [useDismissNativeCallNotifications.ts](../../src/app/hooks/useDismissNativeCallNotifications.ts) и [IncomingCallStripRenderer.tsx](../../src/app/pages/IncomingCallStripRenderer.tsx).
- **Проверочные сценарии:**
  1. Home → <500ms → incoming → только native CallStyle, JS-audio молчит, native **не** дисмиссится. (Reproduce §5.39/§5.42 pre-fix в том же окне.)
  2. Home → 10s wait → incoming → как §5.42 test 2 (native звенит, JS не трогает).
  3. App возвращается в foreground → handoff как §5.42 test 3 (native уходит, JS strip+audio берут).
  4. Cold-boot foreground → incoming → JS strip+audio, native skipped. Как сейчас.
- **Когда чинить:** после §5.43. Оба §5.39 и §5.42 landed — это финальная полировка narrow-окна.

### 5.45. 🟢 Foreground-skip race — переезд с FCM-skip-cache на native ring registry — landed 2026-04-24

#### Симптом (pre-fix)
App foreground, incoming DM call. JS strip + audio начинают отрабатывать, через ~500ms user сворачивает app (Home) → JS audio гасится audio-gate'ом → native CallStyle **не появляется** (FCM irreversibly skipped в момент receipt'а как foreground-call) → полная тишина до повторного ring / expiry.

Корень — асимметрия двух контрактов:
- FCM receipt time ≠ user backgrounding time. FCM решает "foreground → JS owns UX, skip native" irreversibly. JS audio-gate reversible. Между ними нет foreground→background takeover.

#### Путь, которым шли (3 итерации)

**Первая попытка — foreground-skip cache.** Java-side `ConcurrentHashMap<roomId, CachedSkipRing>` хранит payload'ы которые FCM skip'нул в foreground. На `onPause` drain'ит cache → постит native. На `onResume` cancel + clear.

Через несколько раундов ревью закрыли серию подзадач: per-room dedupe с latest-wins eviction, `setOnlyAlertOnce(true)`, ownership-проверка через extras + `getActiveNotifications()`, `cachedAt` fallback для expiry, atomic registry+tombstone mutations через lock, forget-on-suppress для всех early-return paths JS-хука, CallDeclineReceiver / CallCancelReceiver cleanup, MessageId merge, BuildConfig.DEBUG log gate, web audio platform-gate, redaction-first симметрия, orphan sweep на onResume.

**Что заставило перейти на registry.** Подряд появлялись две тенденции:
1. Каждый новый suppress-case требовал explicit `forgetCachedSkippedRing` — scattered calls размножались (not-DM / expired / already-joined / decline-first / resolve-fail / redaction-first / hook teardown). Scaling linearly с количеством JS-branches.
2. Cache хранил **историю** ("какие FCM мы skip'нули"), а не **текущее live состояние**. Ownership encoding (`vojo_cache_event_id` extra + `getActiveNotifications`) появился именно чтобы отличать "наш" native от foreign — это хороший fix, но smell: мы трекаем ownership notification-slot'а поверх platform surface.

**Вторая попытка — native ring registry.** Переворот semantics: Java хранит current live rings (eventId-keyed), JS co-produces через happy-path upsert. Cleanup `useDismissNativeCallNotifications.ts` (-151 LOC) — native cancel теперь sole-ownership на Java registry.

#### Итоговая архитектура

- **`ringRegistry: ConcurrentHashMap<eventId, IncomingRing>`** + **`ringTombstones: ConcurrentHashMap<eventId, long>`** — оба под `registryLock` для atomic remove-wins + same-room eviction.
- **FCM fg → upsert only**; **FCM bg → upsert + render**. Race-guard: после put re-check `isInForeground` — если false, drain.
- **JS happy-path ADD → `upsertIncomingRing`** (idempotent с FCM, Java merges metadata append-only). **JS suppress paths + RTCDecline + redaction + removeByKey + sync-cleanup → `removeIncomingRing`**.
- **MainActivity.onPause** → 150ms render debounce → `renderRegistry` (iterate non-expired, render unrendered). **onResume** → 300ms cancel debounce → `cancelRenderedIncomingRings` (ownership-checked via `vojo_ring_event_id` extra + orphan sweep для process-kill recovery).
- **Latest-wins per-room** сравнивается по `content_sender_ts` (не last-observed): older incoming dropped + tombstoned, preserving newer ring in slot.
- **Re-alert cooldown:** `renderedAt` сбрасывается на cancel; `lastAlertedAt` preserved и сравнивается в renderOne → silent re-render (`builder.setSilent(true)`) в пределах 3s от последнего громкого поста. User всегда видит visual banner, без ringtone stutter на rapid toggle. После cooldown — fresh alert.
- **Lifecycle:** `CallDeclineReceiver` (native tap) и `CallCancelReceiver` (AlarmManager expiry) зовут `removeIncomingRing(ctx, evId)` — cleanup + tombstone + ownership-checked native cancel.
- **Tombstone TTL** = `2 × lifetime + grace` (per-event из `content_lifetime`). Покрывает FCM retry окно + sender-clock skew. Purge on upsert, remove, render.

#### Tradeoffs (почему остановились на registry, а не на альтернативах)

- **Vs cache-design:** registry моделирует "current live" вместо "FCM-skip history". Scaling: один `removeIncomingRing` в sync-cleanup effect + `removeByKey` + happy-path upsert покрывают atom-driven state. Scattered suppress-path calls остались (инвариант "FCM может seed'ить до JS", JS должен killing seed), но encoding чище — `atom REMOVE → bridge remove` линейно.
- **Vs Variant A (не skip'ать FCM в foreground):** самый простой diff, но каждый fg call даёт micro-flash native heads-up + OEM ringtone-stutter risk. Invariant "foreground → JS, background → platform" разрушен.
- **Vs Variant C (conditional JS audio gate):** JS не имеет native-state signal без bridge read — упирается в bridge той же формы, только с другой стороны.
- **Vs Telecom/ConnectionService:** long-term correct path для native call UX. Weeks of work + Play-review re-entry. Registry — middle ground между cache и Telecom.
- **Reviewer agent критика:** agent предупредил что `setSilent` при cooldown-skip render pattern ломается (live test подтвердил: user словил полное исчезновение native banner при rapid toggle, logged). Переработали cooldown: не skip render, render silent + `lastAlertedAt` preserved across cancel / `renderedAt` reset — user всегда видит banner, ringtone только при natural fg→bg edge за пределами cooldown.

#### Known residual gaps

- **FCM никогда не прилетает** (broken push infra, Sygnal без push rule для `m.call.decline`): ring приходит через /sync, но при killed-state remote decline не доходит до Java. Registry expire alarm (30s + grace) остаётся safety net. Не блокер, выделено в §5.46 (defer — Sygnal contract uncertain).
- **Cold-start resume:** MainActivity.onResume 300ms debounce cancels native. Если Matrix client hydration > 300ms (typical 1-3s), user briefly видит пустой UI пока atom не populated. tap-native с `call_action` обрабатывается `pendingCallActionConsumer` независимо от atom state, так что это только при generic app launch при live ring.
- **Same-room eviction требует обе стороны знать `content_sender_ts`.** Fallback на last-observed wins если хотя бы одна сторона не имеет ts.

#### Файлы (финальная конфигурация)

- `VojoFirebaseMessagingService.java` — `IncomingRing`, `ringRegistry`, `ringTombstones`, `registryLock`, `upsertIncomingRing`, `removeIncomingRing(Context, String)`, `renderRegistry`, `cancelRenderedIncomingRings`, `postIncomingCallNotification` (silent-aware).
- `MainActivity.java` — lifecycle debounces (render 150ms, cancel 300ms), handler cleanup on onDestroy.
- `CallForegroundPlugin.java` — `@PluginMethod upsertIncomingRing` / `removeIncomingRing`.
- `CallDeclineReceiver.java` / `CallCancelReceiver.java` — registry cleanup chain.
- `useIncomingRtcNotifications.ts` — happy-path upsert, suppress removes, latest-wins по senderTs, redaction-first симметрия, teardown sync.
- `callForegroundService.ts` — JS bridge wrappers.
- `IncomingCallStripRenderer.tsx` — web audio platform-gate.
- **Deleted:** `useDismissNativeCallNotifications.ts` — dual dismiss plane eliminated.
- `android/app/build.gradle` — `buildFeatures.buildConfig = true` для release log gating.

#### Deferred follow-ups (отдельные тикеты)
- FCM decline event handling (зависит от Sygnal push_rule config).
- Rename hook-local `registryRef` → `ringHandlesRef` чтобы не коллизиться с Java `ringRegistry`.
- Split `CallForegroundPlugin` (FGS retention + ring registry invalidation — orthogonal concerns).
- Unit tests для registry SM transitions (tombstone-rejects-upsert, latest-wins-by-senderTs, merge append-only, cooldown behavior).

### 5.46. 🟡 FCM не push'ит remote decline/hangup

- **Что:** default Sygnal push_rules не содержит правила для `org.matrix.msc4310.rtc.decline` и для membership state changes (other-device answer, caller hangup). Если app killed, remote decline не достигает Java registry через FCM. Ring висит в native CallStyle до AlarmManager expiry (30s + grace).
- **Сейчас работает:** после resume /sync доставит decline → JS `RTCDecline` → bridge `removeIncomingRing` → ownership cancel. Killed-state gap — только window до user next resume или до expiry.
- **Фикс-направления:** server-side push_rule + user account_data config; или client-side "watchdog" polling через SW. Обе опции вне MVP scope.
- **Когда чинить:** если пользователи жалуются на "ring висит после того как caller положил трубку у себя".

### 5.47. 🟡 Widget-api handler ack contract — выявлено ревью §5.21

- **Что:** наш `useClientWidgetApiEvent` в `src/app/plugins/call/hooks.ts:10-20` вызывает callback без `ev.preventDefault()` + `this.widgetApi!.transport.reply(ev.detail, {})`. Element-web ack'ает в `Call.ts:979-998` для Hangup/Close/DeviceMute.
- **Симптом:** PR #2680 commit'е Krishan упоминал "widget spam-hangups". Без ack EC может retrying action'ы в iframe'е. В прод нежалостно не наблюдали, но contract дырявый.
- **Корень:** pre-existing с PR #2680. Наш §5.21 Close listener наследует ту же дыру.
- **Фикс:** refactor `useClientWidgetApiEvent` signature чтобы callback получал `CustomEvent<IWidgetApiRequest>` → вызывать preventDefault + transport.reply.
- **Когда чинить:** scope-separate от §5.21, отдельный PR. Желательно с live-test на EC upgrade где spam-hangup может ломаться.

### 5.48. 🟡 StopMessaging widget death listener — element-web parity

- **Что:** element-web в `Call.ts:191,324-330` слушает `WidgetMessagingStoreEvent.StopMessaging` — "widget died without action". Fallback на случай когда iframe вообще не emit'ит ни Hangup ни Close. У нас этого listener'а нет.
- **Симптом (теоретический):** если iframe полностью умер (crash сценарий, не тривиальный LiveKit reconnect fail), у нас fallback только через user-initiated hangup timeout (§5.21 landed 8с). Без user action — zombie persists.
- **Фикс:** подписаться на аналогичный widget-api stop signal, trigger clearIfCurrent. У нас нет matrix-widget-api `WidgetMessagingStore` абстракции — нужно понять как detect "widget died" в нашем стеке.
- **Когда чинить:** если §5.21 residual gap (EC silent) начинает воспроизводиться в прод. Пока — speculative ticket.

### 5.49. 🟢 Gradle-generated push_strings.xml через AGP variant.sources.res — landed 2026-04-25

- **Что:** перевели генерацию Android push-resource'ов из commit'ящегося артефакта в Gradle-generated resource directory через AGP API `addGeneratedSourceDirectory`. Generated push strings живут в `build/generated/res/push/<variant>/values{,-ru}/push_strings.xml`, не коммитятся.
- **Что landed:**
  - `scripts/gen-push-strings.mjs` принимает `--out <dir>` CLI arg; дефолт — прежнее поведение для manual debug.
  - `android/app/build.gradle`: `GeneratePushStringsTask` (abstract DefaultTask) + `androidComponents { onVariants }` wiring. `inputs.files` = JSON bundles + скрипт → Gradle up-to-date check работает.
  - Committed XMLs удалены из `src/main/res/values{,-ru}/`, добавлены в `.gitignore`.
  - `package.json`: `android:sync` упрощён до `npx cap sync android` (Gradle — owner генерации).
  - `docs/ai/android.md` — секция «Push string resources (generated)» с note про macOS AS + nvm.
- **Verified:** `./gradlew clean :app:assembleDebug` — build successful, XMLs в `build/generated/res/push/debug/`, повторный build → `UP-TO-DATE`.
- **Capacitor sync risk:** confirmed safe — `cap sync` регенерит только `capacitor.build.gradle`, не трогает `build.gradle`.
- **Связь:** follow-up §5.27 (landed 2026-04-24).

### 5.50. 🟡 LeaveRoomPrompt / LeaveSpacePrompt prematurely report success

- **Что:** [LeaveRoomPrompt.tsx](../../src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx) и [LeaveSpacePrompt.tsx](../../src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx) вызывают `mx.leave(roomId)` внутри `async` callback без `await`/`return`. `useAsyncCallback` ждёт callback, а не внутренний network promise, поэтому состояние становится `Success` сразу после запуска запроса.
- **Симптом:** prompt закрывается через `onDone()` до ответа homeserver. Если `POST /rooms/{roomId}/leave` падает, ошибка не попадает в `leaveState`, `Failed to leave room/space` не показывается, возможен unhandled promise rejection, user остаётся в комнате после refresh/sync.
- **Доказательство:** `matrix-js-sdk` `leave(roomId)` возвращает `Promise<EmptyObject>` и внутри делает `authedRequest(POST /rooms/$room_id/leave)`. Текущий callback возвращает `Promise<undefined>`, который resolve'ится сразу.
- **Live reproduce:**
  1. `npm run dev`, залогиниться и открыть joined room.
  2. DevTools → Network throttling → `Offline` (или block request URL `*rooms/*/leave*`).
  3. Room menu → `Leave Room` → confirm.
  4. Фактический pre-fix результат: prompt закрывается как success, error text не появляется; в Network `POST .../rooms/{roomId}/leave` failed/blocked; после возврата online + refresh/sync комната остаётся.
  5. Ожидаемый результат после фикса: prompt остаётся loading до ответа, затем показывает localized error.
- **Фикс-направление:** `await mx.leave(roomId);` в обоих prompt'ах. Не `return mx.leave(roomId)`, если сохраняем `useAsyncCallback<undefined, MatrixError, []>` 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=<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, отдельным планом.
