From 01a84c6851362ab47ec3cd31da70b8fd8f3c65e3 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 02:11:43 +0100 Subject: [PATCH] Retain retiring Telecom connections and reject duplicate registrations --- .../com/utun/chat/data/TelecomCallManager.kt | 105 +++++++++++++----- .../utun/chat/data/UtunConnectionService.kt | 14 ++- 2 files changed, 87 insertions(+), 32 deletions(-) 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 index 3eb7de19..2db04024 100644 --- 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 @@ -20,8 +20,8 @@ import java.util.concurrent.ConcurrentHashMap * - Хранит Connection по callId и прокидывает state/route из контроллера в Connection. * - Обратные события (answer/reject/disconnect из системного ввода) — через Listener в контроллер. * - * Всё «best effort»: если Telecom недоступен (нет прав/стека), методы возвращают false, - * и контроллер продолжает звонок без системной маршрутизации. + * init/изменения реестра — main, lookup — также workers. Отклонённые/удалённые connections сохраняются + * до успешного destroy. Отсутствие Telecom возвращает false из запросов; решение о fallback/завершении принимает controller. */ object TelecomCallManager { @@ -44,6 +44,13 @@ object TelecomCallManager { @Volatile private var listener: Listener? = null private val connections = ConcurrentHashMap() + private data class Closing(val id: Long, val reason: Int, var disconnected: Boolean = false) + private val closing = linkedMapOf() // Main: сильные refs до ACK destroy, включая late/duplicate connections. + private val reasons = mutableMapOf() + private val disconnected = mutableSetOf() + private var cleanupQueued = false + private var cleanupDelay = 250L + private val cleanupRetry = Runnable { cleanupQueued = false; cleanupConnections() } @Volatile private var phoneAccountHandle: PhoneAccountHandle? = null @Volatile private var ready = false @@ -55,15 +62,10 @@ object TelecomCallManager { /** Инициализация из 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 { + val ctx = ChatApplication.instance + val tm = ctx.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager ?: error("TelecomManager unavailable") + val handle = PhoneAccountHandle(ComponentName(ctx, UtunConnectionService::class.java), PHONE_ACCOUNT_ID) /* registerPhoneAccount идемпотентен (перезапись по handle) и для self-managed * аккаунта требует только MANAGE_OWN_CALLS. getPhoneAccount() здесь не зовём — * он на Android 14+ требует READ_PHONE_NUMBERS, которого у нас нет. */ @@ -71,6 +73,7 @@ object TelecomCallManager { .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED) .build() tm.registerPhoneAccount(account) + phoneAccountHandle = handle LogManager.addLog("INFO", TAG, "phone account registered") ready = true } catch (e: Exception) { @@ -125,22 +128,32 @@ object TelecomCallManager { /* ── реестр Connection (вызывается из UtunConnectionService) ── */ - fun registerConnection(callId: Long, conn: UtunConnection) { + fun registerConnection(callId: Long, conn: UtunConnection): Boolean { val st = CallController.state.value if (st == null || st.callId != callId || !st.ongoing) { LogManager.addLog("WARN", TAG, "late connection for ended/stale call id=$callId current=${st?.callId}") - conn.markDisconnected(0) - conn.destroy() - return + retire(callId, conn, 0); return false } - connections[callId] = conn - when (st.phase) { - CallPhase.ACTIVE -> conn.markActive() - CallPhase.INCOMING, CallPhase.PENDING_INCOMING -> conn.markRinging() - else -> conn.setDialing() + val previous = connections.putIfAbsent(callId, conn) + if (previous != null) { + if (previous === conn) return true + LogManager.addLog("WARN", TAG, "duplicate connection rejected id=$callId; original retained") + retire(callId, conn, 0); return false + } + try { + when (st.phase) { + CallPhase.ACTIVE -> conn.markActive() + CallPhase.INCOMING, CallPhase.PENDING_INCOMING -> conn.markRinging() + else -> conn.setDialing() + } + LogManager.addLog("INFO", TAG, "connection registered id=$callId phase=${st.phase}") + listener?.onConnectionAvailable(callId, conn) + return true + } catch (e: Exception) { + LogManager.addLog("ERROR", TAG, "connection registration failed id=$callId; retiring owner: $e") + connections.remove(callId, conn); retire(callId, conn, 7); notifyConnectionFailed(callId) + return false } - LogManager.addLog("INFO", TAG, "connection registered id=$callId phase=${st.phase}") - listener?.onConnectionAvailable(callId, conn) } fun connectionFor(callId: Long): UtunConnection? = connections[callId] @@ -159,23 +172,57 @@ object TelecomCallManager { /* ── управление состоянием из контроллера ── */ - fun setRinging(callId: Long) = connections[callId]?.markRinging() + fun setRinging(callId: Long) { + try { connections[callId]?.markRinging() } catch (e: Exception) { LogManager.addLog("ERROR", TAG, "ringing state failed id=$callId: $e") } + } - fun setActive(callId: Long) = connections[callId]?.markActive() + fun setActive(callId: Long) { + try { connections[callId]?.markActive() } catch (e: Exception) { LogManager.addLog("ERROR", TAG, "active state failed id=$callId: $e") } + } - fun setDisconnected(callId: Long, reason: Int) = connections[callId]?.markDisconnected(reason) + fun setDisconnected(callId: Long, reason: Int) { + val conn = connections[callId] ?: return + reasons[conn] = reason + try { conn.markDisconnected(reason); disconnected.add(conn) } catch (e: Exception) { + LogManager.addLog("ERROR", TAG, "disconnected state failed id=$callId; owner retained until removal: $e") + } + } fun removeConnection(callId: Long) { - connections.remove(callId)?.destroy() + connections.remove(callId)?.let { retire(callId, it, reasons[it] ?: 0) } LogManager.addLog("DEBUG", TAG, "connection removed id=0x%016x".format(callId)) } + private fun retire(callId: Long, conn: UtunConnection, reason: Int) { + closing.putIfAbsent(conn, Closing(callId, reason, conn in disconnected)) + reasons.remove(conn); disconnected.remove(conn); cleanupConnections() + } + + private fun cleanupConnections() { + for ((conn, owner) in closing.toMap()) { + if (!owner.disconnected) try { conn.markDisconnected(owner.reason); owner.disconnected = true } catch (e: Exception) { + LogManager.addLog("ERROR", TAG, "retiring disconnect failed id=${owner.id}; attempting destroy independently: $e") + } + try { conn.destroy(); closing.remove(conn) } catch (e: Exception) { + LogManager.addLog("ERROR", TAG, "connection destroy failed id=${owner.id}; retained: $e") + } + } + if (closing.isEmpty()) { mainHandler.removeCallbacks(cleanupRetry); cleanupQueued = false; cleanupDelay = 250; return } + if (!cleanupQueued) { + cleanupQueued = mainHandler.postDelayed(cleanupRetry, cleanupDelay) + if (!cleanupQueued) LogManager.addLog("ERROR", TAG, "connection cleanup retry rejected count=${closing.size}; owners retained") + else { LogManager.addLog("DEBUG", TAG, "connection cleanup scheduled count=${closing.size} delayMs=$cleanupDelay"); cleanupDelay = (cleanupDelay * 2).coerceAtMost(30_000) } + } + } + /* ── helpers ── */ private fun telecomManager(): TelecomManager? { - if (!ready) return null + if (!ready) { LogManager.addLog("WARN", TAG, "Telecom request before account registration"); return null } return try { - ChatApplication.instance.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + (ChatApplication.instance.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager).also { + if (it == null) LogManager.addLog("ERROR", TAG, "TelecomManager unavailable after account registration") + } } catch (e: Exception) { LogManager.addLog("WARN", TAG, "getTelecomManager failed: %s".format(e.message)) null @@ -183,6 +230,8 @@ object TelecomCallManager { } private fun post(r: () -> Unit) { - mainHandler.post(r) + if (!mainHandler.post { + try { r() } catch (e: Exception) { LogManager.addLog("ERROR", TAG, "Telecom listener callback failed: $e") } + }) LogManager.addLog("ERROR", TAG, "Telecom callback post rejected") } } 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 index cb4b71b1..e7729071 100644 --- 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 @@ -4,6 +4,7 @@ import android.telecom.Connection import android.telecom.ConnectionRequest import android.telecom.ConnectionService import android.telecom.PhoneAccountHandle +import android.telecom.DisconnectCause /** * Self-managed ConnectionService для P2P-звонков uTun. @@ -52,10 +53,15 @@ class UtunConnectionService : ConnectionService() { return createConnection(callId, peerNodeId, name) } - private fun createConnection(callId: Long, peerNodeId: Long, displayName: String): UtunConnection { - val conn = UtunConnection(callId, peerNodeId, displayName) - TelecomCallManager.registerConnection(callId, conn) - return conn + private fun createConnection(callId: Long, peerNodeId: Long, displayName: String): Connection { + return try { + val conn = UtunConnection(callId, peerNodeId, displayName) + if (TelecomCallManager.registerConnection(callId, conn)) conn else Connection.createCanceledConnection() + } catch (e: Exception) { + LogManager.addLog("ERROR", "UtunConnSvc", "connection creation failed id=$callId peer=$peerNodeId: $e") + TelecomCallManager.notifyConnectionFailed(callId) + Connection.createFailedConnection(DisconnectCause(DisconnectCause.ERROR)) + } } private fun parseRequest(request: ConnectionRequest?): Triple? {