android: self-managed Telecom ConnectionService for call audio routing
This commit is contained in:
1 parent
61bf235ef4
commit
15958a078e
6 files changed
+391
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user