# 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).
- `` + `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 **не** требует отдельной `` — 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` (не `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 с токеном.