From b8ffe2ea7186f211d86a7e0b40dd447ec2f63cd3 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 29 Aug 2026 13:05:51 -0400 Subject: [PATCH] Answer a call without opening the app first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A high-priority notification is a banner, not a ringing phone. On a locked device the old one showed a line of text, and the only way to act was to unlock, find the app and open it — by which time the caller has given up. A call is a live event with seconds on the clock. Now a full-screen intent with `CallStyle`, so the lock screen shows Answer and Decline. ⚠️ `CATEGORY_CALL` and `CallStyle` are not decoration: Android 14 grants USE_FULL_SCREEN_INTENT at install only to apps that ring like this, and without them the permission is refused and the call silently degrades to the banner it used to be. ⚠️ Decline is a broadcast, not an activity. The point of answering from a lock screen is that the phone stays locked for the choice the user did NOT make. 🔴 **The notification is `ongoing`**, so it cannot be swiped away by accident mid-ring — which also means it never dismisses itself, and every path that ends a call has to cancel it. Four of them: declined, answered, the caller hung up, and nobody picked up. The last has no event behind it, so the service checks for a parked offer past the dial timeout; without it the lock screen keeps offering to answer a call that is long dead. 🔴 `isStale()` asks without consuming. `take()` is the handover and can only happen once, so a staleness check built on it would swallow a live offer and drop a call that was still ringing — the exact opposite of the bug it exists to prevent. There is a test for that specifically. Answering hands off through the SAME parked offer the app already uses on launch. This session consumed the envelope, so the SDP in hand is the only copy — a second answer path would be a second way to lose a call. engine 168, client 96, app 46. Co-Authored-By: Claude Opus 4.8 --- app/src/main/AndroidManifest.xml | 23 +++++ .../messenger/app/CallActionReceiver.kt | 26 ++++++ .../io/flamenet/messenger/app/CallManager.kt | 21 +++++ .../messenger/app/MessengerService.kt | 91 +++++++++++++++++-- .../messenger/app/MessengerViewModel.kt | 9 ++ .../flamenet/messenger/app/PendingCallTest.kt | 75 +++++++++++++++ 6 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/CallActionReceiver.kt create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/PendingCallTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7ea88c7..55804f4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,20 @@ + + + + diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallActionReceiver.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallActionReceiver.kt new file mode 100644 index 0000000..40e98e0 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallActionReceiver.kt @@ -0,0 +1,26 @@ +package io.flamenet.messenger.app + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat + +/** + * The Decline button on a ringing call notification. + * + * ⚠️ A broadcast, not an activity. Declining must not open the app — the whole + * point of answering from the lock screen is that the phone stays locked for + * the choice the user did NOT make. + * + * 🔴 Clears the parked offer as well as the notification. The notification is + * `ongoing`, so it does not dismiss itself, and an offer left parked would be + * answered the next time the app opened — ringing for a call declined an hour + * ago. + */ +class CallActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != MessengerService.ACTION_DECLINE) return + PendingIncomingCall.clearAll() + runCatching { NotificationManagerCompat.from(context).cancel(3) } + } +} 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 d812bac..24f0ce0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -583,6 +583,27 @@ object PendingIncomingCall { if (parked?.signal?.callId == callId) parked = null } + /** + * Drop whatever is parked, whichever call it is. + * + * ⚠️ For Decline from the lock screen, where the id is not to hand. An + * offer left parked would be answered the next time the app opened — + * ringing for a call the user turned down an hour ago. + */ + fun clearAll() { parked = null } + + /** + * Whether something is parked that is already too old to answer. + * + * Distinct from [take] on purpose: `take` consumes, and the ring has to be + * able to ASK without consuming — a stale check that swallowed a live offer + * would drop a call that was still ringing. + */ + fun isStale(now: Long = System.currentTimeMillis()): Boolean { + val current = parked ?: return false + return now - current.at > CallManager.DIAL_TIMEOUT_MS + } + /** Hand the offer over exactly once, and never one old enough to be dead. */ fun take(): Parked? { val current = parked ?: return null 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 9b2480e..fa635e5 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt @@ -55,6 +55,12 @@ class MessengerService : Service() { private const val CHANNEL_MESSAGES = "flamenet.messages" private const val CHANNEL_CALLS = "flamenet.calls" + /** Set on the launch intent when the user answered from the lock screen. */ + const val EXTRA_ANSWER_CALL = "io.flamenet.messenger.ANSWER_CALL" + + /** Broadcast action for the notification's Decline button. */ + const val ACTION_DECLINE = "io.flamenet.messenger.DECLINE_CALL" + /** ⚠️ Matches the relay's own hold, so a poll returns as it expires. */ private const val WAIT_SECONDS = 20 @@ -172,10 +178,14 @@ class MessengerService : Service() { notifyCall(nameFor(fromUser, s)) } } - io.flamenet.e2e.Content.CallSignal.END -> + io.flamenet.e2e.Content.CallSignal.END -> { // The caller hung up before anyone opened the app. An - // offer left parked would ring for a dead call. + // offer left parked would ring for a dead call — and so + // would the notification, which is now `ongoing` and will + // NOT go away on its own. PendingIncomingCall.clear(signal.callId) + clearCallNotification() + } else -> Unit } } @@ -190,6 +200,11 @@ class MessengerService : Service() { // reminder for a conversation that was answered a moment ago — // which is the one thing that would teach people to ignore them. fireDueReplyReminders(s) + // 🔴 An unanswered ring has to stop ringing. The notification is + // `ongoing`, so nothing dismisses it on its own: without this a + // call nobody picked up leaves a permanent "incoming call" on the + // lock screen, with an Answer button for a call that is long dead. + expireStaleRing() // ⚠️ A short gap, not a poll interval: `receive()` is held open by // the relay for WAIT_SECONDS, so this only paces a relay that // answers immediately. @@ -249,14 +264,74 @@ class MessengerService : Service() { notify(CHANNEL_MESSAGES, 5, text, NotificationCompat.PRIORITY_DEFAULT, open) } + /** + * Ring, in a way that can be answered without opening the app first. + * + * 🔴 **A high-priority notification is a banner, not a ringing phone.** The + * previous version was one: on a locked device it showed a line of text and + * the only way to act on it was to unlock, find the app, and open it — by + * which time the caller has given up. A call is a live event with seconds + * on the clock, so it needs a full-screen intent, which is what wakes a + * locked screen into something answerable. + * + * ⚠️ `CallStyle` and `CATEGORY_CALL` are not decoration. Android 14 grants + * USE_FULL_SCREEN_INTENT at install only to apps that ring like this; + * without them the permission is refused and the call silently degrades to + * the banner it used to be. + * + * ⚠️ The Answer button hands off through the SAME parked offer the app uses + * on launch. The SDP in hand is the only copy — this session consumed the + * envelope — so a second, separate answer path would be a second way to + * lose a call. + */ private fun notifyCall(from: String) { - val open = PendingIntent.getActivity( - this, 2, Intent(this, MainActivity::class.java), + val full = PendingIntent.getActivity( + this, 2, + Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + .putExtra(EXTRA_ANSWER_CALL, true), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) - // A caller IS named: an unnamed ringing phone is not answerable, and a - // call is a live event the user has seconds to act on. - notify(CHANNEL_CALLS, 3, "$from is calling", NotificationCompat.PRIORITY_MAX, open, - category = NotificationCompat.CATEGORY_CALL) + + val decline = PendingIntent.getBroadcast( + this, 6, Intent(this, CallActionReceiver::class.java).setAction(ACTION_DECLINE), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + + val caller = androidx.core.app.Person.Builder().setName(from).setImportant(true).build() + val notice = NotificationCompat.Builder(this, CHANNEL_CALLS) + .setSmallIcon(android.R.drawable.stat_notify_chat) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setPriority(NotificationCompat.PRIORITY_MAX) + // Ongoing: a ringing call is not something to swipe away by + // accident while reaching for the torch. + .setOngoing(true) + .setAutoCancel(false) + .setFullScreenIntent(full, true) + .setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, decline, full)) + .build() + + runCatching { + androidx.core.app.NotificationManagerCompat.from(this).notify(3, notice) + } + } + + /** Stop ringing — the call was declined, or it ended on its own. */ + private fun clearCallNotification() { + runCatching { androidx.core.app.NotificationManagerCompat.from(this).cancel(3) } + } + + /** + * Stop ringing for an offer nobody answered in time. + * + * ⚠️ `PendingIncomingCall.take()` already refuses to hand over an offer + * older than the dial timeout, so the offer itself expires correctly — but + * the notification is a separate object and expires by nothing at all. + * Left alone it sits on the lock screen offering to answer a dead call. + */ + private fun expireStaleRing() { + if (PendingIncomingCall.isStale()) { + PendingIncomingCall.clearAll() + clearCallNotification() + } } private fun notify(channel: String, id: Int, text: String, priority: Int, 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 29fb8bf..fac835e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1848,6 +1848,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // An offer the background service consumed while the app was // off screen. Same process, so the handoff is in memory. PendingIncomingCall.take()?.let { parked -> + // 🔴 Stop the ring. The call notification is `ongoing` so + // that it cannot be swiped away by accident while the phone + // is ringing — which also means it does NOT dismiss itself, + // and every path that ends a call has to cancel it. This is + // the "answered" path. + runCatching { + androidx.core.app.NotificationManagerCompat + .from(getApplication()).cancel(3) + } launch(Dispatchers.IO) { val manager = callManager() val turn = session?.turnCredentials() diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PendingCallTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PendingCallTest.kt new file mode 100644 index 0000000..57250de --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PendingCallTest.kt @@ -0,0 +1,75 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.Content +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The parked incoming offer, which is what makes a call answerable from a + * locked phone. + * + * 🔴 **The ringing notification is `ongoing`**, so it cannot be swiped away by + * accident mid-ring — which also means it never dismisses itself, and every + * path that ends a call has to cancel it: declined, answered, the caller hung + * up, and nobody picked up. The last one is the one with no event behind it, + * and [PendingIncomingCall.isStale] is how it is noticed. + */ +class PendingCallTest { + + @AfterTest fun tidy() = PendingIncomingCall.clearAll() + + private fun offer(id: String = "call-1") = Content.CallSignal( + action = Content.CallSignal.OFFER, callId = id, sdp = "v=0", reason = null, video = false) + + @Test + fun `nothing is stale when nothing is parked`() { + PendingIncomingCall.clearAll() + assertFalse(PendingIncomingCall.isStale()) + } + + @Test + fun `a fresh offer is not stale`() { + PendingIncomingCall.park(42L, offer(), System.currentTimeMillis()) + assertFalse(PendingIncomingCall.isStale()) + } + + /** ⚠️ Without this the lock screen keeps offering to answer a dead call. */ + @Test + fun `an offer older than the dial timeout is stale`() { + PendingIncomingCall.park( + 42L, offer(), System.currentTimeMillis() - CallManager.DIAL_TIMEOUT_MS - 1_000) + assertTrue(PendingIncomingCall.isStale()) + } + + /** + * 🔴 Asking must not consume. A staleness check that swallowed a live offer + * would drop a call that was still ringing — the exact opposite of the bug + * it exists to prevent. + */ + @Test + fun `checking staleness does not take the offer`() { + PendingIncomingCall.park(42L, offer(), System.currentTimeMillis()) + PendingIncomingCall.isStale() + assertNotNull(PendingIncomingCall.take(), "the live offer must survive the check") + } + + /** Decline has no call id to hand, so it clears whatever is parked. */ + @Test + fun `clearAll drops the parked offer`() { + PendingIncomingCall.park(42L, offer(), System.currentTimeMillis()) + PendingIncomingCall.clearAll() + assertNull(PendingIncomingCall.take()) + } + + /** An offer is handed over exactly once — two answers is two call screens. */ + @Test + fun `taking twice yields nothing the second time`() { + PendingIncomingCall.park(42L, offer(), System.currentTimeMillis()) + assertNotNull(PendingIncomingCall.take()) + assertNull(PendingIncomingCall.take()) + } +}