Commit Graph
509 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 7ab5c774cb A file in a chat keeps its name and its label
`Ref.toJson` is what the vault persists next to a message, and it never
wrote `filename`, `classification` or `compartments`. They reach the far
end correctly, as additive top-level keys on the envelope, so a received
document ARRIVED named. It was the local copy that lost them, on the
first read back out of storage. A file in a chat therefore had a name
until the app was restarted and never again, which reads as a damaged
file rather than as the app forgetting.

`fromJson` now defaults those three to null rather than to empty.
Passing nothing means "not a wire parse, read what was stored"; passing
a value, including an empty one, means the envelope said so and wins.
Empty defaults could not tell those two apart, and answering "" for both
is exactly what made a stored file nameless.

The same rule fixes `expires`. The history path hands in 0.0, and 0
means "does not expire", so every stored story read back as immortal:
its bytes were never dropped and it rendered forever. A caller that
names an expiry still means it; one that does not now gets what the
vault kept.

They are stored under the same names the shared backup format uses, so
`BackupPayload` carries them straight through. A document restored from
a backup arrived with no name and no label before this, which is worse
than not restoring it.

Four tests on the storage round trip, one on the envelope still winning,
one that an attachment which never had a name does not invent one, and
one carrying a document through a backup whole. Full suite: 1276 tests,
no failures. No new ktlint violations in Content.kt (3 at HEAD, 3 now).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:29:31 -04:00
Paul Hitt 140affc356 Merge branch 'feat/backup-reads-the-shared-format' into 'main'
Android writes and reads the shared backup format

See merge request paulhitt/flamenet-messenger-android!116
2026-09-10 22:15:24 +00:00
Paul HittandClaude Opus 5 5a5e41bfa2 Android writes and reads the shared backup format
Section 16 pins the envelope and says nothing about what is sealed, so
each app sealed its own storage verbatim and stamped the file with the
platform that wrote it. Each then refused the other's, in as many words:
"import it on a device of the same kind". Somebody moving between an
Android phone and an iPhone lost every conversation they had.

The iPhone half landed first. This is the other half.

`BackupPayload` translates rather than dumps, which is the entire point.
This app stores a message under the keys its wire format uses (`secs`,
`fwd`, `rx`), keeps one-to-one and group threads in one map under
prefixed ids, hangs the attachment kind beside `att` rather than inside
it, abbreviates the sizes, and holds expiries in unix seconds. The
shared shape spells all of that out and counts in milliseconds.
Exporting storage as-is is what made the two formats incompatible in the
first place, so a copy would have reproduced the bug with more steps.

Federated threads are the one thing that does not fit. This app keys
them by address and the iPhone's history is keyed by numeric user id,
with a decoder that skips anything else, so an address left in `threads`
would be dropped in silence. They travel under `addrThreads` instead: an
iPhone ignores the key, an Android phone gets them back, and nothing
disappears without somebody having decided it should.

Import tells the two formats apart by SHAPE, not by file name, because a
name is whatever the person saving it chose. Section 16 announces itself
with `alg` and `ct`; the older export announces itself with `magic`. A
wrong passphrase on a section 16 file reports a wrong passphrase and
does NOT fall through to the legacy reader, which would have answered
"that file is not a Flamenet history export" for a file that is one and
sent somebody hunting for corruption instead of a typo.

Nothing writes the old format any more, and everything still reads it.

Seventeen tests: the shared side against the key names the iPhone
writes, the round trip back into this app's own storage shape, the unit
conversions in both directions, and a payload transcribed from the
iPhone's encoder. That last one is the weak point and the test says so:
it proves this app reads the shape the iPhone is BELIEVED to write. Only
a vector both apps are built against proves they agree, and
`backup-v1.json` pins the envelope alone, which is exactly how the two
shapes drifted apart.

Full suite: 1271 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:11:35 -04:00
Paul Hitt 3bcb7d5900 Merge branch 'release/0.15' into 'main'
Android 0.15, versionCode 17

See merge request paulhitt/flamenet-messenger-android!115
2026-09-10 21:41:58 +00:00
Paul HittandClaude Opus 5 4b5c309a66 Android 0.15, versionCode 17
Both, as always: the code is what tells an existing install a newer
build exists, the name is what a person reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:12:41 -04:00
Paul Hitt 9bd86dd4f6 Merge branch 'fix/accept-actually-answers' into 'main'
Accept answers the call, and a call that ended goes away

