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:
co-authored by
Claude Opus 5
parent
fb0c11cd79
commit
053444cdd9
@@ -1033,6 +1033,32 @@ class Session(
|
||||
* order to tidy up a label, and the label is not the point — the message
|
||||
* is. A contact who reinstalls in a month gets it in a month.
|
||||
*/
|
||||
/**
|
||||
* Whether waiting will ever resolve this, mirroring iOS `E2EError.waitingCanFix`.
|
||||
*
|
||||
* ⚠️ Matched on the refusal types the trust layer throws, not on message
|
||||
* text. A string match here would break the first time somebody reworded
|
||||
* an error, and would break silently — the queue would simply go back to
|
||||
* retrying for ever.
|
||||
*/
|
||||
private fun permanentRefusal(e: Throwable?): Boolean = when (e) {
|
||||
is io.flamenet.client.IdentityChangedException,
|
||||
is io.flamenet.client.KeyNotLoggedException,
|
||||
is io.flamenet.client.KeyLogForkException -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** A short, contentless reason for the reading and the bubble. */
|
||||
private fun refusalReason(e: Throwable?): String = when (e) {
|
||||
is io.flamenet.client.IdentityChangedException ->
|
||||
"their security keys changed"
|
||||
is io.flamenet.client.KeyNotLoggedException ->
|
||||
"their device offered a key the server has no record of"
|
||||
is io.flamenet.client.KeyLogForkException ->
|
||||
"this server's record of published keys does not add up"
|
||||
else -> "the message could not be sent"
|
||||
}
|
||||
|
||||
fun flushOutbox(): Set<String> {
|
||||
val pending = vault.outbox()
|
||||
if (pending.length() == 0) return emptySet()
|
||||
@@ -1059,7 +1085,12 @@ class Session(
|
||||
continue
|
||||
}
|
||||
|
||||
val sent = runCatching { conversations.send(to, text) }.getOrNull()
|
||||
// ⚠️ The failure is CAUGHT, not discarded. `getOrNull()` threw the
|
||||
// exception away, so this loop could not tell a contact who is
|
||||
// merely offline from one whose keys changed — and retried both
|
||||
// for ever.
|
||||
val attempt = runCatching { conversations.send(to, text) }
|
||||
val sent = attempt.getOrNull()
|
||||
if (sent?.reachedSomeone == true) {
|
||||
if (groupThread == null) {
|
||||
restate(Vault.threadForUser(to), at, Delivery.DELIVERED)
|
||||
@@ -1067,6 +1098,21 @@ class Session(
|
||||
} else {
|
||||
groupDelivered += groupThread to at
|
||||
}
|
||||
} else if (permanentRefusal(attempt.exceptionOrNull())) {
|
||||
// 🔴 **Some refusals waiting cannot fix** (ios#22). A send
|
||||
// blocked because the contact's identity keys changed is
|
||||
// refused identically on every retry until a person accepts
|
||||
// them, while the bubble goes on promising "will send".
|
||||
//
|
||||
// ⚠️ Everything else still retries for ever, deliberately: a
|
||||
// contact who is offline, or who has not registered a device
|
||||
// yet, gets the message whenever they return. Giving up there
|
||||
// would delete something somebody wrote to tidy up a label.
|
||||
val why = refusalReason(attempt.exceptionOrNull())
|
||||
io.flamenet.client.DeliveryDiagnostics.sendFailed(why, to)
|
||||
val thread = groupThread ?: Vault.threadForUser(to)
|
||||
restate(thread, at, Delivery.FAILED)
|
||||
touched += thread
|
||||
} else {
|
||||
stillWaiting.put(item)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user