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>
Sensor contacts gain a "Manage Sensor" item that opens the repeater
login and admin hub. Stock sensor firmware handles the same admin login
(SensorMesh::handleLoginReq) and CLI-over-mesh as repeaters, so the
remote settings screen, its preset picker and tempradio option work
there too. Repeater and room server menus are unchanged.
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>
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>
The About card gains a credit line under the existing MeshCore Open
attribution, and the licenses page gains a "MeshCore suggested radio
presets" entry naming Liam Cottle, api.meshcore.nz, MeshCore and the
config-profiles mirror, alongside the Open-Meteo entry.
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>
Gemini recheck: a hardcoded 260px ceiling ellipsizes prematurely when a
large accessibility font is set, even on a wide screen, and looks oddly
constrained on desktop. Now 70% of viewport width, which keeps the
overflow bound on a narrow phone and gives room where there is room.
Co-Authored-By: Claude Opus 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>
Web Bluetooth exposes no ATT MTU to page script; there is no API for it, so
`mtuNow` can only ever be the 23-byte default. `effectiveMaxFrameSize` applied
the BLE floor on every platform whenever the transport was bluetooth, so web
landed on 20 bytes permanently and the channel composer computed a 0-byte
limit. That is the #592 symptom on a third platform.
Unlike Windows (#686) and Linux (#717), this cannot be fixed by reporting an
MTU, because there is none to report. Patching flutter_blue_plus_web would
achieve nothing.
The floor exists because a raw single characteristic write larger than the PDU
is rejected and wedges the send path (#395). That failure mode is not reachable
on web: the browser owns fragmentation, and the Web Bluetooth spec caps one
write at 512 bytes, rejecting anything larger with InvalidModificationError.
maxFrameSize (172) is well inside that, so a write cannot be oversized whatever
the link negotiated. Cited rather than assumed, because "just remove the floor"
was considered and rejected as the fix for #592.
Not unit-testable: `kIsWeb` is a compile-time constant, so a VM test cannot
enter this branch and CI runs the suite on the VM only. The floor's behaviour
on the other platforms stays covered. Proving this one needs a max-length send
over web BLE in a browser, which is the acceptance criterion on #718.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gemini gate (2.5-pro) findings, verified against source before acting.
ACCEPTED, real defect: the notice would have appeared on every disconnect.
`_activeTransport` defaults to bluetooth and is reset to it on teardown, the
device is null while disconnected so `mtuNow` is unavailable, and
`effectiveMaxFrameSize` therefore returns the same 20-byte floor an unreported
MTU produces. Neither chat screen pops on disconnect, so the composer stays
built. The banner would have blamed the link for a small packet size when there
was no link. Now gated on `isConnected`.
ACCEPTED in substance: a permanent error-red banner over a link that
legitimately fits only a few characters is noise, and noise is what teaches
people to ignore the banner that matters. Kept the 16-byte threshold, since
lowering it would let the misleading 4-byte case through, and split the
severity instead: 0 bytes is an error, 1-15 is informational.
REJECTED, unfounded: a semanticLabel on the icon. Flutter's Icon wraps its
glyph in ExcludeSemantics and only announces a label when one is given
(icon.dart:348-356), so an unlabelled icon is already decorative, and the two
Text children carry the whole message. Comment added so it is not "fixed"
later.
analyze clean, format clean, 962 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>
SnackBar wraps its content in Semantics(container, liveRegion, onDismiss)
(snack_bar.dart); the overlay replacement had no Semantics at all, so a
screen-reader user got silence where the message used to be announced.
Mirror the SnackBar wrapper.
Agent: TurquoiseBridge (session 82d77b2f)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bottom-anchored SnackBar sat on top of the chat composer. A SnackBar is
positioned by its Scaffold and can only be raised with a near-full-height
bottom margin, which (per Flutter's ClipRect + heightFactor reveal) delays the
content until the end of the animation and makes the whole screen a hit
target, so this renders through the root Overlay instead.
showDismissibleSnackBar keeps its name and every existing call site. Its
parameter list is trimmed to the four callers actually pass; the rest were
used by nobody, so any future use is now a compile error rather than a
silently ignored argument. persist is preserved: the sender-error path relies
on it to keep an error on screen until read.
Also routes the two composer-screen block confirmations and the five inline
ScaffoldMessenger call sites through the helper so placement is consistent
where a composer exists.
Gemini review: nested-InkWell splash on a dismissing swipe fixed; the
route-scoping finding did not apply (MaterialApp installs one root
ScaffoldMessenger, app.dart:1047, and this app has no nested one, so the old
SnackBars already outlived route pops); replace-instead-of-queue is
deliberate and noted on #522.
Agent: TurquoiseBridge (session 82d77b2f)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
connect() on non-Linux platforms now treats Android GATT status 133
(ANDROID_SPECIFIC_ERROR) as the transient failure it usually is: clean
close, 800 ms delay, one bounded retry, then surface. The scanner's
connect-failure snackbar no longer shows raw exception text: it maps
133, timeouts and everything else to localized plain-language messages,
persists until dismissed, and sends the raw detail to the app log
(#522 presentation acceptance).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
disconnect() now caps the platform-confirm wait at 10 s (the vendored
Android plugin's backstop resolves within ~5 s), retries once after
500 ms, and when both attempts fail sets bleReleaseUnconfirmed instead
of pretending the release worked. The scanner screen shows a
persistent, dismissible banner naming the recovery (toggle Bluetooth /
force-stop), per the error-visibility standard. Cleared on the next
connect or disconnect attempt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#528 surfaced unmatched and late replies in the UI because they were
previously destroyed in silence. Once #529 widened the window, late
arrivals stopped being the exception and the on-screen output became
noise during ordinary use. Owner's call: log it, keep it off the screen.
Removed: the CLI screen's late-response history entry and its renderer
branch, the settings screen's snackbar, and the four l10n strings that
only those two used.
Also removed the settings screen's silent late-apply. A field changing
value seconds after the fact with no explanation is the same problem as
the snackbar, only quieter, and applying while saying nothing would be
worse than either showing or not showing. One line to re-enable if that
turns out to be wanted.
Kept: the service still detects every unmatched reply and writes it to
the app log via appLogger, now including the payload as well as the
repeater, the originating command when known, and how late it arrived.
That is the diagnostics channel and is what keeps this from being a
silent drop.
onUnmatchedResponse and UnmatchedRepeaterResponse stay on the service.
They are the seam the no-silent-drop guarantee is tested through; the
#528 and #532 tests exercise them directly and pass unchanged, which is
the evidence the service contract did not move.
807 tests pass, analyze clean, format clean.
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 Import Config screen, including the detail that nothing is
checked by default. Export opts out, import opts in; that asymmetry is
stock's own and is the safe default in each direction.
Only the sections the chosen file actually carries are offered, since a stock
export omits whatever the exporting user deselected. Channel and contact
counts on this screen come from the file, not the device.
Per-section wording is stock's, because the behavior is stock's: contacts are
updated, existing channels are left unchanged. Replacing the node identity is
irreversible, so it gets its own explicit confirmation instead of riding
along with the rest of the selection.
The result dialog names every channel skipped and every section not applied,
each with its reason. An import that quietly did less than the user asked for
is the silent failure SAFELANE section 6 forbids, and stock's own screen does
not tell you.
A parse failure reports the offending JSON path from the model's exception,
so the user learns which part of the file is wrong rather than 'invalid file'.
Both screens are reachable from Settings, above the GPX exports, which are
map data only and a different thing.
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>