A contact who reinstalled could never be messaged again

🔴 **The chat header said "keys unchanged since you met" while every send was
refused with "identity keys changed".** Found by driving two real apps: the
iOS side relaunched with -FNMSGResetState, which is a genuinely new device with
new keys, and Android correctly refused to send to it. Correct — and then
there was no way out.

`Trust.state()` could return UNKNOWN, VERIFIED or PINNED and nothing else.
CHANGED was computed inside `record()` while a send was in flight and thrown
away, because `Vault.Trust` had nowhere to remember it. So:

- the header kept reporting the OLD pinned keys as unchanged,
- the "Accept new keys" button, which only renders for CHANGED, never appeared,
- `acceptIdentityChange()` was unreachable from the UI,
- and the contact stayed unmessageable for ever, with the app insisting
  nothing was wrong.

That is precisely what a tester does on day one: reinstall, and discover their
friend can no longer reach them.

`Vault.Trust` now carries `changed`, `record()` persists the detection without
adopting the offered keys and drops the verified tick (whatever was compared in
person was compared against the old key), `state()` reports it, and accepting
clears it. Keys that match again also clear it, so a transient wrong answer
does not strand somebody behind a banner for ever.

Verified live on the emulator: the banner appears with its full text, Accept
new keys is reachable, and the header returns to "keys unchanged" afterwards.
Four tests in TrustTest; three of them fail against the old `state()`.

---

🔴 **An expired session told the user their passphrase was wrong.** Hit
immediately afterwards: a send failed with "That handle or passphrase isn't
right", ten hours after a successful sign-in, because AccountsClient mapped
EVERY 401 and 403 to BadCredentials. The natural reading is that the password
stopped working or was changed by someone else, and the natural response is to
change a password that was never wrong.

Only `sessions` and `accounts` actually present a passphrase, so only those can
report one as wrong. Everything else now raises SessionExpired, which the app
states as what it is. Two tests cover both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-26 07:09:08 -04:00
co-authored by Claude Opus 5
parent 3c66d3e9bd
commit cda64d8c2e
12 changed files with 257 additions and 6 deletions
+2
View File
@@ -17,6 +17,8 @@
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.FlamenetMessenger">
@@ -1165,6 +1165,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
*/
private fun describe(failure: Throwable): String = when (failure) {
is AccountsClient.Failure.BadCredentials -> "That handle or passphrase isn't right."
is AccountsClient.Failure.SessionExpired ->
"Your session expired. Sign out and back in to keep going."
is AccountsClient.Failure.HandleTaken -> "That handle is already taken."
is AccountsClient.Failure.RateLimited -> "Too many attempts. Wait a few minutes."
is AccountsClient.Failure.Transport -> failure.message.orEmpty()
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The messenger's mark: a speech bubble with a flame knocked out of it.
⚠️ Deliberately NOT the bare flame that Flamenet Files uses. Two apps from
the same shop with the same icon is a support problem the first time a tester
has both installed.
The content sits inside the adaptive-icon safe zone: a 100-unit drawing
scaled to 66 and centred in the 108 viewport, so nothing important is lost
to a circular or squircle mask.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.66" android:scaleY="0.66"
android:translateX="21" android:translateY="21">
<path
android:fillColor="#FF5A1F"
android:pathData="M22,20 H78 A14,14 0 0 1 92,34 V62 A14,14 0 0 1 78,76 H46 L30,91 V76 H22 A14,14 0 0 1 8,62 V34 A14,14 0 0 1 22,20 Z" />
<path
android:fillColor="#121A2E"
android:pathData="M50,30 C56,40 62,45 62,54 C62,63.5 57,69.5 50,69.5 C43,69.5 38,63.5 38,54 C38,47 43,43 46,36 C47,43 50,44 50,30 Z" />
</group>
</vector>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
⚠️ Themed icons need a SILHOUETTE, not the colour artwork. Pointing
<monochrome> at the full-colour foreground makes the launcher tint artwork
that already has its own colours, which is why the icon came back wearing a
pale ring. One shape, one colour, and the launcher recolours it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.66" android:scaleY="0.66"
android:translateX="21" android:translateY="21">
<path
android:fillColor="#000000"
android:pathData="M22,20 H78 A14,14 0 0 1 92,34 V62 A14,14 0 0 1 78,76 H46 L30,91 V76 H22 A14,14 0 0 1 8,62 V34 A14,14 0 0 1 22,20 Z" />
</group>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The site's panel navy (CSS var: panel), so the icon shares the app palette. -->
<resources>
<color name="ic_launcher_background">#121A2E</color>
</resources>