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