feat(calls): native self-managed Telecom backend for Android (Phase A/B, flag-gated off, validated on Samsung)

This commit is contained in:
heaven 2026-06-23 02:47:17 +03:00
parent 9aaa0b1833
commit f2eb363db4
13 changed files with 1061 additions and 6 deletions

View file

@ -1,4 +1,7 @@
apply plugin: 'com.android.application'
// Kotlin source-set for the Telecom module only (androidx.core:core-telecom is
// coroutine-first). All other native code stays Java; interop is seamless.
apply plugin: 'kotlin-android'
// Mirror of resolveAppVersion() in ../../vite.config.js so the APK's
// versionName matches __APP_VERSION__ rendered in the About screen.
@ -49,6 +52,12 @@ android {
buildConfig = true
}
// Match the Java compileOptions (VERSION_21 from capacitor.build.gradle) so
// the Kotlin telecom module and the Java sources target the same bytecode.
kotlinOptions {
jvmTarget = '21'
}
signingConfigs {
release {
if (project.hasProperty('VOJO_RELEASE_STORE_FILE')) {
@ -102,6 +111,14 @@ dependencies {
// proxy credentials (proxy_support.md §15 #4). Not the deprecated
// androidx.security EncryptedSharedPreferences.
implementation "com.google.crypto.tink:tink-android:1.15.0"
// Telecom self-managed VoIP (docs/plans/telecom_migration.md). core-telecom
// wraps the API-34 transactional CallControl path and the ConnectionService
// backport (API 26-33) behind one CallsManager API. CallsManager is
// @RequiresApi(26) every call site is gated on SDK_INT >= O; on API 24-25
// the Telecom path is skipped. coroutines-android supplies Dispatchers.Main
// that VojoCallsManager runs its CallControlScope on.
implementation "androidx.core:core-telecom:$coreTelecomVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View file

@ -135,6 +135,15 @@
pre-declared for the planned speaker-toggle plugin. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Self-managed Telecom (docs/plans/telecom_migration.md). protectionLevel
`normal` → auto-granted, no runtime prompt, no Play Permissions
Declaration Form, and does NOT require ROLE_DIALER / becoming the
default dialer. Required before TelecomManager will bind to our
CallsManager / accept addCall. Gated at runtime on SDK_INT >= 26.
(FOREGROUND_SERVICE_PHONE_CALL + the phoneCall FGS type are deferred to
the killed-answer phase so we don't perturb the proven §2.2 microphone
FGS retention before it's validated on-device.) -->
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<!-- Required for NotificationCompat.CallStyle on API 31+: NMS's
checkDisqualifyingFeatures rejects CallStyle notifications without
FSI/FGS/UIJ. We DO call setFullScreenIntent(launchPI, true) in

View file

@ -68,6 +68,9 @@ public class MainActivity extends BridgeActivity {
registerPlugin(FullScreenIntentPlugin.class);
registerPlugin(CallForegroundPlugin.class);
registerPlugin(AudioRoutePlugin.class);
// Self-managed Telecom call session (docs/plans/telecom_migration.md).
// Internally no-ops on API < 26; JS gates on TELECOM_ENABLED.
registerPlugin(TelecomCallPlugin.class);
registerPlugin(LaunchSplashPlugin.class);
registerPlugin(ShareTargetPlugin.class);
registerPlugin(PollingPlugin.class);

View file

@ -0,0 +1,182 @@
package chat.vojo.app;
import android.os.Build;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/**
* JS Android bridge for the self-managed Telecom call session
* (docs/plans/telecom_migration.md). Thin: all Telecom logic lives in
* {@link VojoCallsManager} (Kotlin). This plugin only marshals PluginCall
* arguments into the manager and forwards the manager's {@link
* VojoCallsManager.Listener} events back to JS as Capacitor plugin events.
*
* API gate: VojoCallsManager is @RequiresApi(26). Every method short-circuits
* with {@code telecom_unsupported_api} on API 24-25 so the class is never
* loaded there. JS additionally gates on TELECOM_ENABLED + isAndroidPlatform()
* (telecomCall.ts) so on web/iOS/old-Android nothing here is reached.
*
* Events emitted to JS (addListener in telecomCall.ts):
* telecomAnswer { video: boolean }
* telecomDisconnect { causeCode: number }
* telecomSetActive {}
* telecomSetInactive {}
* telecomMute { muted: boolean }
* telecomEndpoint { route: string }
* telecomEndpoints { routes: string[] }
* telecomError { message: string }
*/
@CapacitorPlugin(name = "TelecomCall")
public class TelecomCallPlugin extends Plugin implements VojoCallsManager.Listener {
private boolean supported() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
}
private VojoCallsManager manager() {
// Only referenced under supported() so the @RequiresApi(26) class is
// never resolved on API 24-25.
return VojoCallsManager.getInstance(getContext().getApplicationContext());
}
@Override
public void load() {
if (supported()) {
manager().setListener(this);
}
}
@Override
protected void handleOnDestroy() {
if (supported()) {
manager().setListener(null);
}
}
@PluginMethod
public void startCall(PluginCall call) {
if (!supported()) {
call.reject("telecom_unsupported_api");
return;
}
String roomId = call.getString("roomId");
if (roomId == null || roomId.isEmpty()) {
call.reject("missing_roomId");
return;
}
String displayName = call.getString("displayName", "Vojo");
boolean video = Boolean.TRUE.equals(call.getBoolean("video", false));
boolean incoming = Boolean.TRUE.equals(call.getBoolean("incoming", false));
manager().startCall(roomId, displayName, video, incoming);
call.resolve();
}
@PluginMethod
public void answer(PluginCall call) {
if (!supported()) {
call.reject("telecom_unsupported_api");
return;
}
boolean video = Boolean.TRUE.equals(call.getBoolean("video", false));
manager().answerCall(video);
call.resolve();
}
@PluginMethod
public void setActive(PluginCall call) {
if (!supported()) {
call.reject("telecom_unsupported_api");
return;
}
manager().setCallActive();
call.resolve();
}
@PluginMethod
public void endCall(PluginCall call) {
// End is best-effort on every platform: resolve even when unsupported so
// the JS teardown path never rejects.
if (supported()) {
manager().endCall();
}
call.resolve();
}
@PluginMethod
public void requestEndpoint(PluginCall call) {
if (!supported()) {
call.reject("telecom_unsupported_api");
return;
}
String route = call.getString("route");
if (route == null || route.isEmpty()) {
call.reject("missing_route");
return;
}
manager().requestEndpoint(route);
call.resolve();
}
// --- VojoCallsManager.Listener (main thread) JS events ---
@Override
public void onAnswer(boolean video) {
JSObject d = new JSObject();
d.put("video", video);
notifyListeners("telecomAnswer", d);
}
@Override
public void onDisconnect(int causeCode) {
JSObject d = new JSObject();
d.put("causeCode", causeCode);
notifyListeners("telecomDisconnect", d);
}
@Override
public void onSetActive() {
notifyListeners("telecomSetActive", new JSObject());
}
@Override
public void onSetInactive() {
notifyListeners("telecomSetInactive", new JSObject());
}
@Override
public void onMuteStateChanged(boolean muted) {
JSObject d = new JSObject();
d.put("muted", muted);
notifyListeners("telecomMute", d);
}
@Override
public void onEndpointChanged(String route) {
JSObject d = new JSObject();
d.put("route", route);
notifyListeners("telecomEndpoint", d);
}
@Override
public void onAvailableEndpointsChanged(String[] routes) {
JSArray arr = new JSArray();
for (String r : routes) {
arr.put(r);
}
JSObject d = new JSObject();
d.put("routes", arr);
notifyListeners("telecomEndpoints", d);
}
@Override
public void onError(String message) {
JSObject d = new JSObject();
d.put("message", message);
notifyListeners("telecomError", d);
}
}

View file

@ -0,0 +1,318 @@
package chat.vojo.app
import android.content.Context
import android.net.Uri
import android.os.Build
import android.telecom.DisconnectCause
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.telecom.CallAttributesCompat
import androidx.core.telecom.CallControlScope
import androidx.core.telecom.CallEndpointCompat
import androidx.core.telecom.CallsManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/**
* Self-managed Telecom session manager for Vojo DM calls
* (docs/plans/telecom_migration.md, Phase A/B).
*
* WHAT THIS IS. A thin, imperative, Java-friendly facade over the Jetpack
* Core-Telecom [CallsManager]. It registers a single self-managed PhoneAccount
* and drives exactly one live call session (Vojo enforces one active call).
* Telecom owns call-state arbitration, audio focus and the audio endpoint
* (earpiece / speaker / Bluetooth / wired / Auto / Wear); the actual media is
* NOT here it lives in the Element Call WebView and is controlled separately
* over the widget API ([CallControl.ts]). So this class translates Telecom
* callbacks (answer / disconnect / hold / mute / route) into [Listener] events
* that the Capacitor bridge forwards to JS, and exposes imperative methods JS
* calls to start / answer / end the Telecom call.
*
* THREADING / SCOPE. [CallControlScope] is itself a [CoroutineScope] tied to the
* call's lifetime; its control methods (`setActive`/`answer`/`disconnect`/
* `requestEndpointChange`) and audio `Flow`s are suspend/cold, so they must be
* driven from a coroutine NOT called directly in the (non-suspend) addCall
* block. We capture the scope as [controlScope] and `launch` every command onto
* it; when the call ends, that scope cancels and all launched work (including
* the endpoint/mute collectors) tears down automatically.
*
* WHY KOTLIN. The whole Core-Telecom surface is coroutine-first; this is the
* only Kotlin file in the app. Everything Java-facing (Listener, getInstance,
* void methods) is plain.
*
* API GATE. [CallsManager] is `@RequiresApi(26)`. Callers (TelecomCallPlugin)
* MUST guard on `Build.VERSION.SDK_INT >= O` before referencing this class so
* it is never loaded/constructed on API 24-25.
*
* 5-SECOND CONTRACT. Telecom tears the session down if the answer/disconnect
* transactions don't resolve within ~5s. We drive every transaction natively
* and immediately; the WebView's JoinCall happens in parallel off the critical
* path (the Listener fires JS, JS joins async we never block on it).
*/
@RequiresApi(Build.VERSION_CODES.O)
class VojoCallsManager private constructor(context: Context) {
/** Telecom → app events. All callbacks are delivered on the main thread. */
interface Listener {
/** A remote surface (Bluetooth/Auto/Wear) or the user accepted the call. */
fun onAnswer(video: Boolean)
/** The call ended (local hangup, remote, or system). `causeCode` is an
* [android.telecom.DisconnectCause] code. */
fun onDisconnect(causeCode: Int)
/** Telecom asked the call to resume from hold. */
fun onSetActive()
/** Telecom asked the call to go on hold (e.g. an interrupting GSM call). */
fun onSetInactive()
/** Telecom's global mute state changed (e.g. hardware/BT mute button). */
fun onMuteStateChanged(muted: Boolean)
/** The active audio route changed. `route` ∈ EARPIECE/SPEAKER/BLUETOOTH/WIRED/STREAMING/UNKNOWN. */
fun onEndpointChanged(route: String)
/** The set of selectable audio routes changed. */
fun onAvailableEndpointsChanged(routes: Array<String>)
/** Registration or addCall failed; the Telecom path is unavailable for this call. */
fun onError(message: String)
}
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val callsManager = CallsManager(appContext)
@Volatile private var registered = false
@Volatile private var listener: Listener? = null
// The live call's CallControlScope (a CoroutineScope), captured while the
// session is active. Imperative methods launch their suspend transactions
// onto it; cleared the moment the call ends.
@Volatile private var controlScope: CallControlScope? = null
// Latest endpoint list, kept current by the availableEndpoints collector.
// requestEndpoint reads this synchronously instead of availableEndpoints
// .first(): that Flow does NOT replay its current value to a fresh
// collector, so .first() would suspend until the next change (observed:
// it only resolved at call teardown, delaying every route switch).
@Volatile private var availableEndpointList: List<CallEndpointCompat> = emptyList()
fun setListener(l: Listener?) {
listener = l
}
private fun ensureRegistered() {
if (registered) return
try {
// CAPABILITY_SUPPORTS_VIDEO_CALLING: DM calls may be video. Baseline
// audio is implied. Streaming (Wear/Auto media) is not declared.
callsManager.registerAppWithTelecom(CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING)
registered = true
Log.d(TAG, "registerAppWithTelecom ok")
} catch (t: Throwable) {
Log.e(TAG, "registerAppWithTelecom failed", t)
listener?.onError("register_failed: " + (t.message ?: t.javaClass.simpleName))
}
}
/**
* Begin a Telecom call session for [roomId]. Outgoing calls go ACTIVE
* immediately (media is already negotiated by the WebView); incoming calls
* stay RINGING until [answerCall] / a remote onAnswer drives `answer()`.
*/
fun startCall(roomId: String, displayName: String, video: Boolean, incoming: Boolean) {
ensureRegistered()
if (!registered) return
// Single active call invariant: drop any prior session first.
teardown(DisconnectCause.LOCAL)
val attributes = CallAttributesCompat(
displayName,
// Opaque per-call handle; Telecom only needs a stable Uri. Matrix
// room ids (e.g. "!abc:vojo.chat") are fine as the scheme-specific part.
Uri.fromParts("vojo", roomId, null),
if (incoming) CallAttributesCompat.DIRECTION_INCOMING
else CallAttributesCompat.DIRECTION_OUTGOING,
if (video) CallAttributesCompat.CALL_TYPE_VIDEO_CALL
else CallAttributesCompat.CALL_TYPE_AUDIO_CALL,
CallAttributesCompat.SUPPORTS_SET_INACTIVE,
)
scope.launch {
try {
callsManager.addCall(
attributes,
// onAnswer (remote surface accepted): notify JS to join media.
{ callType ->
Log.d(TAG, "onAnswer callType=$callType")
listener?.onAnswer(callType == CallAttributesCompat.CALL_TYPE_VIDEO_CALL)
},
// onDisconnect (remote/system ended): notify JS to hang up.
{ cause ->
Log.d(TAG, "onDisconnect cause=${cause.code}")
controlScope = null
listener?.onDisconnect(cause.code)
},
// onSetActive (resume from hold).
{
Log.d(TAG, "onSetActive")
listener?.onSetActive()
},
// onSetInactive (hold, e.g. interrupting cellular call).
{
Log.d(TAG, "onSetInactive")
listener?.onSetInactive()
},
) {
// this: CallControlScope — a CoroutineScope valid for the
// call's lifetime. The block is NON-suspend; launch suspend
// work onto it. addCall keeps the session alive until the
// call disconnects, so there is nothing to await here.
controlScope = this
Log.d(TAG, "addCall accepted: call session live (incoming=$incoming)")
if (!incoming) {
launch { Log.d(TAG, "setActive -> ${setActive()}") }
}
// Audio-route + mute mirrors. These collectors are children
// of this scope and cancel automatically when the call ends.
launch {
currentCallEndpoint.collect { ep ->
Log.d(TAG, "endpoint -> ${typeToRoute(ep.type)}")
listener?.onEndpointChanged(typeToRoute(ep.type))
}
}
launch {
availableEndpoints.collect { list ->
availableEndpointList = list
listener?.onAvailableEndpointsChanged(
list.map { typeToRoute(it.type) }.distinct().toTypedArray()
)
}
}
launch {
isMuted.collect { muted ->
Log.d(TAG, "mute -> $muted")
listener?.onMuteStateChanged(muted)
}
}
}
} catch (t: Throwable) {
Log.e(TAG, "addCall failed", t)
listener?.onError("addcall_failed: " + (t.message ?: t.javaClass.simpleName))
} finally {
controlScope = null
availableEndpointList = emptyList()
}
}
}
/** Accept an incoming Telecom call from our own UI (moves RINGING → ACTIVE). */
fun answerCall(video: Boolean) {
val cs = controlScope ?: return
cs.launch {
try {
cs.answer(
if (video) CallAttributesCompat.CALL_TYPE_VIDEO_CALL
else CallAttributesCompat.CALL_TYPE_AUDIO_CALL
)
} catch (t: Throwable) {
Log.w(TAG, "answer failed", t)
}
}
}
/** Move the call to ACTIVE (e.g. after the WebView reports JoinCall). */
fun setCallActive() {
val cs = controlScope ?: return
cs.launch {
try {
cs.setActive()
} catch (t: Throwable) {
Log.w(TAG, "setActive failed", t)
}
}
}
/** End the current Telecom call (local hangup). Idempotent. */
fun endCall() {
teardown(DisconnectCause.LOCAL)
}
/**
* Request an audio route change. [route] EARPIECE/SPEAKER/BLUETOOTH/WIRED.
* No-op if that endpoint isn't currently available. This is the ONLY
* sanctioned way to move the route while Telecom owns the call
* AudioManager.setCommunicationDevice must not be used concurrently.
*/
fun requestEndpoint(route: String) {
val cs = controlScope ?: return
val wantType = routeToType(route)
// Read the cached endpoints (see availableEndpointList) — do NOT use
// availableEndpoints.first() here, it suspends until the next change.
val target = availableEndpointList.firstOrNull { it.type == wantType }
if (target == null) {
Log.w(
TAG,
"requestEndpoint $route unavailable; have=" +
availableEndpointList.map { typeToRoute(it.type) }
)
return
}
cs.launch {
try {
val result = cs.requestEndpointChange(target)
Log.d(TAG, "requestEndpoint $route -> $result")
} catch (t: Throwable) {
Log.w(TAG, "requestEndpoint failed", t)
}
}
}
private fun teardown(causeCode: Int) {
val cs = controlScope ?: return
controlScope = null
Log.d(TAG, "teardown: disconnect cause=$causeCode")
cs.launch {
try {
cs.disconnect(DisconnectCause(causeCode))
} catch (t: Throwable) {
Log.w(TAG, "disconnect failed", t)
}
}
}
companion object {
const val TAG = "VojoTelecom"
@Volatile private var instance: VojoCallsManager? = null
@JvmStatic
fun getInstance(context: Context): VojoCallsManager =
instance ?: synchronized(this) {
instance ?: VojoCallsManager(context).also { instance = it }
}
private fun typeToRoute(type: Int): String = when (type) {
CallEndpointCompat.TYPE_EARPIECE -> "EARPIECE"
CallEndpointCompat.TYPE_SPEAKER -> "SPEAKER"
CallEndpointCompat.TYPE_BLUETOOTH -> "BLUETOOTH"
CallEndpointCompat.TYPE_WIRED_HEADSET -> "WIRED"
CallEndpointCompat.TYPE_STREAMING -> "STREAMING"
else -> "UNKNOWN"
}
private fun routeToType(route: String): Int = when (route.uppercase()) {
"EARPIECE" -> CallEndpointCompat.TYPE_EARPIECE
"SPEAKER" -> CallEndpointCompat.TYPE_SPEAKER
"BLUETOOTH" -> CallEndpointCompat.TYPE_BLUETOOTH
"WIRED" -> CallEndpointCompat.TYPE_WIRED_HEADSET
"STREAMING" -> CallEndpointCompat.TYPE_STREAMING
else -> CallEndpointCompat.TYPE_UNKNOWN
}
}
}

View file

@ -9,6 +9,11 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// Kotlin для telecom-модуля (androidx.core:core-telecom coroutine-first).
// Версия зеркалит kotlinVersion в variables.gradle; здесь литералом, т.к.
// buildscript{} вычисляется до `apply from: variables.gradle`. Совместимо с
// Gradle 8.14.3 / AGP 8.13.0 (Kotlin 2.2.0 поддерживает Gradle до 8.14).
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

View file

@ -13,4 +13,10 @@ ext {
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
// Telecom (нативные звонки, docs/plans/telecom_migration.md). Kotlin source-set
// только для telecom-модуля. kotlinVersion здесь для документации; сам плагин
// подключается по литералу в android/build.gradle (buildscript не видит variables.gradle).
kotlinVersion = '2.2.0'
coreTelecomVersion = '1.0.1'
coroutinesVersion = '1.10.2'
}

View file

@ -0,0 +1,266 @@
# 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 у нас нет. Это ключевое ограничение — оно меняет всё нижеизложенное.
---
## 0. TL;DR
1. **Библиотека:** `androidx.core:core-telecom:1.0.1` (Jetpack Core-Telecom), а не ручной `ConnectionService`. Google сам
рекомендует её для self-managed VoIP; она прячет за собой split «transactional CallControl (API 34+) ↔ ConnectionService
backport (API 2633)». Минус один: `CallsManager` помечен `@RequiresApi(26)`, а наш `minSdk = 24` → весь Telecom-код
гейтим за `Build.VERSION.SDK_INT >= 26`, на 2425 остаётся текущий путь (доля 2425 в 2026 пренебрежимо мала).
2. **Telecom self-managed — это НЕ то, что кажется.** Развенчание мифов в §1. Коротко: он **не рисует системный
incoming-UI**, **не убирает `microphone` FGS**, **не чинит OEM-килл** и **не даёт Answer без разблокировки** (медиа в WebView).
Зато даёт: интероп с GSM-звонком и аудиофокусом, BT/Android Auto/Wear, системный «ongoing call» chip/журнал,
и легитимный `phoneCall`-FGS, который **можно стартовать из killed** (закрывает §5.41).
3. **Главный технический риск — аудио.** Telecom (`CallAudioModeStateMachine`) и Chromium-ADM внутри WebView оба ставят
`MODE_IN_COMMUNICATION` и берут audio focus **в одном процессе** → mode thrash / эхо. И Telecom **запрещает**
`AudioManager.setCommunicationDevice` (то, что делает наш [AudioRoutePlugin](../../android/app/src/main/java/chat/vojo/app/AudioRoutePlugin.java)),
маршрутизацию надо вести через `requestEndpointChange` (на части устройств флапает BT). Это валидируется только на железе.
4. **Reference-реальность:** element-x-android (та же «Element Call в WebView» архитектура, что у нас) везёт **0 Telecom-кода**
`ActiveCallManager` + `IncomingCallActivity` + wakelock + `microphone`-FGS (всё это у нас уже почти есть). У element-android
Telecom-пакет — мёртвый stub (захардкоженный `"+905000000000"`, закомментированный `onAnswer`). Единственная живая
реализация канонического `CallsManager.addCall` — официальный сэмпл Google. **Никто в экосистеме Matrix не рулит WebRTC
из Telecom-Connection.** Мы будем делать новое — выполнимо, но Telecom-стейт надо гнать **нативно** (answer ≤5с) и
транслировать в widget-экшены **асинхронно**, никогда не блокируясь на WebView.
5. **Поза миграции (рекомендация):** **аддитивный self-managed слой за фиче-флагом**, не rip-and-replace. Telecom
ложится *рядом* с проверенным стеком (FGS + CallStyle + ring-registry), а не вместо него. Каждая фаза шиппится и
откатывается флагом; при флаге off / API<26 / web поведение байт-в-байт текущее.
---
## 1. Что self-managed Telecom реально даёт и НЕ даёт
| Ожидание | Реальность | Источник |
|---|---|---|
| ОС нарисует системный incoming-call экран + Answer/Decline на локскрине | **НЕТ.** Self-managed = «вы сами рисуете primary UI». Системный UI Telecom показывает лишь когда новый звонок конфликтует с уже идущим звонком в другом приложении. CallStyle + `setFullScreenIntent` **остаются нашей задачей**. | [voip-app/telecom](https://developer.android.com/develop/connectivity/telecom/voip-app/telecom), [PhoneAccount](https://developer.android.com/reference/android/telecom/PhoneAccount) |
| Можно выкинуть `microphone` FGS — Telecom «держит» звонок | **НЕТ.** В списке FGS background-start exemptions нет ни Telecom, ни `MANAGE_OWN_CALLS`, ни «активного звонка». Phase-0 корни (AppOps revoke `record_audio` ~T+5с + netd firewall ~T+13с на Samsung) лечит **importance процесса от FGS**, не звонок. `microphone`-FGS **остаётся**. | [restrictions-bg-start](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start), [Agora bg-capture](https://docs.agora.io/en/help/quality-issues/android_background) |
| Telecom починит «звонок не доходит» на Samsung/Xiaomi | **НЕТ.** Доминирующий фейл — OEM battery/autostart killing (MIUI autostart-deny 5/5, Samsung «put to sleep»). Лечится `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` + deep-link в OEM-настройки, не Telecom. Element X страдает тем же (issues #6107/#4390/#4611). | [dontkillmyapp/xiaomi](https://dontkillmyapp.com/xiaomi), [/samsung](https://dontkillmyapp.com/samsung) |
| Answer прямо с локскрина без разблокировки | **НЕТ.** Медиа = WebView → `JoinCall` требует загрузки iframe → нужен unlock. Это не меняется с Telecom. | research |
| `phoneCall`-FGS можно стартовать из killed (в отличие от `microphone`) | **ДА** — и это закрывает §5.41. `phoneCall` не while-in-use тип, FCM/notification-tap могут его поднять из бэкграунда. Требует `MANAGE_OWN_CALLS` (норм-перм). Но `phoneCall` **не** даёт background mic-capture → нужны **оба** типа. | [service-types](https://developer.android.com/develop/background-work/services/fgs/service-types) |
| Интероп с сотовым звонком, BT-гарнитура/Auto/Wear, журнал звонков, DND | **ДА** — это и есть основная ценность Telecom для нас. | [core-telecom](https://developer.android.com/develop/connectivity/telecom/voip-app) |
**Вывод:** Telecom стоит брать ради интеропа/аудиофокуса/BT-Auto-Wear/`phoneCall`-FGS и «стандартного трека», а **не** ради
системного UI, ради выкидывания FGS или ради надёжности. Это формирует «аддитивную» позу.
---
## 2. Reference-реализации
- **element-x-android** (архитектурно = Vojo): `ConnectionService`/`CallsManager` = **0 хитов в репо**. Весь нативный
call-surface: `DefaultActiveCallManager` (StateFlow + full-screen ring notif + `PARTIAL_WAKE_LOCK` + ring-timeout →
missed-call), `IncomingCallActivity`, `RingingCallNotificationCreator`, `CallForegroundService` с `TYPE_MICROPHONE`
(байт-в-байт наш). Виджет-мост через `WebViewWidgetMessageInterceptor` (тот же `fromWidget`/`toWidget` postMessage).
- **element-android** (legacy): `telecom/` = 3 файла, но `CallConnection.onAnswer()/startCall()/onShowIncomingCallUi()`
**закомментированы**, тестовые строки `"+905000000000"`. Реальный движок — `CallAndroidService` (`phoneCall`-FGS, `ACTION_*`).
Telecom-Connection в управляющем пути не участвует. **Копировать как API-shape, не как рабочий сэмпл.**
- **Google core-telecom sample** — единственная живая `CallsManager.addCall(...)` + `CallControlScope` реализация.
- **Telegram/Signal** — тоже `CallStyle.forIncomingCall` + `setFullScreenIntent` + FGS, **без** self-managed ConnectionService.
---
## 3. Архитектурное решение — аддитивный self-managed слой
Не «снести FGS+registry, поставить Telecom», а **новый нативный модуль `call/telecom/`, который Telecom-сессию ведёт
параллельно**:
```
JS (CallEmbed / hooks) Native (Android)
───────────────────── ──────────────────────────────
joined=true ───────────────────────▶ VojoCallsManager.onCallActive() ── addCall(OUTGOING/INCOMING) + setActive()
hangup / atom=undefined ────────────▶ VojoCallsManager.onCallEnded() ── disconnect(LOCAL)
toggleMicrophone ◀───── isMuted flow ─ CallControlScope (one source of truth)
onAnswer ─▶ JS: switchOrStartDmCall (async, не блокируем 5с-дедлайн)
onDisconnect ─▶ JS: hangup
availableEndpoints/currentEndpoint ─▶ speaker/BT UI
FCM ring ──▶ VojoFirebaseMessagingService ── (как сейчас: CallStyle+FSI) + addCall(INCOMING) [Phase C]
```
Инварианты:
- Telecom-стейт-машина гонится **нативно и синхронно** (≤5с дедлайны Telecom), WebView-`JoinCall` подключается **асинхронно** и
не на критическом пути. `onAnswer` возвращаемся сразу, join завершаем потом.
- **Одна правда про mute**: либо widget `DeviceMute`, либо Telecom `isMuted`-flow — зеркалим в одну сторону, иначе системный
call-UI и кнопки Element Call разъедутся.
- Весь Telecom-код за `isAndroidPlatform()` (JS) и `SDK_INT >= 26` (Java). Флаг `telecomEnabled` (remote/local) для аварийного отката.
- `microphone`-FGS **не трогаем по сути** — добавляем `phoneCall` к типам сервиса, чтобы получить killed-старт.
---
## 4. Главный риск — аудио-владение (WebView ADM ↔ Telecom)
Симптом-зона: старт и конец звонка. Оба владельца ставят `MODE_IN_COMMUNICATION` + берут focus в одном процессе →
эхо/AEC-фейл/неверный роут на первые секунды. Документация Telecom **прямо запрещает** `setCommunicationDevice`/`startBluetoothSco`
при активном Telecom-звонке ([voip-app/telecom](https://developer.android.com/develop/connectivity/telecom/voip-app/telecom)).
Стратегия:
1. Дать Telecom владеть mode/focus (`setAudioModeIsVoip(true)` под капотом core-telecom). Сами `setMode` **не** зовём (мы и не зовём — `setMode` ставит WebView).
2. В Telecom-режиме **отключить** `setCommunicationDevice`-ветку `AudioRoutePlugin`, маршрут вести через `requestEndpointChange`.
Кнопку «динамик» переключить на `availableEndpoints`/`requestEndpointChange`, читая фактический `currentCallEndpoint` назад в UI
(BT `requestEndpointChange` флапает — [issuetracker 302436283](https://issuetracker.google.com/issues/302436283)).
3. WebView-ADM мы заглушить из приложения не можем → **тяжёлый live-тест эха** на Samsung/OnePlus/Xiaomi. Это gating-критерий Phase B.
Если на железе эхо непобедимо — есть аварийный откат: оставить `AudioRoutePlugin` владельцем роутинга и **не** регистрировать
Telecom-аудио (использовать Telecom только как call-session/интероп-сигнал). Менее «правильно», но рабоче.
---
## 5. Фазы (каждая shippable + reversible флагом)
### Phase A — фундамент (низкий риск, без смены поведения)
- `androidx.core:core-telecom:1.0.1` в [variables.gradle](../../android/variables.gradle)/[build.gradle](../../android/app/build.gradle).
- `<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>` + `FOREGROUND_SERVICE_PHONE_CALL`.
- `VojoCallsManager.java` — обёртка: ленивая `registerAppWithTelecom(CAPABILITY_SUPPORTS_VIDEO_CALLING)`, single PhoneAccount, гейт `SDK_INT>=26`.
- `CallForegroundService`: `foregroundServiceType="microphone|phoneCall"`, `startForeground(..., TYPE_MICROPHONE|TYPE_PHONE_CALL)`.
- Фиче-флаг `telecomEnabled` (по умолчанию off). **Эффект:** легитимный `phoneCall`-FGS, ничего больше пока не делает.
### Phase B — активная исходящая/входящая сессия + аудио
- На `joined` (тот же сигнал, что у FGS) → `addCall(direction, ...)` + `setActive()`. На atom=undefined → `disconnect(LOCAL)`.
- `onDisconnect`→widget `HangupCall`; `onSetActive/onSetInactive`→hold (пауза/DeviceMute); `isMuted``DeviceMute` (одна правда).
- Аудио: §4 (Telecom owns mode, `AudioRoutePlugin.setCommunicationDevice` off-в-Telecom-режиме, роут через endpoints).
- **Gating: live-эхо-тест.** Новый хук `useTelecomConnectionSync` по образцу [useAndroidCallForegroundSync.ts](../../src/app/hooks/useAndroidCallForegroundSync.ts).
### Phase C — входящий через Telecom + answer-from-killed (§5.41)
- В `VojoFirebaseMessagingService` на ring → `addCall(DIRECTION_INCOMING)` **рядом** с текущим CallStyle (CallStyle = видимый ring UI остаётся).
- Native Answer → старт `phoneCall`-FGS (работает из killed) → boot WebView → `JoinCall` async.
- `onAnswer`/`onReject` Telecom ↔ существующие `usePendingCallActionConsumer` / `CallDeclineReceiver` (decline-HTTP оставляем как есть).
- Дедуп: Telecom-entry тромбстонить синхронно с `ringRegistry` (иначе re-ring от FCM-retry).
### Phase D — маршрутизация BT/Auto/Wear + polish
- `availableEndpoints`/`currentCallEndpoint` → in-call speaker/BT тоггл. `AudioRoutePlugin` → observer/убрать.
- Headset-hook / Auto / Wear answer через Telecom-колбэки. Журнал звонков (по желанию).
---
## 6. Карта изменений по файлам
**ADD (ново):**
- `android/.../call/telecom/VojoCallsManager.java` — обёртка `CallsManager`, регистрация PhoneAccount, `addCall`, маппинг колбэков.
- `android/.../call/telecom/TelecomCallPlugin.java` — Capacitor-мост (start/answer/end/setMuted/requestEndpoint/observe).
- `src/app/plugins/call/telecomCall.ts` — JS-обёртка плагина (no-op на не-Android).
- `src/app/hooks/useTelecomConnectionSync.ts` — лайфсайкл-хук (зеркало `useAndroidCallForegroundSync`).
**ADAPT:**
- [AndroidManifest.xml](../../android/app/src/main/AndroidManifest.xml) — `MANAGE_OWN_CALLS`, `FOREGROUND_SERVICE_PHONE_CALL`, FGS-тип `microphone|phoneCall`. (PhoneAccount у self-managed **не** требует отдельной `<service android.telecom.ConnectionService>` — core-telecom держит свой внутри.)
- [CallForegroundService.java](../../android/app/src/main/java/chat/vojo/app/CallForegroundService.java) — добавить `TYPE_PHONE_CALL` к startForeground.
- [AudioRoutePlugin.java](../../android/app/src/main/java/chat/vojo/app/AudioRoutePlugin.java) — в Telecom-режиме не звать `setCommunicationDevice`; роут через Telecom endpoints.
- [VojoFirebaseMessagingService.java](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java) — Phase C: `addCall(INCOMING)` рядом с CallStyle; тромбстоны синхронно.
- [MainActivity.java](../../android/app/src/main/java/chat/vojo/app/MainActivity.java) — `registerPlugin(TelecomCallPlugin.class)`.
- [useIncomingRtcNotifications.ts](../../src/app/hooks/useIncomingRtcNotifications.ts) / [usePendingCallActionConsumer.ts](../../src/app/hooks/usePendingCallActionConsumer.ts) — прокинуть Telecom answer/decline (только под `isAndroidPlatform`).
**KEEP (не трогать — web-shared / проверено):**
- [CallEmbed.ts](../../src/app/plugins/call/CallEmbed.ts), [CallControl.ts](../../src/app/plugins/call/CallControl.ts), [CallWidgetDriver.ts](../../src/app/plugins/call/CallWidgetDriver.ts) (включая cleartext-ring bypass §5.51), [utils.ts](../../src/app/plugins/call/utils.ts).
- Вся ring-registry/atom-логика `useIncomingRtcNotifications` (web-критична), `IncomingCallStrip`, `IncomingCallStripRenderer`, [sw.ts](../../src/sw.ts), `CallDeclineReceiver` HTTP-decline.
> ⚠️ Код-агенты в рекогносцировке местами предлагали `InCallService` и «платформа сама рисует ring» — это про **managed/dialer**,
> для self-managed **неверно**. Не реализуем `InCallService`, не становимся `ROLE_DIALER`.
---
## 7. Web «не сломать» — короткий чеклист
Telecom — чисто Android-нативный слой; web/Electron/iOS его не видят. Регрессии возможны только если тронуть shared-код.
Полная матрица — в результате рекогносцировки (агент `web-sw-path-guardrails`). Главное:
- [sw.ts](../../src/sw.ts) push→ring→notification, `CallWidgetDriver.sanitizeRingContent`, `incomingCallsAtom`/`useIncomingRtcNotifications`,
`IncomingCallStripRenderer` audio-gate (`isAndroidPlatform() ? appActive : true`) — **байт-в-байт без изменений**.
- Любая Telecom-ветка в shared-хуках — строго под `isAndroidPlatform()`.
- Интероп: web-A ↔ Android-B и обратно (ring/answer/hangup идут через Matrix `/sync`, Telecom их не трогает).
---
## 8. Play / разрешения / OEM
- `MANAGE_OWN_CALLS` — protectionLevel `normal`, авто-грант, **не** триггерит Permissions Declaration Form, **не** требует `ROLE_DIALER`.
- Play App-content декларации (release-блокеры на targetSdk 36): `foregroundServiceType` (видео-демо!) для `microphone`+`phoneCall`,
и `USE_FULL_SCREEN_INTENT` (core-functionality = calling → авто-грант FSI на A14+). Без этого FSI деградирует в heads-up.
- Параллельно (вне Telecom, но в той же UX-теме надёжности): once-prompt `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` + deep-link в
Samsung «не усыплять» / Xiaomi «Автозапуск» (§5.40). Это реально двигает доставку звонков сильнее, чем сам Telecom.
---
## 9. Тест-план (нужно железо + 2 аккаунта)
Нужны **2 Matrix-аккаунта с токенами** (A, B) и, в идеале, **физический Samsung One UI + Xiaomi MIUI/HyperOS** (на эмуляторе
аудио-contention и OEM-килл не воспроизводятся). Базовый прогон (debug APK, `adb logcat`):
1. A→B, B отвечает, **двухстороннее аудио без эха** (earpiece) — gating Phase B.
2. Переключение спикер/BT во время звонка (Phase B/D), `currentCallEndpoint` отражает реальность.
3. B блокирует экран 23 мин → звонок жив (mic не отозван, netd не блокирует) — регресс §2.2 не должен вернуться.
4. **Интероп с сотовым:** во время Vojo-звонка приходит GSM-звонок → Telecom арбитрит hold/resume.
5. Answer-from-killed: B убит (swipe, **не** `force-stop`) → FCM ring → CallStyle → Answer → `phoneCall`-FGS стартует из killed (§5.41).
6. Web-интероп: web-A ↔ Android-B (оба направления), ring/answer/hangup.
7. Откат флага `telecomEnabled=off` → поведение = текущему (страховка релиза).
---
## 10. Принятые решения (2026-06-23) и что осталось открытым
**Принято:**
- **Поза:** `full rip-and-replace` — Telecom становится primary нативным бэкендом звонков, FGS/ring-registry/audio
переезжают под него. Но **инкрементально и за флагом** `TELECOM_ENABLED` (Phase A default **off**, чтобы первый билд
не сломал звонки до проверки на железе; после валидации на Samsung флипаем default on и снимаем legacy-путь).
- **Библиотека:** `androidx.core:core-telecom:1.0.1` + новый **Kotlin source-set** только для telecom-модуля
(`VojoCallsManager.kt`), остальное остаётся Java. Версии: Kotlin `2.2.0`, coroutines `1.10.2` (совместимо с Gradle 8.14.3 / AGP 8.13.0 / JDK 21).
- **Тест-железо:** Samsung One UI (физический) — есть. Xiaomi/Pixel — нет; multi-vendor (§3.7) и Xiaomi-autostart (§5.40) остаются открытыми до доступа к устройствам.
**Осталось открытым (решаем на железе в Phase B):**
- **Аудио-владение**: Telecom-owns-routing (правильно, рискованно — §4) vs Telecom-only-signaling + AudioRoutePlugin-owns-audio
(страховка). Решаем по живому эхо-тесту на Samsung. Если эхо непобедимо → fallback на signaling-only.
## 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`) — билд/инсталл/логкат гоняю сам.

View file

@ -13,6 +13,7 @@ import { CallEmbed } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useAndroidCallForegroundSync } from '../hooks/useAndroidCallForegroundSync';
import { useTelecomConnectionSync } from '../hooks/useTelecomConnectionSync';
function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
@ -49,6 +50,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const joined = useCallJoined(callEmbed);
useAndroidCallForegroundSync();
// Self-managed Telecom session (docs/plans/telecom_migration.md). No-op
// unless TELECOM_ENABLED + Android + API>=26; keyed on the same joined signal.
useTelecomConnectionSync();
const selectedRoom = useSelectedRoom();
const chat = useAtomValue(callChatAtom);

View file

@ -28,6 +28,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
import { callEmbedAtom, callSpeakerAtom } from '../state/callEmbed';
import { callForegroundService } from '../plugins/call/callForegroundService';
import { callAudioRoute } from '../plugins/call/callAudioRoute';
import { telecomCall } from '../plugins/call/telecomCall';
import { useCallJoined } from './useCallEmbed';
import { isAndroidPlatform } from '../utils/capacitor';
@ -53,7 +54,11 @@ export const useAndroidCallForegroundSync = (): void => {
// Restore the platform-default audio route so the next call starts on
// the earpiece and no other app inherits a forced loudspeaker. The
// WebView owns the audio session; we only undo our output override.
callAudioRoute.clear();
// Under Telecom the OS owns route cleanup — calling
// clearCommunicationDevice ourselves would fight it, so skip it there.
if (!telecomCall.enabled()) {
callAudioRoute.clear();
}
setSpeaker(false);
};
}, [joined, setSpeaker]);

View file

@ -2,12 +2,22 @@ import { useAtom } from 'jotai';
import { useCallback } from 'react';
import { callSpeakerAtom } from '../state/callEmbed';
import { callAudioRoute } from '../plugins/call/callAudioRoute';
import { telecomCall } from '../plugins/call/telecomCall';
// In-call loudspeaker ⇄ earpiece toggle, backed by the native AudioRoute
// plugin. The atom holds the UI truth; `toggle` optimistically flips it then
// reconciles to the route the native side actually reports (an OEM WebView may
// refuse the request). `available` is false on web / iOS, where the OS owns
// output routing and the UI hides the control.
// In-call loudspeaker ⇄ earpiece toggle. The atom holds the UI truth.
//
// Two backends:
// - Telecom active (TELECOM_ENABLED + API>=26): the OS owns the call's audio
// route, so we ask Telecom to switch endpoints (requestEndpoint). The route
// that actually takes comes back via the `telecomEndpoint` event, which
// useTelecomConnectionSync mirrors into this atom — so we DON'T call
// AudioManager.setCommunicationDevice here (concurrent use corrupts the
// Telecom-owned route).
// - Legacy (no Telecom): the AudioRoute plugin flips the WebView's WebRTC
// output and reports the route it observed; we reconcile to that.
//
// `available` is false on web / iOS, where the OS owns output routing and the
// UI hides the control.
export const useCallSpeaker = (): {
speaker: boolean;
toggle: () => void;
@ -18,6 +28,11 @@ export const useCallSpeaker = (): {
const toggle = useCallback(() => {
const next = !speaker;
setSpeaker(next);
if (telecomCall.enabled()) {
// Telecom owns the route; the telecomEndpoint event reconciles the atom.
telecomCall.requestEndpoint(next ? 'SPEAKER' : 'EARPIECE').catch(() => undefined);
return;
}
callAudioRoute.setSpeaker(next).then(
(actual) => setSpeaker(actual),
() => undefined

View file

@ -0,0 +1,94 @@
// Drive a self-managed Telecom call session alongside the active DM call
// (docs/plans/telecom_migration.md, Phase B). Mirror of
// useAndroidCallForegroundSync: keyed to the widget's JoinCall signal (via
// useCallJoined), not the mere presence of callEmbedAtom.
//
// What this gives us once TELECOM_ENABLED is flipped on:
// - the Vojo call is registered with the OS as a real call → cellular-call
// interop (an incoming GSM call holds/arbitrates against it), audio-focus
// arbitration, and a system "ongoing call" presence;
// - a remote/system Telecom disconnect (the user takes a GSM call, or a
// Bluetooth/Android-Auto "end call") tears the Vojo call down too;
// - Telecom owns the audio route, so its active endpoint is mirrored into the
// speaker-toggle atom (earpiece / speaker / Bluetooth all reflected).
//
// §4 (validated on Samsung One UI: the call gets system call-audio focus and
// the route endpoint flow emits cleanly): Telecom owns routing. The speaker
// toggle now drives Telecom endpoints (see useCallSpeaker) and AudioRoutePlugin
// is kept off the audio session while a Telecom call is live.
//
// Android-only, and additionally gated on TELECOM_ENABLED + API>=26 inside
// telecomCall — on web / iOS / old Android every call here is a no-op.
import { useEffect } from 'react';
import { useAtomValue, useSetAtom, useStore } from 'jotai';
import { callEmbedAtom, callSpeakerAtom } from '../state/callEmbed';
import { telecomCall, type TelecomRoute } from '../plugins/call/telecomCall';
import { useCallJoined } from './useCallEmbed';
export const useTelecomConnectionSync = (): void => {
const callEmbed = useAtomValue(callEmbedAtom);
const joined = useCallJoined(callEmbed);
const store = useStore();
const setSpeaker = useSetAtom(callSpeakerAtom);
useEffect(() => {
if (!telecomCall.enabled()) return undefined;
if (!callEmbed || !joined) return undefined;
let cancelled = false;
const removers: Array<() => void> = [];
const track = (p: ReturnType<typeof telecomCall.addListener>) => {
p.then((handle) => {
if (cancelled) handle.remove();
else removers.push(() => handle.remove());
}).catch(() => {
/* listener registration best-effort */
});
};
telecomCall
.startCall({
roomId: callEmbed.roomId,
// For a DM the room name resolves to the peer's display name; good
// enough for the system call label. Refined per-direction later.
displayName: callEmbed.room.name || 'Vojo',
video: !callEmbed.voiceOnly,
incoming: false,
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[telecom] startCall failed', err);
});
// A Telecom-side disconnect we did NOT initiate (interrupting cellular
// call, BT/Auto "end", system) ends the Vojo call too. hangup() lets
// Element Call tear LiveKit down; the resulting Hangup event clears the
// atom, which unmounts this effect and fires endCall() below. Idempotent:
// our own hangup re-entering here is a no-op via the current-embed guard.
track(
telecomCall.addListener('telecomDisconnect', () => {
if (store.get(callEmbedAtom) !== callEmbed) return;
callEmbed.hangup().catch(() => {
/* widget already gone */
});
})
);
// Telecom owns the route; reflect the active endpoint in the speaker-toggle
// atom so the UI tracks reality (speaker vs earpiece/Bluetooth/wired).
track(
telecomCall.addListener('telecomEndpoint', (data: { route: TelecomRoute }) => {
setSpeaker(data.route === 'SPEAKER');
})
);
return () => {
cancelled = true;
removers.forEach((remove) => remove());
telecomCall.endCall().catch(() => {
/* best-effort teardown */
});
};
}, [callEmbed, joined, store, setSpeaker]);
};

View file

@ -0,0 +1,131 @@
// Typed wrapper around the self-managed Telecom call plugin
// (TelecomCallPlugin.java + VojoCallsManager.kt). See
// docs/plans/telecom_migration.md.
//
// Telecom gives Vojo's native Android calls system-level call interop (a GSM
// call interrupts/holds the Vojo call and vice-versa), audio-focus
// arbitration, Bluetooth / Android Auto / Wear answer + route surfaces, and a
// system "ongoing call" presence. The actual media stays in the Element Call
// WebView; this layer only drives the Telecom call STATE and translates its
// callbacks (answer / disconnect / hold / mute / route) back to JS.
//
// Phase A ships this default-OFF (TELECOM_ENABLED=false) so the first build
// can't regress live calls before the path is validated on a Samsung device.
// Flip the flag to exercise it; once validated it becomes the default and the
// legacy FGS-only path is retired.
//
// Android-only. On web / iOS / API<26 every method is a no-op and never
// touches the native plugin.
import { registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import { isAndroidPlatform } from '../../utils/capacitor';
// Phase A: default off. See plan §10 — flip to test on-device, then promote to
// default once the audio-ownership / echo behavior is confirmed on Samsung.
export const TELECOM_ENABLED = false;
/** Audio routes mirrored to/from CallEndpointCompat types (see VojoCallsManager). */
export type TelecomRoute = 'EARPIECE' | 'SPEAKER' | 'BLUETOOTH' | 'WIRED' | 'STREAMING' | 'UNKNOWN';
/** Events emitted by TelecomCallPlugin (see VojoCallsManager.Listener). */
export type TelecomEvent =
| 'telecomAnswer'
| 'telecomDisconnect'
| 'telecomSetActive'
| 'telecomSetInactive'
| 'telecomMute'
| 'telecomEndpoint'
| 'telecomEndpoints'
| 'telecomError';
export interface TelecomStartOptions {
roomId: string;
displayName: string;
video?: boolean;
incoming?: boolean;
}
export interface TelecomCallPlugin {
startCall(options: TelecomStartOptions): Promise<void>;
answer(options: { video?: boolean }): Promise<void>;
setActive(): Promise<void>;
endCall(): Promise<void>;
requestEndpoint(options: { route: TelecomRoute }): Promise<void>;
addListener(
eventName: 'telecomAnswer',
listenerFunc: (data: { video: boolean }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomDisconnect',
listenerFunc: (data: { causeCode: number }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomSetActive' | 'telecomSetInactive',
listenerFunc: () => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomMute',
listenerFunc: (data: { muted: boolean }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomEndpoint',
listenerFunc: (data: { route: TelecomRoute }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomEndpoints',
listenerFunc: (data: { routes: TelecomRoute[] }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomError',
listenerFunc: (data: { message: string }) => void
): Promise<PluginListenerHandle>;
}
const plugin = registerPlugin<TelecomCallPlugin>('TelecomCall');
/** True only when the Telecom backend should be driven (Android + flag on). */
export const isTelecomEnabled = (): boolean => isAndroidPlatform() && TELECOM_ENABLED;
export const telecomCall = {
enabled: isTelecomEnabled,
// Begin a Telecom session alongside the WebView call. Outgoing goes active
// immediately; incoming stays ringing until answer().
startCall(options: TelecomStartOptions): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.startCall(options);
},
answer(video = false): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.answer({ video });
},
setActive(): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.setActive();
},
// Idempotent best-effort teardown — safe to call on any platform.
endCall(): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.endCall();
},
requestEndpoint(route: TelecomRoute): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.requestEndpoint({ route });
},
// Subscribe to a Telecom event. Returns a no-op handle when disabled so
// callers can unconditionally register + clean up. (Plain union event type:
// TS `Parameters<>` on the overloaded plugin signature only captures the last
// overload, so we don't derive the event name from it.)
addListener(
eventName: TelecomEvent,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
listenerFunc: (data: any) => void
): Promise<PluginListenerHandle> {
if (!isTelecomEnabled()) {
return Promise.resolve({ remove: () => Promise.resolve() });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return plugin.addListener(eventName as any, listenerFunc);
},
};