See merge request paulhitt/flamenet-messenger-android!114
2026-09-10 19:08:01 +00:00
Paul HittandClaude Opus 5 51e9f01323 Do not stop reading the inbox while a call is up
The handover stops this app's polling loop when the screen goes away,
because the background service takes over. That is right for messages
and wrong for a call already in progress: the loop is the only thing
that routes `call-end` into the manager owning the call screen. The
service polls too, but it turns offers into notifications and knows
nothing about a call that is already up.

So a call ended by the far end while this app was off screen had nowhere
to land. The screen stayed, still offering to answer, until the dial
guard gave up forty-five seconds later. Seen on a device: a call placed
at 13:04:13, no `call-end` ever logged, ICE closed at 13:04:58.

Polling now refuses to pause while a call is dialling, ringing,
connecting or active.

And a backstop for when the signal is genuinely lost. ICE DISCONNECTED
was a no-op, so nothing noticed a far end that simply vanished. It now
arms an eight second grace, cancelled if the connection recovers,
because WebRTC blips are ordinary. A call whose audio has stopped is
over whatever the signalling says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:46:51 -04:00
Paul HittandClaude Fable 5.1 63f2758d46 Accept on the call notification answers the call
The notification's Accept button has carried EXTRA_ANSWER_CALL since it
was written, and nothing ever read it. Pressing Accept did what any
other tap did: it opened the app. Answering was left to the next turn of
the polling loop, which refuses a parked offer older than the caller's
patience, so a call taken a little late opened the app onto nothing and
looked like it had dropped the moment you answered it.

MainActivity reads the extra now, on a cold start and on a warm one,
and answers at once rather than waiting for a tick.

The notification also goes away on its own when the offer it shows has
expired. Before, it could outlive the offer by the gap between service
sweeps, which is longer since the loop started holding its poll open,
and during that gap it was a button that could only disappoint.

Seen on the emulator: Accept pressed, app opened, nothing on screen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:03:04 -04:00
Paul Hitt 93d7eaa980 Merge branch 'fix/ring-times-out' into 'main'
A ring that ends, and only one thing reading the inbox

See merge request paulhitt/flamenet-messenger-android!113
2026-09-10 16:58:18 +00:00
Paul HittandClaude Opus 5 9ca58202a6 A ring that ends, and only one thing reading the inbox
Two bugs found by calling a real emulator and watching what it did.

An unanswered ring never ended. Dialling and connecting each had a
timeout and ringing did not, so a call nobody picked up stayed RINGING
until the app was killed. Nothing showed it, because the notification
can be brushed off the top of the screen while the state behind it
survives, and the phone then answered every later caller "busy" while
looking completely idle. A guard now releases it after sixty seconds,
deliberately longer than the caller's own forty-five, so this end gives
up on its own only when their hang-up never arrived. It hangs up with
"timeout", which the far end already renders as "No answer"; an
invented word would have shown a flat "Call ended".

And the handover was half-built. MainActivity starts the background
service when the screen stops and stops it when the screen starts, under
a comment reading "exactly one poller at a time". Only the service half
existed. The view model's loop kept running beside it, both sessions
read the same inbox, and an envelope is consumed once, so whichever
asked first won it.

When the view model won a call offer the phone rang inside a UI nobody
was looking at, and the service, the only half that can post a
notification, never saw the call. Seen on a device: the offer arrived,
CallManager moved to RINGING, and the only notification posted was the
service's own "running" notice. That is why calls did not announce
themselves with the app off screen.

The loop now stops on ON_STOP and starts again on ON_START, so the
comment is finally true.

Verified on the emulator against relay1: an unanswered offer at
12:51:07 released itself at 12:52:07 and told the caller, where before
it would have refused every call after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 12:54:21 -04:00
Paul Hitt f4c52f18fe Merge branch 'release/0.14' into 'main'
Android 0.14, versionCode 16

See merge request paulhitt/flamenet-messenger-android!112
2026-09-10 13:58:58 +00:00
Paul HittandClaude Opus 5 ba3f380aa2 Android 0.14, versionCode 16
Both, as always: the code is what tells an existing install a newer
build exists, the name is what a person reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:56:37 -04:00
Paul Hitt c0ae0503af Merge branch 'fix/android-spent-proof-and-73' into 'main'
Keep the token when only the proof was spent, and review keys at the call

See merge request paulhitt/flamenet-messenger-android!111
2026-09-10 13:54:18 +00:00
Paul HittandClaude Opus 5 d735b79b86 Keep the token when only the proof was spent, and review keys at the call
Two things the iPhone got today and this did not.

