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()) + } +}