A message nobody can receive must not report success

Reported: a message sent from Android while the other end was signed out on
iOS was lost, and a group text vanished the same way.

Envelopes are addressed per device, and signing out deregisters the device.
sendPlaintext looped over an empty bundle list, made no request at all, and
returned Sent(delivered=[], failed={}) - which every caller reads as "it
went". The message was written into the sender's own thread and existed
nowhere else. Group sends call this once per member, so the same path lost
group text.

iOS never had this: transmit throws on an empty device list. The platforms
disagreed about a delivery guarantee for as long as both have existed, and
nothing caught it, because the tests that covered sending had both ends
signed in - and a happy-path test cannot tell a delivery from a no-op, since
both are silent.

Recorded in the failure map rather than thrown, so a group send still reaches
the members who ARE reachable. The banner now says the message was not queued
and has to be sent again, instead of "Delivered to 0 of 1 devices".

SendToNobodyTest pins it; verified failing with the guard removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 17:54:12 -04:00
co-authored by Claude Opus 5
parent 0983e760b4
commit 80b903e168
3 changed files with 141 additions and 9 deletions
@@ -885,9 +885,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
it.copy(
busy = false,
openChat = it.openChat?.copy(lines = current.history(chat.threadId)),
error = if (sent.failed.isEmpty()) null
else "Delivered to ${sent.delivered.size} of " +
"${sent.delivered.size + sent.failed.size} devices.",
error = describeDelivery(sent),
)
}
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
@@ -1197,14 +1195,30 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
it.copy(
busy = false,
openChat = it.openChat?.copy(lines = current.history(chat.threadId)),
error = if (sent.failed.isEmpty()) null
else "Delivered to ${sent.delivered.size} of " +
"${sent.delivered.size + sent.failed.size} devices.",
error = describeDelivery(sent),
)
}
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
}
/**
* What to say about a send.
*
* ⚠️ "Delivered to 0 of 1 devices" is a ratio pretending to be an
* explanation. The case that actually happens — the other person is signed
* out, so they have no devices at all — deserves the sentence, because the
* message is not queued anywhere and will not arrive later.
*/
private fun describeDelivery(sent: io.flamenet.client.Conversations.Sent): String? = when {
sent.failed.isEmpty() -> null
sent.delivered.isEmpty() ->
"Not delivered — they are not signed in on any device. " +
"This message was not queued; send it again once they are back."
else -> "Delivered to ${sent.delivered.size} of " +
"${sent.delivered.size + sent.failed.size} devices."
}
/**
* Bytes for a received attachment, fetched and cached on first use.
*
@@ -1266,9 +1280,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// Partial delivery is stated, not hidden. "Sent" when one
// of someone's three devices got it is a lie the user acts
// on.
error = if (sent.failed.isEmpty()) null
else "Delivered to ${sent.delivered.size} of " +
"${sent.delivered.size + sent.failed.size} devices.",
error = describeDelivery(sent),
)
}
}.onFailure { failure ->