# 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 с токеном.