vojo/docs/plans/dm_calls_techdebt.md

81 KiB
Raw Blame History

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

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

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


Содержание


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:

  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. Манифест объявляет только microphoneNONE не 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 (на будущее).
  • onPreparingErrorsetCallEmbed(undefined) handler в CallEmbedProvider — для очистки zombie-embed в атоме при failure widget'а; не критично для FGS после joined-gating, но полезно само по себе.

Файлы

Валидация

  • 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). Нужно подтвердить, что 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.
  • Решается вместе с §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/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 subscribe'ится на MatrixRTCSessionEvent.MembershipsChanged → если наш user_id появился в session (answer на другом device'е) → removeByRoom.
  • RTCDecline event'ы через /sync обрабатываются в processEvent:251-267removeByNotifId. 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-процесс; JSONObject parse на этом хот-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 вне этого набора; i18next fallbackLng переведён на '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 из 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.tldalice) → пусто. Всегда передаём оба positional arg'а (даже unused), иначе Android's positional formatter throws на %2$s без 2-го аргумента.
  • Landed fix (web):
    • 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) — fetchInviteFromNotificationsGET /_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, 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-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 добавлен 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) 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.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) требует 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.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 dismiss'ит native CallStyle на любом изменении incomingCallsAtom, без различения ADD vs REMOVE и без 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 сценария:
    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.notificationuseIncomingRtcNotifications.processEventsetIncoming({type: 'ADD', ...}).
    4. useDismissNativeCallNotifications effect fires на изменение атома → PushNotifications.removeDeliveredNotifications({ tag: "call_<roomId>", id }) → native CallStyle cancelled, ringtone обрывается.
    5. §5.39 gate в IncomingCallStripRenderer не играет JS-audio (appActive=false) → полная тишина.
  • Почему раньше было менее заметно: до §5.39 fix JS-audio играл безусловно → даже если native dismiss'ился, в ушах хотя бы оставался JS ring.ogg. Это маскировало dismiss-side зазор (и само порождало §5.39). После §5.39 маска снята, баг выходит на передний план в тот же window.
  • Влияет только на "WebView жив и /sync долетает" state: app killed → WebView не запущен → атом не меняется → native звенит нормально до user action / expiry. App foreground → native вообще не показан (foreground skip в сервисе) → нечего dismiss'ить. Зазор именно в "паузнутый но живой" состоянии (обычно первые десятки секунд после Home до того как Android начнёт aggressive suspend'ить WebView).
  • Направление фикса:
    1. В useDismissNativeCallNotifications.ts разделить 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): 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 заведён declinedTimersRef: Map<notifEventId, timeout> с self-expiring 30s entries. rememberDeclined() helper в decline-branch записывает rel.event_id перед removeByNotifId. Notification-branch имеет двойной check declinedTimers.has(evId): pre-await short-circuit (не гоняем resolveCallId для уже отклонённых) и post-await race-guard рядом с существующим membership re-check. Unmount cleanup clear'ит pending timers. Атом / native / receiver не тронуты.
  • Изначальная гипотеза (§5.43 pre-fix) оказалась частично ошибочна: live-логи показали что m.rtc.notification летит cleartext даже в encrypted DM (нужно для push-сервиса), а не как m.room.encrypted. Настоящий race — async внутри processEvent: resolveCallId yields до 5s (MembershipsChanged wait) + fetchRoomEvent. За этот yield Timeline decline проходит через handleTimeline → populates declinedTimers → но notification-branch уже миновал pre-await check и после await проверял только membership, не declined set. Fix-через-set всё равно правильный, просто check нужен с обеих сторон от await, а не только до.
  • Trace (confirmed by live logcat 2026-04-23):
    1. FCM доставляет notification → native CallStyle → user жмёт Decline → CallDeclineReceiver шлёт cleartext m.call.decline PUT (200 OK).
    2. User открывает app (cold boot или resume). /sync подтягивает оба события.
    3. handleTimeline notification → processEvent → notif-branch → pre-await declinedTimers.has === false → await resolveCallId(...) yields.
    4. Во время yield'а: handleTimeline decline → processEvent decline-branch → rememberDeclined(rel.event_id)declinedSizeAfter: 1. removeByNotifId на этом моменте no-op (registry.set ещё не произошёл).
    5. resolveCallId возвращается → post-await re-check membership (clear) → post-await re-check declinedTimers.has(evId) === true → return. Atom НЕ получает ADD.
  • Симптом (pre-fix): app killed/backgrounded, приходит DM-звонок → native CallStyle → user жмёт Decline на native-баннере → у звонящего отклоняется, но user открывает app и видит тот же звонок в in-app strip. Если нажать Accept — initiates outgoing call обратно caller'у (на session, которой уже нет).
  • Связь с §5.35: receiver-path через cleartext raw HTTP — часть §5.35 design. Race возникал из-за async yield в resolveCallId, не из-за encrypt/cleartext asymmetry (обе события cleartext). Fix не меняет receiver design.
  • Не закрывает: §5.44 (onPause→onStop lifecycle drift), §5.37 (foreground cold-start window).

5.44. 🟢 onPause→onStop lifecycle drift — landed 2026-04-23 (commit 649aea7)

  • Landed fix: в обоих useDismissNativeCallNotifications.ts и 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 переименован sawAppStateEventsawLifecycleEvent, остальная логика (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:28isInForeground=false ставится в onPause().
    • Capacitor App plugin BridgeActivity.java:113-123fireStatusChange(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 → onPauseisInForeground=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 / onPauseWebView.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 и IncomingCallStripRenderer.tsx.

  • Проверочные сценарии:

    1. Home → <500ms → incoming → только native CallStyle, JS-audio молчит, native не дисмиссится. (Reproduce §5.39/§5.42 pre-fix в том же окне.)
    2. Home → 10s wait → incoming → как §5.42 test 2 (native звенит, JS не трогает).
    3. App возвращается в foreground → handoff как §5.42 test 3 (native уходит, JS strip+audio берут).
    4. Cold-boot foreground → incoming → JS strip+audio, native skipped. Как сейчас.
  • Когда чинить: после §5.43. Оба §5.39 и §5.42 landed — это финальная полировка narrow-окна.

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

Симптом (pre-fix)

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

Корень — асимметрия двух контрактов:

  • FCM receipt time ≠ user backgrounding time. FCM решает "foreground → JS owns UX, skip native" irreversibly. JS audio-gate reversible. Между ними нет foreground→background takeover.

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

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

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

Что заставило перейти на registry. Подряд появлялись две тенденции:

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

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

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

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

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

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

Known residual gaps

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

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

  • VojoFirebaseMessagingService.javaIncomingRing, 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.gradlebuildFeatures.buildConfig = true для release log gating.

Deferred follow-ups (отдельные тикеты)

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

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

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

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

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

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

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

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

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

5.50. 🟡 LeaveRoomPrompt / LeaveSpacePrompt prematurely report success

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