Say why delivery failed, instead of only logging it (ios#21, #22, #23)

The Android half, matching the iOS change.

**The give-up rule (ios#22).** `flushOutbox` used
`runCatching { … }.getOrNull()`, which threw the exception away — so the loop
could not tell a contact who is merely offline from one whose identity keys
changed, and retried both for ever while the bubble went on promising
"will send". The failure is caught now and classified: a refusal from the
trust layer stops, marks the bubble FAILED and records why. Everything else
still retries for ever, deliberately — a contact who is offline or has no
device yet gets the message whenever they return.

⚠️ Classified on the exception TYPE, never on message text. A string match
would break the first time somebody reworded an error, and would break
silently: the queue would simply go back to retrying for ever.

**The reading (ios#21, #23).** `DeliveryDiagnostics` counts envelopes seen,
drops by reason, session repairs and the last send failure, per conversation.
Three or more repairs against one peer is flagged — a client that repeatedly
repairs is a symptom, not a cure.

🔴 **Shape only.** Refusal messages name what was wrong, which sometimes
means naming a device id: "device ce0c0fb4 has published keys, but none
match". Those are normalised before being counted, so the reading is safe to
screenshot and the map cannot grow a row per device id. ⚠️ Short hex ids like
`9f0e` slip past the obvious rules; the test covers them, and covers that
"base64" survives unmangled.

The new outbox test was checked by disabling the give-up rule: it fails
without it.

581 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-08 14:11:30 -04:00
co-authored by Claude Opus 5
parent fb0c11cd79
commit 053444cdd9
4 changed files with 287 additions and 1 deletions
@@ -0,0 +1,100 @@
package io.flamenet.client
/**
* What the client already knows about delivery, kept where a person can read
* it (ios#21, ios#23 — the Android half).
*
* 🔴 **The client has always known this; it only ever told the log.** Every
* refusal went to `System.err` and nowhere else, so a send blocked by the
* identity guard reached the person sending it as "Something went wrong
* reaching that server" — which was neither true nor actionable.
*
* ⚠️ **Shape only, never content.** Counts, reasons and timestamps. No message
* text, no key material, no payloads, and no identifiers — see
* [normalisedReason]. A diagnostic reading that leaks what it is diagnosing is
* worse than none, and this one is meant to be safe to screenshot.
*
* ⚠️ In memory, deliberately. It describes the session you are in; persisting
* it would be a growing record of who you talked to and when, which is the
* metadata this project spends its effort not keeping.
*/
object DeliveryDiagnostics {
data class Peer(
var envelopesSeen: Int = 0,
var delivered: Int = 0,
/** Reason → how many times. */
val drops: MutableMap<String, Int> = mutableMapOf(),
var repairs: Int = 0,
var lastRepairAt: Long = 0L,
var lastSendFailure: String? = null,
var lastSendFailureAt: Long = 0L,
) {
val dropTotal: Int get() = drops.values.sum()
/**
* 🔴 A client that repeatedly repairs is a symptom, not a cure. One is
* ordinary — a contact reinstalled. Three against the same peer means
* something is not settling, and that is worth saying out loud.
*/
val repairsLookAbnormal: Boolean get() = repairs >= 3
}
private val peers = mutableMapOf<Long, Peer>()
@Synchronized fun reading(userId: Long): Peer = peers[userId] ?: Peer()
@Synchronized fun all(): Map<Long, Peer> = peers.toMap()
@Synchronized fun sawEnvelope(from: Long) {
peers.getOrPut(from) { Peer() }.envelopesSeen++
}
@Synchronized fun delivered(from: Long) {
peers.getOrPut(from) { Peer() }.delivered++
}
@Synchronized fun dropped(reason: String, from: Long) {
val p = peers.getOrPut(from) { Peer() }
val slug = normalisedReason(reason)
p.drops[slug] = (p.drops[slug] ?: 0) + 1
}
@Synchronized fun repaired(userId: Long, at: Long = System.currentTimeMillis()) {
val p = peers.getOrPut(userId) { Peer() }
p.repairs++
p.lastRepairAt = at
}
@Synchronized fun sendFailed(reason: String, to: Long,
at: Long = System.currentTimeMillis()) {
val p = peers.getOrPut(to) { Peer() }
p.lastSendFailure = reason
p.lastSendFailureAt = at
}
@Synchronized fun clearSendFailure(to: Long) {
peers[to]?.let { it.lastSendFailure = null; it.lastSendFailureAt = 0L }
}
/** Signing out ends the session this describes. */
@Synchronized fun reset() = peers.clear()
/**
* Strip anything identifying before a reason is counted.
*
* ⚠️ Refusal messages name the thing that was wrong, which sometimes means
* naming a device id — "device … has published keys, but none match".
* A log may carry that; a screen the user is invited to screenshot may not.
* Runs of digits and hex become "…".
*
* ⚠️ Short hex ids are the ones that slip through: `9f0e` is neither a long
* hex run nor an all-digit run. Hence "four or more hex characters
* containing at least one digit", which catches truncated device ids
* without eating ordinary words like `base64`.
*/
fun normalisedReason(reason: String): String =
reason
.replace(Regex("""\b(?=[0-9a-fA-F]*[0-9])[0-9a-fA-F]{4,}\b"""), "…")
.replace(Regex("""\b\d+\b"""), "…")
}
@@ -0,0 +1,79 @@
package io.flamenet.client
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** What the app is allowed to say about why delivery failed (ios#21, #22, #23). */
class DeliveryDiagnosticsTest {
@Test
fun `identifiers are stripped from reasons before they are counted`() {
// 🔴 A reading meant to be safe to screenshot must not carry device ids.
// These are the real shapes: the refusal messages name what was wrong,
// and that sometimes means naming an id.
val cases = listOf(
"device ce0c0fb4 has published keys, but none match what this relay served",
"device claims 41 but is bound to 12",
"we do not hold signed prekey 7",
"device claims 3f8a9c2e11bd but is bound to 9f0e",
)
for (raw in cases) {
val out = DeliveryDiagnostics.normalisedReason(raw)
assertFalse(out.any { it.isDigit() }, "'$out' still carries a digit from '$raw'")
}
}
@Test
fun `a reason with nothing identifying survives intact`() {
// ⚠️ It has to stay readable, or the screen says nothing. "base64"
// contains a digit and must NOT be mangled.
val plain = "the payload was not valid base64"
assertEquals(plain, DeliveryDiagnostics.normalisedReason(plain))
}
@Test
fun `one repair is ordinary, repeated repairs are a symptom`() {
DeliveryDiagnostics.reset()
DeliveryDiagnostics.repaired(7)
assertFalse(DeliveryDiagnostics.reading(7).repairsLookAbnormal)
DeliveryDiagnostics.repaired(7)
DeliveryDiagnostics.repaired(7)
assertTrue(DeliveryDiagnostics.reading(7).repairsLookAbnormal)
}
@Test
fun `a reading counts per conversation, not globally`() {
DeliveryDiagnostics.reset()
DeliveryDiagnostics.dropped("the payload was not valid base64", 1)
DeliveryDiagnostics.dropped("the payload was not valid base64", 1)
DeliveryDiagnostics.dropped("the envelope did not decode", 2)
assertEquals(2, DeliveryDiagnostics.reading(1).dropTotal)
assertEquals(1, DeliveryDiagnostics.reading(2).dropTotal)
assertEquals(0, DeliveryDiagnostics.reading(99).dropTotal)
}
@Test
fun `the same reason twice is one row, not two`() {
// ⚠️ Normalisation is what makes this true: without it, two refusals
// naming different device ids would be two rows and the map would grow
// without bound.
DeliveryDiagnostics.reset()
DeliveryDiagnostics.dropped("device aaaa1111 offered a key the log has no record of", 5)
DeliveryDiagnostics.dropped("device bbbb2222 offered a key the log has no record of", 5)
assertEquals(1, DeliveryDiagnostics.reading(5).drops.size)
assertEquals(2, DeliveryDiagnostics.reading(5).dropTotal)
}
@Test
fun `signing out ends the reading with the session`() {
DeliveryDiagnostics.reset()
DeliveryDiagnostics.sawEnvelope(4)
DeliveryDiagnostics.sendFailed("their security keys changed", 4)
DeliveryDiagnostics.reset()
assertEquals(0, DeliveryDiagnostics.reading(4).envelopesSeen)
assertNull(DeliveryDiagnostics.reading(4).lastSendFailure)
}
}