F3/E3.1. Upstream 1.18 gates remote CLI (TXT type 3) on contact flag
0x10 (isRemoteCLIAllowed): the receiving radio checks it, so setting it
on a contact grants that contact permission to run CLI on this radio.
Adds the constant, Contact.remoteCliAllowed, a remoteCli param to
setContactFlags (same set/clear pattern as the telemetry flags, reading
a fresh device snapshot first), and an opt-in SwitchListTile in the
shared contact settings dialog (default off). Written with the existing
add/update-contact frame; the flags byte already rides it.
Tests: the 0x10 bit on the write-path frame (set/clear/coexist) and the
getter. Full suite green (1424); Gemini 2.5-pro review: no defects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 750.4 of the contact-naming plan: the UI. Rename sits on the per-contact sheet, above the destructive Block tile, and the sheet shows what the node calls itself whenever a custom name is set.
The dialog shows the node's own name at the moment of the decision, and only while it differs from what is typed, so the user can see the name they are overriding without having to remember it and the same string never appears twice. It also says plainly that the rename is local and that adverts keep the node's own name up to date, so nobody has to guess what renaming costs them.
Empty and whitespace-only are normalized to a clear in the dialog as well as in the connector and the store. Three layers enforce it independently so none is load-bearing and the dialog never hands a blank downstream to be cleaned up.
A 32-character cap keeps tiles readable. Counted in characters, not bytes: a custom name never crosses the wire, so #636's grapheme truncation does not apply and a byte cap would mean 'shorter for CJK' for no reason.
Thirteen tests. T29 and T30 came from the pre-PR review and I had them wrong first: they accepted an empty string, which IS the defect the review found, so they would have passed against a broken save path. Tightened to assert null strictly, then falsified: reverting the normalization makes both fail where before they passed.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1335 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Builds the owner-approved parity design (docs/initiatives/standalone/975-settings-restructure/tones-screen-parity-mockups.html) against meshcore-firmware 3e788e2a:
- #986: the six event tones (DM, Channel, Room, New contact, Ack, Sent) on Buzzer and tones, one shared picker with a play button per row (0x0F/0x10/0x11). DM is one tone for every direct message.
- #970: all eight custom slots listed; the editor reads the stored tune back (0x12), checks typing with a port of firmware's rtttlProblem(), and saves in transport-sized chunks (0x0A), restarting once if displaced.
- #971: Radio tone in the channel sheet and chat menu, Default naming what it plays.
- #987: Screen (text sizes, Screen off, time zone with a phone-matched suggestion) and Battery (full point, set here, back to auto) under Node Settings, gated on caps2 0x40.
Caps2 0x10 is now TUNES (slots, channel tone, event tones, preview). Also corrects the mockup's zone count: 40 zones plus not set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner asked why there were no UI mock-ups to review. There was no good
answer: this Epic's card was built and installed on both his machines without
him ever seeing the design, and the plan had no step requiring otherwise.
So the plan now has B0: mock-ups first, owner-approved before any of it is
built, as a committed HTML file beside the plan in the format his firmware
initiative already uses. epic-b-storage-mockups.html is that file: eleven
numbered variants with real Material 3 tokens, a 360-wide pane, example values
from his own install, and the behaviour to build as bullets under each.
Owner-approved 2026-10-23.
The confirmation (B1a, variants 8 to 10). It states how many copies, roughly
how much space, how many messages are NOT affected, and that it cannot be
undone, with the count on the action button so the scope is the last thing
read. Cancel, a tap outside and back all decline. Nothing eligible opens no
dialog at all, because a dialog that only says zero is not worth opening.
That needed a preview pass: verify every eligible key and total it without
deleting, so the numbers are real rather than estimated. Verification now runs
twice for a confirmed clear, once to preview and once under the key lock. That
is the cost of saying what will go before it goes. approximateBytes is named
for what it is: the sum of blob lengths, not what VACUUM returns, because page
rounding moves the real figure; the dialog says "about" and the result reports
what actually changed.
Writing the preview found a real bug, and it was about to bite him. The
"skip anything stamped this pass" guard exists so installing an update never
deletes a blob on sight, and it was being applied to the button as well. Every
install that migrated before #962 has an empty __msgrows_migrated_at, so the
first press stamped everything and cleared NOTHING. His install is exactly
that: 47 migrated keys, none stamped. He would have pressed the button on a
70.9 MB database with 16.6 MB of reclaimable blobs and been told "nothing to
clear".
Fixed as eligibleByAge, one static rule both preview and run share, so the
dialog cannot promise what the sweep will not do. An explicit press includes
newly stamped keys; the automatic sweep still refuses them. Three tests on the
rule directly, plus one proving the automatic path refuses while the press
clears.
Label stays "Clear Stale History Now" rather than "Compact", owner's call and
the better argument: the name generalises to clearing stale history for other
reasons later, such as #963's retention limit.
14 widget tests and 34 storage tests. Full suite 1,502 pass, 1 skipped.
analyze clean, format clean.
Task: meshcore-open-gan
Agent Mail: TealGlen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Epic B of #962. The manual trigger for Epic A's sweep, so a user does not wait
30 days. It passes olderThan: Duration.zero, which skips the WAIT and not the
safety: the per-key verification inside reclaimVerifiedHistoryBlob is
unchanged, proved by a test that Duration.zero still refuses a key whose rows
cannot be verified.
Its own widget rather than another card in app_settings_view.dart, which is
already past a thousand lines, and so the tests can drive it without standing
up the whole settings screen. Stateful against the repo's usual
StatelessWidget-plus-Consumer habit, because the running flag and the last
result are local to this card and belong to no service.
The sweep is INJECTED, and that is a finding rather than a convenience. The
first version of the widget test called the real sweep and hung past ten
minutes instead of failing: the sweep uses compute, and an isolate deadlocks
pumpAndSettle inside flutter_test's fake-async zone. A widget test should test
the widget. So the card takes the sweep and the size read as parameters, the
nine widget tests feed outcomes in directly, and the two new tests in
stale_history_sweep_test.dart keep the injected semantic honest under plain
test() where isolates work.
Owner decision 2026-10-22, and the reason this Epic exists: the button REPORTS
keys it refused, it does not only log them. "Cleared 1 old copies, 16.0 MB
returned" and "Kept 1 that could not be verified" are both shown, including
when a refusal is the reason nothing was cleared, which is the most important
case to surface rather than the least. The automatic sweep stays log-only.
Two Gemini findings, both fixed:
- A fast double tap ran two sweeps. The disabled button is not enough on its
own, because setState only SCHEDULES a rebuild, so two taps inside one frame
both see a live onPressed. _clear now returns early on _running, and a test
taps twice in one frame and asserts the sweep ran once. Two concurrent
sweeps would have meant two concurrent VACUUMs.
- A throw after real deletes was reported as a total failure. The honest fix
was in the sweep, not the string: a successful sweep must not become an
error because the closing size read threw, so that read now falls back to
bytesBefore, which understates the reclaimed figure rather than inventing
one. The failure string no longer claims nothing happened either.
Strings are English-only, as everywhere else here: gen-l10n falls back to the
template locale, and untranslated.json records the 9 keys across 17 locales,
which is what that tracked file is for.
29 tests in the two files, full suite 1,483 pass, 1 skipped. analyze clean,
format clean.
Task: meshcore-open-gan
Agent Mail: TealGlen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Channel notify modes lived in one global AppSettings map keyed by PSK or a
bare slot index, so one radio's settings bled into another and set-and-back
could not clear a stale alias (#839). Scope them by the connected device-key
prefix like every other per-radio store (prefix -> pskHex -> mode); resolve
All when the radio or PSK is unknown instead of falling back to the slot
index; store a single entry per channel so All truly resets. One-time
migration moves the legacy flat map and name-keyed mute set into the first
radio synced after upgrade; later radios start clean. Removed the now-unused
legacy muteChannel/unmuteChannel/isChannelMuted methods so mutedChannels is no
longer a second, cross-radio source of truth (pre-PR Gemini review finding).
Buzzer Inherit (#544/#545) reads the same path and inherits the fix.
Epic #844. Build #848.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The picker lists only repeaters with a stored path (the only ones that can answer), with a header note explaining that. Search surfaces pathless matches too, but greyed out and non-selectable with a 'No stored path' label, so the user sees the repeater exists and why it is blocked. Default selection skips pathless repeaters; Discover is disabled for them. Widget test covers hidden-by-default + greyed-on-search.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
D1 hardware finding: the repeater picker was an unsorted, unsearchable dropdown, unusable with many repeater contacts. Replaced with a case-insensitive alpha-sorted, searchable list; discovered regions are alpha-sorted too. Widget test asserts sort order and search filtering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A firmware-gated channel overflow action opens a picker that discovers regions from a chosen repeater (reusing RegionDiscoveryService), lets the user select a region or None, and persists it via connector.setChannelRegionScope (C1 store). The dialog is decoupled (data + callbacks + injected service) for widget testing. l10n added.
Co-Authored-By: Claude Opus 4.8 <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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Two defects the adversarial review found, both verified before accepting.
1. `label` trimmed the name for display while the token stayed raw, so
two contacts differing only by surrounding whitespace rendered as
identical rows with no way to tell which one was about to be
addressed. That hides the exact identity #497 exists to preserve.
Display now defaults to the raw name.
2. Candidate deduplication used String.toLowerCase() while matching uses
the ASCII fold. Verified empirically: 'É' and 'é' compare equal under
toLowerCase and unequal under foldAscii, so of two real contacts one
silently vanished from the mention list while remaining matchable.
foldAscii is now public and is the single equivalence rule used for
both dedup and matching.
Regression tests added for both.
Full suite 719 pass, analyze clean, format clean.
Agent: CalmBay (session d14220d9)
Owner ruling 2026-08-01. Mention autocomplete trimmed the contact name
when building the @[...] token while the device stores and matches it
byte-for-byte, so any name with leading or trailing whitespace was
unmentionable and never beeped. Confirmed on the wire: the contact
record keeps the 0x20, the outgoing mention drops it.
@[...] is a wire token, not display text. Entry, firmware memcpy,
advert encode, advert parse and the contact record are all verbatim by
design; this trim was the only transformation applied to a node name
anywhere, and it changed the identity of the addressee.
- MentionCandidate now separates the two concerns: `name` is raw and
goes on the wire, `label` is display-only and may be tidied. The
const constructor is preserved, so existing const call sites still
compile.
- The candidate builder keeps sender and contact names raw. Emptiness is
probed on a trimmed copy; the stored value is untouched.
- _mentionsSelf drops its own trim as a direct consequence: a raw token
requires a raw comparison, or this node stops recognising mentions of
its own name.
- Contract clause written at both the insertion site and _mentionsSelf,
stating the token is byte-for-byte and that a future .trim() tidy-up
is forbidden. The contract was silent on raw vs normalised, which is
why both sides were reasonable and incompatible.
Deliberately out of scope per the ruling: no entry-side trim in
settings_screen. It fixes no deployed name and would silently alter
deliberate spacing.
Test updated to assert the new truth: ' Ben ' does NOT match @[Ben],
and DOES match @[ Ben ]. Full suite 732 pass, analyze clean.
Agent: CalmBay (session d14220d9)
Ben's style rule: no em-dash character in copy, docs, or code comments
(it reads as an AI tell), and the repo is going public. Replaced the
em-dashes in code comments and English UI strings with commas, colons,
or periods, whichever reads best. User-facing strings were hand-tuned
for natural punctuation rather than a blanket comma.
Preserved the lone "no data" glyph placeholders (a standalone dash used
as a not-available indicator in status displays); those are a design
element, not prose.
Regenerated app_localizations*.dart from app_en.arb (the English
fallback for untranslated keys propagates to every locale's generated
file). Non-English ARB translations left untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini flagged two unhandled PlatformException paths (SAFELANE §6):
- isIgnoringBatteryOptimizations could throw -> banner silently never shows;
now caught + logged, leaving the banner hidden on an unknown status.
- openIgnoreBatteryOptimizationSettings could throw -> button did nothing with
no feedback; now caught + logged + a snackbar tells the user it failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On aggressive OEMs (Samsung One UI) a backgrounded app that is not exempt
from battery optimization is slept on screen-off and drops the radio
connection, with no warning. Add a persistent banner on the channels screen
that appears (Android only) when the app is not exempt, explains the risk,
and deep-links to the battery settings via
openIgnoreBatteryOptimizationSettings(). Re-checks on resume so it clears
once the user applies the change; dismissible for the session.
No restricted permission: reads own status and opens the settings screen
(ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); the Play-restricted
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS path is deliberately avoided.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Top-level tabs (Contacts/Channels/Map) no longer auto-imply an AppBar
leading. They build via appBarBuilder(pinned): no leading when the nav
panel is pinned (desktop), the drawer hamburger when transient (mobile).
Detail screens are untouched and keep their working back button.
Removes the unused hideBackButton constructor param (declared, passed
true at quick-switch call sites, never read) and all its call sites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Smaz is a client-side text convention with no MeshCore protocol or firmware
support (findings: GH-315). When enabled it compressed ordinary prose into
`s:`+base64, which any non-lineage client renders as garbage — the same
cross-client interop failure as the old `g:<code>` GIF token.
Phase 1 of the Smaz-removal epic (GH-314): stop sending Smaz and remove the
user-facing surface, while KEEPING decode so messages from lineage peers still
on Smaz, and any legacy compressed rows, keep rendering. Decode removal is
deferred to Phase 2 (GH-421).
Removed: the Smaz branch in prepareContact/ChannelOutboundText (Cyr2Lat branch
and the structured-payload guard preserved); connector state/API (the enabled
maps, is*/set*SmazEnabled, ensureContactSmazSettingLoaded, the warm-up and
channel loaders); the per-channel and per-contact toggles plus their
mutual-exclusion; loadSmazEnabled/saveSmazEnabled and the `*_smaz_` key prefixes
(stores kept, they also hold Cyr2Lat); l10n `channels_smazCompression` and the
orphaned `chat_compressOutgoingMessages` across 18 locales (+ regenerated
app_localizations).
Retained for Phase 2: the 5 Smaz.tryDecodePrefixed decode sites and
helpers/smaz.dart.
Safety (verified): no storage migration, the app already persists plaintext
(receive decodes before store; send stores the pre-compression text), so the
`s:` form was wire-only. ACK matching is unaffected, the expected hash derives
from prepare*'s output, so dropping compression keeps both sides hashing
plaintext.
Behavior change: the composer byte-counter now reflects raw size, so anyone who
had Smaz on can type slightly fewer chars per message.
Tests: gif_url_outbound_guard_test rewritten to pin plaintext passthrough and
decode retention. Full suite green, analyze clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extracts the #262 notify-mode selector out of channels_screen into a shared
widgets/channel_notify_mode.dart (key, icon, label, dialog) and points both
entry points at it, so the Channels list and the in-channel menu cannot drift.
Same PSK-keyed storage; no new settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a KeepScreenAwake controller that holds a wakelock_plus lock while the
setting is on and the app is foregrounded, releasing on background, on toggle
off, and on dispose. Toggle lives in App Settings > Battery (power tradeoff),
defaults OFF, persisted as keep_screen_awake. Strings added for all 18 locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AppShell decided top-level vs detail by `selectedIndex != null`, but pushed
detail screens (a channel chat, the LOS map) also set selectedIndex to keep the
bottom bar visible. So Back from inside a channel backgrounded/left the app
instead of popping to the channel list.
Decouple the two concerns: add `isTopLevel` (default true), separate from
`selectedIndex`. Detail screens pass `isTopLevel: false` (keep the bar, but Back
pops). The decision is extracted into a pure `AppShell.backAction`
(drawer -> close; detail with a route below -> pop; else -> background), with an
assert that a detail is actually poppable. Unit tests cover the matrix; widget
tests exercise the real system-Back -> PopScope -> pop wiring.
Follow-up #390 tracks the separate AppBar back-arrow path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the database can't open (e.g. the native sqlite library fails to load),
every read/write silently failed and the app opened to an empty, normal-looking
screen — the user thinks their history was wiped (SAFELANE §6 violation).
Probe the storage layer in main() before any store reads
(BlobStore.verifyReadWrite). On failure, a StorageHealthService one-way latch
records it, and a persistent, non-dismissable banner is shown above the whole
app: history is not lost, storage is unavailable, messages are NOT being saved,
restart after fixing. Cross-platform (probe goes through drift, covers web too).
Tests: service state/latch + banner show/hide widget test. Gemini-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-contact settings dialog (compression + telemetry grants) was
reachable only from a contact's chat via ellipsis -> Contact settings.
Extract it to a shared showContactSettingsDialog() and add a "Contact
settings" entry as the first item of the contact long-press/right-click
menu, so it opens the same surface from both places.
chat_screen's _showContactSettings now delegates to the shared dialog;
its _buildInfoRow stays (still used by the contact-info dialog).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Back at the root was calling SystemNavigator.pop(). That is not "background
the app": on Android it calls finish() on the activity, which tears down the
Flutter engine and drops the radio connection. Backing out and reopening
therefore landed on a disconnected radio needing a fresh connect. Wrong call
for the job, and worse than the behaviour it replaced.
Backgrounding without finishing needs moveTaskToBack, which has no Flutter
equivalent, so it goes over a method channel:
- MainActivity exposes meshcore_open/app_lifecycle with a moveTaskToBack
method, alongside the existing USB channel.
- AppBackgrounder wraps it and is a no-op off Android, where programmatic
backgrounding either does not apply (desktop windows close by their own
chrome) or is forbidden (iOS). On those platforms back at the root stays
unhandled rather than doing something destructive.
- AppShell awaits it instead of calling SystemNavigator.pop().
The activity stays alive, so the connection survives and reopening returns to
where the user was.
flutter analyze clean, dart format clean, 469 tests pass.
Two bugs, reported together from an S25 FE test.
**Back popped to the scanner.** The previous commit made back pop whenever the
route could be popped. The primary views sit on top of the scanner, so from
the channel list back landed on the radio-connect screen. That is a regression
I introduced: the `PopScope(canPop: !isConnected)` I removed existed to
prevent exactly this, and I removed it without establishing why it was there.
Back now distinguishes the two cases. A primary view (one carrying the bottom
bar) hands back to the OS and backgrounds the app. A detail screen (a channel
chat, which has no selectedIndex and is genuinely pushed) pops to its list.
Reaching the scanner is what Disconnect is for, not what Back is for.
**The scanner was a dead end while connected.** It only routed into the app on
a connection transition, behind a one-shot flag that never reset while
connected. So once showing with a live connection it stayed there, and
pressing Connect did nothing, since there was nothing left to connect. It now
routes in whenever it is the visible route and a connection is live, guarded
against double-push by a dedicated flag rather than reusing
_changedNavigation, which separately gates the disconnect-on-dispose cleanup.
Known limitation, pre-existing and unchanged: this auto-entry is bluetooth
only. USB and TCP connect from their own screens, which navigate themselves.
flutter analyze clean, dart format clean, 469 tests pass.
Owner hardware test on an S25 FE and Windows turned up five issues. Three of
them shared one root cause.
**Map: always show names.** New mapAlwaysShowNames setting, toggled from the
map panel. Names were shown only past zoom 14; the override wins at any zoom.
Evaluated at build rather than only on camera movement, so flipping the switch
repaints immediately instead of waiting for the next pan.
**Panel footer on every screen.** Disconnect and Settings now also appear
inside a channel. The footer is app-level, so it belongs anywhere the panel
is, not just the primary views.
**Back button (three reported symptoms, one cause).** Channels, Contacts and
Map each wrapped themselves in `PopScope(canPop: !isConnected)`, so while
connected the system back press was swallowed whole. That explains all three:
an open drawer would not close, Android would not background the app, and on
Windows the pinned layout has no Scaffold drawer, so the app bar auto-implied
a back arrow whose press PopScope then discarded - a button that visibly did
nothing.
AppShell now owns back handling for every screen that uses it, in order:
close an open drawer, else pop the route (a pushed chat returns to its list),
else hand back to the OS via SystemNavigator.pop() so Android returns to the
home screen or the previous app. The three per-screen PopScope wrappers are
removed. AppShell becomes stateful to hold the scaffold key this needs.
flutter analyze clean, dart format clean, 469 tests pass. Not yet re-tested on
hardware.
Epic D. The Map view has no list, so its nav panel was empty. It now carries
the map's own layer controls.
Those six toggles (chat nodes, repeaters, other nodes, discovery contacts,
guessed locations, overlaps) previously existed only in a modal behind the
floating button, so filtering the map meant covering the map with a dialog to
do it. In the panel, and especially pinned on a wide screen, they stay visible
and the map updates underneath as they are flipped.
Both surfaces read and write the same AppSettings, so a change in one is
reflected in the other. The floating-button dialog is deliberately left in
place: on a phone it is fewer taps than opening the drawer, and the panel is
the pinned-desktop path. If that duplication is unwanted, removing the dialog
is a one-line follow-up.
No new settings or storage; the existing setters were reused unchanged.
flutter analyze clean, dart format clean, 469 tests pass. Not yet run on
hardware.
Epic C, rescoped. The original plan was to eliminate the overflow menu and
move everything into the drawer. An inventory showed that premise was wrong:
the ellipsis is six different menus sharing an icon, and most of what they
hold is screen-level (force flood mode, path management, delete all
discovered). Those cannot live in a global nav panel, which has no notion of
which contact or channel is meant.
Only Disconnect and Settings are app-level, which is exactly why they were
duplicated across three screens. Those move; everything else stays put.
- AppShell gains optional onDisconnect / onSettings, rendered as a pinned
footer in the nav panel. Disconnect keeps the error colour it had as a red
menu entry, since it drops the radio connection.
- Channels, Contacts and Map pass both and drop those two menu entries.
- Map's overflow menu is removed entirely, having nothing left.
- Channels keeps its menu only for Manage Communities, which was already
conditional on having joined a community.
- Contacts keeps its menu for Discovered contacts.
- No detail screen menu is touched.
Pinned on a wide screen, Disconnect and Settings are now visible without
opening anything.
flutter analyze clean, dart format clean, 469 tests pass. Not yet run on
hardware.
The original diagnostic derived hops via realHopCount(), which divides a
value that firmware already defines as a hop count (src/Packet.h:79-84:
getPathByteLen() == getPathHashCount() * getPathHashSize()). It would have
reported hops=1 for a real 2-hop path, misleading the exact investigation
this logging exists to serve.
_pathDiag now reports the hop count directly and prints held-vs-implied
byte counts, flagging TRUNCATED when short. That makes the #309 decode
truncation self-evident in any capture without the device present.
The login dialogs no longer assert a hop count at all: selection.hopCount
is ambiguous by construction (device paths carry hops, user overrides carry
bytes, per #279), so they log the raw field, width and actual byte length,
which discriminates the two cases.
Refs #240, #279, #309
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captures previously logged only a path byte length, making it impossible
to tell one 2-byte hop from two 1-byte hops - the exact question #240 and
#279 turn on. Bandit's 2026-07-18 log hit this wall.
Adds a shared _pathDiag() rendering (len, applied width, derived hops,
hop-grouped bytes) to the existing contact path log sites, and the same
detail to the repeater/room "Login routing" lines.
Also logs the path-hash width transition on every device-info frame, with
the frame length, so a short-frame downgrade to width 1 (#240) is visible
in any capture without needing the device. Warn on change, info otherwise.
Existing log sites only - no new per-frame logging (SAFELANE 11.10).
Display labels (#279) and the calculateTimeout unit bug (#299) are out of
scope here.
From the Gemini pre-PR review (standards#145).
ContactFilterRail computed each row's count with its own `where().length`, so
the contact list was walked once per filter. On a ~350-contact radio that is
~2100 iterations, re-run on every MeshCoreConnector notification, and those
arrive per packet during sync. Now a single pass fills all six counts, and the
per-contact unread lookup happens at most once instead of once per filter.
Also documents why context.select is NOT used in ChannelDrawerList, since the
review recommended it: `channels` is `List.unmodifiable(_channels)`, a fresh
instance per call, and Dart lists have no value equality, so select would
rebuild exactly as often as watch. Selecting on `length` instead would go
stale on a rename or reorder. watch is correct here; the comment records that
so the next reader does not re-litigate it.
Review findings not acted on:
- Claimed BLOCKER (context across an async gap in _blockChannelSender) is a
false positive: `mounted` guards are already present at :614 and :618 and
the messenger is captured before the await. It is also pre-existing code
from #172, not this branch.
- _lastChannelSendAt not cleared on channel switch: real behavior change, but
the suggested fix is questionable. That field is a send cooldown protecting
the radio, so clearing it on switch would let the limit be bypassed by
hopping channels. Raised with the owner rather than changed unilaterally.
flutter analyze clean, dart format clean, 445 tests pass.
The nav panel was empty on the Contacts view. It now carries the prebuilt
filters: All, Favorites, Users, Repeaters, Room Servers, Sensors, with Unread
Only as a toggle beneath them.
- Unread Only stays a toggle rather than a row, so it composes with the type
filter (Users + Unread Only = unread DMs), per the decision on #307.
- typeSupportsUnread() is the single predicate behind that: it currently
reduces to "not repeaters", since the connector refuses to track unread for
repeaters (meshcore_connector.dart:743, :6095) and the pair could only ever
be empty. The toggle is disabled there rather than silently useless.
- Each row shows how many contacts the filter would actually return, with
Unread Only applied when it is on, so the number never disagrees with the
list after tapping. Rendered as muted text rather than UnreadBadge, since it
is a total and not an unread count.
- Selecting a filter closes an unpinned drawer, matching the channel panel.
Selection persists through UiViewStateService, which already stored both the
type filter and the unread flag, so no new storage was needed.
flutter analyze clean, dart format clean, 445 tests pass. Not yet run on
hardware.
Sensors were the one advert type with no filter. advTypeSensor (4) already
existed in the protocol and Contact.typeLabelRaw already returned 'Sensor',
but ContactTypeFilter stopped at rooms, so sensor contacts could not be
isolated in either the contacts list or discovery.
- ContactTypeFilter gains `sensors`. Both exhaustive switches over it were
found by the analyzer rather than by hand: the contacts list predicate and
the search-hint text.
- discovery_screen's predicate has a `default: return false`, so it compiled
without a sensors case but would have silently shown an empty list. Added
explicitly.
- New l10n strings contacts_searchSensors and listFilter_sensors, regenerated
across all 18 locales. The 17 non-English locales fall back to the English
text and are recorded in untranslated.json, matching how existing strings
are handled.
- Persisted round-trip verified: UiViewStateService stores the filter by name
with orElse -> ContactTypeFilter.all, so older persisted values cannot throw
and `sensors` persists like the rest.
Groundwork for the Contacts filter rail (#307), but useful on its own: the
existing filter menu now offers Sensors.
flutter analyze clean, dart format clean, 445 tests pass.
Picking a channel from an unpinned drawer switched the channel but left the
drawer hanging open over it.
The close was attempted from the screen's State context, which sits above the
Scaffold that AppShell builds, so Scaffold.maybeOf never resolved it and
isDrawerOpen was always false. The close now happens in the drawer tile, whose
context is inside the Drawer, and uses closeDrawer() rather than popping a
route. Pinned layouts have no drawer to close and are unaffected.
Removes the two dead close attempts in ChannelChatScreen and ChannelsScreen.
flutter analyze clean, dart format applied. Not yet run on hardware.