From 53ba8ea42e919ead06bc36c5e445f2affb741947 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 28 Aug 2026 21:11:51 -0400 Subject: [PATCH] One MainActivity, ever: a stacked instance made hang-up a no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default launch mode stacked a second MainActivity — with a second ViewModel, session and CallManager — whenever the app was re-entered. The visible screen's Hang up then commanded a manager with no call while the real call rang on underneath, and the peer never got the END. singleTask, plus a 30s connect guard so an answered call whose ICE never finds a path gives up out loud on both screens instead of sitting in Connecting forever. Co-Authored-By: Claude Opus 4.8 --- app/src/main/AndroidManifest.xml | 11 ++++++++++ .../io/flamenet/messenger/app/CallManager.kt | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ff0c37b..7ea88c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,9 +55,20 @@ presents as a corrupt install rather than as a missing key. --> + diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index cab08d2..4e66e81 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -113,6 +113,23 @@ class CallManager( /** Gives up an unanswered outgoing call, like iOS's 45 s dial timeout. */ private var dialGuard: Runnable? = null + /** + * Gives up a call that was answered but whose media never connected. + * Without it, an answer whose ICE cannot find a path — no TURN, hostile + * NAT — left BOTH screens sitting in "Connecting…" until someone forced + * the app closed. + */ + private var connectGuard: Runnable? = null + + private fun armConnectGuard() { + connectGuard?.let { main.removeCallbacks(it) } + val guard = Runnable { + if (state == State.CONNECTING) hangUp(notifyPeer = true, reason = "timeout") + } + connectGuard = guard + main.postDelayed(guard, CONNECT_TIMEOUT_MS) + } + /** Main thread only. */ private fun sendWhenGathered(send: (String) -> Unit) { onGathered = send @@ -372,6 +389,7 @@ class CallManager( dialGuard?.let { main.removeCallbacks(it) } dialGuard = null state = State.CONNECTING + armConnectGuard() connection?.setRemoteDescription( SimpleSdpObserver(), SessionDescription(SessionDescription.Type.ANSWER, sdp), @@ -390,6 +408,7 @@ class CallManager( pendingOffer = null val sdp = offer.sdp ?: return state = State.CONNECTING + armConnectGuard() val pc = open(turn, fromUser, offer.callId, offer.video) ?: run { state = State.IDLE return @@ -458,6 +477,8 @@ class CallManager( gatherGuard = null dialGuard?.let { main.removeCallbacks(it) } dialGuard = null + connectGuard?.let { main.removeCallbacks(it) } + connectGuard = null val active = current if (notifyPeer && active != null) { // Best effort. Telling the other end is worth doing and is not @@ -511,6 +532,7 @@ class CallManager( companion object { /** How long an unanswered outgoing call rings before giving up. */ const val DIAL_TIMEOUT_MS = 45_000L + const val CONNECT_TIMEOUT_MS = 30_000L /** * How long to wait for ICE gathering before sending what we have. An