266 lines
30 KiB
Markdown
266 lines
30 KiB
Markdown
# 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 26–33)». Минус один: `CallsManager` помечен `@RequiresApi(26)`, а наш `minSdk = 24` → весь Telecom-код
|
||
гейтим за `Build.VERSION.SDK_INT >= 26`, на 24–25 остаётся текущий путь (доля 24–25 в 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) 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 блокирует экран 2–3 мин → звонок жив (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` у `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 решён.** Прогон 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 + `phoneCall` FGS → answer-from-killed §5.41). Default флага в
|
||
репо остаётся off (юзер держит `=true` локально); промоут в default после Phase C. adb-доступ к Samsung
|
||
(`192.168.1.71:5555`) — билд/инсталл/логкат гоняю сам.
|