A proof of possession is single use, and the request that spends one is
frequently not a request this code made: OkHttp retries across a
connection failure with the headers it already built, which is what a
phone moving between wi-fi and mobile data produces. The relay refuses
the replay, correctly, and now says so with its own code rather than a
bare "unauthorized". Reading that as a token problem meant discarding a
working token and minting another before carrying on, and during a call
that lands in front of the signalling. The request is signed again
either way; only the token is kept now. A relay too old to name the code
still says so in words, so this works against production as it stands.

And ios#73. A call refused by the trust gate already named the reason
and already offered a button. The button opened the conversation, which
is the same gap one screen along: the reason the conversation banner
does not cover this case is that a call placed from the Calls tab, or
answered from a notification, never passes through a conversation.
Sending somebody to one to find out why their call failed is the
complaint rather than the cure.

So the review is a dialog at the failure. It carries both readings the
chat banner carries, the ordinary one and the one that matters, and it
puts the safety number on screen rather than behind another tap, because
comparing the number is what actually settles the question.

The old test said accepting must not be offered here, on the argument
that a hurried decision is better made elsewhere. The issue argues the
other way and is right: an extra screen in front of the same tap slows
nobody down, it just costs somebody the explanation. The requirement
moves rather than disappears, from "no accept button" to "no accept
button without the sentence saying what accepting costs".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:51:55 -04:00
Paul Hitt 6dd8633b08 Merge branch 'release/0.13' into 'main'
Android 0.13, versionCode 15

See merge request paulhitt/flamenet-messenger-android!110
2026-09-10 13:24:37 +00:00
Paul HittandClaude Opus 5 84b00135d5 Android 0.13, versionCode 15
Both, as always: the code is what tells an existing install a newer
build exists, the name is what a person reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:06:32 -04:00
Paul HittandClaude Opus 5 df598a3961 Say when Android is refusing to let a call ring
The notification was built correctly all along: CATEGORY_CALL, PRIORITY_MAX,
ongoing, autoCancel off, full-screen intent. Android was refusing it.

  USE_FULL_SCREEN_INTENT: default; rejectTime=+2m52s ago

Since Android 14 that permission is auto-granted only to apps the system
already treats as calling apps. Declared and not granted, an incoming call is
demoted to an ordinary heads-up: about two seconds at the top, then gone,
which reads as the call notification being broken.

The system refuses silently, so nothing anywhere said so. Notification
settings now checks canUseFullScreenIntent and offers the one screen that can
grant it.

The fuller fix is a self-managed ConnectionService, which makes this a calling
app in the system's eyes and needs no permission prompt at all. That is a
larger change than telling somebody the truth today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:06:20 -04:00
Paul Hitt 44c7b512d6 Merge branch 'fix/android-long-poll' into 'main'
Ask the relay to hold a poll open

See merge request paulhitt/flamenet-messenger-android!109
2026-09-10 12:44:59 +00:00
Paul HittandClaude Opus 5 a70787e960 Hand CI the backup vectors it was never given
The §16 backup port shipped with a vector suite that finds its input by
walking relative paths out of the checkout. That works on a developer's
machine, where flamenet-e2e sits beside this repo, and cannot work on
the runner, where it does not.

The suite fails rather than skips, deliberately, so it said so: nine
BackupVectorTest failures on every pipeline since the port merged, on
main included. Two things were missing, and both had to be fixed or the
gate would have stayed red.

The pin predates the vectors, so `unit-tests` cloned a flamenet-e2e that
has no backup-v1.json in it. Bumped; nothing else under test/vectors
moved between the two commits, so no other suite changes what it checks.

And neither job exported FLAMENET_BACKUP_VECTORS, so even the job that
clones HEAD, which did have the file, never pointed the suite at it. It
is exported in both now, and named in the pre-flight check, which is
there precisely so a missing file reads as a missing file rather than a
protocol bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:39:25 -04:00
Paul HittandClaude Opus 5 6584d313ca Ask the relay to hold a poll open
The relay has always been willing to hold an inbox request until
something arrives, and the Android client has never asked it to. The
constant naming the hold was declared in MessengerService and read
nowhere, and the comment next to the 500 ms gap already described a
long poll that was not happening.

What that cost, measured against production: two devices sent 1,177
inbox requests in eight minutes, every one of them held open by the
relay for about 30 ms and answered "nothing". Every message and every
call signal waited out the gap to the next poll. Someone had already
noticed the effect on calls and shortened the gap to a second while one
was being set up, which made the polling denser without making the
signal arrive any sooner.

