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")
}