vojo/docs/plans/telecom_migration.md

266 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Telecom — нативный бэкенд звонков для Android (план миграции)
Живой документ. Сиблинг [dm_calls_techdebt.md](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](../../src/app/plugins/call/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](../../android/app/src/main/java/chat/vojo/app/AudioRoutePlugin.java)),
маршрутизацию надо вести через `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](https://developer.android.com/develop/connectivity/telecom/voip-app/telecom), [PhoneAccount](https://developer.android.com/reference/android/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](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start), [Agora bg-capture](https://docs.agora.io/en/help/quality-issues/android_background) |
| 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](https://dontkillmyapp.com/xiaomi), [/samsung](https://dontkillmyapp.com/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](https://developer.android.com/develop/background-work/services/fgs/service-types) |
| Интероп с сотовым звонком, BT-гарнитура/Auto/Wear, журнал звонков, DND | **ДА** это и есть основная ценность Telecom для нас. | [core-telecom](https://developer.android.com/develop/connectivity/telecom/voip-app) |
**Вывод:** 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](https://developer.android.com/develop/connectivity/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](https://issuetracker.google.com/issues/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](../../android/variables.gradle)/[build.gradle](../../android/app/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); `isMuted``DeviceMute` (одна правда).
- Аудио: §4 (Telecom owns mode, `AudioRoutePlugin.setCommunicationDevice` off-в-Telecom-режиме, роут через endpoints).
- **Gating: live-эхо-тест.** Новый хук `useTelecomConnectionSync` по образцу [useAndroidCallForegroundSync.ts](../../src/app/hooks/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:**
- [AndroidManifest.xml](../../android/app/src/main/AndroidManifest.xml) `MANAGE_OWN_CALLS`, `FOREGROUND_SERVICE_PHONE_CALL`, FGS-тип `microphone|phoneCall`. (PhoneAccount у self-managed **не** требует отдельной `<service android.telecom.ConnectionService>` core-telecom держит свой внутри.)
- [CallForegroundService.java](../../android/app/src/main/java/chat/vojo/app/CallForegroundService.java) добавить `TYPE_PHONE_CALL` к startForeground.
- [AudioRoutePlugin.java](../../android/app/src/main/java/chat/vojo/app/AudioRoutePlugin.java) в Telecom-режиме не звать `setCommunicationDevice`; роут через Telecom endpoints.
- [VojoFirebaseMessagingService.java](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java) Phase C: `addCall(INCOMING)` рядом с CallStyle; тромбстоны синхронно.
- [MainActivity.java](../../android/app/src/main/java/chat/vojo/app/MainActivity.java) `registerPlugin(TelecomCallPlugin.class)`.
- [useIncomingRtcNotifications.ts](../../src/app/hooks/useIncomingRtcNotifications.ts) / [usePendingCallActionConsumer.ts](../../src/app/hooks/usePendingCallActionConsumer.ts) прокинуть Telecom answer/decline (только под `isAndroidPlatform`).
**KEEP (не трогать — web-shared / проверено):**
- [CallEmbed.ts](../../src/app/plugins/call/CallEmbed.ts), [CallControl.ts](../../src/app/plugins/call/CallControl.ts), [CallWidgetDriver.ts](../../src/app/plugins/call/CallWidgetDriver.ts) (включая cleartext-ring bypass §5.51), [utils.ts](../../src/app/plugins/call/utils.ts).
- Вся ring-registry/atom-логика `useIncomingRtcNotifications` (web-критична), `IncomingCallStrip`, `IncomingCallStripRenderer`, [sw.ts](../../src/sw.ts), `CallDeclineReceiver` HTTP-decline.
> ⚠️ Код-агенты в рекогносцировке местами предлагали `InCallService` и «платформа сама рисует ring» — это про **managed/dialer**,
> для self-managed **неверно**. Не реализуем `InCallService`, не становимся `ROLE_DIALER`.
---
## 7. Web «не сломать» — короткий чеклист
Telecom чисто Android-нативный слой; web/Electron/iOS его не видят. Регрессии возможны только если тронуть shared-код.
Полная матрица в результате рекогносцировки (агент `web-sw-path-guardrails`). Главное:
- [sw.ts](../../src/sw.ts) pushringnotification, `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. AB, 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 + ListenerJS колбэки 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-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`
+ `compileDebugJavaWithJavac` **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).
- **2026-06-23 Phase B validated на Samsung One UI + §4 решён.** Прогон AB на живом девайсе
(`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 в том же
дампе идентично).
- Эха нет (живой AB) + 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 + `phoneCall` FGS answer-from-killed §5.41). Default флага в
репо остаётся off (юзер держит `=true` локально); промоут в default после Phase C. adb-доступ к Samsung
(`192.168.1.71:5555`) билд/инсталл/логкат гоняю сам.