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.
Owner added GIPHY_API_KEY as a repo secret, resolving the open decision
flagged on this issue.
The app reads the key at COMPILE time via
String.fromEnvironment('GIPHY_API_KEY') (lib/widgets/gif_picker.dart:25),
so CI builds produced without it ship a non-functional GIF picker that
displays a "needs a Giphy API key" hint. That was harmless while CI
output was discarded; it stopped being harmless in #322 when artifacts
became real deliverables.
Generates dart_defines.json from the secret and passes
--dart-define-from-file, matching how the project is built locally
rather than inventing a second mechanism.
Wired into the four jobs that produce artifacts users run: android,
linux, web, windows. NOT wired into ios and macos, which are compile
checks producing nothing installable, and where the key would have no
effect on whether compilation succeeds.
Uses jq on the Linux runners rather than hand-escaped printf, so the
value is JSON-escaped correctly whatever it contains. An earlier printf
attempt silently mangled an escape and broke the YAML, which is the
argument for using the tool instead of string juggling. jq is verified
preinstalled on the runner images (Ubuntu 22.04 has 1.6, 24.04 has
1.7.1). The windows runner uses PowerShell ConvertTo-Json, since jq is
not guaranteed there.
Note: the key is compiled into the binary and is therefore extractable
from any published APK. Putting it in CI does not widen exposure beyond
what shipping the app already does.
Part of epic #312, plan #321 (Phase 3).
Three different Flutter versions were in play and none was the bench:
build.yml channel: stable (floating, resolving to 3.44.6)
flutter_dart.yml channel: stable (floating)
deploy.yml pinned 3.41.2, with a comment claiming it matched
local development, which had not been true for months
bench 3.44.1 / Dart 3.12.1
A floating toolchain is tolerable for a compile check and not tolerable
once CI produces signed release artifacts (#330), so this lands first.
Pinned all 8 flutter-action usages to 3.44.1, matching the bench exactly,
so "works locally" and "works in CI" mean the same thing. Verified against
Flutter's release index that 3.44.1 is a published stable release
(2026-06-01, Dart 3.12.1) rather than assuming a locally-installed
version was publicly fetchable.
Corrected deploy.yml's misleading local-parity comment rather than
leaving it to mislead the next reader.
Note: CI's green history is on 3.44.6 or later, so pinning DOWN may
surface a break the newer toolchain masked. That is the intent. If it
breaks, fix the break or move the bench up deliberately; do not re-float
the pin.
GIPHY_API_KEY as a CI secret was the third item of Phase 3 in #321. It
is deliberately NOT included here, is not dropped, and is flagged on
#331 for the owner's decision, since it is a credential only they can
create.
Part of epic #312, plan #321 (Phase 3).
formatNotificationText used GifHelper.parseGif (Giphy only) while the chat
renders via resolveGifUrl (Giphy + allowlisted Tenor CDN). An allowlisted
Tenor GIF therefore rendered inline but still dumped a raw URL into the
notification tray.
Point both at resolveGifUrl so the tray summarises exactly the set the chat
displays. Off-allowlist URLs still pass through as plain text, pinned by test.
This is the same duplicate-detection drift that caused #284; closing it at the
source rather than shipping a known inconsistency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Notifications summarised a GIF as "Sent a GIF" using their own hand-rolled
`^g:[A-Za-z0-9_-]+$` regex rather than GifHelper. #282 changed the payload to
`https://giphy.com/gifs/<id>`, which that regex does not match, so GIF
notifications regressed to dumping the raw URL into the tray.
Detection now goes through `GifHelper.parseGif`, matching how the adjacent
reaction case already delegates to ReactionHelper. That restores the summary
and covers every form the app can send, including replies (`@[Name] ` +
payload), which the old regex never matched even for legacy `g:<id>`.
Verified no other hand-rolled `^g:`/`^m:`/`^s:`/`^r:` payload regexes remain
outside their owning helpers, so this class of drift is closed.
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>
Adds the client half of the Heltec V4 FEM LNA control surface at the
protocol level: 0xC3 CMD_OFFBAND_FEM_LNA (0x01 SET [value], 0x02 GET),
a 3-byte [0xC3][sub][value] reply parser, and the
OFFBAND_CAP_FEM_LNA = 0x04 capability gate.
Deliberately a fork-private command rather than a sixth byte on the
stock CMD_SET_OTHER_PARAMS (38): that frame is shared with upstream
MeshCore and is sent to every radio regardless of fork, so widening it
would perturb stock firmware. Nothing is emitted unless the capability
bit is set.
The gate checks the bit only, never model or version — firmware derives
it at runtime from the auto-detected FEM chip, so it is a per-unit
answer and two Heltec V4s can legitimately disagree.
PROVISIONAL: firmware owns the caps byte and has not yet confirmed 0x04
is free, and the 0xC3 setter is not built yet. Spec sent to
OffbandMesh/meshcore-firmware#298. Do not merge before firmware
confirms the shape as built.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini adversarial review finding (minor, accepted). _pathDiag derived the
expected byte count from the connector's global _pathHashByteWidth, which is
the CONNECTED device's width, while each Contact now carries the width its
path was actually captured at.
Failure case: a contact discovered on a 1-byte net (3 hops, 3 bytes) viewed
while connected to a 2-byte device computes expected = 3 * 2 = 6 and logs
"bytes=3/6 TRUNCATED" for a complete, correct path. A false TRUNCATED in the
very diagnostic added to investigate truncation would actively mislead.
_pathDiag now takes the per-path width; all seven call sites pass
contact.pathHashWidth.
Refs #298, #309
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 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>
Style rule, comment only. No behaviour change; the handed-over test builds
were produced from 060a808 and differ from this commit by one character in
a doc comment.
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.
Gemini adversarial review caught a real traceability defect.
On a `pull_request` event, `github.sha` evaluates to the last merge
commit of `refs/pull/N/merge`, not the PR head commit. That merge
commit is ephemeral and is not present in branch history, so a PR
artifact named with it cannot be traced back to any real commit,
which defeats the acceptance criterion this change was written to
satisfy.
Verified against GitHub's events-that-trigger-workflows reference:
"Note that GITHUB_SHA for this event is the last merge commit of the
pull request merge branch."
Now uses `github.event.pull_request.head.sha || github.sha`, which
resolves to the PR head on pull_request events and falls back to the
push SHA on pushes to dev/main.
Part of epic #312, plan #321 (Phase 1).
Two diagnostics faults from #313, fixed together:
1. Dead push trigger. build.yml and flutter_dart.yml declared
`push: branches: [main]`, but no `main` branch exists in this
repo (default is `dev`), so the push half of every trigger had
never fired. Nothing built on merge. Added `dev` to both; `main`
is created separately after this merges.
2. No artifacts. All six platform jobs stopped at `flutter build`,
so every binary CI produced was discarded with the runner. Added
actions/upload-artifact to the four jobs that produce something
installable or servable:
android build/app/outputs/flutter-apk/app-release.apk
windows build/windows/x64/runner/Release/
linux build/linux/x64/release/bundle/
web build/web/
ios and macos are compile checks only and upload nothing: ios is
built --no-codesign so it is not installable, and macos is kept
as a compile check per the owner's decision on #321.
Artifacts carry run number + commit SHA so a build traces back to a
commit, and use if-no-files-found: error so a wrong path fails loudly
rather than silently publishing an empty artifact.
Android artifacts are debug-signed for now; real signing is Phase 5.
The web artifact is the plain `flutter build web` output, not the
build_pipe versioned build used for deploys; Phase 4 addresses that.
Part of epic #312, plan #321 (Phase 1, owner-approved).
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.
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.
The channel chat screen had a plain Drawer bolted onto it, so the panel could
only be pinned open on the four primary views. Pinning it while reading a
channel is the wide-screen case that was actually asked for: keeping the
channel list and its unread counts visible without pulling it out.
- AppShell now serves pushed detail screens as well: selectedIndex and
onDestinationSelected are optional, and the bottom bar is omitted when they
are absent (a pushed chat screen has no bottom bar).
- New appBarBuilder(context, pinned) lets a screen vary its app bar with the
dock state. ChannelChatScreen uses it to drop its hamburger when the panel
is already pinned open, since there is nothing left to summon.
- ChannelChatScreen builds on AppShell instead of a bare Scaffold, so it gets
the same transient/pinned layout as everything else.
The four primary views are untouched; the new parameters are additive.
flutter analyze clean, dart format applied. Not yet run on hardware.
Epic B of the navigation redesign (#102). Fills the drawer Epic A left empty
and puts it where the reported pain actually is: inside an open channel.
Previously, checking another channel meant backing out to the list, scanning
it, and navigating back in, with no way to see per-channel unread counts while
reading a channel. Now the hamburger opens a channel list showing every
channel's unread count, and tapping one switches straight to it.
- New lib/widgets/channel_drawer_list.dart. Per-tile unread via
context.select on getUnreadCountForChannelIndex, so a new message rebuilds
only that tile. Reuses UnreadBadge. Highlights the current channel.
- ChannelChatScreen gains the drawer plus an explicit leading hamburger: it is
a pushed route, so Scaffold would render a back arrow in that slot instead.
Switching uses pushReplacement, keeping the back stack one deep so system
back still returns to the channel list (owner decision, #84).
- ChannelsScreen shares one _openChannel path between its tiles and the
drawer, and closes the drawer before pushing.
flutter analyze clean, dart format applied. Not yet run on hardware.
Epic A of the navigation redesign (#102). Adds AppShell, which owns the
bottom QuickSwitchBar that Channels, Contacts, Map and Line-of-Sight each
mounted separately, plus a left nav drawer: a transient slide-out on narrow
layouts and a dockable panel that can be pinned open at >= 720px.
- New lib/widgets/app_shell.dart. Reuses the 720px breakpoint established by
settings_shell.dart. Pinned wide lays the panel out beside the body (Row),
unpinned uses a standard Scaffold drawer so the hamburger is injected
automatically; automaticallyImplyLeading:false was removed from the four
app bars to allow that.
- Pin state persists via UiViewStateService.navDrawerPinned. The load is
placed before the channel-sort block in initialize(), which returns early
and would otherwise skip it.
- drawerContent is an empty slot here; the channel list fills it in Epic B
(#289).
Per owner decisions on #102 (2026-07-16): the bottom bar stays a bottom bar
at every width (no left rail), and the drawer carries no view switcher, since
the bottom bar owns view switching.
flutter analyze clean, dart format applied. Not yet run on hardware.
The picker emitted `g:<id>`, a convention that exists only in this Flutter
client lineage's parseGif (inherited from upstream, not a MeshCore protocol
feature). A recipient on stock or any non-lineage client has no way to expand
it, so a GIF arrived as opaque text (#223).
encodeGif now emits `https://giphy.com/gifs/<id>`, a form parseGif already
matches, so:
- stock clients receive a tappable URL they can actually open
- Offband keeps rendering inline with no render-side change
- legacy `g:<id>` payloads still decode, back-compat unchanged
Cost is ~41B vs ~20B against a ~160B payload. A full message carrying sender
prefix + reply mention + URL measures ~79B, roughly half the cap.
Checked, and did NOT change, the outbound structured-payload guard in
prepareContactOutboundText/prepareChannelOutboundText: Smaz.encodeIfSmaller
declines to compress a URL (base64 overhead exceeds the dictionary gain) and
Cyr2Lat passes unmapped ASCII through, so a GIF URL survives intact without
widening the guard. Both pinned as regression tests instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Blocking yourself silently hid your own traffic with no visible cause,
and because blocks are offloaded to the radio it was pushed there and
could be pulled back by the connect-time union.
BlockService gains a selfKeyHex, injected by the connector when the self
key is learned and cleared on disconnect. Every add path refuses it
(block, importKeys, maybePromote) — importKeys specifically, so a
self-block already on the radio cannot be re-imported. setSelfKey also
heals an existing self-block and pushes REMOVE, covering the case where
the union pull lands before self-info arrives. Contacts and discovery
sheets hide the Block option for your own node.
Per Gemini review: heal on an unchanged key too (an early return would
strand a self-block introduced after the key was known), and order
setSelfKey so all in-memory state settles synchronously, keeping callers
consistent even though the connector fires it unawaited.
Adds test/services/block_service_test.dart — the first coverage for
BlockService (10 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every received DM/channel message now logs arrival wall-clock vs the
sender-claimed payload timestamp (delta + TIME ANOMALY marker beyond
24h), routed directly to AppDebugLogService so the rotating on-disk
trail is unconditional. Models persist a nullable rxTime set at every
live ingest construction site; legacy stored records stay null.
Diagnostic child of #280.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cut v1.1.2-rc.3 (rc.2+59 -> rc.3+60). versionCode 60 clears 59, the build
currently live in closed testing, keeping the Play line strictly increasing.
Carries 21 commits since v1.1.2-rc.2: per-channel notify levels (epic #259,
hardware-tested), firmware block offload (#176-#180), block gating (#252),
USB VID gate (#248), reply/mention/GIF UX (#235/#238), path-len decode
(#224), and flutter_local_notifications 20 -> 22 (#242).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The channel long-press sheet's binary Mute/Unmute tile becomes a
Notifications tile showing the current level, opening a picker with All
messages / Mentions only / Off.
The mode is read and written against the channel's PSK identity via
AppSettings.channelNotifyKey, so it survives a rename and does not follow a
reused slot (#259). Plain ListTiles with a check mark are used rather than
RadioListTile to stay clear of the Radio groupValue deprecation.
Adds channels_notifications / channels_notifyAll / channels_notifyMentionsOnly
/ channels_notifyOff to app_en.arb and regenerates l10n; the other 17 locales
fall back to English and are recorded in untranslated.json.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_maybeNotifyChannelMessage now switches on ChannelNotifyMode instead of the
binary isChannelMuted check. mentionsOnly notifies only when the message
carries a canonical @[selfName] mention.
The mode is keyed by the channel's PSK identity, reusing the same
_findChannelByIndex(index)?.pskHex resolver the message store already uses
for history (#194), so the setting survives a rename and does not follow a
reused slot.
The mention is matched against resolvedText (post-translation) so a
translated mention still fires, and case-insensitively since a hand-typed
mention need not match the advert casing. Bare @Name is deliberately not
matched: it false-positives on ordinary text and cannot be delimited for
names containing spaces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds ChannelNotifyMode { all, mentionsOnly, off } and a channelNotifyModes
map keyed by PSK identity via AppSettings.channelNotifyKey, mirroring the
psk_ marker scheme ChannelMessageStore uses for history (#194). Keying by
PSK rather than display name means the setting survives a channel rename
and does not bleed across a slot reassignment (#193).
Legacy muted_channels stays readable and keeps being written:
AppSettingsService.channelNotifyMode falls back to it by name so a channel
muted before this feature still reports off, and setChannelNotifyMode keeps
it in sync so rolling back to a build without notify modes still honours an
Off channel. Name cannot be resolved to a PSK at the data layer, so the
migration is a read-through fallback rather than an upfront pass.
The notification gate and Channels UI still use the legacy isChannelMuted
path; #261 and #262 switch them over.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guard _sendMessageDirect too (automatic retries route there without
passing sendMessage, so a pre-block message kept retrying post-block),
log both connector drops instead of returning silently, and surface a
blocked-contact snackbar on the manual retry and reaction paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review flagged that the composer swap is UX-only: retry-tap
and reaction sends call connector.sendMessage directly, bypassing the
hidden composer. Add a block guard at sendMessage (covers normal DMs,
reactions, and retries), mirroring the incoming DM-drop so a block is
symmetric. The composer notice bar remains the visible affordance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the DM composer with an "Unblock to message" notice bar when
the contact is blocked, so a block also stops outgoing DMs (previously
only incoming DMs/adverts were suppressed). Inline unblock restores the
composer. Adds block_composerNotice l10n string and documents the
outgoing-DM rule in block-contract-as-built.md §4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Offband rebrand renamed the Windows executable meshcore_open.exe ->
offband_meshcore.exe (verified: the #244 build produced offband_meshcore.exe).
Only the Windows distribution doc was stale; the Android package/namespace,
MethodChannel names, and macOS/iOS bundle names are deliberately still
meshcore_open (those renames are deferred per the rebrand plan).
The DTR low→high pulse in the desktop USB open path resets ESP32 USB-Serial/JTAG
chips (VID 303A: C6/H2/S3-HWCDC) into ROM download mode mid-handshake, wedging
the device until a physical reset. The pulse exists for nRF52 (VID 239A)
reconnect-after-unclean-disconnect, so it can't just be removed.
connect() now recovers the port's USB VID from the flserial enumeration
(hardware_id) and gates the pulse: 239A keeps the DTR low→high pulse; 303A and
any unknown/unparseable VID open with DTR asserted and no pulse. VID lookup is
enumeration-only (never opens the port). New usb_vid helper parses both flserial
hardware_id formats (Windows VID_XXXX and macOS/Linux VID:PID=xxxx:xxxx), unit
tested. Build of epic #245.
CI never compiled Windows, the primary test bench (RAK4631 / COM12 /
USB serial). Add a windows job mirroring the existing platform jobs:
runs-on windows-latest, flutter-action stable, pub get, flutter build
windows --release --no-pub.
Advisory to start (not in branch-protection required checks); flip to
required after 5 green runs (#226). The first run validates the
windows-latest env against the local bench (C++ ATL, Developer Mode,
llamadart/vulkan, flserial); fixes applied per the #226 plan.
Part of #226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolves flutter_local_notifications_windows to 3.1.1, which fixes the
VS 18.6 / MSVC 14.51 experimental-coroutine build error (upstream #2777 /
#2785) that breaks the Windows build. No SDK bump needed (Flutter 3.44 /
Dart 3.12 satisfy the new plugin's 3.38.1 / 3.10.0 mins).
analyze clean (the 2-major bump did not change our notification API usage);
full suite 406 green.
Part of #230 (unblocks the windows CI job, PR #228).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings showed the self public key truncated to 16 hex chars + "..." as
plain Text (settings_screen:262/:410), so it couldn't be shared. Show the
full key, make it selectable, and add a copy button beside it.
- _buildInfoRow gains an optional copyValue: renders the value as
SelectableText + a copy IconButton that copies the full key and shows a
"Public key copied" snackbar. Both self-pubkey rows pass the full key.
- New l10n string settings_publicKeyCopied (regenerated localizations +
untranslated.json).
Part of #234.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The autocomplete only completed an unambiguous emoji on Enter; ambiguous
queries (e.g. `:shrug` with multiple matches) sent the literal text instead of
the highlighted glyph, and mentions only selected via Tab. Enter now accepts
the highlighted item (emoji or mention) like Tab, then sends; Tab still selects
without sending, Esc dismisses to send literal text. Removes the now-dead
_emojiQuery field. (#231, emoji + mentions per Ben)
Widget tests: Enter inserts the highlighted emoji (ambiguous `:shr`) and the
highlighted mention (`@Bo` -> @[Bob]).
Part of #231.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reply prepends `@[Name] `, so a reply-gif renders the gif (prior commit);
this adds the `@Name` reply chip above it so it is clear the gif is a reply.
Extracts TranslatedMessageContent.mentionChip + leadingReplyName (shared by
inline rendering and the reply-gif header); the channel bubble builder renders
the chip above the GifMessage. DMs have no reply feature, so channel-only.
Part of #232.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two shared-root render gaps (message tokens were only matched at fixed
positions):
- TranslatedMessageContent: parse @[Name] anywhere (leading, inline, or
repeated) and render each as a chip; messages without a mention keep full
link support (#233).
- GifHelper.parseGif: strip a leading `@[Name] ` reply prefix before matching
so a reply-with-gif resolves to the gif instead of literal text; arbitrary
text before a gif is still left as text (#232).
Tests: parseGif reply/format cases + widget tests for inline/leading/multiple
mention chips. Full suite 402 green.
Part of #233, #232.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Race (data loss): exclude keys the user block/unblocked WHILE a BLOCK_LIST dump
was in flight from the union (they were already pushed + local is authoritative)
-> no re-import of an unblock, no redundant re-push.
- _pubKeyHexToBytes returns null on malformed hex (try/catch + length) so a corrupt
persisted key can't crash the unawaited push.
- Cancel _blockListRetryTimer + reset dump state on disconnect (timer leak).
Justified/not-fixed: generic [0x01][0x06] error is unreachable (we only build
well-formed 32-byte frames); Completer-correlation is out of scope for v1.
Epic B #166 / #179#180. Gemini audit: docs/llm-consultations/2026-07-14-epicAB-*.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Connector parses ADD/REMOVE/CLEAR replies; ADD ok=0 (node store full at 32) sets
blockOffloadStoreFull (local stays authoritative), cleared on reconcile. BlockedView
shows a firmware-offload status row when supportsOffbandBlock — 'active', or a
'radio block list full' warning. Adds block_offload* l10n.
Epic B #166 / B5 #180.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BlockService gains a firmwareSync sink (fired on real block/unblock/promote) and
importKeys (pull side, no echo). Connector sets the sink in initialize, pushes
ADD/REMOVE via 0xC2 when supportsOffbandBlock, and on device-info (caps landed)
pulls BLOCK_LIST. On dump completion: UNION — import node keys missing locally
(no echo), push local keys the node lacks; never remove (only explicit unblock
sends REMOVE). Non-Offband radios skip all of it (app-local only).
Epic B #166 / B4 #179.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route 0xC2 frames in _handleFrame; accumulate the LIST dump (START 0xFF count ->
KEY index+key:32 -> END 0xFE). Detect truncation (count vs received; APP_START
early-END uses the same 0xFE) and re-request on a 2s settle. Never derive
removals from a partial pull. Union reconcile wired in B4.
Epic B #166 / B3 #178.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build frames for ADD/REMOVE (pubkey:32) / LIST / CLEAR; parse the 3-byte
[0xC2][sub][ok] reply (OffbandBlockReply). Generic error frame is [1][6]
(respCodeErr/errCodeIllegalArg), NOT 0xC2-prefixed. LIST streamed dump parsed
in B3.
Epic B #166 / B2 #177.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>