So `wait` reaches the wire. The signed-in mailbox is asked to hold; a
backup home never is, because two held reads in a row would put a
backup mailbox's latency in front of the one nearly all mail arrives
at. A held request needs longer than the ordinary call timeout, so it
gets a clone of the client with room for the hold.

Both loops now pace on whether the relay held the request rather than
on a fixed gap: a poll that took a while was itself the wait, and the
next one goes straight back out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:18:34 -04:00
Paul Hitt 57411ea703 Merge branch 'feat/area-picker' into 'main'
Areas can be chosen, and named

See merge request paulhitt/flamenet-messenger-android!107
2026-09-10 11:57:02 +00:00
Paul HittandClaude Opus 5 f7ddb263e5 Areas can be chosen, and named
The engine has always carried compartments and both apps sealed them in,
honoured them in clearance checks and rendered them on a received file. No
screen let anybody set one, so a label was half-settable: how sensitive, but
never which part of the business. The public page promised both.

The bits travel with the file. The names do not: they are this organisation's
words, so a file from somebody else shows its areas as numbers, which is
correct rather than a gap.

Nothing is named for you. The site says areas are whatever your organisation
already calls them, so this is where you say so.

Forgetting a name does not free the bit. A file already filed under it still
carries it, and handing that bit to a different area would silently relabel
that file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:56:05 -04:00
Paul Hitt ddfc92cfd2 Merge branch 'release/0-12' into 'main'
Android 0.12, versionCode 14

See merge request paulhitt/flamenet-messenger-android!106
2026-09-09 22:45:34 +00:00
Paul HittandClaude Opus 5 e4daaa6a35 Android 0.12, versionCode 14
Release notes landed on the relay first, which is where the gate lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:45:16 -04:00
Paul Hitt 1ca23f834d Merge branch 'feat/backup-kotlin-port' into 'main'
Encrypted backups in Kotlin, against the published vectors

See merge request paulhitt/flamenet-messenger-android!105
2026-09-09 22:10:46 +00:00
Paul HittandClaude Opus 5 5d6350d157 Encrypted backups in Kotlin, against the published vectors
Spec §16, the third engine, checked against pinned bytes rather than against
either of the other two.

Reuses the module's own primitives: FilesKdf for PBKDF2 and Aead for GCM.
Aead returns nonce || ciphertext || tag and §16 keeps the nonce in its own
field, so the prefix comes off here; what remains is what WebCrypto calls ct.

The AAD is built by interpolation, not by a JSON writer, because it has to
match the other engines down to key order. A JSONObject would be the obvious
choice and would silently disagree, and the AAD is what stops a file being
edited into deriving a cheaper key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:00:24 -04:00
Paul Hitt f3a0495191 Merge branch 'fix/instrumented-suites-can-sign-up' into 'main'
The instrumented suites can get past sign-up again

See merge request paulhitt/flamenet-messenger-android!104
2026-09-09 21:13:11 +00:00
Paul HittandClaude Opus 5 c893824b62 The instrumented suites can get past sign-up again
Two of the six suites tapped submit without filling the age gate. Submit is
disabled until birth-year holds four digits, and performClick on a disabled
Compose node does not throw: it does nothing. Each run then failed up to two
minutes later looking for a screen the tap never reached, blaming whatever
it had waited for.

A JVM test now holds it: any suite that taps submit must fill birth-year, so
the next one written cannot quietly do the same thing.

