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