diff --git a/docs/ai/android.md b/docs/ai/android.md index 5c4f75c6..e1dff469 100644 --- a/docs/ai/android.md +++ b/docs/ai/android.md @@ -10,7 +10,7 @@ ## Config - [`capacitor.config.ts`](../../capacitor.config.ts) — `appId: chat.vojo.app`, `webDir: dist` -- `android/` — generated Android Studio project, `targetSdkVersion 36`, `compileSdkVersion 36`, `minSdkVersion 24` +- `android/` — generated Android Studio project, `targetSdkVersion 36`, `compileSdkVersion 36`, `minSdkVersion 26` (raised from 24 for the self-managed Telecom call backend, whose `CallsManager` is `@RequiresApi(26)`; Android 7.x ≈ 0% in 2026) ## Build scripts @@ -242,3 +242,47 @@ Sygnal config or until a real-world report justifies the parser complexity. 3. `adb connect :` The pair port and the connect port are different — don't mix them up. + +## Calls (Telecom) + +DM calls are the only call surface. Media is **Element Call (LiveKit/WebRTC) in a +WebView iframe**, driven by widget-api actions (`JoinCall`/`HangupCall`/`DeviceMute`) +via [`CallControl.ts`](../../src/app/plugins/call/CallControl.ts) — there is no +native WebRTC. Android adds a **self-managed Telecom** layer beside it for system +call presence (GSM interop, audio focus, BT/Auto/Wear, a legitimate `phoneCall` +FGS that starts from killed). The full design + work journal is +[`docs/plans/telecom_migration.md`](../../docs/plans/telecom_migration.md); this is +the orientation map. + +Layers, outermost to innermost: + +- **Signalling** — MatrixRTC `m.rtc.notification` (ring) + call-membership state, + over `/sync` when the app is alive. The **ring is emitted by the EC widget** as + the first session member (matrix-js-sdk gates it on `oldMemberships.length === 0`), + NOT by our code — so a stale own membership from a just-ended call silently drops + the outgoing ring; the DM-call entry point waits for it to clear first (see + [`useSwitchOrStartDmCall.ts`](../../src/app/hooks/useSwitchOrStartDmCall.ts) and + [`useCallEmbed.ts`](../../src/app/hooks/useCallEmbed.ts) `createCallEmbed`). +- **Background/killed delivery** — Synapse → Sygnal → FCM. Sygnal's V1-flatten + forwards only **string** `content` fields one level deep (numbers/dicts drop on + the killed path), so ring metadata that must survive is string-mirrored. +- **Native ring registry** — [`VojoFirebaseMessagingService.java`](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java): + `ringRegistry` + tombstones (`registryLock`), the single Telecom-ring slot + `telecomRingEventId` + Doze-proof wakelock/`postDelayed` ceiling (`telecomLock`), + expiry alarms. Renders CallStyle + full-screen [`IncomingCallActivity`](../../android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java) + and starts a `phoneCall` FGS ([`CallForegroundService`](../../android/app/src/main/java/chat/vojo/app/CallForegroundService.java)). + **Lock discipline:** `telecomLock` and the Telecom-session start are held together + across the whole ring start; `registryLock` never nests under `telecomLock`/Binder. +- **Telecom session** — [`VojoCallsManager.kt`](../../android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt), + the only Kotlin file. One live self-managed session, serialized by an + `AtomicLong sessionGen` + `lock`; `isBusy()`/`currentRoomId` is the authoritative + "slot taken?" (not `controlScope != null`, which is briefly null while forming). +- **JS lifecycle hooks** — `useTelecomConnectionSync` / `useAndroidCallForegroundSync` + key the Telecom session + FGS to the widget's JoinCall (`useCallJoined`), with an + embed-lifecycle backstop; `useIncomingRtcNotifications` owns the ring atom/registry; + `useCallerAutoHangup` tears a call down on peer-leave / no-answer. +- **Answer provenance** — MainActivity is exported and Capacitor fires + `pushNotificationActionPerformed` on any `google.message_id` intent, so every + Answer PendingIntent carries a per-install secret + ([`CallActionToken`](../../android/app/src/main/java/chat/vojo/app/CallActionToken.java)) + the JS consumer verifies before auto-joining — blocks a forged hot-mic join. diff --git a/docs/plans/telecom_migration.md b/docs/plans/telecom_migration.md index 38d5460b..d576e418 100644 --- a/docs/plans/telecom_migration.md +++ b/docs/plans/telecom_migration.md @@ -512,3 +512,42 @@ Telecom — чисто Android-нативный слой; web/Electron/iOS ег (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 с токеном.