Gemini review of #1117, findings verified against the code and firmware.
Kept:
- planChannelRestore was tested only through the connector. There are now
11 direct tests: Public to slot 0, nothing else there, a live channel
never overwritten, a channel already on the radio left out, erased slots
cleared first, no slot used twice, no key written twice, erased and empty
cache entries never written, and no-room channels reported.
- Removed a Public-first sort that guarded nothing: nothing but Public is
ever placed in slot 0, so the order cannot matter (a mutation dropping
it passed every test, which is how it showed).
Justified, not changed:
- "A second add right after Public displaces slot 0 uses a stale list":
the re-read starts synchronously in the write's finally, before the write
returns, so the table stays busy and the add gets the busy message.
- "Public is refused on new firmware when slot 0 is occupied": unreachable
there. Public can't be deleted, and boot puts a missing Public into the
first empty slot. So Public missing with slot 0 occupied happens only on a
full table, where nothing has room. On older firmware the fallback keeps
slot 0's channel from being overwritten.
- "Restoring Public over an erased slot 0 overwrites a recoverable record":
erased flash holds nothing. A cached channel from slot 0 is re-homed
elsewhere, not lost.
Full suite 2045 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The firmware shipping next (firmware-base, #1896) refuses any channel but
Public in slot 0, even while it is empty, refuses Public in any other slot,
and refuses clearing Public. Every refusal is ERR_CODE_BAD_STATE, the same
code as an occupied slot, so the client has to avoid them, not interpret
them. Owner rulings, 2026-10-28: slot 0 "should always remain public",
"make sure Public can't be deleted", "Of course add the real public".
- decideChannelSlot takes a required isPublic. Public goes to slot 0 when
it is free; nothing else ever does. The fallback that lets Public take
another free slot is reached only when slot 0 holds some other channel,
which happens only on older firmware.
- The shared allocator takes the key being written and decides from it,
so Join Public, a typed private key, a QR share and a hashtag join all
follow the rule, and a second Public is refused on every one of them.
- planChannelImport follows the same rule for a stock config file.
- Deleting Public is refused at the attempt with "Public can't be removed
from the radio. It always stays in slot 0." The control stays live.
- Edit Channel locks Public's key, and refuses typing Public's key into
another channel while Public is on the radio.
- A hashtag join of "public" (leading # and spaces and trailing spaces
dropped, any case: the device's namesPublic rule) adds the real Public,
not sha256("#public").
The #1022 test that asserted Public has no claim on slot 0 is replaced by
tests for the rule above.
On older firmware, slot 0 is no longer handed to other channels, so one
slot of 40 goes unused there. That follows the owner's ruling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gemini review findings on Epic #1021, after verifying each against the
code.
Kept, fixed:
- A sync started while a channel write waits for its reply shares the
ordered reply queue with it, so the sync's empty-slot ERR could be taken
as the write's answer. The trigger is real even though the review named a
path that doesn't exist: pull-to-refresh, the sync button, the 400 ms
channel-changed debounce, and dev's 3 s failed-sync retry all call
getChannels at any time. getChannels now defers while a write is in
flight, and the last write to finish runs it once. The writers' own
re-reads call _syncChannels directly.
- Config import and community delete hold the guard across their whole
loop (batchChannelWrites), so a deferred sync cannot start between two
writes.
- A key change whose clear lands but whose add fails used to report "did
not confirm the change", understating that the channel is now gone. It
returns ChannelWrite.clearedNotAdded, with a message saying the slot is
empty and to add the channel again.
Justified, not changed:
- A reply arriving after the 5 s timeout, once the waiter has been
dropped, shifts the queue by one. Keeping the waiter instead would shift
it permanently whenever a reply is truly lost. Firmware answers
CMD_SET_CHANNEL synchronously, far inside 5 s, and a disconnect clears the
queue, so dropping is the smaller risk.
- Claimed crash on removeAt(0) with an empty queue: both handlers check
isEmpty first.
Mutation-checked: removing the deferral fails 2 tests; returning the add's
own result fails 1. Full suite 1962 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part A of the #1021 test child.
Widget tests (test/helpers/channel_slot_ui_test.dart) for all six refusal
messages: busy, all slots full (with the count), Public already on the
radio, and the three unconfirmed-write outcomes (occupied, rejected, no
reply). Each one shows on its own failure, is still on screen 10 seconds
later (past the 4 second toast default), and none shows when the action is
allowed.
Connector test: a channel sync that fails restores the cached list with
hasLoadedChannels false, and the table still reads as not busy, so an add
gets a free slot. This is the lockout a hasLoadedChannels gate would have
caused on a flaky link, driven through the real sync rather than asserted.
The "Public is already on this radio" check moves from the channels screen
into channel_slot_ui.dart, so all six messages live in one tested place.
Mutation-checked: dropping persist: true fails all 7 persistence tests;
gating the table on hasLoadedChannels fails the failed-sync test. Full suite
1805 pass, analyze clean.
Part B (on-device) is the owner's hardware pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adding #qcchams and #qcchhv from the phone lost Public, from the client and the radio. Three faults combined, all client-side; the firmware's CMD_SET_CHANNEL is a plain indexed write by design and did what it was told.
1. The slot was chosen when the add dialog opened (_showAddChannelDialog computed nextIndex before showDialog and closed over it), so it was stale by the time Add was pressed. 2. The connector empties its channel list for the duration of a sync and refills it slot by slot, so a dialog opened mid-sync saw every slot as free. 3. _findNextAvailableIndex answered 'no free slot' with return 0, which is where Public normally lives, turning a full radio into a silent overwrite of Public.
The decision moves into a pure helper, decideChannelSlot, called at the moment of each write and never earlier. It refuses while isSyncingChannels is true and refuses when full, returning a named reason rather than slot 0. Deliberately isSyncingChannels and not hasLoadedChannels: the latter stays false after a FAILED sync while the cached list is restored into use, so gating on it would disable adding a channel permanently on a flaky link.
Add Public no longer hardcodes slot 0, which it wrote with no occupancy check. Public is identified by its PSK, not its index, so it takes a free slot like any other channel, and is refused if it is already present.
Owner ruling: block the unsafe write but keep the control live, since a greyed-out button is itself a silent failure. Every refusal explains itself and passes persist: true, because showDismissibleSnackBar defaults to a 4 second toast that SAFELANE 6 rules out for an error. A refused add leaves the dialog open so the user can retry.
Mutation-tested rather than trusted: restoring return 0 fails 2 tests, and removing the sync guard fails 4, including the exact reported scenario. Full suite 1779 pass. New strings are English-only with the regenerated untranslated.json, matching the convention of every recent string-adding commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Task 750.1 of the contact-naming plan: model and storage. Stock MeshCore has a per-node CUSTOM NAME field and keeps the original underneath; Offband could not rename a contact at all.
Contact gains receivedName (the name the mesh gave us, wire and storage) and customName (the user's label, client-side only). name becomes a getter returning customName ?? receivedName, so all 85 existing display reads stay correct and a future read cannot forget. Empty and whitespace-only custom names count as unset at both the getter and the store, so clearing the field cannot leave a contact blank with no way back.
The constructor and copyWith take receivedName, which made the rename a compile error at every write site. The compiler enumerated 23 of them across 5 lib files and 11 test files; none was found by reading. The advert handler is the one that matters: receivedName now updates while customName is untouched, so an advert improves accuracy without moving what the user sees. That was the defect making a client-held name impossible.
customName is deliberately NOT in contact_store. That record is rebuilt from radio contact-sync data where no custom name exists, so a save after a sync would wipe it. It lives in contact_settings_store keyed by pubkey hex, which a sync cannot reach.
Both stores persist receivedName, never the getter. The plan's correction on #750 found that persisting the getter is one-way data loss: the custom name would be read back as the received name and the real one destroyed with no advert able to restore it. The ?? 'Unknown' fallback is kept with a comment, since the rename is exactly the edit that drops a trailing fallback and would crash the load for a legacy row.
19 tests, positive and negative. T31 and T32 were falsified before being trusted: breaking the persist and the fallback made exactly those two fail and nothing else.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1305 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(#881): frame 66/29 CLI codec with cc| correlation
Pure codec per the 1.18 contract: ASCII fail-closed builds, lenient reply decode, truncation bound judged on the raw payload. Gemini findings 1+2 fixed, 4 documented.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(#882): local CLI service + shared cc| correlation helpers
PrefixAllocator and splitCorrelated extracted; the repeater service delegates unchanged in behavior. LocalCliService runs correlated frame-66 round trips; the connector routes RESP 29 to listeners.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(#883): device CLI console screen
The local-device counterpart of the repeater CLI screen (interop plan
F2/E2.1): type a command, it goes to the connected radio over frame
66/29 via LocalCliService, and the correlated reply renders with a
per-reply latency chip. No path/flood selection and no retries - one
send, one frame-level timeout window surfaced as its own row.
Built to the owner-approved behaviors: masked secret replies
(long-press to copy, cleartext never in the widget tree), the 165-byte
truncation badge, string-matched error styling, and command history.
Entry point and population gating are #884, deliberately not here.
Security fix surfaced by the #883 Gemini review: LocalCliService logged
the cleartext of an unmatched (late) reply, so a secret reply arriving
after its command timed out could leak to the app log despite the
screen's masking. The log line now withholds the reply text (token +
byte length only); a test pins it.
Also replaces em-dash characters in the sibling E2.1 files flagged by
the #464 CI guard.
Tests: 12 widget tests for the screen and a service test for the log
redaction. Full suite green (1207 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(#902): declutter Settings - user-ops to Actions, reorder Debug
Owner-directed Settings hygiene, split from the #884 entry point so the
Device CLI row lands in a clean Debug pane.
Moved to the Actions pane (they are user operations, not diagnostics):
config Export/Import and the three GPX exports (the whole export card),
plus "Sync queued messages now".
Debug pane reordered to App Debug Logging, Companion Debug, App Debug,
Serial Capture, Mesh Topology (which stays - useful diagnosing with a
user). The Device CLI row at the top is #884, not here.
Behavior preserved: every row keeps its handler, icon and copy; only
their pane and order changed.
Tests: a new widget test pins the spec - the moved rows present under
Actions and absent from Debug, and the full Debug order. A Gemini review
finding of a Debug-ordering defect was refuted (the order matches the
spec; the review misread the toggle for the viewer); its secondary point
was taken and the test now pins the full order. Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#884): Device CLI entry point + capability gate
Adds the entry point for the console (screen landed in #883) to
Settings > Debug. A "Device CLI" row appears at the TOP of the Debug
pane only when the classifier places the connected radio at stock v14+
(classification.allowsCliFrame): stock 1.18+ today, Offband at the
upstream-1.18 absorb via the D1 seam. The row is absent on stock-legacy,
Offband, and when disconnected (classification null). Tapping it opens
DeviceCliScreen.
The gate reads the capability through context.select, so only that
boolean flipping rebuilds the pane, not every connector update (#81).
Depends on the classifier from dev (merged into this branch).
Tests: positive (stock 1.18 shows at top, opens the console) and three
negatives (disconnected, stock-legacy ver 13, Offband all hidden). Per
the Gemini review the test's frame helper now asserts the version fits.
Full suite green (1244 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#881): dart format the codec test (CI format gate)
test/cli_command_codec_test.dart (landed in #881) was not formatted, so
the branch's first run of the "analyze" job (flutter analyze
--fatal-infos + dart format --set-exit-if-changed .) failed on it.
No logic change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#885): mirror set-name character rejection + CLI l10n
The firmware's `set name` rejects the characters [ ] \ : , ? * and
bounces a bad name as a cryptic ERROR. Mirror that client-side so the
name is caught before it is sent, in both places a name is set:
- Settings node-name dialog: live inline error, Save blocked on an
invalid name (nothing sent).
- Device CLI console: a `set name <value>` with a forbidden char is
echoed with a rejection row and never sent; the device stays the
authority for everything else.
Shared validator in lib/helpers/device_name_validator.dart
(forbiddenCharsIn is distinct + canonical-order). Also moves the
"Sync queued messages now" row strings and the new validation messages
onto app l10n (EN copy unchanged).
Tests: validator unit (the exact seven chars, order-stable, deduped),
console pos/neg (+ rejected command still recallable from history, per
the Gemini review), and the Settings dialog pos/neg. Gemini review
otherwise clean; its one finding (rejected command not in history) was
refuted - history derives from the transcript, which the rejection path
appends to - and pinned with a test. Full suite green (1255 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: diffwireauto <330573775+diffwireauto@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Client-side region transport-key derivation matching firmware RegionMap/TransportKeyStore: key = first 16 bytes of SHA256("#"+name), a Region value model, and the HMAC on-air code. Known-answer tests use independent vectors. No firmware change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same gap as the companion form: a repeater on the USA values with a
1- or 2-byte path hash matched MeshCore's "Canada" (3-byte). The remote
matcher now takes the node's path hash mode and applies the same rule.
Epic: #733
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picking a preset that carries a path hash size sets the screen's
existing path hash field to the zero-based mode, so Save sends
`set path.hash.mode N` alongside the radio params.
With "Apply temporarily" checked, a preset's path hash is held back:
firmware has no temporary path hash, so it would outlive the tempradio
revert and leave the node off its mesh. A note says so on screen, and
the value stays pending for a normal save. A path hash the user sets by
hand is always sent. (Default chosen here; flagged for the owner.)
Epic: #733
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A checkbox on the remote-node radio card switches Save from
`set radio` (persisted, applied after a reboot) to stock `tempradio`
(applied now, reverting after the duration, nothing saved). The
duration defaults to 1440 minutes (owner decision) and is limited to
1-35791: firmware schedules the revert at 2000 + minutes * 60000 ms in
a 32-bit int, which overflows beyond that. Because tempradio retunes
the node 2 s after it replies, it is sent after every other command in
the same save. Coding rate goes out as the 4/x denominator, which is
what firmware range-checks. Unchecked behaviour is unchanged.
Epic: #733
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The remote-node radio card gains the same grouped RadioPresetPicker as
Radio Settings. Picking a preset fills frequency, bandwidth, SF and CR
and marks the radio field dirty, so Save sends the existing `set radio`.
TX power is untouched: on these nodes it is a separate command. Room
servers share this screen (contacts_screen.dart:1063), so they get it
too. The selection is matched from the current fields to the nearest
kHz (repeaters report e.g. 908.205017). The list refreshes when stale.
Epic: #733
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unifies the previously-duplicated send timestamps (frame builder vs outgoing
message each called now() separately) into one monotonic-per-channel value, so
every channel send has a unique (ts, channel_idx) key. That is the client-side
guarantee the 0xC6 correlation needs (VioletBarn caught that AES-128-ECB
determinism would otherwise make two same-second messages a wrong-hash query).
Adds transient onAirHash + coreScopeObserverCount to ChannelMessage. Part of #524.
Agent: QuietSnow (session 31eaba02)
Follow-up to #457. Swept the em-dash character (U+2014) out of the test
tree (test descriptions and comments) and cleaned 8 em-dashes that
landed in lib comments via the #456 refactor after #457 merged, so the
tree is back to zero. Same rules: replaced with commas/colons/periods,
preserved the lone "no data" glyph placeholders, left non-English ARB
untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Profile is now a container of capability-scoped sections (schema_version 2):
- wifi (WifiConfig) — any wifi device
- mqtt (MqttSection = region + status_interval + brokers) — observer capability
so MQTT is defined once and shared by observer / observer-repeater /
observer-companion, never redone per device. Future radio/repeater/companion/
display sections slot in alongside.
Parser reads the sectioned YAML and rejects the old flat v1 layout with a clear
message. Enumerator reads from sections but emits the SAME firmware keys, so
apply/diff/screens are unchanged. 45 config-profile tests updated + green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- config_profile_diff.dart: pure current->new diff builder (add/change,
drops no-ops, flags danger + secret rows). 4 tests.
- splitProfileWrites: partitions writes into safe vs credential/identity for
the two-tier apply; a mixed broker splits, enabled rides the safe half only.
- ConfigProfilePreviewScreen: full sub-screen — reads current state, renders
the diff (amber overwrites, red danger section), normal Apply for plain
config + a separate red gate (with confirm dialog listing exactly which
credential/identity values change) for the danger set. Secrets masked.
Re-diffs after apply for partial-save recovery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device-agnostic write enumerator (config_profile_writes.dart): a profile ->
ordered flat writes + per-broker field maps. Skips null/empty (never clobbers),
skips jwt_token (live-minted), holds enabled out for last-write, flags the
danger set (username/password/jwt_owner/jwt_email + wifi.pwd) for #406's gate.
Observer executor (observer_apply_service.dart): flats via setFlat, brokers via
the existing saveBroker (disable-first, fields, enabled LAST, stop-on-error
partial-safe #80); reads current enabled to preserve it when a profile omits it.
Result labels name keys/slots only, never values (no secret leak).
8 enumerator tests. Executor is thin orchestration over the tested service;
end-to-end covered by #408 hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address 3 review findings (all confirmed real): thread section context through
the optional-field accessors so nested errors name the broker (brokers[i]."port"),
reject broker ports outside 1..65535, and reject negative integer fields
(status_interval, jwt_refresh) via a shared _optUint. +3 tests (14 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the yaml dependency and parseConfigProfile(): YAML -> ConfigProfile
(#402). Strict by design since profiles are untrusted input (#139) — unknown
keys, wrong types, out-of-range/duplicate broker slots, and unknown
transport/auth values all throw ConfigProfileFormatException with a
user-facing message. Only keys present populate the model. 11 unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reactions stored only a Map<String,int> of emoji to count, so "who
reacted" was unanswerable and MeshCore One's tap-to-see-who had nothing
to show against. This captures the reactor for every reaction, both our
own r: format and the PocketMesh / MeshCore One format.
Data layer only. The tap-a-badge-to-see-who UI is a separate follow-on;
this makes the data available and does not change any screen.
Additive by design. A new reactionSenders field (Map<String,List<String>>,
emoji -> reactor names) sits alongside the existing count map, serialised
under a new JSON key. Both maps are always written. An older build reading
a newer store ignores the unknown key and still gets correct counts from
reactions; it never hits the hard `value as int` cast that would fail the
whole message-list load (the #355 data-loss shape). Records written before
this field load with an empty sender map, so their counts survive with
names simply absent.
- ChannelMessage + Message: new reactionSenders field, constructor,
copyWith
- both stores: serialise the new key; deserialise via a shared
ReactionHelper.reactionSendersFromJson that returns empty for a missing
key and skips malformed entries rather than throwing
- applyReaction: records the reactor and dedups per reactor per emoji.
This is persistent dedup (survives restart), unlike the connector's
in-memory processed-set. A pre-existing count with no sender list is
incremented from its stored value, not recomputed from the partial
list, so old counts are preserved.
- connector: threads the reactor name into all reaction paths. Channel
uses the frame sender; a room resolves the author via
fourByteRoomContactKey; an outgoing reaction is attributed to self.
- pending queue: retry now hands the stored reactor to its callback
Tests: reactor capture, two-reactor count, same-reactor no-double-count,
pre-#383 count preservation, and JSON round-trip + backward-compat +
malformed-entry handling for the new field. Full suite 604 passing.
Epic #376.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two of three Gemini findings held up against the code; the third did not.
Accepted, message-swallow risk: the emoji check looked only at the first
rune against a range list that included arrows, so a multi-line message
beginning with an arrow and ending in eight Crockford characters would
have been consumed and displayed as a reaction whose "emoji" was the
whole sentence. Ben's own capture has U+2192 mid-sentence in ordinary
channel traffic, so this was reachable, not theoretical.
- drop U+2190-U+21FF and U+2934-U+2935; arrows carry the Unicode Emoji
property but read as punctuation in prose
- cap the emoji segment at 8 runes, which is clear of the longest ZWJ
sequence and nowhere near a sentence. This is the guard that holds
regardless of how the range list evolves.
Accepted, dedup: the contact path keyed on target hash plus emoji only.
A room server is many-party over the 1:1 transport, so two members
sending the same emoji collapsed into one and the count stuck at 1, the
same defect already fixed on the channel path. The reacting author
prefix is now part of the key; in a true 1:1 it is empty and the key is
unchanged.
Rejected, room-server sender matching: the review claimed getSenderName
resolves to the room itself and that room text carries a "Sender: "
prefix. Neither is true. _resolveContactSenderName resolves the author
via fourByteRoomContactKey to the real per-sender contact name, and room
message text is stored bare with the author in its own field. One real
sub-case survives: an author who is not in our contacts resolves to null
and will not match, which degrades to queue-then-expire with a warn log.
Also rejected: switching @[ lookup from first to last occurrence. The
reference implementation uses the first, and diverging risks mismatching
payloads it accepts.
Reactions sent from MeshCore One arrived as junk text: an emoji line
followed by an 8-character token such as "dyps6yf0". Those tokens are
Crockford Base32 target-message hashes, the second line of a two-line
reaction payload we did not recognise.
Receive-side only. Offband keeps sending its own r:hhhh:ii format; their
client already parses ours, so nothing about what we transmit changes.
Wire format (confirmed against a live capture, see #378):
channel: {emoji}@[{targetSender}]\n{hash}
direct: {emoji}\n{hash}
hash: sha256(body utf8 + timestamp uint32 LE seconds)[0:5],
Crockford Base32, 8 chars, lowercase
The body is hashed without the channel "SenderName: " prefix, which is
why the sender travels in @[...] instead.
- crockford_base32.dart: encode and normalise, 12-bit accumulator so the
web target's 32-bit bitwise ops cannot truncate a 40-bit value
- pocketmesh_reaction.dart: hash and a parser mirroring the reference
implementation, with a conservative leading-emoji check so a real
message is never swallowed
- reaction_helper.dart: ReactionInfo carries a dialect; applyReaction
picks the matching hash and, for the channel form, requires an exact
sender-name match (a node name can carry emoji and variation
selectors)
- pending_reactions.dart: a reaction arriving before its target is held
and retried rather than silently dropped, bounded at 50 entries with a
15 minute TTL and a warn-level log on expiry (closes the silent-drop
path in #382)
- the channel dedup key now includes the reacting sender, so two people
sending the same emoji no longer collapse into one
- notification tray summarises the foreign format as a reaction instead
of showing the raw token
Known limitation: on channels with Smaz or Cyr2Lat enabled the wire text
differs from the text we store, so a hash computed by another client
will not match. Flagged for a decision rather than worked around.
Epic #376. Fast-follow #383 adds reactor identity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pasted GIF links rendered as plain text because parseGif only matched the
compact forms. Adds GifHelper.resolveGifUrl, which maps any supported payload
to the URL that renders it, and widens coverage to the links people actually
paste.
Allowlist is deliberately narrow, Giphy and Tenor only. Off-list URLs return
null and stay plain tap-to-open links, never auto-fetched: auto-loading a
stranger's URL leaks the viewer's IP and enables tracking-pixel abuse, and
inline-rendering unmoderated hosts is a malware and inappropriate-content
vector. Tests pin the rejections, including a lookalike host
(giphy.com.evil.example) and a general image host (imgur).
Newly rendered:
- i.giphy.com/<id>.gif and .webp, the form a browser copy produces
- media.tenor.com and c.tenor.com direct assets
A tenor.com/view/<slug>-<id> PAGE url is deliberately NOT matched. The modern
Tenor CDN path uses an opaque hash that cannot be derived from the page id, so
resolving one requires the Tenor API and a key. Such a link stays plain rather
than silently rendering the wrong image. Supporting it is a separate decision.
Also removes the hardcoded 'https://media.giphy.com/media/$gifId/giphy.gif'
literal duplicated across all four render call sites. That duplication is the
same drift that caused the #284 notification regression, where a second
hand-rolled copy of GIF detection went stale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The picker emitted `g:<id>`, a convention that exists only in this Flutter
client lineage's parseGif (inherited from upstream, not a MeshCore protocol
feature). A recipient on stock or any non-lineage client has no way to expand
it, so a GIF arrived as opaque text (#223).
encodeGif now emits `https://giphy.com/gifs/<id>`, a form parseGif already
matches, so:
- stock clients receive a tappable URL they can actually open
- Offband keeps rendering inline with no render-side change
- legacy `g:<id>` payloads still decode, back-compat unchanged
Cost is ~41B vs ~20B against a ~160B payload. A full message carrying sender
prefix + reply mention + URL measures ~79B, roughly half the cap.
Checked, and did NOT change, the outbound structured-payload guard in
prepareContactOutboundText/prepareChannelOutboundText: Smaz.encodeIfSmaller
declines to compress a URL (base64 overhead exceeds the dictionary gain) and
Cyr2Lat passes unmapped ASCII through, so a GIF URL survives intact without
widening the guard. Both pinned as regression tests instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every received DM/channel message now logs arrival wall-clock vs the
sender-claimed payload timestamp (delta + TIME ANOMALY marker beyond
24h), routed directly to AppDebugLogService so the rotating on-disk
trail is unconditional. Models persist a nullable rxTime set at every
live ingest construction site; legacy stored records stay null.
Diagnostic child of #280.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two shared-root render gaps (message tokens were only matched at fixed
positions):
- TranslatedMessageContent: parse @[Name] anywhere (leading, inline, or
repeated) and render each as a chip; messages without a mention keep full
link support (#233).
- GifHelper.parseGif: strip a leading `@[Name] ` reply prefix before matching
so a reply-with-gif resolves to the gif instead of literal text; arbitrary
text before a gif is still left as text (#232).
Tests: parseGif reply/format cases + widget tests for inline/leading/multiple
mention chips. Full suite 402 green.
Part of #233, #232.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trace was width-1 end to end (flags=0, a 1-byte path, and a
1-byte-per-hop parse + render), so on a 2-/3-byte path-hash mesh it
returned 1-byte hops (`6A`, not `6Axx`) and failed to route where a
1-byte prefix is ambiguous ("Path trace not available").
- Send: path_sz = floor(log2(width)); build width-byte hops — the
repeater/room "Ping" prefixes (contacts_screen), the round-trip
buildPath (now mirrors by hop, not byte), and the map trace builder.
- Parse: read the flag byte for the hop width, group the hash bytes into
packed width-byte hops, key contacts by the full prefix, 1 SNR per hop.
- Render: list panel + markers + map points iterate packed hops, labelled
at the configured width.
PathHelper gains tracePathSz / traceHopBytes / packPrefix / tracePathHops
/ buildTraceRoundTrip, all unit-tested (incl. width-2 hop mirroring).
Bumps build to +47 for the b47 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PathHelper.formatPathHex / resolvePathNames assumed 1 byte per hop, so the
direct-chat "full path" and path-management dialogs rendered routing paths
per-byte (84,AB instead of 84AB) and resolved hop names by a single byte on
2-/3-byte path-hash meshes. Group bytes into pathHashByteWidth-sized hops and
match hop names by the full-width prefix.
- formatPathHex(bytes, width) / resolvePathNames(bytes, contacts, width) group
into width-byte hops (trailing partial hop kept); name match uses the full
hop prefix.
- chat_screen + path_management_dialog pass connector.pathHashByteWidth.
analyze clean; 7 PathHelper tests (incl. width-2 collision-avoidance); Gemini: ship.
Closes#154
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings onChanged/onTap callbacks fire-and-forgot AppSettingsService Future<void> setters, so a persist failure rejected unhandled and was swallowed. Add a shared persistSetting(context, action) helper that awaits the setter in a try/catch and shows a dismissible error SnackBar, capturing the messenger before the await so it survives a picker-dialog pop. Wrap the 27 onChanged/onTap setter sites in app_settings_view + message_settings_view; setAppDebugLogEnabled and setNotificationsEnabled (which await + show their own confirmation) get an inline try/catch instead.
Covers the two onChanged panes; the settings_screen action flows (GPX export, location dialogs, version-info mounted guard) follow in a second pass, so #28 stays open. Error strings are English for now (separate settings l10n sweep).
Adds a persistSetting widget test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>