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.
575 lines
78 KiB
Markdown
575 lines
78 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 у нас нет. Это ключевое ограничение — оно меняет всё нижеизложенное.
|
||
|
||
> **⚠️ СТАТУС (2026-06-23, актуально — см. последнюю запись в §11):** позиция сменилась с «аддитивный слой за
|
||
> флагом» на **полную миграцию**. Флаг `TELECOM_ENABLED` **убран**, `minSdk` поднят до **26**, Telecom — **единственный**
|
||
> нативный бэкенд звонков, legacy `AudioRoutePlugin` **удалён**, добавлен нативный full-screen `IncomingCallActivity`
|
||
> (закрыл §5.34). §0–§9 ниже описывают исходный (флаг-гейтед) план и местами **исторические** — фактическое состояние
|
||
> и отступления держи по журналу §11. На железе **ещё не провалидировано**.
|
||
|
||
---
|
||
|
||
## 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)
|
||
|
||
**Доки-факты (сверено WebFetch 2026-06-23, [service-types](https://developer.android.com/develop/background-work/services/fgs/service-types) + [restrictions-bg-start](https://developer.android.com/develop/background-work/services/fgs/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):** Telecom `answer()` (нативно, синхронно, в 5-сек дедлайн) → boot WebView → `switchOrStartDmCall`→`JoinCall` **async** (вне критич. пути). 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 системный) → зеркало **одностороннее Telecom `isMuted` → 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](../../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.
|
||
|
||
**Решения 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-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`) — билд/инсталл/логкат гоняю сам.
|
||
- **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-time `addCall(INCOMING)` + phoneCall-FGS из `VojoFirebaseMessagingService.renderOne`
|
||
(gate `isTelecomEnabledNative` ← `telecomBridge.ts` pref); teardown в `removeIncomingRing` (кроме answered);
|
||
`VojoCallsManager` per-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-aware
|
||
`answerCall(roomId)` — не отвечает чужую сессию при втором ринге; (6) mute-mirror пропускает первый
|
||
(initial) emission, чтобы не разглушить join-muted юзера. Плюс ниты: `setMicrophoneEnabled` .catch,
|
||
`telecomBridge` gate на `isAndroidPlatform`, удалён dead `markAnswered`, поправлены устаревшие комменты/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](../../android/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-time `addCall(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](../../android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java)
|
||
+ [layout](../../android/app/src/main/res/layout/activity_incoming_call.xml)): экран звонка поверх локскрина в стиле DAWN
|
||
(палитра 1:1 из `src/colors.css.ts`), `setShowWhenLocked`/`setTurnScreenOn`, bounded `PARTIAL_WAKE_LOCK`, крупные
|
||
Ответить/Отклонить (= те же intent/broadcast, что кнопки CallStyle), авто-`finishFor` на терминальных переходах,
|
||
en/ru строки. FSI поднимает этот Activity. **Закрывает §5.34** (lockscreen-кнопки Answer/Decline на Samsung).
|
||
- **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, удалён unused `call_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).
|
||
- **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-стиль: аватар-инициал + имя + переиспользованный `CallControl` mute/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). На железе ещё не гонялось.
|
||
- **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-shot `getState()` без
|
||
ре-валидации (гонки с join-fail/unlock) → guard `left` в consumer + `OverLockCallScreen` сверяется с нативной
|
||
правдой (`getState`) на mount и само-дисмиссится, если натив уже вышел из over-lock.
|
||
- **LOW:** `ready()` через double-rAF (снимаем cover только после реальной отрисовки оверлея); чистим `over_lock`
|
||
extra после обработки (recreate по fontScale не ре-входит в over-lock); `onResume` keyguard-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` — зелёное.**
|
||
- **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-декларация).
|
||
- **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). Новый i18n `Call.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.** На железе не гонялось.
|
||
- **2026-06-24 — Фикс критов (после честного аудита halt-перечня).**
|
||
- **M6 — локаль нативного входящего экрана.** `IncomingCallActivity.attachBaseContext` теперь оборачивает контекст
|
||
в in-app язык через новый `PushStrings.wrap()` (тот же `createConfigurationContext`, что у пуш-строк). RU-юзер на
|
||
EN-девайсе видит «Ответить/Отклонить/Входящий звонок», а не системную локаль. Layout-`@string` и `getString`
|
||
резолвятся в выбранном языке.
|
||
- **H4 — mute-mirror сделан robust без зависимости от внутренностей либы.** Проверил API: `isMuted()` это **plain**
|
||
`Flow<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 — полиш, не баг.
|
||
- **2026-06-24 — Внешнее multi-agent ревью сигнального слоя (12 линз → adversarial-verify → second pass) + фиксы RED/ORANGE.**
|
||
Полный отчёт: [calls_server_review_2026-06-24.md](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-сессии берём
|
||
bounded `PARTIAL_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) + `closeCall`
|
||
message-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-clamp `sender_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 по parent `callSessionId` (упирается в тот же Sygnal-starvation — нужен string-mirror полей в
|
||
cleartext-ринге); (d) decline-id из send-response вместо скрейпа таймлайна. **Все RED требуют прогона на Samsung
|
||
(Doze, answer-from-killed, logout-во-время-звонка, 2 устройства).**
|
||
- **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_existing` intent (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_existing` intent (не рингует
|
||
«обратно»); 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 с токеном.
|
||
- **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-op `if (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 живым).
|