vojo/docs/plans/telecom_migration.md

30 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 у нас нет. Это ключевое ограничение — оно меняет всё нижеизложенное.


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)

  • В VojoFirebaseMessagingService на ring → addCall(DIRECTION_INCOMING) рядом с текущим CallStyle (CallStyle = видимый ring UI остаётся).
  • Native Answer → старт phoneCall-FGS (работает из killed) → boot WebView → JoinCall async.
  • onAnswer/onReject Telecom ↔ существующие 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:

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.

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) — билд/инсталл/логкат гоняю сам.