vojo/docs/plans/telecom_migration.md
heaven 0f5bc33619 fix(calls): resolve call glare via co-presence, not answer
Both users starting a call in the same DM at once collapsed both attempts: two useSwitchOrStartDmCall instances (header outgoing + strip answer) race on the shared callEmbedAtom, the counter-ring surfaces during the outgoing embed's forming window, and answering it disposes+recreates the embed — putting two MembershipManagers on one (user,device) state-key, driving the SDK 'Missing own membership: force re-join' thrash. Fix per MatrixRTC canon (a call is co-presence, there is no 'answer'): a formingCallRoomIdAtom set synchronously before the outgoing awaits marks the room as forming; an incoming call for a room we already hold/are forming becomes a no-op (element-x model), the counter-ring is suppressed while forming, and the native-answer consumer won't tear down our forming call. Root + design from the internal research workflow. Not yet validated on device.
2026-07-04 02:40:47 +03:00

78 KiB
Raw Blame History

Telecom — нативный бэкенд звонков для Android (план миграции)

Живой документ. Сиблинг dm_calls_techdebt.md — там этот переезд несколько раз отложен как «правильный long-term путь, но недели работы» (§2.2, §5.34, §5.41, §5.45). Этот файл — план, как сделать его минимальной кровью, не сломав web, с учётом нюансов по версиям Android и OEM.

Стек, который надо держать в голове: медиа = Element Call (LiveKit/WebRTC) в WebView-iframe, управляемый widget-api экшенами (JoinCall/HangupCall/DeviceMute) через CallControl.ts. Нативного WebRTC у нас нет. Это ключевое ограничение — оно меняет всё нижеизложенное.

⚠️ СТАТУС (2026-06-23, актуально — см. последнюю запись в §11): позиция сменилась с «аддитивный слой за флагом» на полную миграцию. Флаг TELECOM_ENABLED убран, minSdk поднят до 26, Telecom — единственный нативный бэкенд звонков, legacy AudioRoutePlugin удалён, добавлен нативный full-screen IncomingCallActivity (закрыл §5.34). §0§9 ниже описывают исходный (флаг-гейтед) план и местами исторические — фактическое состояние и отступления держи по журналу §11. На железе ещё не провалидировано.


0. TL;DR

  1. Библиотека: androidx.core:core-telecom:1.0.1 (Jetpack Core-Telecom), а не ручной ConnectionService. Google сам рекомендует её для self-managed VoIP; она прячет за собой split «transactional CallControl (API 34+) ↔ ConnectionService backport (API 2633)». Минус один: CallsManager помечен @RequiresApi(26), а наш minSdk = 24 → весь Telecom-код гейтим за Build.VERSION.SDK_INT >= 26, на 2425 остаётся текущий путь (доля 2425 в 2026 пренебрежимо мала).
  2. Telecom self-managed — это НЕ то, что кажется. Развенчание мифов в §1. Коротко: он не рисует системный incoming-UI, не убирает microphone FGS, не чинит OEM-килл и не даёт Answer без разблокировки (медиа в WebView). Зато даёт: интероп с GSM-звонком и аудиофокусом, BT/Android Auto/Wear, системный «ongoing call» chip/журнал, и легитимный phoneCall-FGS, который можно стартовать из killed (закрывает §5.41).
  3. Главный технический риск — аудио. Telecom (CallAudioModeStateMachine) и Chromium-ADM внутри WebView оба ставят MODE_IN_COMMUNICATION и берут audio focus в одном процессе → mode thrash / эхо. И Telecom запрещает AudioManager.setCommunicationDevice (то, что делает наш AudioRoutePlugin), маршрутизацию надо вести через requestEndpointChange (на части устройств флапает BT). Это валидируется только на железе.
  4. Reference-реальность: element-x-android (та же «Element Call в WebView» архитектура, что у нас) везёт 0 Telecom-кодаActiveCallManager + IncomingCallActivity + wakelock + microphone-FGS (всё это у нас уже почти есть). У element-android Telecom-пакет — мёртвый stub (захардкоженный "+905000000000", закомментированный onAnswer). Единственная живая реализация канонического CallsManager.addCall — официальный сэмпл Google. Никто в экосистеме Matrix не рулит WebRTC из Telecom-Connection. Мы будем делать новое — выполнимо, но Telecom-стейт надо гнать нативно (answer ≤5с) и транслировать в widget-экшены асинхронно, никогда не блокируясь на WebView.
  5. Поза миграции (рекомендация): аддитивный self-managed слой за фиче-флагом, не rip-and-replace. Telecom ложится рядом с проверенным стеком (FGS + CallStyle + ring-registry), а не вместо него. Каждая фаза шиппится и откатывается флагом; при флаге off / API<26 / web — поведение байт-в-байт текущее.

1. Что self-managed Telecom реально даёт и НЕ даёт

