vojo/docs/plans/telecom_migration.md
heaven 0f5bc33619 fix(calls): resolve call glare via co-presence, not answer
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.
2026-07-04 02:40:47 +03:00

575 lines
78 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 у нас нет. Это ключевое ограничение — оно меняет всё нижеизложенное.
> **⚠️ СТАТУС (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 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)
**Доки-факты (сверено 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) 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.
**Решения 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 + 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`) билд/инсталл/логкат гоняю сам.
- **2026-06-23 Phase C реализована (весь код, за флагом, собрано) + multi-agent self-review + фиксы.** Не
провалидировано на железе (по решению юзера проверки единым фронтом позже; флаг остаётся `false`).
- **Код (флаг-офф = байт-в-байт текущее поведение):** `FOREGROUND_SERVICE_PHONE_CALL` + тип сервиса
`microphone|phoneCall` (merged-манифест `0x84`); `CallForegroundService.computeForegroundType` (ringphoneCall,
joinmicrophone|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-killringinstant-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 и FSIIncomingCallActivity теперь **безусловны** на каждом фоновом 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` (unlockexit, call-endexit), полноэкранный **`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` (новый
эффект на embedundefined в `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 stripnative), 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, finishForonCreate гонка, рингтон за 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 AB (не только на `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`
по кругу, членство 01210, `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 живым).