From 74429be153d714109cb9144b3c5291f2870b641c Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 14 Sep 2026 09:33:37 -0400 Subject: [PATCH] An end wakes a phone; it does not ring one The matching half of the iOS fix, and this side is where the ghost was actually made for an iPhone receiving an Android hangup. iOS gives no choice: every VoIP push must report a call to CallKit or the app is killed. Ends were marked urgent, urgent means a VoIP push, so every hangup and every busy sent from here arrived on an iPhone as an incoming call. Ends were marked for a real reason: hanging up before the other phone was picked up left it ringing for a caller who had gone. What that missed is that the phone which needs to hear a hangup is, by definition, ringing, and therefore awake: it has a call up, the system keeps it running for one, it is already polling, and the relay answers a held poll through Redis in under half a second. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/client/Conversations.kt | 26 ++++++++++++++----- .../io/flamenet/client/UrgentCallOfferTest.kt | 24 +++++++++++++---- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index f5a7147..deca3f2 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -1636,19 +1636,33 @@ class Conversations( companion object { /** - * Whether this envelope has to reach a locked, suspended phone now + * Whether this envelope has to RING a locked, suspended phone now * (spec ยง6.14). Held out here so it can be tested for what it decides * rather than for how it is written. * - * The end of a call is as time-critical as its start. Only the offer - * was marked, so hanging up before the other phone was picked up sent - * the end as ordinary traffic: a backgrounded phone never woke for it - * and went on ringing for a caller who had already gone. + * Only an offer. An end travels the ordinary wake path, and that is + * not a downgrade: it is the difference between waking a phone and + * ringing one. + * + * This matters on the other platform rather than this one. iOS gives + * no choice about it: every VoIP push must report a call to CallKit or + * the app is killed. So an urgent end from here arrived on an iPhone as + * an incoming call -- every hangup, every busy, every end for a call + * long over. The owner, on a real handset: "I place a call, it calls me + * back." Taking the ghost back afterwards is not a fix, because + * somebody has already seen it. + * + * Ends were marked for a real reason: hanging up before the other + * phone was picked up left it ringing for a caller who had gone. But a + * ringing phone is not asleep. It has a call up, the system keeps it + * running for one, it is already polling, and the relay answers a held + * poll through Redis in under half a second. The end arrives on its + * own, and a phone that never rang has nothing to stop. */ fun ringsAPhone(body: String): Boolean { if (!body.startsWith("""{"fnc":""")) return false val action = Content.parse(body).call?.action - return action == Content.CallSignal.OFFER || action == Content.CallSignal.END + return action == Content.CallSignal.OFFER } } } diff --git a/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt b/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt index 6e7d1fe..c9b8cef 100644 --- a/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt @@ -56,13 +56,27 @@ class UrgentCallOfferTest { } /** - * And so does the end of one. A call that is over has to stop the ring on - * a phone that is asleep, which takes the same push the offer took: this - * is why hanging up first left the other phone ringing to nobody. + * But NOT the end of one, and this is the whole of the ghost ringing on + * the other platform. + * + * iOS gives no choice: every VoIP push must report a call to CallKit or + * the app is killed. So an urgent end from here arrived on an iPhone as an + * incoming call -- every hangup, every busy, every end for a call long + * over. Taking the ghost back afterwards is not a fix; somebody has + * already seen it. + * + * Ends were marked for a real reason: hanging up before the other phone + * was picked up left it ringing for a caller who had gone. But a ringing + * phone is not asleep. It has a call up, the system keeps it running for + * one, it is already polling, and the relay answers a held poll through + * Redis in under half a second. */ @Test - fun hangingUpIsUrgentToo() { - assertTrue(Conversations.ringsAPhone(signal(Content.CallSignal.END))) + fun hangingUpWakesAPhoneRatherThanRingingIt() { + assertFalse( + Conversations.ringsAPhone(signal(Content.CallSignal.END)), + "an urgent end becomes a reported call on an iPhone", + ) } /** Nothing else does. */