vojo/docs/plans/dm_calls_techdebt.md

592 lines
81 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, а не только до.
- **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.395.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. Между ними нет foregroundbackground 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 fgbg 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'а.