From db9ca2b4427c2baa1a9c610b40ac5ca087927f97 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 9 Sep 2026 08:33:12 -0400 Subject: [PATCH] Mark a call offer time-critical, so it rings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a physical iPhone: an incoming call showed no notification and no full-screen call. Android never sent `urgent` on any envelope. The relay cannot work out that an envelope is a call: it holds ciphertext, and an offer and a birthday message are the same bytes to it. So §6.14 makes the sender say, and only an urgent envelope gets a VoIP push. Only a VoIP push rings a locked, suspended iPhone; anything else is a "New message" banner, or nothing at all when the phone is locked. So every call placed from Android reached an iPhone as an ordinary notification, and the call screen never appeared. iOS has set this since the flag existed (`ringsAPhone` in E2EInbox), which is why calls the other way round work. The detection was already here and used for the opposite question: `warrantsAlert` identifies an OFFER to decide whether to make a sound. Now `ringsAPhone` asks the same thing to decide whether to ring, and the two stay separate, an answer or a hangup must not ring a phone for a call that is already over. 4 new tests. 1221 tests pass, counted from the XML. ⚠️ Not yet observed ringing a real locked iPhone; that needs two handsets and is the check to run before believing it. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/client/Conversations.kt | 14 ++++ .../kotlin/io/flamenet/client/RelayClient.kt | 8 ++ .../io/flamenet/client/UrgentCallOfferTest.kt | 73 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 7d238ae..ba777b8 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -551,6 +551,19 @@ class Conversations( return content.isRenderable } + /** + * §6.14, must ring a locked phone. A call offer, and nothing else. + * + * The relay cannot tell: it holds ciphertext, so an offer and a birthday + * message are the same bytes to it. Without this the relay sends an + * ordinary alert, which on iOS is a "New message" banner rather than the + * ringing call screen, and a locked iPhone shows nothing at all. + */ + private fun ringsAPhone(body: String): Boolean { + if (!body.startsWith("""{"fnc":""")) return false + return Content.parse(body).call?.action == Content.CallSignal.OFFER + } + private fun sendPlaintext( toUserId: Long, text: String, @@ -694,6 +707,7 @@ class Conversations( outgoing, identity.deviceId, silent = !warrantsAlert(text), + urgent = ringsAPhone(text), // The key only when there is no capability. deliveryKey = if (peerToken == null) peerKey?.let(Wire::b64) else null, deliveryToken = peerToken, diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index 6773cbc..3f5c33e 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -210,6 +210,13 @@ class RelayClient( * Deliver without announcing: protocol traffic, not a message. */ silent: Boolean = false, + /** + * §6.14, time-critical: it must ring a locked, suspended phone. Only a + * VoIP push does that on iOS, and the relay only sends one when this + * is set, so a call offer without it arrives as an ordinary + * notification and never rings. + */ + urgent: Boolean = false, ): JSONObject = postJson( "/e2e/messages", @@ -224,6 +231,7 @@ class RelayClient( body.put("from_device", fromDevice) } if (silent) body.put("silent", true) + if (urgent) body.put("urgent", true) }.put( "messages", JSONArray().also { array -> diff --git a/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt b/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt new file mode 100644 index 0000000..6318423 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt @@ -0,0 +1,73 @@ +package io.flamenet.client + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A call offer must be marked time-critical (spec §6.14). + * + * The relay holds ciphertext, so it cannot tell an offer from a birthday + * message. It sends a VoIP push only when the sender says urgent, and only a + * VoIP push rings a locked, suspended iPhone. Android set `silent` and never + * `urgent`, so an Android call reached an iPhone as an ordinary "New message" + * notification and the call screen never appeared. + */ +class UrgentCallOfferTest { + private fun source(path: String): String { + val f = File(path) + assertTrue(f.exists(), "missing $path") + return f.readText() + } + + private val conversations = source("src/main/kotlin/io/flamenet/client/Conversations.kt") + private val relayClient = source("src/main/kotlin/io/flamenet/client/RelayClient.kt") + + /** The flag reaches the wire. */ + @Test + fun urgentIsSentToTheRelay() { + assertTrue(relayClient.contains("urgent: Boolean = false"), "submit must accept it") + assertTrue( + relayClient.contains("""if (urgent) body.put("urgent", true)"""), + "and must put it on the body", + ) + } + + /** And a call offer sets it. */ + @Test + fun aCallOfferIsUrgent() { + assertTrue(conversations.contains("private fun ringsAPhone(body: String): Boolean")) + assertTrue( + conversations.contains("urgent = ringsAPhone(text),"), + "the send path must pass it", + ) + assertTrue( + conversations.contains("Content.parse(body).call?.action == Content.CallSignal.OFFER"), + "only an offer rings", + ) + } + + /** + * Urgent and silent answer different questions. An offer is both loud and + * time-critical; an answer or a hangup is neither, and must not ring a + * phone for a call that is already over. + */ + @Test + fun theTwoFlagsStaySeparate() { + assertTrue(conversations.contains("silent = !warrantsAlert(text),")) + assertTrue(conversations.contains("urgent = ringsAPhone(text),")) + assertFalse( + conversations.contains("urgent = !silent") || + conversations.contains("urgent = warrantsAlert"), + "urgent must not be derived from silent", + ) + } + + /** A body that is not an fnc envelope is an ordinary message, and never rings. */ + @Test + fun aPlainMessageNeverRings() { + val fn = conversations.substringAfter("private fun ringsAPhone").substringBefore("}") + assertTrue(fn.contains("return false"), "must refuse a non-envelope body") + } +}