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.
78 KiB
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 — единственный нативный бэкенд звонков, legacyAudioRoutePluginудалён, добавлен нативный full-screenIncomingCallActivity(закрыл §5.34). §0–§9 ниже описывают исходный (флаг-гейтед) план и местами исторические — фактическое состояние и отступления держи по журналу §11. На железе ещё не провалидировано.
0. TL;DR
- Библиотека:
androidx.core:core-telecom:1.0.1(Jetpack Core-Telecom), а не ручнойConnectionService. Google сам рекомендует её для self-managed VoIP; она прячет за собой split «transactional CallControl (API 34+) ↔ ConnectionService backport (API 26–33)». Минус один:CallsManagerпомечен@RequiresApi(26), а нашminSdk = 24→ весь Telecom-код гейтим заBuild.VERSION.SDK_INT >= 26, на 24–25 остаётся текущий путь (доля 24–25 в 2026 пренебрежимо мала). - Telecom self-managed — это НЕ то, что кажется. Развенчание мифов в §1. Коротко: он не рисует системный
incoming-UI, не убирает
microphoneFGS, не чинит OEM-килл и не даёт Answer без разблокировки (медиа в WebView). Зато даёт: интероп с GSM-звонком и аудиофокусом, BT/Android Auto/Wear, системный «ongoing call» chip/журнал, и легитимныйphoneCall-FGS, который можно стартовать из killed (закрывает §5.41). - Главный технический риск — аудио. Telecom (
CallAudioModeStateMachine) и Chromium-ADM внутри WebView оба ставятMODE_IN_COMMUNICATIONи берут audio focus в одном процессе → mode thrash / эхо. И Telecom запрещаетAudioManager.setCommunicationDevice(то, что делает наш AudioRoutePlugin), маршрутизацию надо вести черезrequestEndpointChange(на части устройств флапает BT). Это валидируется только на железе. - 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. - Поза миграции (рекомендация): аддитивный 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/toWidgetpostMessage). - 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, либо TelecomisMuted-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).
Стратегия:
- Дать Telecom владеть mode/focus (
setAudioModeIsVoip(true)под капотом core-telecom). СамиsetModeне зовём (мы и не зовём —setModeставит WebView). - В Telecom-режиме отключить
setCommunicationDevice-веткуAudioRoutePlugin, маршрут вести черезrequestEndpointChange. Кнопку «динамик» переключить наavailableEndpoints/requestEndpointChange, читая фактическийcurrentCallEndpointназад в UI (BTrequestEndpointChangeфлапает — issuetracker 302436283). - 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→widgetHangupCall;onSetActive/onSetInactive→hold (пауза/DeviceMute);isMuted↔DeviceMute(одна правда).- Аудио: §4 (Telecom owns mode,
AudioRoutePlugin.setCommunicationDeviceoff-в-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): Telecomanswer()(нативно, синхронно, в 5-сек дедлайн) → boot WebView →switchOrStartDmCall→JoinCallasync (вне критич. пути). 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 системный) → зеркало одностороннее TelecomisMuted→ 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:
- AndroidManifest.xml —
MANAGE_OWN_CALLS,FOREGROUND_SERVICE_PHONE_CALL, FGS-типmicrophone|phoneCall. (PhoneAccount у self-managed не требует отдельной<service android.telecom.ConnectionService>— core-telecom держит свой внутри.) - CallForegroundService.java — добавить
TYPE_PHONE_CALLк startForeground. - AudioRoutePlugin.java — в Telecom-режиме не звать
setCommunicationDevice; роут через Telecom endpoints. - VojoFirebaseMessagingService.java — Phase C:
addCall(INCOMING)рядом с CallStyle; тромбстоны синхронно. - MainActivity.java —
registerPlugin(TelecomCallPlugin.class). - useIncomingRtcNotifications.ts / usePendingCallActionConsumer.ts — прокинуть Telecom answer/decline (только под
isAndroidPlatform).
KEEP (не трогать — web-shared / проверено):
- CallEmbed.ts, CallControl.ts, CallWidgetDriver.ts (включая cleartext-ring bypass §5.51), utils.ts.
- Вся ring-registry/atom-логика
useIncomingRtcNotifications(web-критична),IncomingCallStrip,IncomingCallStripRenderer, sw.ts,CallDeclineReceiverHTTP-decline.
⚠️ Код-агенты в рекогносцировке местами предлагали
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,IncomingCallStripRendereraudio-gate (isAndroidPlatform() ? appActive : true) — байт-в-байт без изменений. - Любая Telecom-ветка в shared-хуках — строго под
isAndroidPlatform(). - Интероп: web-A ↔ Android-B и обратно (ring/answer/hangup идут через Matrix
/sync, Telecom их не трогает).
8. Play / разрешения / OEM
MANAGE_OWN_CALLS— protectionLevelnormal, авто-грант, не триггерит 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):
- A→B, B отвечает, двухстороннее аудио без эха (earpiece) — gating Phase B.
- Переключение спикер/BT во время звонка (Phase B/D),
currentCallEndpointотражает реальность. - B блокирует экран 2–3 мин → звонок жив (mic не отозван, netd не блокирует) — регресс §2.2 не должен вернуться.
- Интероп с сотовым: во время Vojo-звонка приходит GSM-звонок → Telecom арбитрит hold/resume.
- Answer-from-killed: B убит (swipe, не
force-stop) → FCM ring → CallStyle → Answer →phoneCall-FGS стартует из killed (§5.41). - Web-интероп: web-A ↔ Android-B (оба направления), ring/answer/hangup.
- Откат флага
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. Версии: Kotlin2.2.0, coroutines1.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-android2.2.0) +core-telecom:1.0.1+coroutines-android:1.10.2;VojoCallsManager.kt(self-managedCallsManagerобёртка: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уaddCall— non-suspendCallControlScope.() -> Unit. Поэтому suspend-вызовы (setActive/answer/disconnect/requestEndpointChange/collect Flow) нельзя звать прямо в block — толькоlaunch { }на самом scope. Императивные команды из Javalaunch-аются на захваченномcontrolScope. НикакогоCompletableDeferred.await()для удержания сессии не нужно —addCallсам держит её до disconnect. - Верификация локально (SDK
/home/ubuntu/Android/sdk, platform-36 + build-tools 36)::app:compileDebugKotlincompileDebugJavaWithJavac— BUILD SUCCESSFUL; полный:app:assembleDebug— APK собран (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).
- Ключевой API-нюанс (подтверждён javap по AAR):
- 2026-06-23 — Phase B validated на Samsung One UI + §4 решён. Прогон A→B на живом девайсе
(
adb 192.168.1.71:5555, логVojoTelecom+dumpsys telecom):registerAppWithTelecom ok→addCall accepted: call session live→setActive -> CallControlResult(Success)→endpoint -> EARPIECE→teardown: 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-тоггл (
useCallSpeaker→telecomCall.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/EARPIECE→endpoint -> SPEAKER/EARPIECE→requestEndpoint … -> 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 +
phoneCallFGS → answer-from-killed §5.41). Default флага в репо остаётся off (юзер держит=trueлокально); промоут в default после Phase C. adb-доступ к Samsung (192.168.1.71:5555) — билд/инсталл/логкат гоняю сам.
- Спикер/earpiece-тоггл (
- 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-timeaddCall(INCOMING)+ phoneCall-FGS изVojoFirebaseMessagingService.renderOne(gateisTelecomEnabledNative←telecomBridge.tspref); teardown вremoveIncomingRing(кроме answered);VojoCallsManagerper-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-awareanswerCall(roomId)— не отвечает чужую сессию при втором ринге; (6) mute-mirror пропускает первый (initial) emission, чтобы не разглушить join-muted юзера. Плюс ниты:setMicrophoneEnabled.catch,telecomBridgegate наisAndroidPlatform, удалён deadmarkAnswered, поправлены устаревшие комменты/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-timeaddCall(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, boundedPARTIAL_WAKE_LOCK, крупные Ответить/Отклонить (= те же intent/broadcast, что кнопки CallStyle), авто-finishForна терминальных переходах, en/ru строки. FSI поднимает этот Activity. Закрывает §5.34 (lockscreen-кнопки Answer/Decline на Samsung).
- layout): экран звонка поверх локскрина в стиле DAWN
(палитра 1:1 из
- 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, удалён unusedcall_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).
- minSdk 24 → 26 (variables.gradle).
- 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-стиль: аватар-инициал + имя + переиспользованныйCallControlmute/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). На железе ещё не гонялось.
- Реализация: на Answer
- 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-shotgetState()без ре-валидации (гонки с join-fail/unlock) → guardleftв consumer +OverLockCallScreenсверяется с нативной правдой (getState) на mount и само-дисмиссится, если натив уже вышел из over-lock. - LOW:
ready()через double-rAF (снимаем cover только после реальной отрисовки оверлея); чистимover_lockextra после обработки (recreate по fontScale не ре-входит в over-lock);onResumekeyguard-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— зелёное.
- MUST-FIX (приватность): на завершении звонка под локом JS снимал свой непрозрачный оверлей ДО того, как
натив снимет
- 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-декларация).
- B1 (BLOCKER) — session-generation guard в
- 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). Новый i18nCall.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. На железе не гонялось.
- In-call экран над локом (
- 2026-06-24 — Фикс критов (после честного аудита halt-перечня).
- M6 — локаль нативного входящего экрана.
IncomingCallActivity.attachBaseContextтеперь оборачивает контекст в in-app язык через новыйPushStrings.wrap()(тот жеcreateConfigurationContext, что у пуш-строк). RU-юзер на EN-девайсе видит «Ответить/Отклонить/Входящий звонок», а не системную локаль. Layout-@stringиgetStringрезолвятся в выбранном языке. - H4 — mute-mirror сделан robust без зависимости от внутренностей либы. Проверил API:
isMuted()это plainFlow<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 — полиш, не баг.
- M6 — локаль нативного входящего экрана.
- 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-сессии берём boundedPARTIAL_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) +closeCallmessage-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-clampsender_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 по parentcallSessionId(упирается в тот же Sygnal-starvation — нужен string-mirror полей в cleartext-ринге); (d) decline-id из send-response вместо скрейпа таймлайна. Все RED требуют прогона на Samsung (Doze, answer-from-killed, logout-во-время-звонка, 2 устройства).
- RED-1 — Doze-устойчивый потолок ринга.
- 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_existingintent (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_existingintent (не рингует «обратно»); 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 с токеном.
- Корень «перезвон после сорвавшегося glare-звонка не проходит» (F1, blocker): ring-событие шлёт ТОЛЬКО EC-виджет
и ТОЛЬКО как первый член сессии (matrix-js-sdk
- 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-opif (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 живым).
- Корень (research