Browse Source

android: self-managed Telecom ConnectionService for call audio routing

master
evgeny 2 days ago
parent
commit
15958a078e
  1. 13
      tools/chatgui-android/app/src/main/AndroidManifest.xml
  2. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  3. 168
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt
  4. 131
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt
  5. 57
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt
  6. 18
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

13
tools/chatgui-android/app/src/main/AndroidManifest.xml

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -60,6 +61,18 @@
android:value="ptt_overlay_service" />
</service>
<service
android:name=".data.UtunConnectionService"
android:exported="true"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
<meta-data
android:name="android.telecom.INCLUDE_SELF_MANAGED_CALLS"
android:value="true" />
</service>
<receiver
android:name=".data.IncomingCallActionReceiver"
android:exported="false" />

4
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 {

168
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<Long, UtunConnection>()
@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)
}
}

131
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<CallEndpoint>()
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<CallEndpoint>) {
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<Void, CallEndpointException> {
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
}
}

57
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<Long, Long, String>? {
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)
}
}

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

Loading…
Cancel
Save