89 KiB
DM-звонки — техдолг и замечания по реализации
Живой документ. Дополнение к dm_calls.md. Здесь — регистр открытых багов, отложенных тестов и polish-задач. Имплементационные детали уходят в код / коммиты, сюда пишем только: симптом, влияние, направление фикса, когда возвращаться.
Принцип: каждый пункт имеет статус (🟢/🟡/🔴), краткое описание, и где вернуться (фаза/приоритет).
Содержание
- 1. Phase 1 — статус готовности
- 2. Активные баги и наблюдения
- 3. Отложенные тесты
- 4. Polish backlog
- 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 — критерии мержа
- Живые сценарии 1.1
- 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:
- AppOps revocation of
android:record_audio(Android 12+ while-in-use gating). В ~T+5s после lock ОС переводит op вActive: false. Мик-track в WebRTC становится muted/ended — peer перестаёт нас слышать. - 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-telecomself-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_audioAppOps в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). Причина:
- К моменту
JoinCallElement 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, CallForegroundPlugin.java, callForegroundService.ts, useAndroidCallForegroundSync.ts.
- Изменённые: AndroidManifest.xml (permissions + service declaration), MainActivity.java (plugin register), CallEmbedProvider.tsx (hook mount + preparingError), 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, 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 и 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). Нужно подтвердить, чтоmicrophoneFGS лифтит те же 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.directDM → резолв партнёра может дать 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. - Решается вместе с §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
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/keydownlistener → dummyaudio.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.closelistener вCallEmbedProvider.tsx(mirror element-webCall.ts:901,995-999) — EC error-state fallback auto-clears atom, даже если widget не emit Hangup.- Hangup timeout 8с в
CallControl.tsx(mirror element-webperformDisconnectionTIMEOUT_MS=16s, у нас 8s под mobile UX и matchesPEER_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 тоже не делает), не добавляем
StopMessaginglistener — в §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-effortprev.hangup()+ directprev.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 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
- Симптом: вкладки закрыты → SW без access_token →
fetchEventDetailsне запускается → показывается "New message" вместо "Incoming call". - Почему не чиним сейчас: dm_calls.md:540-547 явно декларирует 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 делал
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звал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/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 читает 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-процесс;JSONObjectparse на этом хот-path добавил бы новый класс «uncaught → missed push» failures. aapt валидация + Android Studio tools сохранены. - 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 вне этого набора; i18nextfallbackLngпереведён на'en'в эту же сессию для consistency между поверхностями). pushLanguageBridge.ts мирроитi18n.on('languageChanged')на ОБЕ поверхности:@capacitor/preferencesдля Java +postMessage({type:'setLanguage', lang})для SW. Монтируется в App.tsx. showSystemNotificationв VojoFirebaseMessagingService.java — invite-ветка (детектtype=m.room.member+content_membership=invite): title =PushStrings.inviteTitle(ctx), body = 1 из 4R.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-го аргумента.
- Build-time emitter scripts/gen-push-strings.mjs читает Push namespace → конвертит
- Landed fix (web):
- src/sw.ts — runtime loader
loadBundle(lang)делаетfetch(new URL('public/locales/{lang}.json', self.registration.scope))(scope-relative на случай если Vitebaseстанет не-root), кэширует parsed Push namespace в модульнойbundleCache. Failures НЕ кэшируются — на transient 5xx следующий push попробует снова.pushFallback()теперь async, достаёт строки из bundle или из встроенногоHARDCODEDsafety-net (8 коротких строк per lang) если bundle unavailable. - SW language bridge через postMessage
{type:'setLanguage', lang}+ IndexedDB (vojo-sw-metadb,kvstore, keylanguage). 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 варианта.
- src/sw.ts — runtime loader
- Что НЕ сделано (известные 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+ требует permissionMANAGE_OWN_CALLSили dialer role, иначеstartForeground(PHONE_CALL_TYPE)бросает SecurityException. Мы не диалер, полноценная регистрация как ConnectionService — недели работы + Play-review риск.
- Full native
- Текущий компромисс: живём с §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, sessionBridge.ts, usePendingDeclinesFlusher.ts, 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-triggeredCallStyle. - Контекст: текущая архитектура сознательно избегает второго 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 добавлен
App.appStateChangesubscription, JSaudio.play()гейтится наhasIncoming && appActive. Listener регистрируется первым,sawAppStateEventфлаг блокирует staleApp.getState()-снапшот от затирания свежего event (race-guard). Initial state —document.visibilityState === 'visible'(синхронный корректный снапшот и на web, и в Capacitor WebView). Визуал / атом / native не тронуты. - Симптом (pre-fix): открыть Vojo, свернуть (Home), в течение 5 секунд получить incoming call → одновременно играют и native
CallStyleringtone + vibration (черезvojo_calls_v2channel), и 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) 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 добавить
App.isActivesubscription через 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) требует 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 diff разделён на
added/removed;removedдисмиссится всегда,added— только приappActive=true. ДобавленаApp.appStateChangesubscription с race-guard'ом (sawAppStateEventблокирует stalegetState()от затирания свежего 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 dismiss'ит native
CallStyleна любом измененииincomingCallsAtom, без различенияADDvsREMOVEи без gate наApp.isActive. Инвариант "foreground → JS strip, background → platform surface" enforced для native show path (VojoFirebaseMessagingService.java:71-82) и для JS-audio (после §5.39), но не для dismiss path. Комментарий хука ("OR newly takes ownership of one after the app returns foreground") описывает желаемое поведение — реализация шире. - Trace сценария:
- MainActivity.onPause →
isInForeground=false. - FCM долетает в
VojoFirebaseMessagingService.onMessageReceived→ routed в call-branch →showIncomingCallNotification→ native CallStyle + channel ringtone. - /sync (long-poll в живом но backgrounded WebView) доставляет тот же
m.rtc.notification→useIncomingRtcNotifications.processEvent→setIncoming({type: 'ADD', ...}). useDismissNativeCallNotificationseffect fires на изменение атома →PushNotifications.removeDeliveredNotifications({ tag: "call_<roomId>", id })→ native CallStyle cancelled, ringtone обрывается.- §5.39 gate в
IncomingCallStripRendererне играет JS-audio (appActive=false) → полная тишина.
- MainActivity.onPause →
- Почему раньше было менее заметно: до §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).
- Направление фикса:
- В 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).
- Добавить подписку на
App.appStateChangeв том же хуке. На transitionisActive: false → trueс непустым атомом — one-shot dismiss sweep для всех rooms в атоме (закрывает handoff: "ADD случился в background → native остался → user вернулся в foreground → теперь JS владеет → пора убрать native"). - Race с initial
getState()— применять тот же паттерн что в §5.39 fix (IncomingCallStripRenderer.tsx): listener регистрируется первым,sawAppStateEventфлаг блокирует stalegetState()-снапшот от затирания свежего event.
- В useDismissNativeCallNotifications.ts разделить
- Размер: ~30-40 строк, один хук. Native не трогать. Атом не трогать.
- Проверочные сценарии (после фикса):
- App foreground → incoming → atom ADD → native не показывался (foreground skip) → dismiss no-op → JS strip + JS audio. Как сейчас.
- App уже > ~30s в background → incoming → native CallStyle + channel ringtone → /sync ADDs атом → dismiss НЕ срабатывает (
appActive=false) → native звенит дальше. Фикс: было silent, стало native-ring. - 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. - App foreground → user accepts/declines через strip → atom REMOVE → dismiss срабатывает безусловно. Как сейчас.
- 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 заведён
declinedTimersRef: Map<notifEventId, timeout>с self-expiring 30s entries.rememberDeclined()helper в decline-branch записываетrel.event_idпередremoveByNotifId. Notification-branch имеет двойной checkdeclinedTimers.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:resolveCallIdyields до 5s (MembershipsChanged wait) + fetchRoomEvent. За этот yield Timeline decline проходит через handleTimeline → populates declinedTimers → но notification-branch уже миновал pre-await check и после await проверял только membership, не declined set. Fix-через-set всё равно правильный, просто check нужен с обеих сторон от await, а не только до. - Корректировка 2026-04-29 (см. §5.51): утверждение «
m.rtc.notificationлетит cleartext даже в encrypted DM» оказалось НЕВЕРНЫМ. Logcat 2026-04-29 показал FCM payload сtype=m.room.encrypted, cn_type=nullдля encrypted-DM ring'а — то есть SDK всё-таки шифровал event. Скорее всего тест 2026-04-23 случайно был на unencrypted DM, либо сделан через другой path. Race-fix черезdeclinedTimersRefсам по себе правильный (race в processEvent существует независимо от encryption), но вывод о cleartext был ошибочным. После §5.51 cleartext-bypass для ring уже сделан явно вCallWidgetDriver, так что текущее состояние согласовано: ring действительно cleartext, но только потому что мы сами переписали путь, а не потому что SDK так делал. - Trace (confirmed by live logcat 2026-04-23):
- FCM доставляет notification → native CallStyle → user жмёт Decline →
CallDeclineReceiverшлёт cleartextm.call.declinePUT (200 OK). - User открывает app (cold boot или resume). /sync подтягивает оба события.
- handleTimeline notification → processEvent → notif-branch → pre-await
declinedTimers.has=== false →await resolveCallId(...)yields. - Во время yield'а: handleTimeline decline → processEvent decline-branch →
rememberDeclined(rel.event_id)→declinedSizeAfter: 1.removeByNotifIdна этом моменте no-op (registry.set ещё не произошёл). - resolveCallId возвращается → post-await re-check membership (clear) → post-await re-check
declinedTimers.has(evId)=== true → return. Atom НЕ получает ADD.
- FCM доставляет notification → native CallStyle → user жмёт Decline →
- Симптом (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 и IncomingCallStripRenderer.tsx подписка на
App.appStateChangeзаменена на паруpause+resumeподisAndroidPlatform()branch'ем; iOS/web остался наappStateChange.pause/resumeCapacitor App плагина fires вBridgeActivity.onPause/onResumeчерезAppPlugin.handleOnPause/Resume— тот же Activity callback, что переключаетMainActivity.isInForeground, — и доходит до JS через WebView bridge с латентностью ms. Race-guard переименованsawAppStateEvent→sawLifecycleEvent, остальная логика (initialdocument.visibilityState,App.getState()race-guarded fallback, handoff sweep на foreground) не тронута. -
Live-измерение drift'а (test 2026-04-23): на test-девайсе
onPause → onStop27-51ms (меньше предсказанных 100-1000ms, но реальный).pauseevent в JS приходит за 1-2ms после JavaonPause— практически на том же edge'е. -
iOS/web semantics intentionally preserved:
pause/resumeна iOS bind todidEnterBackground/willEnterForeground, аappStateChange— towillResignActive/didBecomeActive. Для iOSappStateChangeэто правильный 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 —
isInForeground=falseставится вonPause(). - Capacitor App plugin BridgeActivity.java:113-123 —
fireStatusChange(false)вызывается в onStop, не onPause.appStateChangeevent иgetState().isActiveсэмплируют именно этот edge. - Между onPause и onStop — десятки мс до секунд (зависит от девайса / анимаций / системной загрузки). В этом окне Java считает app backgrounded (FCM → native CallStyle), JS считает appActive=true (dismiss-ветка в useDismissNativeCallNotifications, audio-play в IncomingCallStripRenderer).
- Java MainActivity.java:28 —
-
Trace (§5.42 residual):
- User нажал Home →
onPause→isInForeground=false, WebView ещё видимый для Capacitor. - FCM ring → service видит
isInForeground=false→ native CallStyle. - /sync в живом WebView → атом ADD.
useDismissNativeCallNotificationsвидитappActive=true(onStop ещё не пришёл) → dismiss'ит native. Silent ring.
- User нажал Home →
-
Trace (§5.39 residual): тот же gap — audio.play() в IncomingCallStripRenderer на
hasIncoming && appActive=true, играет double-ring вместе с native ringtone пока onStop не переключит. -
Направление фикса: варианты:
- 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 может отличаться). - Custom Capacitor plugin: свой plugin с
@PluginMethod+ lifecycle hooks (handleOnPause,handleOnResume) шлёт JS event. Авторитетно, но overhead и требует aar build. - SharedPreferences bridge: MainActivity пишет
vojo.isInForegroundв CapacitorStorage.xml, JS читает на каждом atom-change через Preferences plugin. Polling-heavy, плохой fit.
- DOM
-
Приоритет (1) — дёшево проверить. Если на test devices visibilitychange fires ближе к onPause → закрывает residual одной строкой.
-
Размер: 5-10 строк в каждом из useDismissNativeCallNotifications.ts и IncomingCallStripRenderer.tsx.
-
Проверочные сценарии:
- Home → <500ms → incoming → только native CallStyle, JS-audio молчит, native не дисмиссится. (Reproduce §5.39/§5.42 pre-fix в том же окне.)
- Home → 10s wait → incoming → как §5.42 test 2 (native звенит, JS не трогает).
- App возвращается в foreground → handoff как §5.42 test 3 (native уходит, JS strip+audio берут).
- Cold-boot foreground → incoming → JS strip+audio, native skipped. Как сейчас.
-
Когда чинить: после §5.43. Оба §5.39 и §5.42 landed — это финальная полировка narrow-окна.
5.45. 🟢 Foreground-skip race — переезд с FCM-skip-cache на native ring registry — landed 2026-04-24
Симптом (pre-fix)
App foreground, incoming DM call. JS strip + audio начинают отрабатывать, через ~500ms user сворачивает app (Home) → JS audio гасится audio-gate'ом → native CallStyle не появляется (FCM irreversibly skipped в момент receipt'а как foreground-call) → полная тишина до повторного ring / expiry.
Корень — асимметрия двух контрактов:
- FCM receipt time ≠ user backgrounding time. FCM решает "foreground → JS owns UX, skip native" irreversibly. JS audio-gate reversible. Между ними нет foreground→background takeover.
Путь, которым шли (3 итерации)
Первая попытка — foreground-skip cache. Java-side ConcurrentHashMap<roomId, CachedSkipRing> хранит payload'ы которые FCM skip'нул в foreground. На onPause drain'ит cache → постит native. На onResume cancel + clear.
Через несколько раундов ревью закрыли серию подзадач: per-room dedupe с latest-wins eviction, setOnlyAlertOnce(true), ownership-проверка через extras + getActiveNotifications(), cachedAt fallback для expiry, atomic registry+tombstone mutations через lock, forget-on-suppress для всех early-return paths JS-хука, CallDeclineReceiver / CallCancelReceiver cleanup, MessageId merge, BuildConfig.DEBUG log gate, web audio platform-gate, redaction-first симметрия, orphan sweep на onResume.
Что заставило перейти на registry. Подряд появлялись две тенденции:
- Каждый новый suppress-case требовал explicit
forgetCachedSkippedRing— scattered calls размножались (not-DM / expired / already-joined / decline-first / resolve-fail / redaction-first / hook teardown). Scaling linearly с количеством JS-branches. - Cache хранил историю ("какие FCM мы skip'нули"), а не текущее live состояние. Ownership encoding (
vojo_cache_event_idextra +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 viavojo_ring_event_idextra + 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;lastAlertedAtpreserved и сравнивается в 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 +lastAlertedAtpreserved across cancel /renderedAtreset — 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чтобы не коллизиться с JavaringRegistry. - 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→ bridgeremoveIncomingRing→ 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
useClientWidgetApiEventsignature чтобы 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 и LeaveSpacePrompt.tsx вызывают
mx.leave(roomId)внутриasynccallback без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-sdkleave(roomId)возвращаетPromise<EmptyObject>и внутри делаетauthedRequest(POST /rooms/$room_id/leave). Текущий callback возвращаетPromise<undefined>, который resolve'ится сразу. - Live reproduce:
npm run dev, залогиниться и открыть joined room.- DevTools → Network throttling →
Offline(или block request URL*rooms/*/leave*). - Room menu →
Leave Room→ confirm. - Фактический pre-fix результат: prompt закрывается как success, error text не появляется; в Network
POST .../rooms/{roomId}/leavefailed/blocked; после возврата online + refresh/sync комната остаётся. - Ожидаемый результат после фикса: prompt остаётся loading до ответа, затем показывает localized error.
- Фикс-направление:
await mx.leave(roomId);в обоих prompt'ах. Неreturn mx.leave(roomId), если сохраняемuseAsyncCallback<undefined, MatrixError, []>contract. - Scope: не связано с i18n-диффом; pre-existing bug, найден при ревью локализации leave prompt'а.
5.51. ✅ Encrypted RTC ring FCM misclassifies as message on Android — RESOLVED 2026-04-29
- Симптом (до фикса): Android background/killed мог показать обычное message-уведомление вместо CallStyle для входящего звонка в encrypted DM.
- Логкат 2026-04-29 (до фикса): ring пришёл в
VojoFirebaseMessagingServiceкакrecv: type=m.room.encrypted cn_type=null ... event=<rtc-notification-event-id>. JS затем расшифровал тот же event и сделалCallForegroundService.upsertIncomingRing, поэтому foreground in-app звонок прошёл. Background Java FCM path не мог классифицировать payload как ring. - Root cause: matrix-js-sdk шифрует
m.rtc.notificationчерезclient.sendEventв e2ee комнате (MatrixRTCSession.sendCallNotify→client.sendEvent). Server-side push ruleEventMatch type=org.matrix.msc4075.rtc.notificationматчится только на outer cleartext type, который для encrypted envelope всегдаm.room.encrypted. Sygnal падает в default message-rule. Java classifier тоже видит только outer type и не может распознать ring. Element Web обходит через SW fetch+decrypt; Element X — через matrix-rust-sdk native; Vojo Android FCM не имеет ни того, ни другого. - Что не является доказательством:
adb shell am force-stop chat.vojo.appпереводит приложение в Android stopped state; FCM обычно не доставляется до ручного запуска. Для killed-process теста использовать swipe-away / обычное убийство процесса, не force-stop. - Применённый фикс: cleartext bypass только для
m.rtc.notificationсnotification_type === 'ring'черезCallWidgetDriver.sendEvent. Server видит outer type =org.matrix.msc4075.rtc.notification, push rule матчит, Java classifier распознаёт ring, CallStyle появляется. Symmetрично существующемуCallDeclineReceiver.sendDecline, который уже шлётm.rtc.declinecleartext через прямой HTTP PUT.- Threat model. Утечка: server и push gateway видят факт «в этой комнате был ring» + room_id + sender + sender_ts + lifetime + relation. НЕ утечка: message bodies, LiveKit token, media keys, SDP / session secrets, аудио-поток. По MSC4075 ring content и так не несёт secret payload. Эквивалент legacy
m.call.invitecleartext signaling в WebRTC-over-Matrix. В коде и доках называем это «cleartext ring metadata for Android FCM classification», не «unencrypted calls». - Allowlist-only. Чтобы upstream Element Call в будущем случайно не протащил secret в cleartext, форвардятся только:
notification_type, m.relates_to, sender_ts, lifetime, m.mentions, m.call.intent. Всё неизвестное дропается на send-side. - Stable-name fallback — narrow scope. Покрыты только Android push pipeline компоненты, которые блокировали encrypted-DM ring delivery: server-side override push rule + Java FCM classifier — оба теперь матчат ОБА
org.matrix.msc4075.rtc.notification(unstable) иm.rtc.notification(stable). Send-side остаётся unstable:CallWidgetDriverшлётEventType.RTCNotification(matrix-js-sdk константа), при promotion SDK значение переключится автоматически.- НЕ покрыто намеренно: SW classification (
src/sw.ts), JS hooks (useIncomingRtcNotifications,useCallerAutoHangupчерезEventType.RTCNotification— auto-upgrade при SDK bump), widget capability declarations (src/app/plugins/call/utils.ts), и timeline event-type filters вRoomTimeline.tsx— все они на hardcoded unstable string. По решению вdocs/ai/desired_features.md#6 миграция всех MSC RTC префиксов делается единым диффом, когда стабилизация landed (MSC4075 + MSC4143 + MSC4195 + MSC4310 параллельно). Текущий фикс не делает преждевременную частичную миграцию.
- НЕ покрыто намеренно: SW classification (
- Legacy
EventType.CallNotify(org.matrix.msc4075.call.notify) defense. matrix-js-sdk'sMatrixRTCSession.sendCallNotifyпараллельно отправляет ОБА новыйm.rtc.notificationИ legacy CallNotify черезPromise.all. Защита двухслойная:- Primary: capability denial —
getCallCapabilitiesне грантит Send дляEventType.CallNotify, widget capability check rejects request ещё внутри widget API доCallWidgetDriver.sendEvent. - Secondary: silent no-op в
CallWidgetDriver.sendEventдляEventType.CallNotifyс sentinel event_id ($vojo:suppressed-legacy-call-notify). На случай если future widget release обходит capability check — событие на сервер не уходит. Resolve вместо throw сохраняет совместимость с upstreamPromise.allumbrella вsendCallNotify:.thenотрабатывает,DidSendCallNotificationemit'ится (Vojo не consumer'ит, но keep upstream contract clean), нет «Unhandled promise rejection» в консоли. Push-rule layer не используется здесь намеренно: в encrypted DM HS видит только outerm.room.encrypted,EventMatchна inner type silently no-op'ит, suppress rule был бы misleading.
- Primary: capability denial —
- Shape validation.
sanitizeRingContentотвергает payloads с invalidm.relates_to(неm.reference/ non-string event_id), non-finite или non-positivesender_ts, иlifetimeвне (0, 5min]. На invalid — fallback на encrypted send-path: in-app strip всё равно получит event через /sync; теряется только Android background CallStyle. Это укрепляет инвариант «cleartext только корректная ring metadata» против будущих upstream Element Call изменений. - Что НЕ покрыто фиксом и почему: (a)
m.rtc.member— state event, не идёт через push pipeline; (b)m.rtc.decline— для killed-state уже cleartext черезCallDeclineReceiver, для foreground шлётся черезmx.sendRtcDeclineи шифруется в e2ee, но decline path не показывает CallStyle banner — отдельная проблема, не блокирующая. Если decline cleartext bypass понадобится — добавить тем же allowlist-паттерном вCallWidgetDriver.
- Threat model. Утечка: server и push gateway видят факт «в этой комнате был ring» + room_id + sender + sender_ts + lifetime + relation. НЕ утечка: message bodies, LiveKit token, media keys, SDP / session secrets, аудио-поток. По MSC4075 ring content и так не несёт secret payload. Эквивалент legacy
- Deferred follow-up — strict threat model. Если когда-нибудь нужно скрыть от сервера сам факт звонка (не только содержимое), потребуется native-decrypt path аналогично Element X: либо порт matrix-rust-sdk в Android shell, либо headless WebView crypto bridge (хрупко из-за cold-start time budget). Большой scope, отдельным планом.