Two accepted findings from the standards#145 adversarial review.
Finding 4, the sharp one: the app emitted the compact <key:type:name>
card but could not parse it. A user copying a card out of a channel and
pasting it into Add by public key would have been rejected by the app
that produced it. Adds Contact.fromChannelShare and tries it after
fromShareUri in the dialog, so both real formats are accepted.
The parser splits on the FIRST TWO colons and takes the remainder as the
name, because names carry colons, spaces, emoji and CJK. It also finds a
card embedded in a longer message, which is how one actually arrives,
since people caption them.
Rendering a received card as a tappable affordance remains #610; this
only covers text pasted or scanned into the add flow. #610 is
correspondingly smaller now.
Finding 2, performance: resolveContactVerification ran an O(N) message
scan from a widget build inside a ListView. Now scans newest-first,
since a delivered message is overwhelmingly likely to be recent, and
advert-verified contacts still return on a single comparison without
touching the message list.
A lastMessageAt == epoch shortcut was written, then removed after
checking _setContactLastMessageAt: it maintains that field only for
advTypeChat, so a key-added repeater that had been messaged would have
shown the wrong badge. A cheap wrong answer is worse than a slightly
slower right one, and the rejected approach is documented in place.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer GIF button becomes a +, opening a short picker with GIF and
My contact card. Owner decision after seeing the build: one affordance
beside the text entry rather than a button per attachable thing, because
the sendable set stays small when a channel message shares a 160-byte
payload with the Sender: prefix.
Sends the COMPACT format, not the meshcore:// URI:
<{64-hex key}:{type}:{name}>
That shape was observed on the live mesh, twice, from different senders
in #test and #hamradio four weeks apart. It costs about 75 bytes against
117 for the equivalent URI, so on a 160-byte budget the difference is
airtime rather than tidiness. The URI form stays correct for a QR, a DM,
or an out-of-band paste; this is the channel idiom.
Angle brackets are stripped from an emitted name because they are the
delimiters, and a name carrying one would truncate the payload for every
parser reading it. A colon is left alone: the name is the final field,
so a correct parser splits on the first two colons and takes the rest.
Inserts into the composer rather than sending, matching the GIF picker,
so the card can be captioned and reviewed first. It appends, so a
caption already typed is not destroyed.
Parsing a received share is #610 and is not in this change, so an
inbound card still renders as raw text for now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Contact.toShareUri and the static Contact.buildShareUri, the inverse
of #625's parser. Emitting the reference-app format is what makes an
Offband card or QR importable by a stock, non-Offband user.
buildShareUri takes raw parts rather than a Contact so the local device
can share its OWN identity, which is a public key plus a node name and
never a Contact instance.
Spaces are percent-encoded rather than emitted as '+'. Both decode to a
space, and this matches Channel.toShareUri, which the codebase already
documents as round-tripping with the reference app's QR (#161).
Tests cover the emitted parameter shape, the companion default, and
round-tripping through fromShareUri for names with spaces, emoji and
reserved characters. A raw & or = in a name would otherwise truncate or
forge query parameters, so that case is pinned explicitly.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Contact.fromShareUri / isValidShareUri for the reference-app format
documented in firmware docs/qr_codes.md:
meshcore://contact/add?name=<url-encoded>&public_key=<64 hex>&type=<1-4>
The payload is a bare public key, not a signed advert, so the parsed
Contact is an identity stub: no path, no position, no rawPacket. A QR is
this same URI rendered visually, so scanning will share this parser.
lastSeen is deliberately the epoch rather than DateTime.now(). It maps to
the firmware's last_advert_timestamp, which the advert handler compares
with 'timestamp <= last_advert_timestamp' and treats as a replay attack
(BaseChatMesh.cpp:142-145). Stamping now would leave the contact
permanently deaf to its own adverts, since advert timestamps come from
the sender's clock and two nodes on this mesh currently advertise with
2024 clocks. A regression test pins this.
The fork's older meshcore://<raw advert hex> form returns null here, so
callers keep routing it to the existing advert import path.
Epic #619. Not yet wired to the UI: the import path needs the send half
(#627) before there is anything to add.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7 taps on the About version row (rolling ~3s window) reveal a neutral
'Experimental' settings category; persisted on AppSettings, survives
restart, re-hidden from a switch inside the section. Empty of toggles
for now (Fast Sync #118, CoreScope #524 land into it later).
Countdown snackbars after tap 4; already-unlocked feedback. Version-text
tap is absorbed so it counts without opening the About dialog.
Agent: QuietSnow (session 31eaba02)
resolvePathSelection returned no width and, on the override branch, a byte
count in place of a hop count. preparePathForContactSend then called
setContactPath without a width, so encodePathLen packed mode bits 00 and a
2-byte route went out as twice as many 1-byte hops (path_len 0x06 for a
6-hop 2-byte route instead of 0x46). The radio routed on wrong 1-byte
prefixes, confirmed on Bandit's 2026-08-01 log and via CoreScope. Direct
and flood were immune because neither uses the path bytes.
PathSelection now carries hashWidth and a true hop count on every branch,
using the contact's pathHashWidth as the single width authority. Both
setContactPath call sites thread it. Also addresses the override timeout
inflation half of #299 (hopCount was a byte count feeding calculateTimeout).
Gemini review found two override/history width edge cases -> split to #494.
Refs #299
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Ben's style rule: no em-dash character in copy, docs, or code comments
(it reads as an AI tell), and the repo is going public. Replaced the
em-dashes in code comments and English UI strings with commas, colons,
or periods, whichever reads best. User-facing strings were hand-tuned
for natural punctuation rather than a blanket comma.
Preserved the lone "no data" glyph placeholders (a standalone dash used
as a not-available indicator in status displays); those are a design
element, not prose.
Regenerated app_localizations*.dart from app_en.arb (the English
fallback for untranslated keys propagates to every locale's generated
file). Non-English ARB translations left untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Firmware #465 now writes RX RSSI into reserved2 (byte[3]) of the v3
contact-msg-recv frame as a clamped int8 dBm, 0 when unset
(MyMesh.cpp:550-551). Read it in the parse instead of skipping: gate on
!= 0 (RSSI is always negative for a real RX, so 0 = no data), null for
device-composed outgoing messages.
reserved1 (byte[2]) is untouched — that's #429's outgoing flag; RSSI
lives in byte[3] after the res1/res2 collision fix (#464/#465).
The Message.rssi field, persistence, param-passing, and the (hidden-
while-null) RSSI row on the Packet Path screen were all staged in #438,
so this is just the wire read + 3 gate tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a DM opens the Path screen; it now shows the RF details the app
was already receiving but discarding:
- SNR: captured from the v3 contact-msg-recv frame (was skipBytes(1)).
Firmware sends (int8)(snr_dB*4), so dB = byte/4.0 (MyMesh.cpp:512) —
the old commented-out code multiplied by 4, which was 16x wrong.
- Path type: firmware sends path_len 0xFF for a direct/routed frame and
the hop count for a flood (MyMesh.cpp:545). The app collapsed 0xFF->0,
colliding "direct" with "flood, 0 hops". Capture the distinction into
Message.isFloodRoute so the Path row reads "direct (routed)" vs
"flood, N hops".
- RSSI: row wired to Message.rssi but left null — the wire byte is a
hardcoded reserved 0 today; populated once firmware ships it (#439).
Message gains snr/rssi/isFloodRoute (nullable, additive, persisted like
rxTime). 10 tests cover scaling, the 0xFF discriminator, and copyWith.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The firmware path-len byte is packed and self-describing (src/Packet.h:79-84):
high 2 bits = hash size - 1, low 6 bits = hash COUNT (hops), byte length =
count * size. The app treated the low 6 bits as a byte count and ignored the
width, on the strength of a comment asserting the high bits were "not reliably
populated". Both halves of that comment were wrong.
Receive: Contact.fromFrame read `count` bytes where firmware meant `count`
hops, so at 2-byte width it kept HALF of every path and discarded the rest.
DAYTON VA's 2-hop route became a 2-byte fragment, which is why every repeater
login in Bandit's capture timed out.
Send: buildUpdateContactPathFrame wrote the count raw, leaving mode bits 00.
That tells the radio "1-byte hashes" while handing it 2-byte hash data, so it
routed to nodes never on the route. This is the likelier cause of the observed
"routes via c6 and 5c separately" behaviour.
Also fixes a third unit inconsistency: setContactPath wrote customPath.length
(bytes) into pathLength (hops) after every path set.
Contact now carries pathHashWidth per path, so a stored path can never be
re-sliced at a width it was not captured at. realHopCount() is removed rather
than adjusted: it divided an already-correct hop count, halving it at 2-byte
width, and every call site was compensating for a bug that no longer exists.
Migration (Ben's call, option A): records written before this change have no
pathHashWidth and hold truncated bytes that cannot be recovered by
reinterpretation, so their path is dropped and the contact reverts to flood
until the radio re-supplies it. Logged, not silent.
Tests asserted the old semantics (expect(realHopCount(2, 2), 1)) and were
rewritten against firmware truth rather than adjusted to keep passing. Added
send-side encoding coverage, which did not exist. 456 tests pass.
Refs #240, #279, #299
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>
The firmware path-len byte packs a hash-mode hint in the high 2 bits and the
path byte-length in the low 6. Contact.fromFrame stored it raw, so a direct
node at 2-byte mode (0x40) read as pathLength 64: "64 hops" in the UI, a ~195s
ACK timeout, and up to 64 junk bytes pulled into contact.path that broke
repeater status/telemetry/neighbors until the path was cleared (#222).
- contact.dart: decode via pathHopCount (& 0x3F); keep 0xFF as the flood
sentinel; use the decoded length for safePathLen so path bytes stay clean.
- chat_screen _currentPathLabel: show realHopCount(pathLength, pathHashByteWidth)
so 2/3-byte nodes read the correct hop count; 0 -> Direct.
- Regression test for 0x40/0x00/0x03/0x44/0xFF; updated two model_changes tests
that codified the old raw mapping.
Part of #222 (stays open for on-hardware validation by the reporter).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Channel.toShareUri/fromShareUri/isValidShareUri encode the reference
MeshCore app's format: meshcore://channel/add?name=<url-encoded>&secret=<hex32>.
A "Share QR Code" action in the channel menu shows the QR via the existing
QrCodeShareDialog, so it round-trips with the reference app.
Encode/decode unit-tested against both real reference examples (#test, #echo).
Scan-to-add follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consume the additive OCFG_BROKERS dump fields from firmware #172/#173
(backward-compatible — absent fields read as today's enabled/disabled):
- BrokerRuntimeState (`state`) + BrokerLastError (`last_error`) + a pure
BrokerConfig.status mapping -> Connected / Connecting / Failed (reason) /
Held (no clock|low heap) / Enabled / Disabled; held shown neutral, not error.
- Brokers list shows the real status line + colour instead of just "enabled".
- Editor shows jwt_owner / iata_override resolved defaults (`jwt_owner_resolved`,
`iata_resolved`) as greyed placeholders when the raw key is blank; only writes
the raw key when the user enters a value (blank stays the source of truth).
Sourced from the pool dump (getBrokers); getBroker's per-field GET list is
unchanged pending the firmware GET-ability answer on #173. Unit-tested mapping,
parsing, and backward-compat (9 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A firmware build can ACK a broker enable/disable as success without applying it
(HV4; meshcore-firmware#179). The quick toggle and the editor save previously
trusted the ACK and showed a success snackbar, so an ack-but-no-op looked like
it worked.
Both paths now SET, wait a short settle, re-read the slot, and report what the
device ACTUALLY did:
- applied -> confirm
- notApplied -> warn "possible firmware issue"; the editor stays and re-seeds to
the device's true state instead of popping a false success
- unverified -> re-read failed (e.g. an enable that rebooted the device)
Every device is treated identically: the UI reflects the re-read, never assuming
a change took that the device didn't confirm.
- BrokerConfig.classifyApply pure classifier + BrokerApplyOutcome (unit-tested)
- ObserverConfigService.applySettleDelay
- wired into mqtt_brokers_screen quick toggle + broker_editor_screen save
- widget test for the ACK-but-not-applied path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The models are the existing draft (1983ed9); Gemini flagged nothing in them.
This pins the defensive behavior they give untrusted wire data: enum fromWire
fallback, numeric tryParse fallback, secret-presence flag, SecretField
three-state, copyWith. 7 tests, all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>