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