30 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 у нас нет. Это ключевое ограничение — оно меняет всё нижеизложенное.
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)
- В
VojoFirebaseMessagingServiceна ring →addCall(DIRECTION_INCOMING)рядом с текущим CallStyle (CallStyle = видимый ring UI остаётся). - Native Answer → старт
phoneCall-FGS (работает из killed) → boot WebView →JoinCallasync. onAnswer/onRejectTelecom ↔ существующиеusePendingCallActionConsumer/CallDeclineReceiver(decline-HTTP оставляем как есть).- Дедуп: Telecom-entry тромбстонить синхронно с
ringRegistry(иначе re-ring от FCM-retry).
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.
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-тоггл (