Say which envelopes are worth interrupting somebody for

The relay announced every envelope as "New message" because it holds
ciphertext and cannot tell. It now takes `silent` from the sender.

Marked silent: call answers, call ends, delivery keys, key-log gossip,
reactions, avatars — everything that is a layer talking to a layer. A
call offer still announces itself, and so does anything a person would
see in the thread. `Content.isRenderable` already drew exactly this line
for the receive side; this is the same question asked on the way out.

⚠️ A plain string is a message: text from an older build carries no
envelope at all, and the safe reading of something unrecognised is that
a person meant to say it.
This commit is contained in:
Paul Hitt
2026-09-01 15:59:07 -04:00
parent fc0dff18ca
commit 1cda405072
2 changed files with 37 additions and 0 deletions
@@ -532,6 +532,28 @@ class Conversations(
fun sendPlaintextForGroupInvite(toUserId: Long, text: String): Sent =
sendPlaintext(toUserId, text)
/**
* Whether an envelope is worth interrupting somebody for.
*
* 🔴 **Everything this app sends was.** The relay holds ciphertext and
* cannot tell a call answer from a birthday message, so it announced every
* envelope as "New message" — and call answers, call ends, delivery keys,
* key-log gossip, reactions and avatars are all envelopes. One call
* produced a burst of banners on the far end for things nobody sent.
*
* ⚠️ A plain string is a message: text from an older build carries no
* envelope at all, and the safe reading of something unrecognised is that a
* person meant to say it.
*/
private fun warrantsAlert(body: String): Boolean {
if (!body.startsWith("""{"fnc":""")) return true
val content = Content.parse(body)
// Only the OFFER announces itself. An answer and a hang-up are the far
// end's half of a conversation already on screen.
content.call?.let { return it.action == Content.CallSignal.OFFER }
return content.isRenderable
}
private fun sendPlaintext(toUserId: Long, text: String): Sent {
// 🔴 **The DIRECTORY, not the key route.** `/e2e/keys` pops a one-time
// prekey per device and is metered per contact pair — calling it on
@@ -696,6 +718,7 @@ class Conversations(
try {
relay.sendEnvelopes(
toUserId, outgoing, identity.deviceId,
silent = !warrantsAlert(text),
// ⚠️ The key only when there is no capability. Sending both
// would let the relay accept on the key when the capability
// had been revoked, so it would never learn it was refused
@@ -217,6 +217,19 @@ class RelayClient(
* revocation is the whole lever §21 adds.
*/
deliveryToken: String? = null,
/**
* Deliver without announcing: protocol traffic, not a message.
*
* 🔴 **The relay announced everything, because it cannot tell.** It
* holds ciphertext, so a call answer, a delivery key and a birthday
* message are the same bytes to it — and every one of them raised
* "New message" on the far end. One call produced a burst of banners
* for things nobody sent. The sender is the only party that knows.
*
* ⚠️ Advisory and unauthenticated. A sender that lies suppresses its
* own notifications and nobody else's.
*/
silent: Boolean = false,
): JSONObject =
postJson("/e2e/messages", JSONObject()
.put("to", toUser)
@@ -224,6 +237,7 @@ class RelayClient(
if (deliveryToken != null) body.put("delivery_token", deliveryToken)
else if (deliveryKey != null) body.put("delivery_key", deliveryKey)
else body.put("from_device", fromDevice)
if (silent) body.put("silent", true)
}
.put("messages", JSONArray().also { array ->
envelopes.forEach {