From 15958a078e5d750802af291c63efa0847ffc4063 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 29 Sep 2026 21:26:24 +0300 Subject: [PATCH] android: self-managed Telecom ConnectionService for call audio routing --- .../app/src/main/AndroidManifest.xml | 13 ++ .../java/com/utun/chat/ChatApplication.kt | 4 + .../com/utun/chat/data/TelecomCallManager.kt | 168 ++++++++++++++++++ .../java/com/utun/chat/data/UtunConnection.kt | 131 ++++++++++++++ .../utun/chat/data/UtunConnectionService.kt | 57 ++++++ .../com/utun/chat/viewmodel/ChatViewModel.kt | 18 ++ 6 files changed, 391 insertions(+) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt diff --git a/tools/chatgui-android/app/src/main/AndroidManifest.xml b/tools/chatgui-android/app/src/main/AndroidManifest.xml index 0ea5be10..41c714a0 100644 --- a/tools/chatgui-android/app/src/main/AndroidManifest.xml +++ b/tools/chatgui-android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -60,6 +61,18 @@ android:value="ptt_overlay_service" /> + + + + + + + diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 1621ad5d..2f790aa2 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -7,6 +7,7 @@ import com.utun.chat.data.ConfigProvider import com.utun.chat.data.NativeLib import com.utun.chat.data.NotificationSound import com.utun.chat.data.IncomingCallManager +import com.utun.chat.data.TelecomCallManager import com.utun.chat.data.ChatEventCallback import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -41,6 +42,9 @@ class ChatApplication : Application() { /* Incoming-call system effects (wake + ringtone + notification) — app-scoped */ IncomingCallManager.init() + /* Telecom self-managed call integration (system audio routing for calls) */ + TelecomCallManager.init() + /* First launch config */ if (configProvider.isFirstLaunch()) { kotlinx.coroutines.runBlocking { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt new file mode 100644 index 00000000..0f41a2fa --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt @@ -0,0 +1,168 @@ +package com.utun.chat.data + +import android.content.ComponentName +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.telecom.PhoneAccount +import android.telecom.PhoneAccountHandle +import android.telecom.TelecomManager +import com.utun.chat.ChatApplication +import java.util.concurrent.ConcurrentHashMap + +/** + * Мост между жизненным циклом P2P-звонка (ChatViewModel) и Telecom. + * + * - Регистрирует self-managed PhoneAccount (делает вызовы uTun «настоящими» для системы). + * - placeCall/addIncomingCall создают Connection через UtunConnectionService. + * - Хранит Connection по callId и прокидывает state/route из VM в Connection. + * - Обратные события (answer/reject/disconnect из системного ввода) — через Listener в VM. + * + * Всё «best effort»: если Telecom недоступен (нет прав/стека), методы возвращают false, + * и VM продолжает звонок прежним путём без системной маршрутизации. + */ +object TelecomCallManager { + + interface Listener { + fun onAnswer(callId: Long) + fun onReject(callId: Long) + fun onDisconnect(callId: Long) + } + + const val EXTRA_CALL_ID = "com.utun.chat.extra.CALL_ID" + const val EXTRA_PEER_NODE_ID = "com.utun.chat.extra.PEER_NODE_ID" + const val EXTRA_DISPLAY_NAME = "com.utun.chat.extra.DISPLAY_NAME" + + private const val PHONE_ACCOUNT_ID = "utun_p2p_call" + private const val TAG = "TelecomCall" + + @Volatile private var listener: Listener? = null + + private val connections = ConcurrentHashMap() + + @Volatile private var phoneAccountHandle: PhoneAccountHandle? = null + @Volatile private var ready = false + + private val mainHandler = Handler(Looper.getMainLooper()) + + fun setListener(l: Listener?) { listener = l } + + /** Инициализация из ChatApplication.onCreate: регистрирует PhoneAccount. Идемпотентно. */ + fun init() { + if (ready) return + val ctx = ChatApplication.instance + val tm = ctx.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager ?: run { + LogManager.addLog("ERROR", TAG, "TelecomManager unavailable") + return + } + val component = ComponentName(ctx, UtunConnectionService::class.java) + val handle = PhoneAccountHandle(component, PHONE_ACCOUNT_ID) + phoneAccountHandle = handle + try { + /* registerPhoneAccount идемпотентен (перезапись по handle) и для self-managed + * аккаунта требует только MANAGE_OWN_CALLS. getPhoneAccount() здесь не зовём — + * он на Android 14+ требует READ_PHONE_NUMBERS, которого у нас нет. */ + val account = PhoneAccount.builder(handle, "uTun Chat") + .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED) + .build() + tm.registerPhoneAccount(account) + LogManager.addLog("INFO", TAG, "phone account registered") + ready = true + } catch (e: Exception) { + LogManager.addLog("ERROR", TAG, "phone account registration failed: %s".format(e.message)) + } + } + + fun isReady(): Boolean = ready + + /** Создать исходящий вызов в Telecom. Возвращает false, если Telecom недоступен. */ + fun placeCall(callId: Long, peerNodeId: Long, displayName: String): Boolean { + val tm = telecomManager() ?: return false + val handle = phoneAccountHandle ?: return false + val uri = Uri.fromParts("node", "0x%016x".format(peerNodeId), null) + val extras = Bundle().apply { + putLong(EXTRA_CALL_ID, callId) + putLong(EXTRA_PEER_NODE_ID, peerNodeId) + putString(EXTRA_DISPLAY_NAME, displayName) + putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, handle) + } + return try { + tm.placeCall(uri, extras) + LogManager.addLog("INFO", TAG, "placeCall id=0x%016x peer=0x%016x".format(callId, peerNodeId)) + true + } catch (e: Exception) { + LogManager.addLog("WARN", TAG, "placeCall failed: %s".format(e.message)) + false + } + } + + /** Зарегистрировать входящий вызов в Telecom. Возвращает false, если Telecom недоступен. */ + fun addIncomingCall(callId: Long, peerNodeId: Long, displayName: String): Boolean { + val tm = telecomManager() ?: return false + val handle = phoneAccountHandle ?: return false + val extras = Bundle().apply { + putLong(EXTRA_CALL_ID, callId) + putLong(EXTRA_PEER_NODE_ID, peerNodeId) + putString(EXTRA_DISPLAY_NAME, displayName) + } + return try { + tm.addNewIncomingCall(handle, extras) + LogManager.addLog("INFO", TAG, "addIncomingCall id=0x%016x peer=0x%016x".format(callId, peerNodeId)) + true + } catch (e: Exception) { + LogManager.addLog("WARN", TAG, "addIncomingCall failed: %s".format(e.message)) + false + } + } + + /* ── реестр Connection (вызывается из UtunConnectionService) ── */ + + fun registerConnection(callId: Long, conn: UtunConnection) { + connections[callId] = conn + } + + fun connectionFor(callId: Long): UtunConnection? = connections[callId] + + /* ── события из Connection (системный ввод) → VM ── */ + + fun notifyAnswer(callId: Long) = post { listener?.onAnswer(callId) } + + fun notifyReject(callId: Long) = post { listener?.onReject(callId) } + + fun notifyDisconnect(callId: Long) = post { listener?.onDisconnect(callId) } + + fun notifyAbort(callId: Long) = post { listener?.onDisconnect(callId) } + + /* ── управление состоянием из VM ── */ + + fun setRinging(callId: Long) = connections[callId]?.markRinging() + + fun setActive(callId: Long) = connections[callId]?.markActive() + + fun setDisconnected(callId: Long, reason: Int) = connections[callId]?.markDisconnected(reason) + + fun setRoute(callId: Long, route: AudioRoute) = connections[callId]?.routeTo(route) + + fun removeConnection(callId: Long) { + connections.remove(callId) + LogManager.addLog("DEBUG", TAG, "connection removed id=0x%016x".format(callId)) + } + + /* ── helpers ── */ + + private fun telecomManager(): TelecomManager? { + if (!ready) return null + return try { + ChatApplication.instance.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + } catch (e: Exception) { + LogManager.addLog("WARN", TAG, "getTelecomManager failed: %s".format(e.message)) + null + } + } + + private fun post(r: () -> Unit) { + mainHandler.post(r) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt new file mode 100644 index 00000000..83c3f0a2 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt @@ -0,0 +1,131 @@ +package com.utun.chat.data + +import android.net.Uri +import android.os.Build +import android.os.OutcomeReceiver +import android.telecom.CallAudioState +import android.telecom.CallEndpoint +import android.telecom.CallEndpointException +import android.telecom.Connection +import android.telecom.DisconnectCause +import android.telecom.TelecomManager +import androidx.core.content.ContextCompat +import com.utun.chat.ChatApplication +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Self-managed Telecom Connection для P2P-звонка uTun. + * + * Сам звонок (сигналинг + аудио) полностью ведёт приложение; Connection лишь + * регистрирует вызов в Telecom, чтобы система считала его настоящим и корректно + * маршрутизировала аудио VOICE_COMMUNICATION на разговорный динамик (иначе на + * Android 14+ вызов без Connection не получает earpiece-маршрут). + * + * Владелец — TelecomCallManager (создаётся в UtunConnectionService, хранится в + * реестре по callId). Ответ/отбой/разрыв приходят сюда из системного ввода + * (например, кнопка BT-гарнитуры) и делегируются в VM через TelecomCallManager.Listener. + */ +class UtunConnection( + private val callId: Long, + private val peerNodeId: Long, + displayName: String +) : Connection() { + + /** Доступные CallEndpoint'ы (для requestCallEndpointChange), обновляются системой. */ + private val availableEndpoints = CopyOnWriteArrayList() + + init { + setConnectionProperties(PROPERTY_SELF_MANAGED) + setConnectionCapabilities(CAPABILITY_SUPPORT_HOLD or CAPABILITY_MUTE) + setAudioModeIsVoip(true) + setAddress(Uri.fromParts("node", "0x%016x".format(peerNodeId), null), TelecomManager.PRESENTATION_UNKNOWN) + if (displayName.isNotEmpty()) setCallerDisplayName(displayName, TelecomManager.PRESENTATION_UNKNOWN) + setInitializing() + LogManager.addLog("INFO", "UtunConn", + "created callId=0x%016x peer=0x%016x name=%s".format(callId, peerNodeId, displayName)) + } + + override fun onAnswer() = TelecomCallManager.notifyAnswer(callId) + + override fun onReject() = TelecomCallManager.notifyReject(callId) + + override fun onDisconnect() = TelecomCallManager.notifyDisconnect(callId) + + override fun onAbort() = TelecomCallManager.notifyAbort(callId) + + override fun onStateChanged(state: Int) { + LogManager.addLog("DEBUG", "UtunConn", "state -> %s".format(Connection.stateToString(state))) + } + + override fun onCallAudioStateChanged(audioState: CallAudioState?) { + LogManager.addLog("DEBUG", "UtunConn", + "audioState route=%s muted=%s".format(audioState?.route ?: -1, audioState?.isMuted ?: false)) + } + + override fun onAvailableCallEndpointsChanged(availableEndpoints: MutableList) { + this.availableEndpoints.clear() + this.availableEndpoints.addAll(availableEndpoints) + LogManager.addLog("DEBUG", "UtunConn", + "availableEndpoints -> %s".format(availableEndpoints.joinToString { "${it.endpointName}/${it.endpointType}" })) + } + + /** Перевести вызов в звонящее состояние (входящий). */ + fun markRinging() = setRinging() + + /** Вызов пошёл (принят или исходящий дозвонился). */ + fun markActive() = setActive() + + /** Завершение с причиной (коды приложения: 0 — отбой, 2 — отклонено, 7 — ошибка). */ + fun markDisconnected(reason: Int) { + val code = when (reason) { + 2 -> DisconnectCause.REJECTED + 7 -> DisconnectCause.ERROR + else -> DisconnectCause.LOCAL + } + setDisconnected(DisconnectCause(code)) + LogManager.addLog("INFO", "UtunConn", "disconnected callId=0x%016x reason=%d".format(callId, reason)) + } + + /** Маршрутизация аудио через Telecom (CallEndpoint API на Android 14+, иначе setAudioRoute). */ + fun routeTo(route: AudioRoute) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val type = endpointTypeFor(route) + val ep = availableEndpoints.firstOrNull { it.endpointType == type } + if (ep != null) { + try { + val executor = ContextCompat.getMainExecutor(ChatApplication.instance) + requestCallEndpointChange(ep, executor, object : OutcomeReceiver { + override fun onResult(result: Void?) { + LogManager.addLog("INFO", "UtunConn", "endpoint change ok -> %d".format(ep.endpointType)) + } + override fun onError(error: CallEndpointException) { + LogManager.addLog("WARN", "UtunConn", "endpoint change failed: %s".format(error.message)) + } + }) + return + } catch (e: Exception) { + LogManager.addLog("WARN", "UtunConn", "requestCallEndpointChange threw: %s".format(e.message)) + } + } else { + LogManager.addLog("DEBUG", "UtunConn", "no endpoint of type %d, fallback to setAudioRoute".format(type)) + } + } + val stateRoute = when (route) { + AudioRoute.EARPIECE -> CallAudioState.ROUTE_EARPIECE + AudioRoute.SPEAKER -> CallAudioState.ROUTE_SPEAKER + AudioRoute.HEADSET -> CallAudioState.ROUTE_WIRED_OR_EARPIECE + } + try { + setAudioRoute(stateRoute) + LogManager.addLog("INFO", "UtunConn", "audio route -> %d (route=%s)".format(stateRoute, route)) + } catch (e: Exception) { + LogManager.addLog("WARN", "UtunConn", "setAudioRoute failed: %s".format(e.message)) + } + } + + private fun endpointTypeFor(route: AudioRoute): Int = when (route) { + AudioRoute.EARPIECE -> CallEndpoint.TYPE_EARPIECE + AudioRoute.SPEAKER -> CallEndpoint.TYPE_SPEAKER + AudioRoute.HEADSET -> CallEndpoint.TYPE_WIRED_HEADSET + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt new file mode 100644 index 00000000..10934eb1 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt @@ -0,0 +1,57 @@ +package com.utun.chat.data + +import android.telecom.Connection +import android.telecom.ConnectionRequest +import android.telecom.ConnectionService +import android.telecom.PhoneAccountHandle + +/** + * Self-managed ConnectionService для P2P-звонков uTun. + * + * Telecom вызывает onCreateOutgoingConnection (placeCall) / onCreateIncomingConnection + * (addNewIncomingCall); здесь создаётся UtunConnection, который регистрируется в + * TelecomCallManager по callId из extras. Само аудио и сигналинг ведёт приложение. + */ +class UtunConnectionService : ConnectionService() { + + override fun onCreateIncomingConnection( + connectionManagerPhoneAccount: PhoneAccountHandle?, + request: ConnectionRequest? + ): Connection? { + val (callId, peerNodeId, name) = parseRequest(request) ?: run { + LogManager.addLog("WARN", "UtunConnSvc", "onCreateIncomingConnection: bad request extras") + return Connection.createCanceledConnection() + } + LogManager.addLog("INFO", "UtunConnSvc", + "onCreateIncomingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId)) + return createConnection(callId, peerNodeId, name).also { it.markRinging() } + } + + override fun onCreateOutgoingConnection( + connectionManagerPhoneAccount: PhoneAccountHandle?, + request: ConnectionRequest? + ): Connection? { + val (callId, peerNodeId, name) = parseRequest(request) ?: run { + LogManager.addLog("WARN", "UtunConnSvc", "onCreateOutgoingConnection: bad request extras") + return Connection.createCanceledConnection() + } + LogManager.addLog("INFO", "UtunConnSvc", + "onCreateOutgoingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId)) + return createConnection(callId, peerNodeId, name).also { it.setDialing() } + } + + private fun createConnection(callId: Long, peerNodeId: Long, displayName: String): UtunConnection { + val conn = UtunConnection(callId, peerNodeId, displayName) + TelecomCallManager.registerConnection(callId, conn) + return conn + } + + private fun parseRequest(request: ConnectionRequest?): Triple? { + val extras = request?.extras ?: return null + val callId = extras.getLong(TelecomCallManager.EXTRA_CALL_ID, 0L) + val peer = extras.getLong(TelecomCallManager.EXTRA_PEER_NODE_ID, 0L) + val name = extras.getString(TelecomCallManager.EXTRA_DISPLAY_NAME) ?: "" + if (callId == 0L) return null + return Triple(callId, peer, name) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index e7bfa1d4..f4faf7d2 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -218,6 +218,18 @@ class ChatViewModel : ViewModel() { viewModelScope.launch { AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) } } + /* Системный ввод по вызову (кнопка BT-гарнитуры, разрыв из Telecom) → те же действия, что и UI. */ + TelecomCallManager.setListener(object : TelecomCallManager.Listener { + override fun onAnswer(callId: Long) { acceptCall() } + override fun onReject(callId: Long) { declineCall() } + override fun onDisconnect(callId: Long) { + val st = _callState.value + if (st != null && st.callId == callId && st.phase != CallPhase.ENDED) { + LogManager.addLog("INFO", "VM", "telecom disconnect -> hangup 0x%016x".format(callId)) + hangupCall() + } + } + }) viewModelScope.launch(Dispatchers.IO) { val key = ChatApplication.instance.configProvider.getString("node.public_key") if (key != null && key.length == 64) _pubKey.value = key @@ -611,6 +623,7 @@ class ChatViewModel : ViewModel() { callIncomingAtMs = System.currentTimeMillis() _callState.value = CallState(callId = callId, channelId = chId, peerNodeId = caller, peerName = "0x%016x".format(caller), phase = CallPhase.INCOMING) + TelecomCallManager.addIncomingCall(callId, caller, "0x%016x".format(caller)) resolveCallerName(chId, caller) } 35 -> { /* CALL_RINGING: [call_id:8] */ @@ -630,6 +643,7 @@ class ChatViewModel : ViewModel() { if (st != null && st.callId == callId) { LogManager.addLog("INFO", "VM", "CALL_ACCEPTED id=0x%016x".format(callId)) CallRingback.stop() + TelecomCallManager.setActive(callId) callAudio.router.setPreferredRoute( ChatApplication.instance.configProvider.getCallAudioRoute()) if (!callAudio.start(callId)) { @@ -714,6 +728,8 @@ class ChatViewModel : ViewModel() { LogManager.addLog("INFO", "VM", "CALL_$tag id=0x%016x reason=$reason".format(callId)) if (st == null || st.callId != callId) return callAudio.stop() + TelecomCallManager.setDisconnected(callId, reason) + TelecomCallManager.removeConnection(callId) CallRingback.stop() IncomingCallManager.stopRinging() callIncomingAtMs = 0L @@ -740,6 +756,7 @@ class ChatViewModel : ViewModel() { _callState.value = CallState(callId = callId, channelId = channelId, peerNodeId = peerNodeId, peerName = name, phase = CallPhase.OUTGOING) NativeLib.callStart(channelId, peerNodeId, callId) + TelecomCallManager.placeCall(callId, peerNodeId, name) CallRingback.startConnecting() LogManager.addLog("INFO", "VM", "startCall ch=$channelId peer=0x%016x id=0x%016x".format(peerNodeId, callId)) } @@ -926,6 +943,7 @@ class ChatViewModel : ViewModel() { fun setAudioRoute(r: AudioRoute) { callAudio.router.setRoute(r) + _callState.value?.let { TelecomCallManager.setRoute(it.callId, r) } viewModelScope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(r) } LogManager.addLog("INFO", "VM", "setAudioRoute -> $r") }