android#21 also reports that nav-contacts and nav-account no longer exist
anywhere in the app. They do. They are declared in NavDestinations.tabs and
applied through a variable, so a grep for the literal testTag("nav-" finds
nothing, which reads as the navigation having been rebuilt without them. A
third test pins that too, so the claim cannot be made again from a grep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:12:40 -04:00
Paul Hitt e3df77272f Merge branch 'docs/link-the-site' into 'main'
Link the site from the README

See merge request paulhitt/flamenet-messenger-android!103
2026-09-09 20:59:34 +00:00
Paul HittandClaude Opus 5 daff2b9a05 Link the site from the README
None of the four repository READMEs linked to flamenetmessenger.com. The
only occurrence of the domain was inside a security contact address, which
is not a link and is not what somebody arriving at the repository needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:58:55 -04:00
Paul Hitt 9b4628a5d3 Merge branch 'files/level-note-parity' into 'main'
The level picker explains itself, as it does on iOS

See merge request paulhitt/flamenet-messenger-android!102
2026-09-09 19:51:20 +00:00
Paul HittandClaude Opus 5 7bd836636b The level picker explains itself, as it does on iOS
This screen showed chips and no explanation; iOS has carried one all along,
under a comment claiming both apps use the same words. They now do.

The last sentence is the boundary: a level decides who can open a file, not
where it can be put. Neither app stops somebody filing sensitive material
somewhere low, and saying so is the honest half of android#15.

Also repairs a comment the emoji sweep truncated, which left the test
saying 'the -property is NOT enforced'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:32:00 -04:00
Paul Hitt db30bb460e Merge branch 'docs/no-dated-self-corrections' into 'main'
docs: drop dated self-corrections

See merge request paulhitt/flamenet-messenger-android!101
2026-09-09 13:40:33 +00:00
Paul Hitt f26fda3e50 Merge branch 'fix/an-android-call-must-ring-a-locked-iphone' into 'main'
An Android call must ring a locked iPhone

See merge request paulhitt/flamenet-messenger-android!100
2026-09-09 13:39:30 +00:00
Paul Hitt 712895a8a3 Merge branch 'fix/a-call-blocked-by-keys-offers-the-cure' into 'main'
A call blocked by a key change offers the review

See merge request paulhitt/flamenet-messenger-android!99
2026-09-09 13:38:25 +00:00
Paul HittandClaude Opus 5 41cdef5e07 docs: drop dated self-corrections
Remove dates and "this used to say X" framing from prose and comments.
What the tree does now is the only thing that matters; the history is in
git.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 09:30:32 -04:00
Paul HittandClaude Opus 5 db9ca2b442 Mark a call offer time-critical, so it rings
Reported from a physical iPhone: an incoming call showed no notification
and no full-screen call.

Android never sent `urgent` on any envelope. The relay cannot work out
that an envelope is a call: it holds ciphertext, and an offer and a
birthday message are the same bytes to it. So §6.14 makes the sender
say, and only an urgent envelope gets a VoIP push. Only a VoIP push
rings a locked, suspended iPhone; anything else is a "New message"
banner, or nothing at all when the phone is locked.

So every call placed from Android reached an iPhone as an ordinary
notification, and the call screen never appeared. iOS has set this since
the flag existed (`ringsAPhone` in E2EInbox), which is why calls the
other way round work.

The detection was already here and used for the opposite question:
`warrantsAlert` identifies an OFFER to decide whether to make a sound.
Now `ringsAPhone` asks the same thing to decide whether to ring, and the
two stay separate, an answer or a hangup must not ring a phone for a
call that is already over.

4 new tests. 1221 tests pass, counted from the XML.

⚠️ Not yet observed ringing a real locked iPhone; that needs two
handsets and is the check to run before believing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:33:12 -04:00
Paul HittandClaude Opus 5 3d9f18f922 Offer the review when a key change blocks a call
ios#73. Android already named the reason: a call signal refused with
IdentityChangedException hung up and said "Their security code changed".
But it then told the person to go and find the conversation themselves,
and the banner that compares and accepts lives in the conversation
header, which a call started from the Calls tab or answered from a
notification never passes through. So the failure described the cure
without offering it.

The blocked peer is now carried on the phase rather than only named in
a sentence, and the failure draws a "Review their security code" button
that opens that conversation and clears the failure.

Compare and accept stay behind the existing banner. A second accept
button, phrased on a call screen in a hurry, is the thing the issue's
wording requirement exists to prevent.

4 new tests. 1217 tests pass, counted from the XML.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:06:22 -04:00
Paul Hitt 1e0bf0ba1f Merge branch 'fix/restore-the-parity-table' into 'main'
Put the parity table back

See merge request paulhitt/flamenet-messenger-android!98
2026-09-09 02:13:16 +00:00
Paul HittandClaude Opus 5 23f4022b4a Put the parity table's status column back, as words
My emoji sweep emptied the iOS and Android columns of every table in
this file. The check and cross marks were the values, not decoration:
this document exists to say which features exist on which client, and
with those cells blank it said nothing at all. It is also the file the
weekly parity scrub reads.

98 marks were removed by commit 4dd5440. They are back as yes, no and
partial, so the "Zero key material after use" row still records that
Android's guarantee is weaker than iOS rather than flattening it to a
tick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:09:06 -04:00
Paul Hitt 37bfb84809 Merge branch 'chore/read-like-a-codebase' into 'main'
Read like a codebase

See merge request paulhitt/flamenet-messenger-android!97
2026-09-09 00:28:32 +00:00
Paul HittandClaude Fable 5.1 788be4259e Merge main and re-derive the formatting pass
Conflicting files take main's version and ktlint and the comment
pipeline are re-run on them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 20:22:03 -04:00
Paul Hitt b938d161ba Merge main 2026-09-08 20:21:39 -04:00
Paul Hitt 8343b10c66 Merge branch 'fix/a-photo-must-not-turn-on-its-side' into 'main'
Photos must not turn on their side; the keyboard must not cover the chat

See merge request paulhitt/flamenet-messenger-android!96
2026-09-09 00:09:31 +00:00
Paul HittandClaude Fable 5.1 2fa430f21c API-style comments and ktlint formatting
Every comment is reduced to its first sentence plus @param, @return
and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied
with its default (official Kotlin) style across all modules; 262 files
reformatted. The 264 remaining ktlint findings are naming and filename
rules, which are code changes, and are left alone.

Comments and formatting only. 1208 tests pass, counted from the XML.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 20:02:07 -04:00
Paul HittandClaude Fable 5.1 8a7f40fb13 Second pass with the avoid-ai-writing detector
The skill's detector scored the README, PARITY.md and the extracted
comment prose. Fixed what it flagged: hollow intensifiers, "in order
to", "additionally", "the whole point", bold sentences in docs, and
bold that had wrapped across comment lines. Comment prose scores a
maximum of 4 out of 100 across the repo.

One sweep rule mangled four comments ("which is the entire point of X"
lost its clause); repaired, and the rule now keeps the clause.

Comments and strings only. 1208 tests pass, counted from the XML.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 19:20:07 -04:00
Paul HittandClaude Fable 5.1 4dd544083c Remove machine-writing tells from comments and docs
Emoji markers, markdown bold inside code comments, and em-dashes are
gone from source comments, string literals, scripts, Gradle files and
the markdown docs. Counted before and after:

  emoji markers   2680 -> 1 (the emoji picker's own list, which is the feature)
  em-dashes       2490 -> 9 (table placeholders and fenced code)

Two warning glyphs in user-facing strings in ChatScreens.kt are removed
as well; the sentences say the same thing without them. Two backtick
test names carried an em-dash and now carry a comma.

Nothing else changed. 1208 tests pass, counted from the result XML.
The three obfs4 interop tests are opted out with OBFS4_INTEROP_OPTOUT=1
for this run, as their own message instructs when no bridge is set up.

CONTRIBUTING gets a four-line rule so it does not grow back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:52:12 -04:00
Paul HittandClaude Opus 5 4a8cb64c65 Turn photos the right way up, and lift the chat off the keyboard
Two reports from a real phone.

**A photo auto-rotated.** BitmapFactory does not apply EXIF orientation,
and every image this app sends is re-encoded to strip EXIF — which is
right, because that block carries the GPS coordinates the shot was taken
at. The same block carries the tag saying which way is up, and a phone
camera rarely writes upright pixels: it writes landscape pixels plus
"turn this 90°". Decoding ignored that, the re-encode dropped the tag,
and the only thing that knew which way was up was gone.

The tag is now read and applied to the pixels before it is discarded, on
all three paths: preparing to send, scrubbing metadata, and rendering an
image received from a peer. The mirrored tags are handled too — a front
camera writes them, and a selfie coming back flipped is the same bug
facing sideways.

🔴 iOS is NOT affected, which is why this survived: UIImage(data:)
applies orientation as it decodes, so the identical strip-and-re-encode
is safe there. Same design, opposite platform default — and the reason
"works fine on iOS" said nothing about Android.

**The keyboard covered what was being typed into.** The manifest has
said windowSoftInputMode="adjustResize" all along, and that stopped
meaning anything at targetSdk 35: Android 15 forces apps targeting it
edge-to-edge, and an edge-to-edge window does not resize for the IME. So
the line has been a no-op on every device this ships to (minSdk 33). The
root now consumes the IME inset with imePadding() — at the root rather
than per screen, because every screen with a text field has this and one
added later would too.

5 new tests, including one that fails if the manifest line ever becomes
the only thing relied on again. 1213 tests green, counted from the
result XML rather than from BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:50:37 -04:00
Paul Hitt 04e06452b6 Merge branch 'feat/say-why-delivery-failed' into 'main'
Say why delivery failed, instead of only logging it (ios#21, #22, #23)

See merge request paulhitt/flamenet-messenger-android!95
2026-09-08 18:30:01 +00:00
Paul HittandClaude Opus 5 053444cdd9 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>
2026-09-08 14:11:30 -04:00