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>
The firmware path-len byte packs a hash-mode hint in the high 2 bits and the
path byte-length in the low 6. Contact.fromFrame stored it raw, so a direct
node at 2-byte mode (0x40) read as pathLength 64: "64 hops" in the UI, a ~195s
ACK timeout, and up to 64 junk bytes pulled into contact.path that broke
repeater status/telemetry/neighbors until the path was cleared (#222).
- contact.dart: decode via pathHopCount (& 0x3F); keep 0xFF as the flood
sentinel; use the decoded length for safePathLen so path bytes stay clean.
- chat_screen _currentPathLabel: show realHopCount(pathLength, pathHashByteWidth)
so 2/3-byte nodes read the correct hop count; 0 -> Direct.
- Regression test for 0x40/0x00/0x03/0x44/0xFF; updated two model_changes tests
that codified the old raw mapping.
Part of #222 (stays open for on-hardware validation by the reporter).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cut v1.1.2-rc.2 (rc.1+46 -> rc.2+59). versionCode 59 clears the
b45-b58 GitHub test-build tags for a clean monotonic line ahead of
the first Google Play closed-test upload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bound ACCESS_FINE_LOCATION and ACCESS_COARSE_LOCATION to maxSdkVersion=30,
mirroring the already-bounded BLUETOOTH/BLUETOOTH_ADMIN. On Android 12+ the
app scans with BLUETOOTH_SCAN + neverForLocation and reads no phone location
(no location dependency or API usage in lib/), so location is only needed for
legacy scanning on API <=30. Cleaner permission profile + Data Safety story
for the Play submission (#159).
Epic #210. Test gate #212 (closed-beta hardware validation).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the invented whole-channel-mute (never specified; already exists in
notification settings). Repurpose §6 to the real spec: blocking a channel sender
who can't be resolved to a pubkey adds a GLOBAL name-fallback (hides them in every
channel, not one at a time), which promotes to a pubkey block once the identity is
learned via advert OR DM (§7). Single/invisible-char rename evasion explicitly out
of scope. A4/#171 closed not-planned; behavior lives in A3/#170 + A5/#172 + A7/#174.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ben's UX call: do NOT hide blocked contacts to a settings-only list. Keep them
in Contacts/Discovery with a red block icon + muted styling + inline unblock.
A 'hide blocked entirely' toggle is a post-implementation follow-on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini review (HIGH): add !mounted guard after the async block/blockName before
showing the snackbar. Captured messenger already made it safe, but this hardens
against showing on a popped route. (MEDIUM 'stale itemBuilder read' assessed as a
false positive: context.read in itemBuilder returns live state at menu-open.)
Epic A #165 / A5 #172.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BlockService.maybePromote(name,key): when a name-only block links to a pubkey,
add the key to blockedKeys and drop the name entry. Connector calls it on every
contact advert AND incoming DM (the two ways we learn a name<->key link).
_pruneExpiredNames on load drops name-only blocks older than 30 days.
Epic A #165 / A7 #174. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New BlockedView pane (Settings category, after Privacy): lists blocked pubkeys
(contact name when resolvable, else short key) and name-only channel blocks, each
with Unblock; empty state when nothing blocked. App-local; firmware-offload
indicator deferred to Epic B. Adds block_* settings l10n.
Epic A #165 / A6 #173. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the A5 entry points: contact long-press sheet, DM app-bar overflow, and
discovery item sheet each get a Block/Unblock toggle (by pubkey, reflecting current
state). Pairs with the channel 'Block sender' from 8a2e764.
Epic A #165 / A5 #172. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Long-press a channel post -> Block sender: resolve the claimed name to known
pubkey(s) and block each (full block across DM+adverts+all channels, multi-device
covered); if unresolved, block the name globally across ALL channels (promotes to
pubkey later, #174). Adds block_* l10n strings. A5 channel surface;
contacts/chat/discovery + unblock still to come.
Epic A #165 / A5 #172. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Connector: resolveContactKeysByName(name) maps an anonymous channel sender
(name-only) to matching contact/discovered pubkeys (self-heals via adverts
keeping contact names current).
- channel_chat_screen: hide a post only when the name resolves and EVERY
matching pubkey is blocked (ambiguous namesake with any unblocked match still
shown), or the name is in blockedNames. View-layer filter only.
Epic A #165 / A3 #170. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Connector: BlockService injected via initialize(); incoming DM from a blocked
key dropped (no thread/unread/notify), advert notifications gated at all 3
sites (advert still processed for name self-heal). App-push-layer only.
- Contacts + Discovery: blocked entries stay VISIBLE with a red block icon
(BlockedBadge) + strikethrough/muted name (not hidden).
- New lib/widgets/blocked_badge.dart.
Epic A #165 / A2 #169. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
App-global (NOT per-device) block list, source of truth for the client:
- BlockStore persists blockedKeys (pubkey hex) + blockedNames (name->first-seen
epoch, for A7 promote-and-prune) via PrefsManager, global keys.
- BlockService (ChangeNotifier): isBlocked/isNameBlocked/block/unblock/
blockName/unblockName + unmodifiable accessors.
- Wired into main.dart MultiProvider.
Epic A #165 / A1 #168. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror every queue-drain decision -- requested, deferred (which guard),
asking radio, message received, no-more, timeout/give-up, post-channel
proactive fire -- into the App Debug Log via _logQueueSync, so a missed or
stuck drain on reconnect is diagnosable from the device.
Diagnostic only; the targeted fix follows once the log shows the failure.
Refs #51
Reply-shape [0xC2][sub][ok] (result byte, not ack/err frame); generic error
frame [0x01][0x06] is NOT 0xC2-prefixed. Overflow at MAX_BLOCKED_KEYS=32 returns
ok=0 (not error) + already-stored returns ok=1 (dedup short-circuit). BLOCK_LIST
truncation detect via START count vs key-frames -> re-request on APP_START
early-END; never derive removals from LIST. DM-drop is silent (no notif/frame) ->
no firmware block counts for UX. No unsolicited pushes. Design confirmed by
PearlMeadow; app half locked to shipped firmware, no open discrepancies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Align to firmware PR #247 (merged): cap OFFBAND_CAP_BLOCK=0x02, FIRMWARE_VER_CODE
15, 0xC2 CMD_OFFBAND_BLOCK (ADD/REMOVE/LIST/CLEAR), MAX_BLOCKED_KEYS=32, BLOCK_LIST
streamed-dump framing (0xFF count / index / 0xFE end) + APP_START early-END.
Switch sync model to union / always-retain-local (app-local never wiped; portable
across Offband clients, local-only on non-Offband). Global (by-pubkey) app store.
Fold in channel all-matching-pubkeys rule, dual name-key index sourcing (0x8A vs
0x80), promote-and-prune age-out, gaps G1/L1/L2/L3, interop invariant.
Feature #164 / Epic A #165 / Epic B #166; firmware doc #244.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per meshcore-firmware/docs/trace-path-format.md: a TRACE route must exclude
the origin (implicit at both ends). If route[0] is our own node's prefix it
matches no forwarding node (we are the sender) -> the packet never advances
-> RESP_CODE_SENT then a silent timeout (the doc's #1, most-common cause).
buildPath now drops a leading self hop before assembling the round trip.
Bumps build to +51 for the b51 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
App Debug Log showed every failing trace sending an empty path ->
"Firmware responded with error code: 1". Cause: repeaters with a flood
route (path stored as all-zeros) were treated as routed; buildPath
collapsed the all-zero path to empty; the firmware rejects an empty trace.
- buildPath: an empty OR all-zero path now traces the target's own width
prefix (never returns an empty payload).
- _doPathTrace: guard - never send an empty path; surface "not available".
- Repeater menu: constant "Path Trace" label (no more Ping/Path-Trace
flip-flop); a flood path (all-zero) counts as no route -> direct ping.
Bumps build to +50 for the b50 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The map "Current path" panel joined _pathTrace byte-by-byte, so once the
builder started capturing the full configured-width prefix per tap (#150),
a single 2-byte node read as "84,f4" — looking like two hops. Format via
PathHelper.formatPathHex at the configured width so it reads "84F4".
Bumps build to +49 for the b49 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The repeater contact menu hardcoded "Ping" and always sent a target-only
(direct) trace, so a multi-hop repeater offered a "Ping" that could never
return. Mirror the room behavior: a repeater with a known route is
labelled "Path Trace" and traced along that route (round-trip); only a
direct neighbour (no path) shows "Ping" and pings directly.
Bumps build to +48 for the b48 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trace was width-1 end to end (flags=0, a 1-byte path, and a
1-byte-per-hop parse + render), so on a 2-/3-byte path-hash mesh it
returned 1-byte hops (`6A`, not `6Axx`) and failed to route where a
1-byte prefix is ambiguous ("Path trace not available").
- Send: path_sz = floor(log2(width)); build width-byte hops — the
repeater/room "Ping" prefixes (contacts_screen), the round-trip
buildPath (now mirrors by hop, not byte), and the map trace builder.
- Parse: read the flag byte for the hop width, group the hash bytes into
packed width-byte hops, key contacts by the full prefix, 1 SNR per hop.
- Render: list panel + markers + map points iterate packed hops, labelled
at the configured width.
PathHelper gains tracePathSz / traceHopBytes / packPrefix / tracePathHops
/ buildTraceRoundTrip, all unit-tested (incl. width-2 hop mirroring).
Bumps build to +47 for the b47 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini review flagged setPublicKeyHex using `value.length > 10`, which would
store an empty scope for an exactly-10-char device key (unreachable today —
MeshCore public keys are 64 hex — but inconsistent with ChannelStore, which
uses >=10). Align to >=10 for consistency and to remove the latent off-by-one.
Channel message history was persisted keyed by the reusable slot index, so a
channel added to a slot that once held another channel inherited its messages.
Storage now keys by the channel's PSK identity via a resolver on the store
(index -> channel PSK), leaving all call sites unchanged. A one-time migration
adopts pre-existing slot-index history into the PSK key on first load (only when
the PSK is known); clearChannelMessages now wipes both the PSK key and the raw
slot-index key so Build-B's reuse-clear can't be re-migrated onto a new occupant.
Build-A (durable) of epic #190; supersedes Build-B's (#193) guard. Also removes
the dead device-wide fallback read in loadChannelMessages.
Channel message history is keyed by the reusable slot index, not by the
channel's PSK. Adding a channel to a slot that previously held a different
channel surfaced the prior occupant's messages (e.g. #echo history appearing
in a newly QR-added channel).
setChannel now clears the slot's stored history unless the slot already holds
the same channel (same PSK, e.g. a rename). Only deleteChannel cleared before.
Build-B (bleed-stop) of epic #190. Build-A (PSK-keyed history + migration) to
follow. Accepted edge: re-adding the exact same channel that was orphaned
device-side loses its old history (messages carry no PSK to match on).
The Add Channel "Scan QR Code" scanner now accepts channel share URIs
(meshcore://channel/add) alongside community QR JSON: it parses name +
secret, confirms, and adds the channel to the next free slot via
setChannel. Completes the channel-QR round-trip in-app, and interops with
the reference app's channel QRs.
Bumps build to +52 for the b52 combined test binary (path-hash + trace +
channel QR).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Channel.toShareUri/fromShareUri/isValidShareUri encode the reference
MeshCore app's format: meshcore://channel/add?name=<url-encoded>&secret=<hex32>.
A "Share QR Code" action in the channel menu shows the QR via the existing
QrCodeShareDialog, so it round-trips with the reference app.
Encode/decode unit-tested against both real reference examples (#test, #echo).
Scan-to-add follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three routing-match sites keyed/compared nodes by only the first
public-key byte, which collides when two nodes share a first byte on a
2-/3-byte path-hash mesh:
- DirectRepeater.matchesPathStart() replaces the 1-byte
`pubkeyFirstByte == pathBytes.first` direct-repeater check in
chat_screen and path_management_dialog (x3 each).
- map _computeGuessedLocations packs the full configured-width prefix as
the repeaterByHash key and looks up the contact-side hop at the same
width — was keying publicKey[0] but reading pathBytes.last, a silent
miss at width>1 that stopped location-guessing on multi-byte meshes.
- map _filterContactsBySettings overlap detection compares the
configured-width prefix via _samePubkeyPrefix (was publicKey.first).
Adds DirectRepeater.matchesPathStart unit tests. Bumps build to +46 for
the combined path-hash sweep test binary (b46: #151+#154+#155+#156).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Set Path" dialog parsed, built, and displayed hop prefixes as a fixed
1 byte (substring(0,2)), so on a 2-/3-byte path-hash mesh manual entry produced
wrong-width routing paths and the helper text claimed "1 byte". Make it width-
aware from connector.pathHashByteWidth.
- parsePathPrefixes(text, width, invalid): static + testable; each entry must be
EXACTLY width*2 hex chars (wrong-length / malformed go to invalid, never
silently truncated); width clamped >= 1.
- _updateTextFromContacts / display / maxLength group by the configured width;
the display substring is guarded against short keys.
- show() takes pathHashByteWidth; the chat + path-management callers pass
connector.pathHashByteWidth.
- l10n: reworded the path-entry helper / example / instructions to be width-
agnostic (dropped the "2 hex / 1 byte / first byte" claims). The 17 non-English
locales keep their prior strings, flagged for re-translation (English fixed).
analyze clean; 6 parsePathPrefixes tests (incl. exact-length + clamp). Gemini v1
BLOCKER (unguarded substring) + MAJOR (silent truncation) fixed; v2 MAJORs were
the pre-existing refresh button (out of scope), v2 MINOR (1-byte example) fixed.
Closes#155
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PathHelper.formatPathHex / resolvePathNames assumed 1 byte per hop, so the
direct-chat "full path" and path-management dialogs rendered routing paths
per-byte (84,AB instead of 84AB) and resolved hop names by a single byte on
2-/3-byte path-hash meshes. Group bytes into pathHashByteWidth-sized hops and
match hop names by the full-width prefix.
- formatPathHex(bytes, width) / resolvePathNames(bytes, contacts, width) group
into width-byte hops (trailing partial hop kept); name match uses the full
hop prefix.
- chat_screen + path_management_dialog pass connector.pathHashByteWidth.
analyze clean; 7 PathHelper tests (incl. width-2 collision-avoidance); Gemini: ship.
Closes#154
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>