From 63f2758d4648ea72f63bf3ceb001daa9593c1a79 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 10 Sep 2026 13:03:04 -0400 Subject: [PATCH 1/2] Accept on the call notification answers the call The notification's Accept button has carried EXTRA_ANSWER_CALL since it was written, and nothing ever read it. Pressing Accept did what any other tap did: it opened the app. Answering was left to the next turn of the polling loop, which refuses a parked offer older than the caller's patience, so a call taken a little late opened the app onto nothing and looked like it had dropped the moment you answered it. MainActivity reads the extra now, on a cold start and on a warm one, and answers at once rather than waiting for a tick. The notification also goes away on its own when the offer it shows has expired. Before, it could outlive the offer by the gap between service sweeps, which is longer since the loop started holding its poll open, and during that gap it was a button that could only disappoint. Seen on the emulator: Accept pressed, app opened, nothing on screen. Co-Authored-By: Claude Fable 5.1 --- .../io/flamenet/messenger/app/MainActivity.kt | 28 +++++++++++++++++++ .../messenger/app/MessengerService.kt | 6 ++++ .../messenger/app/MessengerViewModel.kt | 26 +++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 93818c1..5d6eb5a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -1,5 +1,7 @@ package io.flamenet.messenger.app +import android.content.Intent +import androidx.activity.viewModels import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -152,10 +154,36 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { } } + private val model: MessengerViewModel by viewModels() + + /** + * Accept, pressed on the notification, has to answer the call. + * + * The intent has carried `EXTRA_ANSWER_CALL` since the notification was + * written and nothing read it, so pressing Accept only opened the app and + * left the answering to a polling tick that refuses a stale offer. The call + * then looked like it had dropped the moment you took it. + */ + private fun answerIfAsked(intent: Intent?) { + if (intent?.getBooleanExtra(MessengerService.EXTRA_ANSWER_CALL, false) == true) { + intent.removeExtra(MessengerService.EXTRA_ANSWER_CALL) + model.answerParkedCall() + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + answerIfAsked(intent) + } + // The handover: exactly one poller at a time. override fun onStart() { super.onStart() MessengerService.stop(this) + // Also on the way in: a cold start from the notification arrives here + // rather than through onNewIntent. + answerIfAsked(intent) } override fun onStop() { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt index 1f1e50d..6ccbb26 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt @@ -310,6 +310,12 @@ class MessengerService : Service() { // accident while reaching for the torch. .setOngoing(true) .setAutoCancel(false) + // Goes away by itself when the offer it is showing has expired. + // `take` refuses a parked offer older than the caller's own + // patience, so a notification that outlived that was a button + // which opened the app onto nothing and read as a dropped call. + // Android cancels this without needing the service to notice. + .setTimeoutAfter(CallManager.DIAL_TIMEOUT_MS) .setFullScreenIntent(full, true) .setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, decline, full)) .build() diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index d0c8f51..e222c9f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -3430,6 +3430,32 @@ class MessengerViewModel( * arrived, `CallManager` moved to RINGING, and the only notification posted * was the service's own "running" notice. */ + /** + * Answer the call the notification is showing, now. + * + * The notification's Accept button has always carried + * `EXTRA_ANSWER_CALL`, and nothing ever read it. Tapping Accept therefore + * did what any other tap did: it opened the app. Answering was left to the + * next turn of the polling loop, which refuses a parked offer older than + * the caller's patience, so a call answered a little late opened the app + * onto nothing and looked like it had dropped. + */ + fun answerParkedCall() { + val parked = PendingIncomingCall.take() ?: return + runCatching { + androidx.core.app.NotificationManagerCompat + .from(getApplication()) + .cancel(3) + } + viewModelScope.launch(Dispatchers.IO) { + val manager = callManager() + val turn = session?.turnCredentials() + manager.onSignal(parked.fromUser, parked.signal, turn, parked.at) + // The offer only sets it ringing; this is the answer itself. + manager.accept(turn) + } + } + fun pausePolling() { polling?.cancel() polling = null From 51e9f0132370702e9450336bddb4bdded2c83239 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 10 Sep 2026 14:46:51 -0400 Subject: [PATCH 2/2] Do not stop reading the inbox while a call is up The handover stops this app's polling loop when the screen goes away, because the background service takes over. That is right for messages and wrong for a call already in progress: the loop is the only thing that routes `call-end` into the manager owning the call screen. The service polls too, but it turns offers into notifications and knows nothing about a call that is already up. So a call ended by the far end while this app was off screen had nowhere to land. The screen stayed, still offering to answer, until the dial guard gave up forty-five seconds later. Seen on a device: a call placed at 13:04:13, no `call-end` ever logged, ICE closed at 13:04:58. Polling now refuses to pause while a call is dialling, ringing, connecting or active. And a backstop for when the signal is genuinely lost. ICE DISCONNECTED was a no-op, so nothing noticed a far end that simply vanished. It now arms an eight second grace, cancelled if the connection recovers, because WebRTC blips are ordinary. A call whose audio has stopped is over whatever the signalling says. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/messenger/app/CallManager.kt | 50 ++++++++++++++++++- .../messenger/app/MessengerViewModel.kt | 26 ++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) 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 fbc509e..c10395b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -102,6 +102,35 @@ class CallManager( */ private var ringGuard: Runnable? = null + /** + * Ends a call whose far end stopped answering the media. + * + * `call-end` is the polite path and usually arrives first. When it does not, + * because it was lost or the app was somewhere it could not be read, this is + * what notices. Short, because a call whose audio has stopped is over + * whatever the signalling says. + */ + private var goneGuard: Runnable? = null + + private fun armGoneGuard() { + if (goneGuard != null) return + val guard = + Runnable { + goneGuard = null + if (state == State.ACTIVE || state == State.CONNECTING) { + android.util.Log.i("fnmsg-call", "far end gone, ending the call") + hangUp(notifyPeer = true, reason = "timeout") + } + } + goneGuard = guard + main.postDelayed(guard, GONE_TIMEOUT_MS) + } + + private fun cancelGoneGuard() { + goneGuard?.let { main.removeCallbacks(it) } + goneGuard = null + } + private fun armRingGuard() { cancelRingGuard() val guard = @@ -258,11 +287,22 @@ class CallManager( when (newState) { PeerConnection.IceConnectionState.CONNECTED, PeerConnection.IceConnectionState.COMPLETED, - -> state = State.ACTIVE + -> { + cancelGoneGuard() + state = State.ACTIVE + } // CLOSED is what our OWN close() produces. PeerConnection.IceConnectionState.CLOSED -> Unit + // The far end went away. Transient by definition, + // WebRTC recovers from it often, so this is a grace + // rather than a hang-up: if it comes back the timer + // is cancelled below. Without it a lost `call-end` + // left the screen up until the dial guard gave up + // three quarters of a minute later. + PeerConnection.IceConnectionState.DISCONNECTED -> armGoneGuard() + PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true) else -> Unit @@ -584,6 +624,8 @@ class CallManager( dialGuard = null connectGuard?.let { main.removeCallbacks(it) } connectGuard = null + cancelGoneGuard() + cancelRingGuard() val active = current if (notifyPeer && active != null) { // Best effort. @@ -646,6 +688,12 @@ class CallManager( * only when their hang-up never reached us. */ const val RING_TIMEOUT_MS = 60_000L + + /** + * How long a connected call may stay disconnected before it is over. + * Long enough for a real blip, short enough not to be a stuck screen. + */ + const val GONE_TIMEOUT_MS = 8_000L const val CONNECT_TIMEOUT_MS = 30_000L /** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index e222c9f..2e3dc31 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -2334,6 +2334,16 @@ class MessengerViewModel( onStateChanged = { state -> // Filed once, at the end. if (state == CallManager.State.ENDED || state == CallManager.State.IDLE) { + // The ring notification belongs to the service, and the + // manager knows nothing of it. A ring that ended here, + // by the caller hanging up or by the guard giving up, + // used to leave it standing: an Answer button for a + // call that no longer existed. Seen on a device. + runCatching { + androidx.core.app.NotificationManagerCompat + .from(getApplication()) + .cancel(3) + } calls?.current?.let { active -> if (!callFiled) { callFiled = true @@ -3457,6 +3467,22 @@ class MessengerViewModel( } fun pausePolling() { + // Never while a call is in flight. This loop is the only thing that + // routes `call-end` into the manager that owns the call screen; the + // service polls too, but it turns offers into notifications and knows + // nothing about a call already up. Stopping here mid-call left the far + // end's hang-up with nowhere to land, so the screen sat there offering + // to answer a call that had gone, until the dial guard gave up 45 + // seconds later. Seen on a device. + when (calls?.state) { + CallManager.State.DIALLING, + CallManager.State.RINGING, + CallManager.State.CONNECTING, + CallManager.State.ACTIVE, + -> return + + else -> Unit + } polling?.cancel() polling = null }