One MainActivity, ever: a stacked instance made hang-up a no-op

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 21:11:51 -04:00
co-authored by Claude Opus 4.8
parent 6a0f642e0d
commit 53ba8ea42e
2 changed files with 33 additions and 0 deletions
+11
View File
@@ -55,9 +55,20 @@
presents as a corrupt install rather than as a missing key.
-->
<!--
🔴 singleTask is load-bearing, not cosmetic. The default launch mode
stacks a NEW MainActivity — with a NEW ViewModel, session, poll loop
and CallManager — every time the app is entered through a
notification or a fresh launcher intent. Two instances means the
visible screen's Hang up commands a CallManager that has no call,
while the real call keeps ringing in the instance underneath: the
peer never receives the END, and "I ended the call but their phone
kept going" is exactly how it presents.
-->
<activity
android:name=".app.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -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