ChannelRegionScopeStore persists a channel-index -> region-name map per device (first 10 hex of the device key, matching the other channel stores). set/clear/scopeFor, null = unscoped. Tests cover round-trip, per-device and per-channel isolation, persistence, and no-device-key guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR Gemini review (Epic #814) flagged a use-after-dispose: if the screen pops while a discovery is in flight, the completing future would call notifyListeners on the disposed service and throw. Added a _disposed flag + _safeNotify guard + dispose override, with a regression test that disposes mid-discovery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A firmware-gated repeater-hub tile opens a Discover Regions screen that runs RegionDiscoveryService against the selected repeater and renders each state distinctly (loading / region list / no-regions / timeout / error, with retry). Shows region name + source repeater; the wire reply has no per-region SNR, so none is shown (the B2 spec's SNR is not available). Widget tests cover all four states via an injected fake discoverer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RegionDiscoveryService (ChangeNotifier) drives one discovery run via an injected discoverer function and exposes a distinct state for each outcome: idle/loading/success/empty/timeout/error. Regions deduped by Region value-equality. Concurrent starts ignored. Injected seam keeps it unit-testable without a live connector.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR Gemini review (Epic #813 T4) flagged that RESP 28 carries no correlation tag, so two concurrent getDefaultFloodScope calls could orphan the first completer. Coalesce the second caller onto the in-flight request, matching the single-in-flight guard on exportPrivateKey. (Gemini's second finding, reserveTransportCode(0xFFFF), was rejected: firmware does code-- to 0xFFFE, which the code already matches.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
discoverRegions() correlates the 0x8C reply by the echoed tag (mirrors the 0xC6 pkt-hash pattern); setChannelSendScope/clearSendScope/setSendUnscoped and setDefaultFloodScope/clearDefaultFloodScope/getDefaultFloodScope wrap CMD 54/63/64 with RESP 28 handling. All gated fail-closed on supportsRegionScope. Two new dispatch cases (0x8C, RESP 28). Tested via the connector test seams.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the stock flood-scope companion frames (CMD 54/63/64, RESP 28), the regions-discovery anon request (CMD 57 type 0x01) and its 0x8C CSV reply parser, and firmwareSupportsRegionScope (FIRMWARE_VER_CODE >= 13). Wire layouts verified in firmware source; frame build/parse round-trip tested incl. empty and non-matching frames.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client-side region transport-key derivation matching firmware RegionMap/TransportKeyStore: key = first 16 bytes of SHA256("#"+name), a Region value model, and the HMAC on-air code. Known-answer tests use independent vectors. No firmware change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini gate, three findings, all verified against the source before acting.
ACCEPTED: the first log line reported a budget computed from a null name.
`_selfName` is only assigned while parsing SELF_INFO (line ~6127), which
arrives after CMD_APP_START, long after the MTU listener attaches. Now logged
on either input changing, with the reason named: `(mtu)` and `(self-info)`.
Worth recording because the review had the direction backwards: it said the
early line would overstate the budget. It understates it.
`_senderNameBytes(null)` returns the 31-byte maximum, so a null name produces
the largest possible prefix and the smallest possible channel budget. The
line was pessimistic, not optimistic. Same defect, opposite sign, and a
maintainer reading it would have been misled the other way.
ACCEPTED: the budget also drifts if the name changes mid-session. The
SELF_INFO hook covers the case that matters, since that is where the name
arrives or changes.
ACCEPTED, and it matters more than the review framed it: the name is no
longer logged, only its length. These files get attached to public issues,
and a radio name is usually an amateur callsign, which resolves publicly to a
real name and address. The length is the entire input to the budget, so
nothing diagnostic is lost. Logs `nameLen=N`, or -1 when unknown, which also
makes the null-name state visible rather than silently rendering as empty.
analyze clean, format clean, 1088 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app logged the negotiated MTU and raw frame lengths, but never the number
#592 was actually about: the composer's byte limit. The consequence showed up
while working #687. We have Windows BLE logs back to August proving MTU 247,
and hundreds of Android BLE sessions, and not one of them can evidence that
the composer was usable, because the log records what was sent and never the
limit the field applied.
Logs `composer budget: frame=172 dm=156 channel=148 transport=bluetooth
name="..."` beside the existing `MTU now:` line, and appends an explicit
marker when the channel budget is below usable, so a collapsed composer is
greppable rather than inferred.
Derived from the same functions the composers call (resolveFrameBudget,
maxContactMessageBytes, maxChannelMessageBytes, isComposerBudgetUsable) rather
than reimplemented, so the log cannot drift from the behaviour it describes.
The sender name is included because the channel budget subtracts its prefix,
and an 8+ character name is what took that budget to 0 at the floor.
Connect and MTU-change only. Not per keystroke or per send: diagnostics must
not become the outage (SAFELANE 11 rule 10).
Why it matters beyond tidiness: it converts #687 from a hardware-scheduling
problem into a log-reading one. Linux (#717) and Web (#718) have no
verification path today because nobody here runs either with a radio. With
this, verification is "send me your debug log", which the #592 reporters have
already shown willing to do.
No new tests: the three numbers come from functions already pinned by
frame_budget_platform_test.dart, and the added code is a log call on a path
that needs a live BLE device.
analyze clean, format clean, 1088 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second pre-PR review: a refresh that came back complete while the by-key
checks were still running cleared the union, and the leftover check loop
then reported a shortfall against it. The complete path now bumps the
run counter and stops the loop and the settle timer. Test proves it:
fails without the fix, passes with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found in the owner's hardware test: a manual refresh after the 3
automatic syncs logged "full sync 4 of 3", and the traffic log showed
the by-key request as CODE_30. Log text only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A contact the radio answered "not found" for, then held again (an
advert or contact frame mid-recovery), was still removed. Any frame for
a key now clears it from the gone set and counts it as delivered.
- A link that never went quiet postponed recovery forever, so a short
sync never reached the banner: the silent failure #668 exists to
prevent. After 2 minutes of waiting, recovery stops sending and goes
straight to the owner's decision.
- The by-key checks judged completeness against the declared total from
when they started; a refresh during them can change it. They now read
the latest one.
Also: dismissing the banner is a no-op while the decision is open (the
modal already covers it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The banner and the Keep / Use radio dialog now appear only after the
forced resync, so their numbers come from the whole recovery: how many
contacts the app has after checking again, and, when the radio answered
"not found" for any saved contact, how many are no longer on the radio.
That sentence is absent when there are none.
English strings only; other locales fall back, regenerated
untranslated.json lists them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaces the single retry with the owner-approved recovery (#707). After a
short full sync the app waits until the radio has been quiet for 10 s and
no other startup sync is running, syncs again, and stops at 3 full syncs
per connection. Contacts delivered by any of them count toward the
declared total. Saved contacts still missing are then checked one at a
time by key, 250 ms apart.
Only a contact the radio answers "not found" for is removed, and only
once every declared contact is accounted for: a union of several syncs
can reach the total while a different contact is still missing, so
"absent from the union" is never proof. Unanswered contacts are kept.
The banner appears only if the list is still short after all of this.
Recovery state belongs to one connection and one radio; a disconnect or
radio switch bumps a run counter that stops any pending step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A forced resync (#703) needs to know whether a saved contact the radio
did not stream is still on the radio. CMD_GET_CONTACT_BY_KEY answers
with the contact or ERR_CODE_NOT_FOUND, but the ERR frame carries no
key, so any other in-flight request could own it.
checkContactOnRadio runs one check at a time with a timeout. A matching
CONTACT means on the radio. NOT_FOUND counts as gone only when no
contact stream, channel sync, caplog start, generic ack or other by-key
request could have drawn it; everything else, including timeouts, send
failures and disconnects, is unresolved. "Gone" is what may later let
the owner remove a contact, so it is never guessed.
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>
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>