feat(calls): native self-managed Telecom backend for Android (Phase A/B, flag-gated off, validated on Samsung)
This commit is contained in:
parent
9aaa0b1833
commit
f2eb363db4
13 changed files with 1061 additions and 6 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
182
android/app/src/main/java/chat/vojo/app/TelecomCallPlugin.java
Normal file
182
android/app/src/main/java/chat/vojo/app/TelecomCallPlugin.java
Normal 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);
|
||||
}
|
||||
}
|
||||
318
android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt
Normal file
318
android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
266
docs/plans/telecom_migration.md
Normal file
266
docs/plans/telecom_migration.md
Normal 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 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).
|
||||
- `<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 блокирует экран 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.
|
||||
|
||||
## 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`) — билд/инсталл/логкат гоняю сам.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
94
src/app/hooks/useTelecomConnectionSync.ts
Normal file
94
src/app/hooks/useTelecomConnectionSync.ts
Normal 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]);
|
||||
};
|
||||
131
src/app/plugins/call/telecomCall.ts
Normal file
131
src/app/plugins/call/telecomCall.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue