# 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//`; 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_", 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` с 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.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` хранит 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`** + **`ringTombstones: ConcurrentHashMap`** — оба под `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` → вызывать 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//values{,-ru}/push_strings.xml`, не коммитятся. - **Что landed:** - `scripts/gen-push-strings.mjs` принимает `--out ` 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` и внутри делает `authedRequest(POST /rooms/$room_id/leave)`. Текущий callback возвращает `Promise`, который 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` contract. - **Scope:** не связано с i18n-диффом; pre-existing bug, найден при ревью локализации leave prompt'а.