Every preset test faked the network, so presets missing from
config-profiles main passed them all and reached the owner as
"Couldn't update presets" on a test build twice. The live test
refreshes from the real URLs and fails on any refresh error; a second
live test proves a missing file comes back as HTTP 404. The picker
test now checks that a good refresh shows no error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two app-side halves of the #702 fix.
The native USB service read one 8 KB chunk per flserial callback, which
left the rest of a burst waiting in the native ring. It now drains
until the ring is empty, bounded at 32 reads (the ring's 256 KB) so a
stuck read cannot spin.
The frame decoder skipped bytes it could not frame (noise, an oversized
length) without a word, so when bytes were lost upstream, whole frames
vanished with no trace. It now counts them and keeps the first 16; the
native USB, web USB and TCP services log each batch as an error with
the count and a hex sample (SAFELANE 6). The web service shares the
decoder, so it gets the same logging, matching the plan's "all
transports" intent.
Tests cover no report on clean input, noise before a frame, an
oversized header, take-once, the capped sample and reset; with the
count disabled, 4 fail.
Epic #702.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 30 s staleness rule only ran when a later getContacts call checked it.
If the radio drops END (firmware #1283 can refuse it like any frame) and
nothing else asks for contacts, the request stayed in flight: the loading
state stuck and a queued refresh never ran.
A watchdog now fires at the stale mark, re-arms if frames are still
arriving, and otherwise ends the request and runs the queued one. It never
prunes: an unfinished stream is not a complete sync.
Found by the pre-PR Gemini review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The banner sits in MaterialApp.builder, above the Navigator, where
there is no Overlay. Its close IconButton had a tooltip, and a Tooltip
without an Overlay throws ("No Overlay widget found"). In the owner's
release build that painted the window gray the moment the pointer
reached the button.
The close button drops the tooltip and keeps the accessible "Close"
label on the icon. New tests mount the banner in MaterialApp.builder,
as the app does, and hover the close button; both failed before the
fix with the Overlay error and pass now. The earlier tests mounted it
under home:, which has an Overlay, so they could not catch this.
Epic #668.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The firmware refuses a second CMD_GET_CONTACTS while it is streaming
(ERR_CODE_BAD_STATE), and the app had no contact handling for that
error. Two refreshes that overlapped could leave the in-flight state
tangled, and a request the radio refused left the list half-synced.
A request made while one runs is now queued and sent after END (a full
request wins over an incremental one). A request with no contact frames
for 30 s is treated as lost, so a missing END cannot block every later
refresh (owner decision on #672; the hang itself stays with #86/#504).
An ERR before the stream starts, when no caplog download or tracked
command could own it, ends the request and keeps the saved list. A
send that throws resets the in-flight state.
Tests cover queueing, the queued request running after END, full over
incremental, ERR, the 30 s stale rule, keep-alive from contact frames
and a failed send; with the guard disabled, 4 fail.
Epic #668.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A short contact sync is now loud (SAFELANE 6). A banner above the whole
app says the radio reported N contacts but sent M and that the saved
contacts were kept; it stays until dismissed. When the automatic retry
is short too, a modal asks: Keep my contacts (the default, focused, so
Enter keeps) or Use the radio's list, which states how many contacts it
would remove. Owner decisions on #670.
The banner sits in MaterialApp.builder next to the storage banner
(#385), above the Navigator, so the modal is drawn in place rather than
pushed as a route. Strings are English; other locales fall back through
untranslated.json, as the storage banner's do.
Widget tests cover hidden, shown with counts, the no-total wording,
dismiss, the decision's default and both choices; with the Keep default
removed, two fail.
Epic #668.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ContactStore.loadContacts returned an empty list when the stored JSON
failed to decode, and the next save wrote the in-memory list over the
stored contacts. A read failure was treated as "no contacts".
A decode failure is now logged as an error, the raw value is kept
under contacts<key>.unreadable-<timestamp>, and saves to that key are
refused (and logged) until a load succeeds. Other radios' keys are
unaffected.
Tests cover the round trip, the failure report, the raw copy, the
refused save, recovery after a good load, and another radio's key;
with the refusal disabled the save test fails.
Epic #668.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A full contact sync cleared the app's list, then saved whatever the
radio streamed as the whole list. The owner's RAK declared 350 contacts
and streamed 106; the app kept the 106 and silently lost the rest,
favorites included (#660).
A full sync now leaves the local list in place and records which
contacts the radio delivered. Only a complete sync (received equals the
declared total) drops the contacts the radio no longer holds. A short
or undeclared sync merges what arrived, removes nothing, logs an error,
and retries once per connection. If the retry is short too, the
connector exposes a pending decision (keep local, default, or use the
radio's list) for the UI in #674.
Removes the now-dead _preserveContactsOnRefresh flag. Adds test-only
seams to feed frames and capture sent frames. Tests cover complete,
short, undeclared, retry bound, both decisions, mid-sync adverts and
incremental sync; with the old clear reinstated, 5 of them fail.
Epic #668.
Co-Authored-By: Claude Opus 5.5 <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>
MeshCore's "Canada" and "USA" share 910.525 / 62.5 / SF7 / CR5 and
differ only in path hash (Canada 3-byte). Matching ignored path hash,
and Canada sorts first, so a USA radio showed "Canada" (D1 screenshot).
A preset with a path hash size now matches only a radio on that size;
presets without one match as before.
Epic: #726
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>
RadioPreset.frequencyHz returned kHz; renamed frequencyKHz (the unit
CMD_SET_RADIO_PARAMS carries). The picker is disabled while a refresh
runs, so a pick can't vanish when the refreshed list lands, and the
selected-item builder uses an id-to-title map.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving Radio Settings already sends radio params (cmd 11) and TX power
(cmd 12). When the selected preset carries a path hash size, save now
also sends CMD_SET_PATH_HASH_MODE (61) with the zero-based mode. It is
sent whenever the preset has one, not compared against the reported
width, because that reads as 1 when device info is short (#240).
Off-grid repeat handling is unchanged.
The CMD_SET_RADIO_PARAMS comment said the frequency was in Hz; stock
firmware stores freq / 1000.0 as MHz, so it is kHz. Comment corrected.
A frame test pins the bytes a preset puts on the wire.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Radio Settings now takes its presets from RadioPresetService instead of
the hardcoded list, which is removed (radio_settings.dart keeps only the
LoRa enums). The picker, extracted as RadioPresetPicker for reuse on the
remote-node screen, groups presets under non-selectable region headers,
labels each MeshCore or Offband, and shows where the list came from with
a refresh button; a failed refresh stays on screen in the error colour.
Selection is tracked by preset id rather than list index, because a
refresh can change the list under an open form. Upstream presets carry
no TX power, so a preset without one matches any power and applying it
leaves TX power as it is. The form refreshes a stale list when opened.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RadioPresetService starts from the last good copy of each preset file,
or the copy bundled in assets/radio_presets/ (config-profiles 857d4e9),
so the picker works offline. refresh() fetches both files from
config-profiles; each file that parses becomes the new saved copy, and
one that fails or comes back empty never replaces a good one. Failures
are logged and kept in refreshError for the picker to show.
refreshIfStale() refreshes at most every 12 hours. The merged list puts
the overlay over upstream on a matching title, sorted by region.
An overlay edit in config-profiles therefore reaches users on their
next refresh with no app release (test: "an overlay edit reaches the
app with no new build"). Closes the gap recorded in #648.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Presets publish path hash size in bytes; firmware takes a zero-based
mode (1 byte = mode 0, 2 = 1, 3 = 2). RadioPreset.pathHashMode does
the conversion and returns null when the preset carries none or an
out-of-range size, so nothing is sent. Tests pin all three sizes and
that a published 2 never becomes mode 2.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RadioPreset carries frequency, bandwidth, SF, CR, and optional TX power,
path hash (bytes) and off-grid flag, plus its region and source.
parseUpstreamPresets reads the mirrored MeshCore list (string numbers);
parseOverlayPresets reads Offband's overlay (JSON numbers, retired
entries dropped). Malformed entries are skipped and counted; a wrong
format or a newer format_version is rejected. Upstream titles carry no
region, so upstreamRegionFor derives the picker group from the title.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three accepted findings, plus a real defect the adversarial test cases
flushed out.
A long name overflowed the chip by 405 pixels. The name comes off a
public radio channel and is attacker-controlled, so anyone could break a
message list by posting a card with a long name. The label is now
bounded and ellipsized, with an explicit width ceiling because a
WidgetSpan hands its child unbounded width.
fromChannelShare no longer scans from the first < to the last >. Two
cards in one message were read as a single span running from the first
key to the last name. That was reachable: the add dialog passes raw
pasted text straight to it. It now takes the first well-formed card via
a regex whose name class cannot cross a closing bracket.
The known-contact check is O(1) against the connector's existing
_knownContactKeys set, through a new isKnownContact. Scanning contacts
ran per chip on every notification, and the public knownContactKeys
getter copies the whole set, so neither belonged in a build method.
Card-versus-mention is now carried on the match instead of inferred from
text[m.start] == '<', which would mis-route the day a third bracketed
pattern is added.
Two findings were rejected on evidence and are justified in the PR body:
a claimed missing null terminator (firmware sets it from the frame
length itself) and a claimed greedy-regex failure (disproven by test).
Epic #610.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The receive half of contact sharing. Until now an incoming
<key:type:name> rendered as raw text while the stock app showed a native
Add Contact button for the identical payload, so sharing worked outbound
only: a stock user could add an Offband user from a card, but not the
reverse. Confirmed by the owner on hardware.
Reuses the proven mention-chip mechanism in TranslatedMessageContent
rather than adding new machinery. Both patterns are now collected and
sorted by position, so a message carrying a mention AND a card renders
both in the right order; they cannot overlap, since a mention is @[...]
and a card is <...>.
Tapping opens the add dialog seeded with the card rather than adding
silently. A contact is an identity, so adding one stays a deliberate act
with the key, name and type visible first.
Two deliberate refusals:
- A card whose key is already a contact renders inert, mirroring stock,
which warned the owner rather than silently re-adding.
- A card matching the shape but failing the parser, such as an
out-of-range type, falls back to plain text. Showing an Add chip there
would promise something the parser will refuse.
Parsing already existed from the #611 Gemini review, so this change is
rendering and dedupe only.
Epic #610 under Feature #609.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three writers copied raw UTF-8 bytes up to a 31-byte boundary with no
codepoint check, so any name crossing it was cut mid-character and
invalid UTF-8 reached the radio. It was then stored that way and handed
back to every client that later read the contact or channel. ASCII names
were unaffected, which is why it went unnoticed.
An 11-character Chinese or Japanese name is 33 bytes against a 31-byte
budget, so it corrupted every time.
Adds utf8TruncateToBytes, which stops on a GRAPHEME CLUSTER boundary.
Codepoint-safe is not sufficient: a ZWJ emoji sequence is several
codepoints joined by U+200D plus a variation selector, so a
codepoint-safe cut can still leave a bare glyph, a dangling joiner or an
orphaned selector. The characters package was already a dependency, and
flutter/widgets re-exports it, so this costs nothing new.
Fixed at all three sites:
- writeCString, used for contact names and channel names
- buildSetAdvertNameFrame, which sets THIS device's own advert name
That last one mattered most: it is the name the whole mesh sees, the
name embedded in the contact cards this device emits, and what other
clients match against. Corrupt it once and it propagates outward.
The strongest test sweeps every budget from 0 upward across a string
mixing 1, 3 and 4-byte characters and asserts the output always decodes
strictly as UTF-8, which is precisely the property that was broken. ASCII
output is unchanged.
Found by the standards#145 Gemini review on the #619 branch, which was
told not to re-report writeCString and located the third site instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One getter decides the composer's byte budget on every platform, and it had
collapsed that budget to zero on three of them before anyone noticed: Windows
(#686), Linux (#717) and Web (#718). Each surfaced as a user reporting "the
keyboard doesn't respond", two months and three platforms apart. Nothing
exercised the getter, so nothing went red.
Extracts the decision into `MeshCoreConnector.resolveFrameBudget`, a pure
function of (transport, isWeb, mtuNow). The getter is now a one-line delegate
and behaves identically. This is what makes the web branch testable at all:
`kIsWeb` is a compile-time constant, so a VM test could never enter it and CI
runs the suite on the VM only.
19 new cases, covering what we now know rather than what we guessed:
- wired transports are never floored, and ignore a bogus MTU
- web is exempt from the floor, and both composers stay usable at MTU 23
- native BLE keeps the #395 write-safety floor: unknown MTU yields 20, never
the full frame, because defaulting an unknown link to the largest size would
authorize an oversized write
- the MTUs actually measured on hardware (Android 247, Windows 176) leave a
usable channel composer
- the original failure stays reproducible: an unreported MTU still gives a
0-byte channel budget and 4 bytes for a DM, the two numbers users reported
Also adds widget tests for the #684 notice, including the disconnect case the
pre-PR review caught, and the severity split between a zero and a small budget.
analyze clean, 981 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#592 was reported twice as "the keyboard doesn't respond". It never was an
input bug: the byte budget had collapsed to 0, so the length limiter rejected
every keystroke while the field still took focus and blinked a caret. Nothing
on screen said why, and the one element that would have (the `used / max`
counter) is hidden while the field is empty, which a dead field always is.
Adds `minUsableComposerBytes` (16) and `isComposerBudgetUsable()` next to the
budget arithmetic, and a shared `ComposerBudgetNotice` rendered above both
composers. 16 is a judgement call, set above the degenerate values #592
produced (DM 4, channel 1 or 0) so the guard catches the near-misses too, not
only a literal zero. A budget of 0 and a budget of 4 are described differently:
one cannot send at all, the other has almost no room.
Persistent inline notice rather than a toast, deliberately. The condition is a
state that lasts as long as the link, not an event, so a snackbar would scroll
away and leave the same silent field behind (SAFELANE 6). It renders nothing on
a healthy link, so both composers include it unconditionally.
Does NOT change sending or the frame budget: out of scope per the issue, and
the write-safety cap from #395 stays honest. A small but nonzero budget still
sends, it just says so now.
The DM composer's Container moved into _buildComposerBar so a Column could sit
above it; no change to the bar itself.
Tests: guard assertions added to the existing budget characterization file,
including the negative case that a healthy link (and the 247 / 176 MTUs
measured on hardware) leaves the notice invisible. 962 passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
App-side half of the #592 fix; the plugin-side GattSession patch is a separate
commit on packages/flutter_blue_plus_winrt.
- dependency_overrides points flutter_blue_plus_winrt at the vendored copy
(version 0.0.20+offband.1; interface constraint relaxed to >=8.2.2 since the
Dart layer uses nothing 9.x-only - the 9.x/2.3.x upgrade was deliberately
skipped, owner decision on #685).
- Connector: requestMtu is now attempted on Android only. flutter_blue_plus
throws "android-only" on every other platform before any platform code runs
(#683), so the old call could never succeed off-Android and its failure log
was noise.
- Connector: subscribe to device.mtu and notifyListeners on each value. The
composers read effectiveMaxFrameSize on rebuild, so this is what lets the
message byte budget recover from the 20-byte floor the moment the platform
reports the real MTU, instead of waiting for an unrelated rebuild.
Subscription is cancelled on reconnect and disconnect.
- test/connector/mtu_composer_budget_test.dart: characterizes the collapsed
budgets at the unknown-MTU floor (DM 4 bytes, channel 1 or 0) - the numbers
both field reports reduced to - and the healthy-link budgets. Rescued from
the throwaway diagnosis branch (#683).
- Regenerated plugin registrants picked up plugins missing from the committed
copies (file_selector, screen_retriever, window_manager); stale on dev,
refreshed by pub get after the override.
analyze clean, 958 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review raised three findings. Two survived verification.
Rejected: a claimed RangeError when abbreviating a short public key. The
code already guards with a length check before either substring, so the
crash it describes cannot occur.
Rejected as described, fixed as found: contact position (0, 0) was said to
be dropped on import. It is not. The frame builder writes the position block
whenever lastModified is present, which it always is here, so a suppressed
position still writes 0/0 and the bytes are identical either way. The
hasPosition conditional was therefore doing nothing except misleading a
reader, which is exactly what happened. Removed, and the behavior is pinned
with a test.
Accepted: the service added sections to 'applied' after sendFrame returned,
which only means the frame left our side. A lost or refused frame was
indistinguishable from success, so the user could be told an import worked
when nothing changed on the device.
A correct fix needs an awaited per-command acknowledgement in the connector,
keyed on command code so concurrent commands cannot steal each other's OK.
That is real connector work and is filed as #584. What is fixed here is the
overclaim: 'applied' and the two counts are documented as sent rather than
confirmed, and the result string now reads 'Sent N contacts and M channels
to the device'. The code no longer states something it cannot know.
Mirrors stock's Export Config screen: section list, Select All and Deselect
All, everything checked by default. The asymmetry with import, which will
start with nothing checked, is stock's own and is deliberate: opt out on the
way out, opt in on the way in.
A section the user ticked that could not be gathered is shown before the file
is written, with its specific reason, and the user chooses whether to export
anyway. Firmware-built-without-it reads differently from device-did-not-answer
because only one of them is worth retrying.
The screen also states plainly that this is the stock format and cannot carry
Offband-only data, so importing the file back will not restore it.
File naming follows stock, <name>_meshcore_config_<stamp>.json, and is
sanitized for the filesystem without touching the name written inside the
file, which has to round-trip exactly. Real device names in the reference
corpus contain emoji and a trailing space, both covered by tests.
Writing reuses LogExport for the per-platform mechanism (share sheet on
mobile, Save As on desktop, download on web) rather than adding a second
export path that could drift. The temp file can contain the node private key,
so it is written under the app's own temp directory.
The public key is abbreviated on screen; the private key is never rendered.
6 tests on the file name.
Applies a stock config file to the device with stock's own merge semantics,
quoted from its Import Config screen:
- contacts are upserted on public key, and nothing is ever deleted
- channels are additive only, so a rotated PSK for a channel the user
already has does not apply
- importing an identity overwrites the node outright
We match that behavior but not its silence. Every skipped channel comes back
named, with a reason, so the UI can show it; a channel that quietly did not
import is the silent no-op SAFELANE section 6 forbids.
The channel merge rule is extracted as planChannelImport, a pure function, so
the surprising part is testable without a radio. It treats a channel as
already present when either its PSK or its name matches, which is the
non-destructive reading of a rule stock states without defining. Pinning down
what stock actually does is a T1 question, recorded in the doc comment.
New channels take the lowest free slot, since the file carries no index.
other_settings is reported as not fully writable rather than claimed as
applied: buildSetOtherParamsFrame deliberately pins the auto-add-contacts
byte, so manual_add_contacts cannot be written without changing app-wide
behavior. Advert location policy is applied.
10 tests over the planner and the result type.
Gathers live device state into a StockConfig, section by section, matching
stock's Export Config screen where each section is independently selectable
and the file simply omits what was not ticked.
A requested section that cannot be gathered is reported in the result rather
than dropped, with the reason kept specific: unsupported (firmware built
without identity export) is distinct from noReply and rejected, so the
export screen can say the radio cannot do it instead of offering a pointless
retry.
Two unit traps handled explicitly:
- currentFreqHz is misnamed; the value is kHz, which is also what stock's
frequency field carries, so it passes through unconverted. currentBwHz
really is Hz. The mismatch exists in both our state and the file.
- other_settings.manual_add_contacts must be the raw device byte. The
connector's _manualAddContacts is an inverted derived view of bit 0
(firmware treats a clear bit as auto-add enabled), so exporting it would
have written the wrong value. The raw byte is now retained and exposed,
with the inversion documented at the parse site. No behavior change.
Path mapping keeps our hash count and per-hop width (#309) intact: stock
encodes one hash per comma-separated element, so the width survives as
element length. A flood route, an over-long declared hop count, or a width
stock cannot express all yield no path rather than a guessed one.
13 tests over the pure mapping functions.
Prerequisite for the export/import epic (#568): two device values the
connector could not read or write, both already exposed by firmware.
Identity (CMD_EXPORT_PRIVATE_KEY 23 / CMD_IMPORT_PRIVATE_KEY 24):
- adds the commands, RESP_CODE_PRIVATE_KEY 14 and the previously unhandled
RESP_CODE_DISABLED 15
- both firmware commands sit behind build flags, so a radio can legitimately
answer DISABLED. IdentityTransfer keeps unsupported distinct from rejected
so the UI can say the radio cannot do it rather than offering a retry that
can never succeed
- a short PRIVATE_KEY frame reports rejected instead of handing back a
truncated key that could be written to an export file
- import is destructive and documented as such; the key is never logged
Auto-add hop limit:
- GET already returned max_hops as a third byte and we discarded it; it is
now parsed and exposed
- SET learns an optional maxHops. Omitting it keeps the frame two bytes, so
firmware leaves the device value alone and existing callers are unchanged
- clamped to 64 on our side to match the firmware clamp
Adds handleFrameForTest so parse paths can be exercised without a radio,
following the file's existing visibleForTesting convention. 12 new tests.
Models the MeshCore stock companion config export JSON so files interchange
in both directions (epic #568). Shape reverse-engineered from ten real
exports across five radios; evidence recorded on #569.
Format rules the parser enforces, none of them obvious:
- no version field exists, so validation is by shape and every top-level
section is independently optional
- public_key and private_key travel as one unit
- radio_settings mixes units: frequency kHz, bandwidth Hz, coding_rate a
bare denominator
- coordinates are JSON strings, never numbers
- channels carry no index; array position is the index
- out_path_list is one comma-separated hop hash per element, so hop count
and hash width are both recovered rather than inferred (#309)
- contact timestamps are untrusted and never sanitized
- null and empty-string out_path_list stay distinct on re-encode; both mean
no route, but real exports contain both and the difference is unexplained
Unknown top-level keys are ignored so a future stock release cannot break
the reader. Errors carry the offending JSON path for the import UI.
Test fixtures are synthetic with obviously fake keys. Validated separately
against the owner's ten real exports, which round-trip key-for-key; that
harness reads a path outside the repo and was deliberately not committed.
The radio is authoritative for the expected-ACK hash it reports, but the
client looked that hash up in a map keyed by a hash it recomputed locally.
When the two disagreed the lookup missed silently (debugPrint only), the
8000ms watchdog from #395 marked the message failed, and the genuine ACK
later matched nothing because every downstream map is populated only on the
match path. Delivery worked the whole time.
Captured on a Wadamesh HV4 TFT in #449: five DMs, all shown as errors, two
with a confirmed ACK whose hash equalled the radio's RESP_CODE_SENT value
exactly. Wadamesh builds the payload differently, so the client cannot
predict its digest, and Offband is expected to work against stock MeshCore,
Wadamesh and other forks.
RESP_CODE_SENT is the reply to our own CMD_SEND_TXT_MSG, so adopt the radio's
value when exactly one send awaits confirmation. Zero or several candidates
keep the previous behaviour rather than guessing. Channel sends draw the same
frame, so adoption is refused while one is outstanding.
The lookup miss is now a warn on debugLogService instead of a bare
debugPrint (SAFELANE section 6).
A send-order correlation queue was developed alongside this and has been
split out: its ordering premise needs a transport send mutex that does not
exist yet, and three review rounds each found a fresh defect in it. This
commit deliberately carries only the adoption fallback, which is what the
owner's hardware test validated.
Refs #449
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A truncated caplog download used to completeError and discard every received
byte, so the user got nothing from a buffer still intact on the device (a tester
lost 8461/8608 bytes, 98.3%, deterministically per firmware#711 so retry can't
help). Now truncation is a soft outcome:
- downloadCaplog() returns a CaplogDownload (bytes + received/expected/chunks +
truncated) instead of throwing; busy/timeout still throw.
- serial_capture_screen still writes + LogExport.shareFile the partial bytes,
marks the file "# PARTIAL ..." with the counts, and shows a non-fatal warning
instead of a red error.
- Button label is now platform-aware (Download & save on desktop, & share on
mobile) via LogExport.actionVerb, matching the App/BLE log screens.
Replaces CaplogTruncatedException with CaplogDownload. Reassembler unchanged
(already keeps the bytes). Adds CaplogDownload contract tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Owner feedback from testing: a scan button living only inside the public
key field is awkward placement, because scanning is how most people will
actually add someone and it should not be reachable only from inside a
field they have to open first.
Now in both places, routed to the right thing:
- Contacts overflow menu, Scan contact QR, opens the scanner and hands
the result to the add dialog already populated. Nothing to paste.
- The key field keeps its scan button, which fills in place, for when
the dialog is already open.
Same scanner and same parser either way; only the entry point differs.
The dialog gains an optional initialKeyText, and seeds it through the
same handler as a paste, so a scanned link populates name and type
rather than sitting there as raw text.
Platform availability moves to a shared contactQrScanAvailable getter on
the scanner screen, since it now has two consumers. Both the menu entry
and the field button hide on Windows and Linux, where mobile_scanner has
no support and the render half is the desktop path.
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 a calm three-state indicator beside each contact name, per the
owner steer: a green check for advert-verified, a neutral outline check
for key-confirmed, a muted key for key-only. No amber and no hazard
glyph, because nothing is wrong with a key-added contact. The scale
reads as how much we know, never as how risky.
The states are not cosmetic:
- advertVerified: a signed advert arrived, so the node itself asserted
its name, type and position, and the raw packet is stored, which is
also what makes the contact re-shareable.
- keyConfirmed: a message with this contact went through. Direct
messages are encrypted with an ECDH secret derived from the contact
key, and the ACK is computed over the decrypted plaintext
(BaseChatMesh.cpp:442,451), so this is proof the holder of the
matching private key is live. It does NOT prove the person is who the
name claims.
- keyOnly: someone supplied a key and nothing has confirmed it on air.
Costs almost nothing to compute. Contact.isAdvertVerified falls out of
the epoch last_advert_timestamp that #627 already writes, and it clears
itself when a real advert rewrites the field. The advert check runs
first so only an unconfirmed contact pays for a message scan, which
keeps a long contact list cheap.
Also fixes a defect the epoch sentinel introduced: _formatLastSeen ran
the epoch through the relative formatter and claimed a key-added contact
was last seen tens of thousands of days ago. It now reads Not heard yet.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both halves of the visual exchange, sharing one format and one parser.
Render: showMyContactQrDialog puts this device's own identity on screen
as a QR plus the same link as selectable, copyable text. It refuses to
render when not connected, since an empty key would encode a QR nobody
can add.
Scan: ContactQrScannerScreen validates with Contact.isValidShareUri, the
same check the paste path uses, and pops the raw string. The add dialog
routes a scan through the same handler as a paste, so a QR gets no
separate code path.
The scan affordance is gated to platforms mobile_scanner supports
(Android, iOS, macOS, web). On Windows and Linux the QR half is the
render side, which is the better desktop flow anyway: put your code on
the big screen and let the other person scan it with a phone.
Fixes a real defect found by the new test, not a test artifact:
QrCodeDisplay built its QrImageView through a LayoutBuilder, which
cannot answer intrinsic dimension queries, so any intrinsic-measuring
parent threw. AlertDialog measures its content's max intrinsic height,
so the dialog crashed on open. The QR is now bounded by a tight
SizedBox, which answers the intrinsic itself. This was the widget's
first real call site, so the bug had never been exercised.
Entry point is provisional alongside Add by key; #632 reorganises.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First user-reachable piece of the identity exchange. A dialog takes a
public key, a name and a contact type, and hands the stub to
addContactByKey.
The key field also accepts a whole meshcore://contact/add link and
absorbs every field from it, because that is what someone actually
pastes, and a QR is only that link rendered visually. Whitespace is
stripped so a key copied across a line break still works.
The stub is built by round-tripping through buildShareUri and
fromShareUri rather than constructing a Contact directly, so manual
entry and a scanned QR cannot drift apart. One code path, one set of
invariants.
An informational note states plainly that the contact is not confirmed
on air and that the name is whatever the user typed until the node
adverts. Deliberately informational rather than a warning: nothing is
wrong with a key-added contact (#630).
Entry point is provisional, sitting in the contacts overflow menu so the
feature is reachable. The proper add-contact surface, split away from the
advert affordance, is #632 under epic #623.
Five widget tests pin what reaches the connector: key, typed name,
chosen type, the flood sentinel, and the epoch lastSeen that keeps the
firmware replay guard from muting the contact. Also covered: pasted-link
prefill, wrapped-key whitespace, an invalid key sending nothing, and the
missing-name fallback.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds MeshCoreConnector.addContactByKey, the device-side half of the
identity exchange. It uses the stock CMD_ADD_UPDATE_CONTACT (command 9),
whose handler creates the contact outright when the public key is
unknown. Verified byte-identical to upstream meshcore-dev/MeshCore at
0679dbef, so this needs no capability gate and works on stock firmware,
not only Offband builds.
buildUpdateContactPathFrame gains an optional lastAdvert. It defaults to
now, leaving all three existing path-update callers byte-identical, and
addContactByKey passes the epoch.
That epoch is the whole point. The firmware compares this field against
every incoming advert with 'timestamp <= last_advert_timestamp' and
silently discards the non-greater ones as replay attacks
(BaseChatMesh.cpp:142-145). Advert timestamps come from the sender's
clock, and two nodes on this mesh currently advertise with 2024 clocks,
so stamping now would leave a key-added contact permanently deaf to its
own adverts. A negative input clamps to zero rather than wrapping to a
huge value, which would be the worst case for that guard.
The frame is sent at full length with the 0xFF flood sentinel. The
firmware length guard is only 'len >= 36' but updateContactFromFrame
reads through offset 136 regardless (MyMesh.cpp:295-318), so the frame
must never be trimmed. A test pins that too.
Local state keeps lastSeen at the epoch so the contact reads as
unverified until a real advert upgrades it (#630).
Epic #619.
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>
Channel frames carry no key, so the sender name is resolved against known
and discovered contacts. Adds resolveContactsByName (returning Contacts,
which importDiscoveredContact needs) and rebuilds resolveContactKeysByName
on top of it so one matching rule serves both.
The avatar is the shortcut; the sender name stays inert because it sits too
close to the message body to hit reliably on a phone. Long-press keeps every
action it had and gains the same contact rows. A name several nodes claim
lists all of them, and an unheard name says so instead of failing silently.
1. An unprefixed reply no longer resolves an arbitrary pending command.
#532 constrained prefixed replies only; the unprefixed fallback still
used firstWhere, which with two or more in flight is map-iteration
order, so a caller could receive another command's output. The
fallback cannot just be deleted: firmware only echoes the prefix when
the command exceeds four characters including it
(simple_repeater/MyMesh.cpp, strlen(command) > 4 && command[2] == '|'),
so a very short command legitimately answers without one. It now
resolves only when exactly one command is pending, and surfaces the
ambiguity otherwise.
2. A late reply no longer overwrites unsaved edits. The settings screen
applied a late `get` payload unconditionally, so a reply arriving after
its timeout could revert a field the user had typed into while waiting.
It now applies only when nothing is dirty, and says which happened.
3. _expiredCommands is pruned on timeout as well as on receive. It was
pruned only in handleResponse, so a run of commands that all time out
with no traffic coming back accumulated records until disposal.
The reviewer described 3 as unbounded growth. It is bounded at 256 by the
prefix token space, and stale records could never be mis-attributed
because handleResponse prunes before matching, so the severity was
overstated. Fixed anyway; it is nearly free.
A fourth finding, that the new l10n strings are untranslated in the other
17 locales, is rejected. That is this project's established pipeline:
strings are authored in app_en.arb, gen-l10n emits English fallbacks, and
untranslated.json tracks the gap, which it now does for these keys. Every
string in the app arrived this way, so it is not a defect this branch
introduces.
The ambiguity test was verified to fail against the pre-fix code.