Ожидание Реальность Источник
ОС нарисует системный incoming-call экран + Answer/Decline на локскрине НЕТ. Self-managed = «вы сами рисуете primary UI». Системный UI Telecom показывает лишь когда новый звонок конфликтует с уже идущим звонком в другом приложении. CallStyle + setFullScreenIntent остаются нашей задачей. voip-app/telecom, PhoneAccount
Можно выкинуть microphone FGS — Telecom «держит» звонок НЕТ. В списке FGS background-start exemptions нет ни Telecom, ни MANAGE_OWN_CALLS, ни «активного звонка». Phase-0 корни (AppOps revoke record_audio ~T+5с + netd firewall ~T+13с на Samsung) лечит importance процесса от FGS, не звонок. microphone-FGS остаётся. restrictions-bg-start, Agora bg-capture
Telecom починит «звонок не доходит» на Samsung/Xiaomi НЕТ. Доминирующий фейл — OEM battery/autostart killing (MIUI autostart-deny 5/5, Samsung «put to sleep»). Лечится REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + deep-link в OEM-настройки, не Telecom. Element X страдает тем же (issues #6107/#4390/#4611). dontkillmyapp/xiaomi, /samsung
Answer прямо с локскрина без разблокировки НЕТ. Медиа = WebView → JoinCall требует загрузки iframe → нужен unlock. Это не меняется с Telecom. research
phoneCall-FGS можно стартовать из killed (в отличие от microphone) ДА — и это закрывает §5.41. phoneCall не while-in-use тип, FCM/notification-tap могут его поднять из бэкграунда. Требует MANAGE_OWN_CALLS (норм-перм). Но phoneCall не даёт background mic-capture → нужны оба типа. service-types
Интероп с сотовым звонком, BT-гарнитура/Auto/Wear, журнал звонков, DND ДА — это и есть основная ценность Telecom для нас. core-telecom

Вывод: Telecom стоит брать ради интеропа/аудиофокуса/BT-Auto-Wear/phoneCall-FGS и «стандартного трека», а не ради системного UI, ради выкидывания FGS или ради надёжности. Это формирует «аддитивную» позу.


2. Reference-реализации

  • element-x-android (архитектурно = Vojo): ConnectionService/CallsManager = 0 хитов в репо. Весь нативный call-surface: DefaultActiveCallManager (StateFlow + full-screen ring notif + PARTIAL_WAKE_LOCK + ring-timeout → missed-call), IncomingCallActivity, RingingCallNotificationCreator, CallForegroundService с TYPE_MICROPHONE (байт-в-байт наш). Виджет-мост через WebViewWidgetMessageInterceptor (тот же fromWidget/toWidget postMessage).
  • element-android (legacy): telecom/ = 3 файла, но CallConnection.onAnswer()/startCall()/onShowIncomingCallUi() закомментированы, тестовые строки "+905000000000". Реальный движок — CallAndroidService (phoneCall-FGS, ACTION_*). Telecom-Connection в управляющем пути не участвует. Копировать как API-shape, не как рабочий сэмпл.
  • Google core-telecom sample — единственная живая CallsManager.addCall(...) + CallControlScope реализация.
  • Telegram/Signal — тоже CallStyle.forIncomingCall + setFullScreenIntent + FGS, без self-managed ConnectionService.

3. Архитектурное решение — аддитивный self-managed слой

Не «снести FGS+registry, поставить Telecom», а новый нативный модуль call/telecom/, который Telecom-сессию ведёт параллельно:

JS (CallEmbed / hooks)                 Native (Android)
─────────────────────                  ──────────────────────────────
joined=true  ───────────────────────▶  VojoCallsManager.onCallActive()   ── addCall(OUTGOING/INCOMING) + setActive()
hangup / atom=undefined ────────────▶  VojoCallsManager.onCallEnded()    ── disconnect(LOCAL)
toggleMicrophone ◀───── isMuted flow ─  CallControlScope (one source of truth)
                                        onAnswer  ─▶ JS: switchOrStartDmCall (async, не блокируем 5с-дедлайн)
                                        onDisconnect ─▶ JS: hangup
                                        availableEndpoints/currentEndpoint ─▶ speaker/BT UI
FCM ring ──▶ VojoFirebaseMessagingService ── (как сейчас: CallStyle+FSI) + addCall(INCOMING) [Phase C]

Инварианты:

  • Telecom-стейт-машина гонится нативно и синхронно (≤5с дедлайны Telecom), WebView-JoinCall подключается асинхронно и не на критическом пути. onAnswer возвращаемся сразу, join завершаем потом.
  • Одна правда про mute: либо widget DeviceMute, либо Telecom isMuted-flow — зеркалим в одну сторону, иначе системный call-UI и кнопки Element Call разъедутся.
  • Весь Telecom-код за isAndroidPlatform() (JS) и SDK_INT >= 26 (Java). Флаг telecomEnabled (remote/local) для аварийного отката.
  • microphone-FGS не трогаем по сути — добавляем phoneCall к типам сервиса, чтобы получить killed-старт.

4. Главный риск — аудио-владение (WebView ADM ↔ Telecom)

Симптом-зона: старт и конец звонка. Оба владельца ставят MODE_IN_COMMUNICATION + берут focus в одном процессе → эхо/AEC-фейл/неверный роут на первые секунды. Документация Telecom прямо запрещает setCommunicationDevice/startBluetoothSco при активном Telecom-звонке (voip-app/telecom).

Стратегия:

  1. Дать Telecom владеть mode/focus (setAudioModeIsVoip(true) под капотом core-telecom). Сами setMode не зовём (мы и не зовём — setMode ставит WebView).
  2. В Telecom-режиме отключить setCommunicationDevice-ветку AudioRoutePlugin, маршрут вести через requestEndpointChange. Кнопку «динамик» переключить на availableEndpoints/requestEndpointChange, читая фактический currentCallEndpoint назад в UI (BT requestEndpointChange флапает — issuetracker 302436283).
  3. WebView-ADM мы заглушить из приложения не можем → тяжёлый live-тест эха на Samsung/OnePlus/Xiaomi. Это gating-критерий Phase B.

Если на железе эхо непобедимо — есть аварийный откат: оставить AudioRoutePlugin владельцем роутинга и не регистрировать Telecom-аудио (использовать Telecom только как call-session/интероп-сигнал). Менее «правильно», но рабоче.


5. Фазы (каждая shippable + reversible флагом)

Phase A — фундамент (низкий риск, без смены поведения)

  • androidx.core:core-telecom:1.0.1 в variables.gradle/build.gradle.
  • <uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/> + FOREGROUND_SERVICE_PHONE_CALL.
  • VojoCallsManager.java — обёртка: ленивая registerAppWithTelecom(CAPABILITY_SUPPORTS_VIDEO_CALLING), single PhoneAccount, гейт SDK_INT>=26.
  • CallForegroundService: foregroundServiceType="microphone|phoneCall", startForeground(..., TYPE_MICROPHONE|TYPE_PHONE_CALL).
  • Фиче-флаг telecomEnabled (по умолчанию off). Эффект: легитимный phoneCall-FGS, ничего больше пока не делает.

Phase B — активная исходящая/входящая сессия + аудио

  • На joined (тот же сигнал, что у FGS) → addCall(direction, ...) + setActive(). На atom=undefined → disconnect(LOCAL).
  • onDisconnect→widget HangupCall; onSetActive/onSetInactive→hold (пауза/DeviceMute); isMutedDeviceMute (одна правда).
  • Аудио: §4 (Telecom owns mode, AudioRoutePlugin.setCommunicationDevice off-в-Telecom-режиме, роут через endpoints).
  • Gating: live-эхо-тест. Новый хук useTelecomConnectionSync по образцу useAndroidCallForegroundSync.ts.

Phase C — входящий через Telecom + answer-from-killed (§5.41)

Доки-факты (сверено WebFetch 2026-06-23, service-types + restrictions-bg-start):

  • phoneCall-FGS: рантайм-предусловие = MANAGE_OWN_CALLS или ROLE_DIALER (у нас первое). Активный Telecom-звонок для старта не требуется. Это не while-in-use тип → правило «нужна видимая Activity» (которое бьёт microphone из бэкграунда) на него не распространяется.
  • Но у phoneCall нет своей строки в общем bg-start-исключении → стартовать из killed можно по исключению «high-priority FCM» (ring приходит именно так) и/или «юзер нажал на нотификацию» (tap Answer). Оба применимы.
  • microphone-FGS остаётся keyed на JoinCall (там RECORD_AUDIO уже выдан + foreground) — §2.2-инвариант не меняем.

Дизайн (industry-standard self-managed; эталоны: Google core-telecom sample = ring-time addCall(INCOMING); element-android = phoneCall-FGS из FCM; element-x = «видимый ring-UI рисуем сами»):

  • Ring (FCM, любое состояние вкл. killed): рядом с текущим CallStyle+FSI (он остаётся видимым ring-UI — self-managed системный UI не рисует) → (а) поднять phoneCall-FGS из VojoFirebaseMessagingService (легально: high-priority FCM + MANAGE_OWN_CALLS, не-while-in-use) — это держит процесс+сессию весь ринг без best-effort; (б) addCall(DIRECTION_INCOMING) → RINGING. Telecom владеет состоянием; onAnswer может прийти с CallStyle/BT/Auto/Wear/системы.
  • Answer (CallStyle tap / Telecom onAnswer / BT-Auto): Telecom answer() (нативно, синхронно, в 5-сек дедлайн) → boot WebView → switchOrStartDmCallJoinCall async (вне критич. пути). FGS уже жив → на JoinCall апгрейд типа до microphone|phoneCall (ретеншн §2.2). Так §5.41 закрыт самим фактом живого FGS, без отдельной «подпорки».
  • Decline / expiry / hangup: disconnect(REJECTED/MISSED/LOCAL) + стоп FGS + тромбстон — через существующие CallDeclineReceiver / CallCancelReceiver / removeIncomingRing (decline-HTTP оставляем как есть).
  • Дедуп: Telecom-entry стартовать/тромбстонить под registryLock синхронно с ringRegistry (FCM-retry не должен поднять вторую сессию; single-active-call инвариант VojoCallsManager.startCall уважать через callSessionId-гард, а не слепой teardown).
  • Контракт shared-FGS-лайфсайкла (важно — раньше FGS был JS-only): старт может прийти и от Java (ring), и от JS (useAndroidCallForegroundSync на join); стоп — тоже с обеих сторон. Сервис = синглтон, start/stop идемпотентны; тип считается на каждом старте (ring → phoneCall; join → microphone|phoneCall если RECORD_AUDIO). Неотвеченный ринг (нет JS) → Java обязан стопнуть FGS на decline/expiry.
  • mute-mirror (одна правда): в core-telecom CallControlScope сеттера mute нет (mute системный) → зеркало одностороннее Telecom isMuted → widget (CallControl: toggle-если-расходится, guard от петли). Синхронит BT/hardware-mute с кнопкой Element Call.
  • Гейты на железе (Samsung, под lock): (1) аудио-фокус во время RINGING не должен конфликтовать с рингтоном CallStyle (рингтон на STREAM_RING; Telecom-фокус берётся только на ACTIVE) — живой тест §4; (2) регресс §2.2 не вернулся (звонок жив 2-3 мин под lock); (3) answer-from-killed под lock (свайп-килл, не force-stop).

Phase D — маршрутизация BT/Auto/Wear + polish

  • availableEndpoints/currentCallEndpoint → in-call speaker/BT тоггл. AudioRoutePlugin → observer/убрать.
  • Headset-hook / Auto / Wear answer через Telecom-колбэки. Журнал звонков (по желанию).

6. Карта изменений по файлам

ADD (ново):

  • android/.../call/telecom/VojoCallsManager.java — обёртка CallsManager, регистрация PhoneAccount, addCall, маппинг колбэков.
  • android/.../call/telecom/TelecomCallPlugin.java — Capacitor-мост (start/answer/end/setMuted/requestEndpoint/observe).
  • src/app/plugins/call/telecomCall.ts — JS-обёртка плагина (no-op на не-Android).
  • src/app/hooks/useTelecomConnectionSync.ts — лайфсайкл-хук (зеркало useAndroidCallForegroundSync).

ADAPT:

KEEP (не трогать — web-shared / проверено):

⚠️ Код-агенты в рекогносцировке местами предлагали InCallService и «платформа сама рисует ring» — это про managed/dialer, для self-managed неверно. Не реализуем InCallService, не становимся ROLE_DIALER.


7. Web «не сломать» — короткий чеклист

Telecom — чисто Android-нативный слой; web/Electron/iOS его не видят. Регрессии возможны только если тронуть shared-код. Полная матрица — в результате рекогносцировки (агент web-sw-path-guardrails). Главное:

  • sw.ts push→ring→notification, CallWidgetDriver.sanitizeRingContent, incomingCallsAtom/useIncomingRtcNotifications, IncomingCallStripRenderer audio-gate (isAndroidPlatform() ? appActive : true) — байт-в-байт без изменений.
  • Любая Telecom-ветка в shared-хуках — строго под isAndroidPlatform().
  • Интероп: web-A ↔ Android-B и обратно (ring/answer/hangup идут через Matrix /sync, Telecom их не трогает).

8. Play / разрешения / OEM

  • MANAGE_OWN_CALLS — protectionLevel normal, авто-грант, не триггерит Permissions Declaration Form, не требует ROLE_DIALER.
  • Play App-content декларации (release-блокеры на targetSdk 36): foregroundServiceType (видео-демо!) для microphone+phoneCall, и USE_FULL_SCREEN_INTENT (core-functionality = calling → авто-грант FSI на A14+). Без этого FSI деградирует в heads-up.
  • Параллельно (вне Telecom, но в той же UX-теме надёжности): once-prompt REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + deep-link в Samsung «не усыплять» / Xiaomi «Автозапуск» (§5.40). Это реально двигает доставку звонков сильнее, чем сам Telecom.

9. Тест-план (нужно железо + 2 аккаунта)

Нужны 2 Matrix-аккаунта с токенами (A, B) и, в идеале, физический Samsung One UI + Xiaomi MIUI/HyperOS (на эмуляторе аудио-contention и OEM-килл не воспроизводятся). Базовый прогон (debug APK, adb logcat):

  1. A→B, B отвечает, двухстороннее аудио без эха (earpiece) — gating Phase B.
  2. Переключение спикер/BT во время звонка (Phase B/D), currentCallEndpoint отражает реальность.
  3. B блокирует экран 23 мин → звонок жив (mic не отозван, netd не блокирует) — регресс §2.2 не должен вернуться.
  4. Интероп с сотовым: во время Vojo-звонка приходит GSM-звонок → Telecom арбитрит hold/resume.
  5. Answer-from-killed: B убит (swipe, не force-stop) → FCM ring → CallStyle → Answer → phoneCall-FGS стартует из killed (§5.41).
  6. Web-интероп: web-A ↔ Android-B (оба направления), ring/answer/hangup.
  7. Откат флага telecomEnabled=off → поведение = текущему (страховка релиза).

10. Принятые решения (2026-06-23) и что осталось открытым

Принято:

  • Поза: full rip-and-replace — Telecom становится primary нативным бэкендом звонков, FGS/ring-registry/audio переезжают под него. Но инкрементально и за флагом TELECOM_ENABLED (Phase A default off, чтобы первый билд не сломал звонки до проверки на железе; после валидации на Samsung флипаем default on и снимаем legacy-путь).
  • Библиотека: androidx.core:core-telecom:1.0.1 + новый Kotlin source-set только для telecom-модуля (VojoCallsManager.kt), остальное остаётся Java. Версии: Kotlin 2.2.0, coroutines 1.10.2 (совместимо с Gradle 8.14.3 / AGP 8.13.0 / JDK 21).
  • Тест-железо: Samsung One UI (физический) — есть. Xiaomi/Pixel — нет; multi-vendor (§3.7) и Xiaomi-autostart (§5.40) остаются открытыми до доступа к устройствам.

Осталось открытым (решаем на железе в Phase B):

  • Аудио-владение: Telecom-owns-routing (правильно, рискованно — §4) vs Telecom-only-signaling + AudioRoutePlugin-owns-audio (страховка). Решаем по живому эхо-тесту на Samsung. Если эхо непобедимо → fallback на signaling-only.

Решения Phase C (2026-06-23, новая сессия):

  • Момент регистрации входящего: ring-time (как только пришёл FCM), а не на Answer. Обоснование — industry-standard для self-managed Telecom (Google core-telecom sample), «архитектурно чисто, без костылей». Процесс/сессию весь ринг держит phoneCall-FGS из FCM (element-android-паттерн), не best-effort wakelock.
  • phoneCall-FGS: юзер подтвердил правку проверенного §2.2-CallForegroundService (добавить тип phoneCall, FOREGROUND_SERVICE_PHONE_CALL) с обязательным ретестом под lock. Доки-сверка (см. Phase C выше) сняла прежний риск §5.34 «phoneCall бросает SecurityException»: предусловие = MANAGE_OWN_CALLS (есть), активный звонок не нужен, не-while-in-use.
  • §5.41 закрывается живым phoneCall-FGS (стартует из killed по high-priority-FCM / notification-tap исключениям), а не отдельным prestart-хаком.

11. Журнал работ

  • 2026-06-23 — Phase A (фундамент) + Phase B-hook, default-off, собрано локально. Добавлены: MANAGE_OWN_CALLS; Kotlin-плагин в Gradle (kotlin-android 2.2.0) + core-telecom:1.0.1 + coroutines-android:1.10.2; VojoCallsManager.kt (self-managed CallsManager обёртка: registerAppWithTelecom + addCall + Java-friendly фасад startCall/answer/setActive/endCall/requestEndpoint + Listener→JS колбэки endpoint/mute/answer/disconnect); Capacitor-мост TelecomCallPlugin.java (+ регистрация в MainActivity); JS-обёртка telecomCall.ts с TELECOM_ENABLED=false; хук useTelecomConnectionSync.ts (по образцу useAndroidCallForegroundSync, keyed на joined) смонтирован в CallEmbedProvider. CallForegroundService / FGS-тип и AudioRoutePlugin не тронуты (не регрессим §2.2; audio-ownership §4 решаем на железе).
    • Ключевой API-нюанс (подтверждён javap по AAR): CallControlScope extends CoroutineScope и не @RestrictsSuspension; параметр block у addCallnon-suspend CallControlScope.() -> Unit. Поэтому suspend-вызовы (setActive/answer/disconnect/requestEndpointChange/collect Flow) нельзя звать прямо в block — только launch { } на самом scope. Императивные команды из Java launch-аются на захваченном controlScope. Никакого CompletableDeferred.await() для удержания сессии не нужно — addCall сам держит её до disconnect.
    • Верификация локально (SDK /home/ubuntu/Android/sdk, platform-36 + build-tools 36): :app:compileDebugKotlin
      • compileDebugJavaWithJavacBUILD SUCCESSFUL; полный :app:assembleDebugAPK собран (20.6 MB), MANAGE_OWN_CALLS смержён в манифест (проверено aapt2 dump permissions). Web: tsc --noEmit (весь src) + eslint --max-warnings 0 + prettier --check — всё зелёное, shared/web путь не тронут.
    • Поведение по умолчанию = текущему (флаг off; на web/iOS/API<26 — no-op).
  • 2026-06-23 — Phase B validated на Samsung One UI + §4 решён. Прогон A→B на живом девайсе (adb 192.168.1.71:5555, лог VojoTelecom + dumpsys telecom):
    • registerAppWithTelecom okaddCall accepted: call session livesetActive -> CallControlResult(Success)endpoint -> EARPIECEteardown: disconnect cause=2 (clean LOCAL hangup). Полный per-call lifecycle работает.
    • dumpsys telecom: PhoneAccount {chat.vojo.app} Capabilities: SelfManaged SuppVideo Video TransactOps, Audio Routes: BESW; CallFocus state=DIALING … TransactionalFocusRequestCallbackнаш звонок получил системный call-audio focus, core-telecom выбрал transactional CallControl API (не legacy backport). "Skipping binding … doesn't support self-mgd calls" — норма (self-managed UI рисуем сами; у WhatsApp в том же дампе идентично).
    • Эха нет (живой A↔B) + endpoint-flow эмитит → §4 РЕШЁН: Telecom владеет аудио чисто. Идём «standard track»: спикер/BT-тоггл → Telecom requestEndpoint, AudioRoutePlugin.setCommunicationDevice отключаем в Telecom-режиме.
    • adb достаёт Samsung из dev-окружения → build/install/logcat/dumpsys могу гонять сам; от юзера нужны только two-party audio/answer прогоны.
  • 2026-06-23 — §4 follow-through (Telecom-owned routing) сделан и провалидирован на Samsung.
    • Спикер/earpiece-тоггл (useCallSpeakertelecomCall.requestEndpoint) переведён на Telecom endpoints; AudioRoutePlugin.setCommunicationDevice отключён в Telecom-режиме (gate в useCallSpeaker + useAndroidCallForegroundSync); активный endpoint Telecom (telecomEndpoint) мирроится в callSpeakerAtom. Web: tsc/eslint/prettier зелёные.
    • Баг найден и пофикшен: первая версия requestEndpoint искала endpoint через availableEndpoints.first(), но этот Flow не реплеит текущее значение свежему коллектору → .first() висел до следующего изменения роута (на практике — до teardown звонка), и переключение спикера срабатывало только при завершении звонка. Фикс: кешируем список endpoint'ов в @Volatile availableEndpointList из уже работающего коллектора и читаем синхронно на тапе.
    • Validated на железе (логкат bh9xx9fdo): requestCallEndpointChange SPEAKER/EARPIECEendpoint -> SPEAKER/EARPIECErequestEndpoint … -> CallControlResult(Success) срабатывают мид-колл, в обе стороны, многократно; setCommunicationDevice(speaker/earpiece)=true; аудио слышимо следует за роутом (подтверждено юзером). Samsung даже отдаёт локализованные имена endpoint'ов («Динамик» / «Динамик телефона»). Транзакция ~800ms, в пределах 5s-дедлайна.
    • Итог: Phase B полностью закрыта и провалидирована — исходящий звонок, live Telecom-сессия, audio focus, спикер/earpiece routing. BT тоже пойдёт через Telecom endpoints (не тестировался без гарнитуры).
    • Следующий шаг: Phase C (incoming через Telecom + phoneCall FGS → answer-from-killed §5.41). Default флага в репо остаётся off (юзер держит =true локально); промоут в default после Phase C. adb-доступ к Samsung (192.168.1.71:5555) — билд/инсталл/логкат гоняю сам.
  • 2026-06-23 — Phase C реализована (весь код, за флагом, собрано) + multi-agent self-review + фиксы. Не провалидировано на железе (по решению юзера — проверки единым фронтом позже; флаг остаётся false).
    • Код (флаг-офф = байт-в-байт текущее поведение): FOREGROUND_SERVICE_PHONE_CALL + тип сервиса microphone|phoneCall (merged-манифест 0x84); CallForegroundService.computeForegroundType (ring→phoneCall, join→microphone|phoneCall); ring-time addCall(INCOMING) + phoneCall-FGS из VojoFirebaseMessagingService.renderOne (gate isTelecomEnabledNativetelecomBridge.ts pref); teardown в removeIncomingRing (кроме answered); VojoCallsManager per-room идемпотентность + answered/hasSession/isAnswered + fallbackListener; telecomCall.answer(roomId) в обоих answer-путях; mute-mirror (Telecom isMuted → widget, односторонне).
    • Self-review workflow (5 сеньор-линз → adversarial verify → synth): 32 находки, 28 подтверждено. Вердикт: структурно здраво, flag-off безопасно (§2.2/§5.45 не регрессят, web/iOS не тронуты) — но teardown-контракт answered был хрупкий. Применены все must-fix + should-fix: (1) race-guard отложенного startCall (re-check слота под telecomLock, иначе orphan-сессия вешает слот); (2) leak при провале join после answer — .catch в обоих JS-путях зовёт telecomCall.endCall() + REMOVE; (3) answerCall ставит answered синхронно ДО null-check + pendingAnswer для answer-from-killed-гонки; (4) clearSessionState() на каждом конце сессии (onDisconnect/finally/teardown) — нет залипания answered/fallbackListener; (5) room-aware answerCall(roomId) — не отвечает чужую сессию при втором ринге; (6) mute-mirror пропускает первый (initial) emission, чтобы не разглушить join-muted юзера. Плюс ниты: setMicrophoneEnabled .catch, telecomBridge gate на isAndroidPlatform, удалён dead markAnswered, поправлены устаревшие комменты/javadoc.
    • Остаточные (low/nit) — в Phase D / на device-test, задокументированы: BAL у нативного fallback onAnswer (BT/Auto answer-from-killed — Phase D); двойная нотификация во время ринга (phoneCall-FGS «ongoing» + CallStyle — polish/слияние в Phase D); same-room eviction не рвёт Telecom-сессию (нужен Context в upsert); outgoing-во-время-ринга и WebView-убит-мид-колл — FGS чистится по expiry-аларму/смерти процесса; fast-decline → stopService до startForeground (тот же accepted risk class, что у legacy FGS — проверить swipe-kill→ring→instant-decline на Samsung); Play Console foregroundServiceType declaration (см. §8).
    • Верификация: tsc + eslint --max-warnings 0 + prettier (тронутые файлы) + :app:assembleDebug — всё зелёное.
    • НЕ сделано (вне Phase C-плана): нативный полноэкранный IncomingCallActivity (showWhenLocked/turnScreenOn, wakelock, кнопки) по эталону element-x — это даёт «как в Телеграме» lock-screen UX и закрывает §5.34; решается отдельно.
  • 2026-06-23 — Полная миграция: флаг убран, Telecom = единственный нативный бэкенд звонков + нативный full-screen IncomingCallActivity. По решению юзера («выпили флаг, начинаем полную миграцию»):
    • minSdk 24 → 26 (variables.gradle). CallsManager @RequiresApi(26) теперь всегда доступен; Android 7.x (24-25, ~0% в 2026) больше не поддерживается. Это убирает все SDK-гарды и второй аудио-путь.
    • TELECOM_ENABLED удалён. telecomCall.enabled() = isAndroidPlatform() (на web/iOS — no-op). Удалены telecomBridge.ts + его маунт в App.tsx + нативный isTelecomEnabledNative (FCM). Ring-time addCall(INCOMING) + phoneCall-FGS и FSI→IncomingCallActivity теперь безусловны на каждом фоновом DM-ринге.
    • Legacy AudioRoute retired. Удалены callAudioRoute.ts + AudioRoutePlugin.java + регистрация в MainActivity. Маршрут аудио ведёт только Telecom (requestEndpoint/currentCallEndpoint); useCallSpeaker/useAndroidCallForegroundSync переведены на Telecom-only. (§4 «audio-ownership open» закрыт в пользу Telecom-owns-routing — провалидировано на Samsung в Phase B.)
    • Нативный full-screen IncomingCallActivity (IncomingCallActivity.java
      • layout): экран звонка поверх локскрина в стиле DAWN (палитра 1:1 из src/colors.css.ts), setShowWhenLocked/setTurnScreenOn, bounded PARTIAL_WAKE_LOCK, крупные Ответить/Отклонить (= те же intent/broadcast, что кнопки CallStyle), авто-finishFor на терминальных переходах, en/ru строки. FSI поднимает этот Activity. Закрывает §5.34 (lockscreen-кнопки Answer/Decline на Samsung).
    • Self-review (3 линзы → verify → synth, 14 находок/12 подтв.): один must-fix — answer на secure-keyguard запускал MainActivity до фактической разблокировки (showWhenLocked-экран финишировался, юзер оставался на голом PIN-prompt без call-UI). Пофикшено: requestDismissKeyguard с KeyguardDismissCallback → launch только в onDismissSucceeded, fallback-таймер, onDismissCancelled сохраняет ринг. Плюс ниты (порядок onCreate, re-acquire wakelock в onNewIntent, удалён unused call_bg_raised, комменты).
    • Верификация: tsc + eslint --max-warnings 0 + prettier (тронутые) + :app:assembleDebug — зелёное; merged-манифест minSdkVersion=26, AudioRoute удалён из APK, IncomingCallActivity + WAKE_LOCK смержены.
    • ⚠️ НЕ провалидировано на железе. Флаг убран → Telecom едет на каждой сборке. Перед релизом обязателен прогон на Samsung (2 аккаунта): ring/answer-from-killed под lock, регресс §2.2, decline/expiry, аудио-роут/эхо, интероп с GSM. Остаточные Phase D: BT/Auto/Wear answer-from-killed (BAL), двойная нотификация во время ринга (FGS+CallStyle), Play Console foregroundServiceType declaration (§8).
  • 2026-06-23 — Answer-без-разблокировки (over-lock call screen, паттерн element-x). По жалобе юзера «разблокировка требуется чтобы взять трубку». Корень: медиа в WebView (MainActivity), а она не showWhenLocked → над защищённым локскрином WebView не резюмится → ОС требует unlock. Развязка (сверено по element-x — наш точный стек): их ElementCallActivity (хост WebView звонка) в onCreate делает setShowWhenLocked(true) + FLAG_KEEP_SCREEN_ON и не зовёт requestDismissKeyguard — WebView звонка грузится поверх локскрина одними оконными флагами.
    • Реализация: на Answer IncomingCallActivity передаёт over_lock = isKeyguardLocked (убран мой прежний requestDismissKeyguard-must-fix — он решал не ту задачу). MainActivity при over_lock=true на залоченном девайсе делает setShowWhenLocked(true) + setTurnScreenOn + FLAG_KEEP_SCREEN_ON и накрывает WebView непрозрачным cover-View (#0d0e11), чтобы над локом не мелькнули чаты, пока не отрисуется JS-экран звонка.
    • Новый плагин OverLockCallPlugin (getState/ready/exit + событие overLockUnlocked). Веб: overLockCall.ts, overLockCallAtom, хук useOverLockCall (unlock→exit, call-end→exit), полноэкранный OverLockCallScreen (DAWN-стиль: аватар-инициал + имя + переиспользованный CallControl mute/speaker/hangup; на mount зовёт ready() → снимает cover). Вход в режим — в usePendingCallActionConsumer (на answer: getState().overLock → атом true; на провал join → exit). ACTION_USER_PRESENT (разблокировка) → нативно снимаем showWhenLocked + cover, событие в JS → оверлей убирается, обычный (уже аутентифицированный) апп виден.
    • Приватность: над локом виден ТОЛЬКО звонок (cover + полноэкранный оверлей перекрывают чаты); fail-safe — если JS не позвал ready(), cover остаётся (тёмный экран, не утечка). Разблокировка нужна только чтобы пользоваться остальным аппом, не чтобы говорить.
    • Верификация: tsc + eslint + prettier (зелёное) + полный npm run build:android:debug (web-бандл + APK) — BUILD SUCCESSFUL, APK device-ready (21M). На железе ещё не гонялось.
  • 2026-06-23 — Over-lock self-review (3 линзы → verify → synth, 10/9) + фиксы. Вердикт: механизм приватности здравый, но был один реальный leak + lifecycle-ловушки.
    • MUST-FIX (приватность): на завершении звонка под локом JS снимал свой непрозрачный оверлей ДО того, как натив снимет showWhenLocked (exit() лишь планировал teardown через lifecycleHandler.post), → таймлайн DM-комнаты на несколько кадров над локскрином. Фикс: exitOverLock(Runnable done) резолвит JS-промис exit() после очистки showWhenLocked; useOverLockCall/catch снимают оверлей только в .then после exit() — WebView никогда не остаётся голым над локом.
    • MEDIUM: (1) ответ-под-лок без embed (cold-start, комната не в сторе → switchOrStartDmCall резолвит без embed) оставлял юзера на вечном спиннере без hangup → теперь self-heal (если embed нет — выходим из over-lock) + OverLockCallScreen всегда рисует hangup-кнопку. (2) overLockCallAtom ставился из one-shot getState() без ре-валидации (гонки с join-fail/unlock) → guard left в consumer + OverLockCallScreen сверяется с нативной правдой (getState) на mount и само-дисмиссится, если натив уже вышел из over-lock.
    • LOW: ready() через double-rAF (снимаем cover только после реальной отрисовки оверлея); чистим over_lock extra после обработки (recreate по fontScale не ре-входит в over-lock); onResume keyguard-backstop для OEM/ biometric-разблокировок без ACTION_USER_PRESENT; shade-Answer теперь тоже шлёт over_lock (консистентно с FSI, MainActivity ре-чекает keyguard).
    • Verified-safe (из ревью): unlock-exit путь корректен (натив чистит до флипа атома); оверлей непрозрачен с первого кадра; showWhenLocked снимается на каждом реальном выходе, ресивер не течёт; обычные пути и web/iOS не тронуты. tsc/eslint/prettier + полный build:android:debug — зелёное.
  • 2026-06-23 — Внешнее QA/код-ревью фичи (9 линз → adversarial-verify → second pass, 79 агентов, 51 находка) + фиксы блокера/high/части medium-low. Ревью гоняла другая сессия по переданному промту; вердикт: архитектура здравая, happy-path целостен, но state-machine VojoCallsManager и ring-time маршрутизация имели реальные дыры (утечка Telecom-сессии + phoneCall-FGS, потеря аудио/mute на edge-путях), ловимые из кода. Применено:
    • B1 (BLOCKER) — session-generation guard в VojoCallsManager. clearSessionState() звался безусловно из onDisconnect/finally/teardown и затирал состояние УЖЕ НОВОЙ сессии (controlScope присваивается асинхронно после binder-round-trip, поэтому «нет scope» ≠ «нет сессии»). Корень закрыт AtomicLong sessionGen: addCall-блок захватывает myGen; onDisconnect/finally/коллекторы no-op при несовпадении генерации; addCall, увидев устаревшую генерацию на accept, сам делает disconnect() и НЕ публикует scope; teardownLocked бампит генерацию (старые колбэки глохнут). Все start/teardown/answer-решения сериализованы под lock. Заодно закрывает M5 (идемпотентность startCall теперь по currentRoomId одному — синхронно ставится до scope) и L8.
    • H1 — маршрутизация ring-time событий. (a) clearSessionState/teardownLocked больше НЕ зануляют fallbackListener (им владеет FCM-путь: ставит в ensureTelecomIncoming, чистит в maybeTeardownTelecomRing) — раньше pre-emptive teardown внутри startCall убивал только что поставленный нативный listener → killed-ринг onAnswer/onDisconnect уходили в никуда. (b) lifecycle-события (answer/disconnect/error) теперь доставляются notifyLifecycle() в ОБА listener'а (JS + native fallback), когда они различны: alive-in-background JS-listener затеняет fallback, но во время неотвеченного ринга JS не подписан → событие терялось; теперь нативная очистка/ бут-аппа происходят всегда. Audio/route/mute — по-прежнему один activeListener().
    • H4 — mute-mirror. Убран count-based skip первого telecomMute (isMuted у core-telecom 1.0.1 — Channel-flow без replay, как availableEndpoints; нативный коллектор стартует на ринге задолго до JS, события до подписки дропаются) — съедался ПЕРВЫЙ реальный hardware/BT/Auto mute → горячий mic при «muted». Теперь зеркалим безусловно (setMicrophoneEnabled идемпотентен).
    • H3 — same-room supersede. upsertIncomingRing при вытеснении старого same-room ринга собирает evicted eventId под registryLock и ПОСЛЕ релиза лока зовёт maybeTeardownTelecomRing + IncomingCallActivity.finishFor (инвариант: Binder/telecomLock не под registryLock) — раньше сессия+FGS вытесненного ринга висели и telecomRingEventId залипал, блокируя pre-arm нового. Пробросил Context (оба колл-сайта).
    • H2 — join-fail после answer. Teardown Telecom-сессии + phoneCall-FGS привязан к lifecycle callEmbed (новый эффект на embed→undefined в useTelecomConnectionSync + useAndroidCallForegroundSync), не только к joined — если widget не дошёл до JoinCall (preparingError/EC-Close/LiveKit-fail), joined-гейтнутые cleanup'ы не срабатывали и сессия+FGS текли (hasSession()==true деградировал все следующие звонки). Self-heal без embed + .catch в consumer теперь явно зовут callForegroundService.stop().
    • M1 — GSM hold/resume. Подписаны telecomSetInactive/telecomSetActive: на hold мутим widget-mic (запомнив pre-hold состояние), на resume восстанавливаем — интероп с сотовым звонком теперь паузит и медиа, не только Telecom-состояние. M2 (часть): слушаем telecomError (→ hangup), current-embed guard на telecomEndpoint.
    • M3/M4 — over-lock. Нативный safety-таймер (15s) на cover: если JS не позвал ready()/exit() (boot-crash, login-redirect), MainActivity сам выходит из over-lock (чистит showWhenLocked + cover) — не залипаем на чёрном экране над локом. handleOverLockIntent теперь ДОВЕРЯЕТ over_lock-флагу (его вычислил IncomingCallActivity по keyguard на тапе), а не ре-чекает isKeyguardLocked()ре-чек давал false-negative в окне turnScreenOn → чаты над локом; over-enter само-корректируется в onResume.
    • M9/L2 — IncomingCallActivity. onAnswer сразу нативно зовёт answerCall(roomId) (помечает сессию answered синхронно — закрывает окно cold-answer, где expiry/remote-disconnect рвал только что принятый звонок); JS-answer позже идемпотентен. Wakelock освобождается перед ре-acquire в onNewIntent (supersede больше не орфанит держащийся PARTIAL_WAKE_LOCK). M12: на answer бриджим removeIncomingRing(notifEventId) напрямую после резолва telecomCall.answer() (cold-start ринг может быть не в incomingCallsAtom → атомный REMOVE no-op → telecomRingEventId залипал ~32с).
    • Верификация: tsc 0 · eslint --max-warnings 0 src 0 · prettier clean · полный npm run build:android:debug — BUILD SUCCESSFUL. На железе не гонялось.
    • Отложено (документировано, требует железа / UX-полиш / риск §2.2): M6 (in-app локаль для IncomingCallActivity вместо system — нужен механизм PushStrings), M7 (порядок Answer/Decline strip↔native), M8/M10 (второй ринг поверх over-lock; remote-onAnswer watchdog), M11 (апгрейд FGS до microphone|phoneCall сразу на answer — трогает валидированный §2.2), M13 (гонки общего FGS на ring-пути), M14, L1/L3/L6/L7/L9/L10 (FGS start-before-stop окно, 1-кадровый cover в warm-answer, finishFor↔onCreate гонка, рингтон за FSI-Activity, cancellation token, аватар/ пульс/mxid в over-lock & native ring). Известные Phase-D остатки без изменений (BT/Auto answer-from-killed BAL, double-notification ring, Play Console FGS-декларация).
  • 2026-06-23 — Дизайн-доработка экранов звонка (по обратной связи юзера).
    • In-call экран над локом (OverLockCallScreen) — контраст + прод-вид. Был «чёрный текст на тёмном»: экран рисовался через color.Surface.* токены, но портал над локом паинтится до/вокруг ThemeManager (cold-start answer-from-killed), а в светлой теме Surface.Container=белый. Фикс: явная DAWN-тёмная палитра (1:1 с нативным IncomingCallActivity/colors.xml #0d0e11/#e6e6e9) + скоуп DarkTheme.classNames на портале, чтобы и токены переиспользованного CallControl резолвились тёмными независимо от темы аппа (звонок = всегда тёмный, как любой нативный дайлер). Добавлено: живой таймер (useCallDuration+formatCallTimer), строка статуса («Соединение…» → m:ss) с live-dot, pulse-halo на аватаре пока не joined, кнопка Свернуть (chevron, выход из over-lock БЕЗ hangup — звонок живёт, возврат через ongoing-notification). Новый i18n Call.minimize (en/ru).
    • Drag-to-activate на нативном входящем (IncomingCallActivity). Обе кнопки (Answer/Decline) теперь и тап, и драг. Дуализм по требованию юзера: короткий тап (движение < touchSlop) — срабатывает сразу; как только начался драг — кнопка едет за пальцем и коммитит только пройдя «за свою область» (≈ 0.55×ширины); отпустил раньше — пружинит назад (OvershootInterpolator), ничего не делает. Lift-scale на press, доп-scale + однократный haptic при пересечении порога. Commit идёт через performClick() — click-listener/TalkBack путь жив; отдельный listener-инстанс на каждую кнопку (multitouch-safe).
    • Верификация: tsc 0 · eslint 0 · prettier clean · полный build:android:debug — BUILD SUCCESSFUL. На железе не гонялось.
  • 2026-06-24 — Фикс критов (после честного аудита halt-перечня).
    • M6 — локаль нативного входящего экрана. IncomingCallActivity.attachBaseContext теперь оборачивает контекст в in-app язык через новый PushStrings.wrap() (тот же createConfigurationContext, что у пуш-строк). RU-юзер на EN-девайсе видит «Ответить/Отклонить/Входящий звонок», а не системную локаль. Layout-@string и getString резолвятся в выбранном языке.
    • H4 — mute-mirror сделан robust без зависимости от внутренностей либы. Проверил API: isMuted() это plain Flow<Boolean> (не StateFlow), как availableEndpoints (который эмпирически не реплеит). Но логику переписал так, что корректна в любом случае: MUTE зеркалим всегда, а авто-UNMUTE — только если это Telecom нас замьютил (telecomMutedUs). Спурьёзный/начальный «not muted» больше физически не может размьютить того, кто зашёл в муте.
    • M13 — гонка общего FGS на ring-пути. Старт phoneCall-FGS перенесён ВНУТРЬ main-post, ПОСЛЕ повторного подтверждения слота. Конкурентный decline/expiry/supersede в окне диспатча больше не оставляет осиротевший FGS и не стопит чужой звонок — abort-ветка просто return (ничего не стартовало).
    • M11 — окно без mic-FGS на answer-from-killed. Consumer на ответе сразу переиздаёт FGS с microphone-типом (start({phoneCall:true, title})); computeForegroundType нативно пересекает с грантом RECORD_AUDIO → upgrade или no-op, без краша. Закрывает несколько секунд под локом, где §2.2 mic-retention был не гарантирован.
    • M8 — второй входящий поверх over-lock. OverLockCallScreen теперь рисует баннер второго звонка сверху, переиспользуя IncomingCallStrip (вся логика answer/decline/switch — single-source). Любой ринг на комнату, отличную от активной, всплывает; раньше его не было видно (оверлей перекрывал strip, FCM считал апп foreground).
    • M7 — порядок кнопок. Strip приведён к раскладке нативного экрана: Decline слева, Accept справа (убрал риск мис-тапа деструктивного действия).
    • Verified: tsc 0 · eslint 0 · prettier clean · полный build:android:debug — BUILD SUCCESSFUL. На железе не гонялось.
    • Честно осталось (НЕ криты — не код-фиксится или полиш): H1 alive-bg ответ с гарнитуры (платформенное BAL- ограничение Android 12+, как Phase-D; чистка/disconnect надёжны, бут аппа — нет); B1 — реализован, нужна проверка под реальным переплетением потоков на устройстве; GSM-hold — мьют мика + OS audio-focus ducking входящего (widget API не даёт полноценный «hold» медиа); реальный аватар на over-lock и «свернуть»=bubble/PiP — полиш, не баг.
  • 2026-06-24 — Внешнее multi-agent ревью сигнального слоя (12 линз → adversarial-verify → second pass) + фиксы RED/ORANGE. Полный отчёт: calls_server_review_2026-06-24.md (+ raw findings). Корневой вывод подтверждён первоисточником (Sygnal gcmpushkin.py:710-717): V1-flatten форвардит только строковые поля content на один уровень → content.sender_ts/content.lifetime (числа) и content.m.relates_to (dict) дропаются на FCM-direct, т.е. нативные latest-wins/expiry/composite-dedup были мертвы на killed-пути. Вердикт ревью: «в прод на устройствах нельзя». Реализовано и собрано (tsc + eslint --max-warnings 0 + prettier + :app:assembleDebug — всё зелёное; на железе НЕ гонялось):
    • RED-1 — Doze-устойчивый потолок ринга. VojoFirebaseMessagingService: на старте ring-time Telecom-сессии берём bounded PARTIAL_WAKE_LOCK("vojo:ring") + Handler.postDelayed(removeIncomingRing, lifetime+grace), привязанные к telecomRingEventId, отменяемые на answer/decline/supersede (releaseRingCeilingLocked). FGS держит процесс, но не CPU — неточный setAndAllowWhileIdle под Doze слипал на минуты; AlarmManager остаётся backstop'ом на смерть процесса. (Зеркало element-x.)
    • RED-2 — own-2nd-device disarm. useCallerAutoHangup: peer теперь user-scoped (m.userId !== selfId), no-answer-дедлайн привязан к реальному lifetime ринга (guard от stale own-ring). ORANGE-12 закрыт тем же.
    • RED-3 — logout-teardown. Новый bridge callForegroundService.clearAllIncomingRings() (нативный sweep: все ринги + Telecom-сессия + FGS + ceiling) вызывается перед reload() в handleLogout/logoutClient/clearLocalSessionAndReload.
    • RED-4 — FGS-краш. CallForegroundService: промоция в onCreate (phoneCall-placeholder, атомарно гасит startForegroundService-обещание до возможного stopService), started-флаг, onStartCommand лишь уточняет тип/тайтл.
    • RED-5 — web SW не снимал ринг. sw.ts: self-dismiss баннера по lifetime/sender_ts (waitUntil-bounded) + closeCall message-handler; useIncomingRtcNotifications шлёт closeCall в SW на каждом удалении ринга. (Web — не провалидировано без HS+WebPush.)
    • RED-6 (online) — caller-cancel. subscribeMemberships детектит peer-empty (вызывающий ушёл) и снимает ринг с 8-сек grace (flap-absorb, recall внутри окна не убивается). Offline-часть (push-правило на membership-leave/m.rtc.decline) — серверная, отложена (см. ниже).
    • RED-7 — мёртвый звонок у отвечающего. Прокинут флаг incoming через CallEmbed/createCallEmbed/switchOrStartDmCall; отвечающий, чей пир не появился, рвёт за PEER_LEAVE_GRACE (8s), а не ждёт 40s с живым миком.
    • RED-8 — second-ring рвёт forming-звонок. VojoCallsManager.isBusy() (синхронный currentRoomId под lock) заменил hasSession() в ensureTelecomIncoming.
    • ORANGE-1 нативный clamp lifetime(≤5мин) + skew-clamp sender_ts против receipt-anchor (isExpired, expiry-alarm, tombstones).
    • ORANGE-6 fallbackListener чистится безусловно на teardown ринга + stale-ring guard в nativeRingListener.onAnswer.
    • ORANGE-10 waitLeave диспозит prev в finally (fail-open switch). YELLOW-2/3 decline-гейт rel_type==='m.reference'+sender.
    • Опровергнут самим ревьюером: YELLOW-5 (нет await между key-check и set → лика нет).
    • ОТЛОЖЕНО (серверное / большой объём, документировано): (a) push-правило для caller-cancel offline — override-rule на membership-leave или cleartext cancel-маркер → нативный removeIncomingRing (без него killed-callee ждёт ceiling, ~lifetime); (b) UnifiedPush для de-Googled/FCM-blocked (сейчас только 15-мин polling → missed-card); (c) F15 same-room eviction по parent callSessionId (упирается в тот же Sygnal-starvation — нужен string-mirror полей в cleartext-ринге); (d) decline-id из send-response вместо скрейпа таймлайна. Все RED требуют прогона на Samsung (Doze, answer-from-killed, logout-во-время-звонка, 2 устройства).
  • 2026-07-03 — Внутреннее multi-agent ревью (архитектура/QA/бэкенд/стиль + отдельный разбор glare-гонки, 77 агентов, 40 подтв. находок) + фиксы. Корень репортнутого юзером бага найден и закрыт. tsc + eslint --max-warnings 0 + prettier + полный :app:assembleDebug — зелёное; на железе НЕ гонялось.
    • Корень «перезвон после сорвавшегося glare-звонка не проходит» (F1, blocker): ring-событие шлёт ТОЛЬКО EC-виджет и ТОЛЬКО как первый член сессии (matrix-js-sdk MatrixRTCSession: notify лишь при oldMemberships.length===0, и pendingNotificationToSend безвозвратно гасится при memberships.length>0). Своё stale-membership после glare+cancel держалось до delayed-leave и (а) уводило createCallEmbed в join_existing intent (ring не взводится), (б) заваливало SDK-гейт. Фикс: createCallEmbed считает ongoing для DM peer-scoped (своё-текущее-устройство не в счёт); useSwitchOrStartDmCall перед исходящим startCall ждёт (bounded, fail-open) исчезновения своего membership (waitOwnMembershipGone); zombie-ветка same-room даёт joined-виджету короткий waitLeave ДО dispose, чтобы leave реально ушёл (F2). Хост-сайд собственный ring (defense-in-depth для неотвечающего виджета на не-MSC4140 HS) — сознательно НЕ делаем (риск дублей/санитайза), задокументировано ниже.
    • Прочий glare/приёмная сторона: F4 — suppress входящего ринга больше не срабатывает по stale own-device membership (только live-embed-здесь ИЛИ membership на ДРУГОМ устройстве); F5 — useCallJoined ресетит joined на смену embed A→B (не только на undefined); F6 — ответ (incoming) всегда join_existing intent (не рингует «обратно»); F8 — healthy-no-op по user-scoped пиру (своё 2-е устройство не пир); F19 — no-answer-таймер перевзводится на реально отправленный свой ring; F3 (нативно) — same-room join помечает Telecom-сессию answered (ceiling/suppress не рвёт живой звонок); F14 — старт ring-сессии целиком под telecomLock (нет orphan-слота); F11 — провал Telecom на killed-пути деградирует в notification-only, не сносит видимый ринг.
    • Безопасность (F15) + M9-добор (F22): каждый Answer-PendingIntent несёт per-install секрет (CallActionToken, приватные prefs, переживает смерть процесса); JS-консюмер (usePushNotifications) верифицирует его перед авто-джойном (fail-closed на mismatch, fail-open на ошибку плагина), MainActivity гейтит over-lock тем же. Трамплин-ресивер НЕ используем (бан notification-trampoline на Android 12+) — MainActivity сам зовёт нативный answerCall на shade-Answer (закрывает cold-answer окно M9). F17 — onResume-бэкстоп over-lock ждёт settle-окно после входа (не рвёт экран в turnScreenOn-transient).
    • Мелочь: F12 (suppress шлёт closeWebRing), F20 (SW self-dismiss сверяет eventId), F24 (нативный ring-экран таймаутит по реальному lifetime), F25 (IncomingRing.data → ConcurrentHashMap), F26 (Telecom-disconnect чистит атом при мёртвом виджете), F30 (mute-mirror по last-sent, explicit-state), F31 (decline-тумбстон на MAX lifetime), F32 (строки FGS-нотификации/канала через PushStrings en/ru), F33 (dlog-гейт в VojoCallsManager), F34 (мёртвый telecomCall.setActive/события удалены), F37/F41 (именованные/шаренные константы), F42/F43/F44 (dead i18n-ключ, isAndroidPlatform-гейт, unused import). docs/ai/android.md обновлён (minSdk 26 + секция «Calls (Telecom)»).
    • СОЗНАТЕЛЬНО ОТЛОЖЕНО (риск регрессии / нужен девайс / крупный рефактор — не код-фиксим наспех): F1(c) хост-сайд ring для неотвечающего виджета; F9 ceiling для notification-only ринга под Doze (нужен per-ring wakelock/слот-промоушен — риск утечки); F16 split-brain при cross-room supersede живого ринга (нужна аккуратная cross-lock нотификация координатора); F28 Telecom-сессия для foreground-ринга; F18 декомпозиция VFMS (2499 строк — высокий риск); F35/F36 чистка stale API-24-комментов и DAWN-палитра в ресурсы; F38 OverLockCallScreen inline-styles → css.ts (риск визуальной регрессии); F39/F40 nit-рефакторы. Всё glare-критичное на железе (2 аккаунта Samsung): перекрёстный звонок → cancel → перезвон в окне 8s; decline→перезвон; hangup одного при живом втором → перезвон; answer-from-killed под lock с токеном.
  • 2026-07-03 — Прогон на Samsung + research-воркфлоу (5 агентов по open-source Matrix/telecom) + фикс glare-коллапса (fix 1). На живом железе (@heaven Android ↔ @test6 web) подтверждено: применённые фиксы работают (двойной-answer guard, answered-keeps-session, gen-смена сессий, FGS-тип 132, ноль крашей/BAL). Но одновременный старт звонка с обоих устройств (glare) схлопывал обе попытки.
    • Корень (research glare + код): в MatrixRTC нет «ответа» — звонок это co-presence (оба просто члены одной RTC-сессии; MSC3401 glare для групповых не разрешает — клиентское дело). Наш баг: два РАЗНЫХ инстанса useSwitchOrStartDmCall (исходящий в хедере + «ответ» в strip) с раздельными inFlightRef не сериализуются, но пишут общий callEmbedAtom. В окне waitOwnMembershipGone (до 2.5с) атом пуст → genuinelyParticipating не подавляет встречный ring → strip → Accept → второй инстанс → узкий same-room-guard (prev.joined && peerPresent) не ловит формирующийся эмбед → zombie dispose+recreate → два MembershipManager на одном (user,device) state-key[MembershipManager] Missing own membership: force re-join по кругу, членство 0→1→2→1→0, TypeError: a is not iterable (краш внутри EC-бандла). element-x-android НИКОГДА не плодит второй call-объект.
    • Фикс 1 (co-presence, не answer): новый formingCallRoomIdAtom (state/callEmbed.ts) — roomId формирующегося исходящего, ставится СИНХРОННО до await'ов. (a) doSwitchOrStart: ранний no-op if (incoming && (prev.roomId===roomId || forming===roomId)) return (модель element-x); (b) маркер ставится перед disposal/waitOwnMembershipGone, чистится в finally после callEmbedAtom; (c) genuinelyParticipating подавляет встречный ring по маркеру (strip не рендерится, Accept недостижим); (d) guard в usePendingCallActionConsumer — не рвать формирующийся свой звонок. tsc/eslint/prettier — зелёное.
    • ⚠️ НЕ провалидировано на железе — нужен повторный glare-прогон: одновременный старт → ДОЛЖЕН выйти один 2-сторонний звонок без force-re-join и скачков членства.
    • Отложенное (ранжировано research, impact/effort): (1) FCM cancel-маркер [high/M] — звонящий на отмене эмитит m.rtc.notification со строковым content_notification_type='cancel' + m.mentions.user_ids=[callee] (триггерит дефолтное .m.rule.is_user_mention, всегда пушит сквозь Sygnal-flatten); нативная ветка 'cancel' рядом с 'ring' → снимает CallStyle/ring/FGS. Закрывает и висящий ринг, и висящий teardown при замороженном WebView. БЕЗ правки Synapse-конфига. (2) host-side ring [med/M] — слать ring самим (mx.sendEvent плоский IRTCNotificationContent) вместо one-shot виджета; отключить ring виджета. (3) EC-бандл 0.16.3→0.20.3 [med/M] — не чинит glare (наш хост), но закрывает CVE-2026-48007 (утечка URL в analytics, фикс с 0.19.4) + снижает крэш-шум (v0.17 rejoin-crash #3693, v0.18 circular-logger #3733 — кандидаты на a is not iterable); держать multi-SFU off, пропустить 0.21-rc. (4) не-фриз WebView / нативный teardown [med/L] — research-агент вернул пусто, нужно отдельное копание (фризит ли фоновый WebView EC-teardown; как element-x держит WebView живым).