6 changed files with 391 additions and 0 deletions
@ -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) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue