Mark a call offer time-critical, so it rings

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-09 08:33:12 -04:00
co-authored by Claude Opus 5
parent 1e0bf0ba1f
commit db9ca2b442
3 changed files with 95 additions and 0 deletions
@@ -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,
@@ -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 ->
@@ -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")
}
}