Channel messages had no resend path: unlike DMs (auto-retried by
MessageRetryService), a channel send that never gets its generic ack/repeat-back
goes to failed with no recovery. Adds a "Send Again" action to the channel
long-press sheet for outgoing messages that are not yet acked (status != sent,
i.e. pending or failed), re-sending via sendChannelMessage.
Also renames the existing direct-chat "Retry" action to "Send Again" (matching
stock MeshCore's "Send Again" for cross-client familiarity); DM gating unchanged
(offered at failed, after auto-retry is exhausted). New l10n strings
message_sendAgain + chat_sendingAgain. Build of epic #256.
Owner placement. Node Settings order is now node name, location, GPS
enable, Button and buzzer, public key.
Epic: #474, #475
Agent: CalmBay (session d14220d9)
A radio without the caps bits has no button and no buzzer to configure,
so it gets nothing: no tile, no screen, no command.
I had changed the tile to appear whenever a radio was connected and
added a diagnostics pane showing its raw caps byte 2. Nobody asked for
that. I added it on my own initiative because a silently absent screen
looked like a broken client, and it broke the negative test #474
specifies verbatim:
"A device that does not advertise the bit shows no screen and the
client emits no command."
"Settings screen appears ONLY when the device advertises the
capability bit"
That negative test is the reason client code ships ahead of firmware:
cross-compatibility with radios that lack the bits can only be exercised
if the client is out in front. My change would have made it fail on
something I invented rather than on anything real.
The tile is now gated on supportsButtonMatrix || supportsNotifyScope.
The pane keeps a matching guard as belt and braces, not a reachable
path. The no-command-emitted half was already correct and is unchanged.
Full suite 740 pass, analyze clean, format clean.
Epic: #474, #475
Agent: CalmBay (session d14220d9)
Client UI for the button-action matrix (#474) and the device notification
scope (#475), under Settings > Node Settings.
Speaks the canonical 0xC5 contract published by firmware in
OffbandConfigProtocol.h: one command byte, sub-code selects the surface
(0x01/0x02 scope get/set, 0x03/0x04 matrix get/set, 0x7F error). Not
folded into 0xC0, which is observer-only and would make the feature
unreachable on the headless trackers it exists for.
- Notification scope All/Self/None, labelled as this radio's buzzer and
kept distinct from the per-channel app notify mode. Re-read on open and
on every device-info refresh so a scope changed by triple-pressing the
device is never shown stale.
- Button actions per press sequence, assignable only from the action set
the DEVICE reports via its supported-actions mask, so a board with no
buzzer or no GPS never offers a choice it would refuse. Single press
defaults to unassigned.
- Long press is deliberately not assignable. Firmware owns it for CLI
rescue and power off, and remapping it could leave a screenless board
unrecoverable.
- Failures show the device's own reason via the firmware-owned reason
codes, not a generic error, and the banner persists until dismissed.
An unrecognised reason is surfaced with its raw code rather than
swallowed.
- State only ever follows the device's reply, never the request, so the
UI can never show an assignment the radio rejected and nothing is
faked or stored unacknowledged.
- A radio advertising neither capability bit gets a diagnosis, its raw
caps byte 2 and an explicit statement that no command will be sent,
rather than a blank screen that is indistinguishable from a bug.
caps2 bit assignment is firmware-confirmed: 0x01 notification scope, set
only where PIN_BUZZER is defined, 0x02 button matrix. That is the reverse
of the order the epics were filed in, so it cannot be inferred from issue
numbers.
21 protocol tests: gating, frame encoding, a lying count byte, unknown
sequence/action/scope codes, truncated and foreign frames, reason-code
mapping, and that no sequence models a long press.
NOT YET EXERCISED AGAINST A DEVICE. The firmware 0xC5 handler is written
but unmerged, so until it answers, a read shows its loading row and a
write is not confirmed. Hardware validation of the pair is the owner's
gate and has not happened.
Full suite 740 pass, analyze clean, format clean.
Epic: #474, #475
Agent: CalmBay (session d14220d9)
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)
The byte was only observable in the app debug log, which makes
validating byte-2 firmware a log hunt instead of a glance.
Renders as "caps2 absent" or "caps2 0xNN" on the existing Offband caps
row, added by #304 for exactly this purpose. "absent" is shown rather
than the row being omitted, because telling "the radio sent byte 2"
apart from "the radio is too old to send it" is the whole point, and
all-bits-zero is a valid present value.
Unblocks hardware validation of firmware PR #515 (#481).
Epic: #474
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 pass 2 flagged that the banner shrinks the viewport while the "no
channels" / "no results" empty-states used a fixed MediaQuery.height - N
SizedBox, pushing the message below the fold. Replace both with
LayoutBuilder + SingleChildScrollView + ConstrainedBox(minHeight:
constraints.maxHeight) so they center in the actual available space (banner
or not) and stay pull-to-refresh scrollable. Removes the fragile -200/-300
magic numbers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface the warning on the connection/device-selection screen so the user
can fix battery settings before connecting, not only after landing on the
channels screen. Reuses BatteryOptimizationBanner (Android-only, self-checking).
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>
_confirmDanger awaited showDialog then called _apply unconditionally; if the
screen was disposed while the dialog was open, _apply's setState would throw.
Guard with && mounted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ConfigProfileImportScreen: browse the curated catalog (default OffbandMesh
profiles.json) or enter any source URL (tail-detection routes catalog vs
single profile), then open the #406 preview/apply screen with the fetched
profile. Surfaces skipped-entry counts and fetch/parse errors inline.
Observer settings gains an 'Import config profile' entry that opens it with the
live ObserverConfigService. Completes the observer chain (#404-407); ready for
the #408 hardware test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- config_profile_diff.dart: pure current->new diff builder (add/change,
drops no-ops, flags danger + secret rows). 4 tests.
- splitProfileWrites: partitions writes into safe vs credential/identity for
the two-tier apply; a mixed broker splits, enabled rides the safe half only.
- ConfigProfilePreviewScreen: full sub-screen — reads current state, renders
the diff (amber overwrites, red danger section), normal Apply for plain
config + a separate red gate (with confirm dialog listing exactly which
credential/identity values change) for the danger set. Secrets masked.
Re-diffs after apply for partial-save recovery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a DM opens the Path screen; it now shows the RF details the app
was already receiving but discarding:
- SNR: captured from the v3 contact-msg-recv frame (was skipBytes(1)).
Firmware sends (int8)(snr_dB*4), so dB = byte/4.0 (MyMesh.cpp:512) —
the old commented-out code multiplied by 4, which was 16x wrong.
- Path type: firmware sends path_len 0xFF for a direct/routed frame and
the hop count for a flood (MyMesh.cpp:545). The app collapsed 0xFF->0,
colliding "direct" with "flood, 0 hops". Capture the distinction into
Message.isFloodRoute so the Path row reads "direct (routed)" vs
"flood, N hops".
- RSSI: row wired to Message.rssi but left null — the wire byte is a
hardcoded reserved 0 today; populated once firmware ships it (#439).
Message gains snr/rssi/isFloodRoute (nullable, additive, persisted like
rxTime). 10 tests cover scaling, the 0xFF discriminator, and copyWith.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A max-length DM built a 170-byte frame but this BLE link only writes
ATT_MTU-3 (=169) bytes, so writeCharacteristic threw. The DM path swallowed
the throw and never resolved the message, leaving it "active" forever and
silently blocking every later DM to that contact until force-stop+reconnect.
- Size: maxContact/ChannelMessageBytes take an MTU-aware frame budget
(BLE = mtuNow-3, USB/TCP = maxFrameSize); composers pass
connector.effectiveMaxFrameSize. The channel cap now also subtracts the
"Name: " prefix so a small-MTU link can't overflow.
- DM wedge: _sendMessageDirect propagates the failure and the retry callback
is awaited, so a failed send marks the message failed and drains the
per-contact queue. Added a RESP_CODE_SENT safety timeout.
- Channel wedge: sendChannelMessage clears the stuck queue id and marks the
message failed on a failed write.
- Tests: MTU-aware caps + failed-send-does-not-wedge regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From Ben's testing:
- The buffer readout went stale while idle (STATUS poll only ran during
capture). Split the poll from the elapsed tick: the STATUS poll now runs
continuously while the screen is on a connected caplog device (stops only on
disconnect/dispose); the 1s elapsed tick stays capture-only.
- An empty download now shows "Buffer is empty - nothing to download" instead
of saving a 0-byte file.
analyze clean; full suite 643 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frame decoder capped companion frames at 172, but the firmware's real
MAX_FRAME_SIZE is 176 (BaseSerialInterface.h, "+4 for transport codes").
Full-size caplog CHUNK frames (176 B) were silently rejected; only the final
sub-172 partial chunk survived, so a download reassembled just the last chunk
(e.g. "received 95 of 5141"). Caplog is the first feature to use full frames,
so nothing exposed this before.
- usb_serial_frame_codec.dart: usbSerialMaxPayloadLength 172 -> 176.
- serial_capture_screen.dart: erase-on-start (Start / Start&Reboot) for a clean
session ("didn't start at 0").
- 3 decoder regression tests; 113 tests total green; analyze clean.
Root cause confirmed with firmware (TopazHill): firmware streams the full
buffer correctly (wire trace 5175/5175); the client decoder dropped oversized
chunks. My earlier firmware-ring hypothesis was wrong.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the reboot-test failure (support latched "unsupported" after the
reconnect window) and reworks the boot-log UX per Ben.
- meshcore_protocol.dart: offbandCapCaplog (0x20) + firmwareSupportsOffbandCaplog
(0x20 bit AND FIRMWARE_VER_CODE >= 17), mirroring the block-cap pattern.
- meshcore_connector.dart: supportsOffbandCaplog getter.
- serial_capture_screen.dart: gate support on the static cap bit (reactive via
the connector), not a one-shot STATUS probe, so it never latches "unsupported"
after a reboot. Derive capturing state from device STATUS so an auto-resumed
capture (post firmware #428) shows STOP not START. Cancel timers on disconnect,
re-query STATUS on reconnect. New red "Start & Reboot" (no timer) boot-log flow.
- 4 cap-gate unit tests; 18 caplog tests total green; analyze clean.
Root cause confirmed with firmware (TopazHill): caplog cap bit (0x20) is
advertised statically across reboots; the client's probe raced the reconnect
window and latched. Firmware #428 (persist flag + boot capture) is the other half.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Confirmation-gated Reboot action on the serial-capture screen so an operator
can enable capture, reboot the radio, and record the boot log for retrieval
(the #428 boot-log flow). Available while capturing; uses the existing
connector.rebootDevice(). Full boot-log capture needs firmware retained-enable
(#428); the client affordance lands now.
flutter analyze clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2b UI. SerialCaptureScreen drives the 0xC4 caplog control + download
from the prior commits: probe support (STATUS) on open, start/stop capture
(default 5-min window or until stopped) with live elapsed + auto-stop +
buffer fill, download to a file handed to LogExport.shareFile, and erase.
Reached from Settings > Debug.
English-only strings for now (localization follow-up, mirrors LogExport
#427); capture duration is screen-local (persisting it is a small follow-up).
flutter analyze clean on all touched files; 14 connector unit tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The share helper only invoked the OS share sheet on mobile; on all desktop it
opened the containing folder, which for a temp-dir capture dumped the user into
TEMP amid unrelated files. Now:
- Android/iOS: OS share sheet (unchanged).
- Windows/macOS/Linux: native Save As dialog (file_selector) writing the file
to a user-chosen location.
- Web: browser download of the log text (no on-disk file on web).
Adds file_selector; web download via a js_interop helper behind a conditional
import. Folds in #427 (localized share strings). LogExport.shareFile keeps a
compatible signature for the serial-capture screen (#430).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route LogExport's Share tooltip, share subject, and file-logging-unavailable
snackbar through context.l10n instead of hardcoded English. Adds four keys to
app_en.arb (debugLog_shareLog, debugLog_openLogsFolder, debugLog_shareSubject,
debugLog_fileLoggingUnavailable) and regenerates all locales (English fallback
until translated). Resolves the deferred Gemini finding from #393.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the BLE screen's on-disk log export into lib/utils/log_export.dart and
reuse it on the App-log screen, so both log screens have one consistent Share
action: OS share sheet on mobile, open logs folder on desktop. The shared file
already holds the app log and BLE frames combined.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No in-app signal of which binary is running has caused repeated confusion
(a debug-signed APK silently not installing over the release app, old layout
persisting with nothing to indicate the new build never landed).
BuildInfo reads GIT_SHA/GIT_BRANCH/BUILD_TIME from --dart-define with dev
fallbacks, so every build carries its own identity independent of the pubspec
version. Surfaced as its own copyable Build row in Device Info.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stores are keyed by the first 10 hex of the connected radio's public key, so
connecting a different radio silently swaps which contacts, channels, and
history you are viewing with nothing in the UI saying so. Device Info showed
the full public key but never tied it to storage.
Adds a Data scope row with the key actually in effect, plus a one-line
explanation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Groups the list by kind: configuration (Node, Radio, Radio Stats, Privacy,
Contacts, Blocked, Messages, Observer, App Settings), then readout (Device
Info), then rarely-used Actions, then Debug.
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>
The App Settings pane carried a Debug card holding only the app-debug-logging
switch, while the Debug category already owned the log viewers. Moved the
switch next to the App debug log viewer it controls and dropped the now-empty
Debug card from App Settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The button used Icons.ios_share for all mobile, so Android showed the iOS
share glyph. Split by platform: Android -> Icons.share, iOS -> Icons.ios_share,
desktop -> Icons.folder_open (reveal the saved logs).
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>
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>
The scanner's auto-entry was gated on activeTransport == bluetooth, so the
dead end fixed in the previous commit still applied to USB and TCP: a user
landing on the scanner with a live serial or network connection stayed stuck,
and pressing Connect did nothing, since there was nothing left to connect.
Both are real deployment paths, so the gate is removed and the check is now
transport-agnostic.
The first-connect path for USB and TCP is unaffected. Those screens are pushed
on top of the scanner and navigate themselves with pushReplacement; while they
are connecting they are the current route, so the scanner's isCurrentRoute
guard is false and it does not also push. That guard, not the transport check,
is what prevents a double navigation.
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.
Pasted GIF links rendered as plain text because parseGif only matched the
compact forms. Adds GifHelper.resolveGifUrl, which maps any supported payload
to the URL that renders it, and widens coverage to the links people actually
paste.
Allowlist is deliberately narrow, Giphy and Tenor only. Off-list URLs return
null and stay plain tap-to-open links, never auto-fetched: auto-loading a
stranger's URL leaks the viewer's IP and enables tracking-pixel abuse, and
inline-rendering unmoderated hosts is a malware and inappropriate-content
vector. Tests pin the rejections, including a lookalike host
(giphy.com.evil.example) and a general image host (imgur).
Newly rendered:
- i.giphy.com/<id>.gif and .webp, the form a browser copy produces
- media.tenor.com and c.tenor.com direct assets
A tenor.com/view/<slug>-<id> PAGE url is deliberately NOT matched. The modern
Tenor CDN path uses an opaque hash that cannot be derived from the page id, so
resolving one requires the Tenor API and a key. Such a link stays plain rather
than silently rendering the wrong image. Supporting it is a separate decision.
Also removes the hardcoded 'https://media.giphy.com/media/$gifId/giphy.gif'
literal duplicated across all four render call sites. That duplication is the
same drift that caused the #284 notification regression, where a second
hand-rolled copy of GIF detection went stale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A capability-gated control that doesn't appear is indistinguishable from
a broken one. Diagnosing "the FEM LNA toggle is missing" on real hardware
required a rebuild, because the caps byte was never surfaced and the app
debug log records nothing in a release build unless logging is enabled.
Adds an "Offband capabilities" row to Device Info showing the raw caps
byte, firmware version code, and which gated features it grants, e.g.
"0x02 (v16) - block". That turned an unexplained missing toggle into a
one-look answer: firmware v16 running, FEM LNA bit clear, client correct.
Also logs the same values at device-info parse for anyone who does have
logging on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the client half against the firmware as-built (#298):
- Device-info offset 83 carries the FEM LNA state on v16+, appended
unconditionally (reads 0 on non-capable boards). parseFemLnaState is
length-guarded and returns null pre-v16. Byte presence signals firmware
version, not capability — the cap BIT gates the UI.
- 0xC3 reply handler adopts the reported value verbatim: firmware returns
post-apply hardware state, not an echo, so a refused write surfaces as
truth rather than a lie.
- setFemLna / requestFemLnaState no-op unless the capability bit is set,
so a non-capable radio never sees 0xC3 traffic.
- Radio Settings toggle rendered only when the bit is set, driven by
connector state via ListenableBuilder rather than local optimistic
state, so it always shows what the radio reports.
- errCodeUnsupportedCmd documented: a mis-gated request draws [0x01][0x01],
not [0x01][0x06]. Neither is 0xC3-prefixed, so no new error path.
Gating is on the bit alone, never model or version: firmware derives it
from a runtime FEM probe, so two Heltec V4s can legitimately disagree and
rak3401 reports false by design (its SKY66122 gates LNA and PA together).
13 tests. Not hardware-validated — firmware 0xC3 is still on a branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The firmware path-len byte is packed and self-describing (src/Packet.h:79-84):
high 2 bits = hash size - 1, low 6 bits = hash COUNT (hops), byte length =
count * size. The app treated the low 6 bits as a byte count and ignored the
width, on the strength of a comment asserting the high bits were "not reliably
populated". Both halves of that comment were wrong.
Receive: Contact.fromFrame read `count` bytes where firmware meant `count`
hops, so at 2-byte width it kept HALF of every path and discarded the rest.
DAYTON VA's 2-hop route became a 2-byte fragment, which is why every repeater
login in Bandit's capture timed out.
Send: buildUpdateContactPathFrame wrote the count raw, leaving mode bits 00.
That tells the radio "1-byte hashes" while handing it 2-byte hash data, so it
routed to nodes never on the route. This is the likelier cause of the observed
"routes via c6 and 5c separately" behaviour.
Also fixes a third unit inconsistency: setContactPath wrote customPath.length
(bytes) into pathLength (hops) after every path set.
Contact now carries pathHashWidth per path, so a stored path can never be
re-sliced at a width it was not captured at. realHopCount() is removed rather
than adjusted: it divided an already-correct hop count, halving it at 2-byte
width, and every call site was compensating for a bug that no longer exists.
Migration (Ben's call, option A): records written before this change have no
pathHashWidth and hold truncated bytes that cannot be recovered by
reinterpretation, so their path is dropped and the contact reverts to flood
until the radio re-supplies it. Logged, not silent.
Tests asserted the old semantics (expect(realHopCount(2, 2), 1)) and were
rewritten against firmware truth rather than adjusted to keep passing. Added
send-side encoding coverage, which did not exist. 456 tests pass.
Refs #240, #279, #299
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
An open channel had no exit on desktop. Two changes combined badly: the
hamburger replaced the app bar's back arrow, and the chat screen carried no
bottom bar. On phone system back still worked, but Windows has no system back
button, so the channel was a dead end.
- ChannelChatScreen now passes selectedIndex/onDestinationSelected to AppShell,
so the bottom bar is present here as well. This also matches the decision
that the bottom bar appears at every width.
- Tapping Channels pops back to the channel list. Tapping Contacts or Map pops
the chat first, then replaces the list, so the chat is not stranded beneath
the destination.
flutter analyze clean, dart format applied. Not yet run on hardware.
Selecting a channel from the nav panel pushed a replacement route, so the
whole screen was rebuilt: a pinned panel was torn down and re-created on every
switch. Now the conversation swaps in place and the panel stays docked, which
is the point of pinning it.
- The open channel moves from widget.channel to a _currentChannel state field.
- Per-channel binding (mark active, unread divider, jump-to-oldest-unread) is
extracted from initState into _activateChannel, reused on every switch.
- Per-channel view state (reply target, message keys, unread divider, composer
text) is cleared on switch so it cannot leak between channels.
- The back stack is untouched, so system back still returns to the channel
list rather than walking back through visited channels.
Same code path on phone and wide: switching is instant either way, with no
route animation.
flutter analyze clean, dart format applied. Not yet run on hardware.