#528 surfaced unmatched and late replies in the UI because they were
previously destroyed in silence. Once #529 widened the window, late
arrivals stopped being the exception and the on-screen output became
noise during ordinary use. Owner's call: log it, keep it off the screen.
Removed: the CLI screen's late-response history entry and its renderer
branch, the settings screen's snackbar, and the four l10n strings that
only those two used.
Also removed the settings screen's silent late-apply. A field changing
value seconds after the fact with no explanation is the same problem as
the snackbar, only quieter, and applying while saying nothing would be
worse than either showing or not showing. One line to re-enable if that
turns out to be wanted.
Kept: the service still detects every unmatched reply and writes it to
the app log via appLogger, now including the payload as well as the
repeater, the originating command when known, and how late it arrived.
That is the diagnostics channel and is what keeps this from being a
silent drop.
onUnmatchedResponse and UnmatchedRepeaterResponse stay on the service.
They are the seam the no-silent-drop guarantee is tested through; the
#528 and #532 tests exercise them directly and pass unchanged, which is
the evidence the service contract did not move.
807 tests pass, analyze clean, format clean.
Adversarial review raised three findings. Two survived verification.
Rejected: a claimed RangeError when abbreviating a short public key. The
code already guards with a length check before either substring, so the
crash it describes cannot occur.
Rejected as described, fixed as found: contact position (0, 0) was said to
be dropped on import. It is not. The frame builder writes the position block
whenever lastModified is present, which it always is here, so a suppressed
position still writes 0/0 and the bytes are identical either way. The
hasPosition conditional was therefore doing nothing except misleading a
reader, which is exactly what happened. Removed, and the behavior is pinned
with a test.
Accepted: the service added sections to 'applied' after sendFrame returned,
which only means the frame left our side. A lost or refused frame was
indistinguishable from success, so the user could be told an import worked
when nothing changed on the device.
A correct fix needs an awaited per-command acknowledgement in the connector,
keyed on command code so concurrent commands cannot steal each other's OK.
That is real connector work and is filed as #584. What is fixed here is the
overclaim: 'applied' and the two counts are documented as sent rather than
confirmed, and the result string now reads 'Sent N contacts and M channels
to the device'. The code no longer states something it cannot know.
Applies a stock config file to the device with stock's own merge semantics,
quoted from its Import Config screen:
- contacts are upserted on public key, and nothing is ever deleted
- channels are additive only, so a rotated PSK for a channel the user
already has does not apply
- importing an identity overwrites the node outright
We match that behavior but not its silence. Every skipped channel comes back
named, with a reason, so the UI can show it; a channel that quietly did not
import is the silent no-op SAFELANE section 6 forbids.
The channel merge rule is extracted as planChannelImport, a pure function, so
the surprising part is testable without a radio. It treats a channel as
already present when either its PSK or its name matches, which is the
non-destructive reading of a rule stock states without defining. Pinning down
what stock actually does is a T1 question, recorded in the doc comment.
New channels take the lowest free slot, since the file carries no index.
other_settings is reported as not fully writable rather than claimed as
applied: buildSetOtherParamsFrame deliberately pins the auto-add-contacts
byte, so manual_add_contacts cannot be written without changing app-wide
behavior. Advert location policy is applied.
10 tests over the planner and the result type.
Gathers live device state into a StockConfig, section by section, matching
stock's Export Config screen where each section is independently selectable
and the file simply omits what was not ticked.
A requested section that cannot be gathered is reported in the result rather
than dropped, with the reason kept specific: unsupported (firmware built
without identity export) is distinct from noReply and rejected, so the
export screen can say the radio cannot do it instead of offering a pointless
retry.
Two unit traps handled explicitly:
- currentFreqHz is misnamed; the value is kHz, which is also what stock's
frequency field carries, so it passes through unconverted. currentBwHz
really is Hz. The mismatch exists in both our state and the file.
- other_settings.manual_add_contacts must be the raw device byte. The
connector's _manualAddContacts is an inverted derived view of bit 0
(firmware treats a clear bit as auto-add enabled), so exporting it would
have written the wrong value. The raw byte is now retained and exposed,
with the inversion documented at the parse site. No behavior change.
Path mapping keeps our hash count and per-hop width (#309) intact: stock
encodes one hash per comma-separated element, so the width survives as
element length. A flood route, an over-long declared hop count, or a width
stock cannot express all yield no path rather than a guessed one.
13 tests over the pure mapping functions.
The radio is authoritative for the expected-ACK hash it reports, but the
client looked that hash up in a map keyed by a hash it recomputed locally.
When the two disagreed the lookup missed silently (debugPrint only), the
8000ms watchdog from #395 marked the message failed, and the genuine ACK
later matched nothing because every downstream map is populated only on the
match path. Delivery worked the whole time.
Captured on a Wadamesh HV4 TFT in #449: five DMs, all shown as errors, two
with a confirmed ACK whose hash equalled the radio's RESP_CODE_SENT value
exactly. Wadamesh builds the payload differently, so the client cannot
predict its digest, and Offband is expected to work against stock MeshCore,
Wadamesh and other forks.
RESP_CODE_SENT is the reply to our own CMD_SEND_TXT_MSG, so adopt the radio's
value when exactly one send awaits confirmation. Zero or several candidates
keep the previous behaviour rather than guessing. Channel sends draw the same
frame, so adoption is refused while one is outstanding.
The lookup miss is now a warn on debugLogService instead of a bare
debugPrint (SAFELANE section 6).
A send-order correlation queue was developed alongside this and has been
split out: its ordering premise needs a transport send mutex that does not
exist yet, and three review rounds each found a fresh defect in it. This
commit deliberately carries only the adoption fallback, which is what the
owner's hardware test validated.
Refs #449
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. An unprefixed reply no longer resolves an arbitrary pending command.
#532 constrained prefixed replies only; the unprefixed fallback still
used firstWhere, which with two or more in flight is map-iteration
order, so a caller could receive another command's output. The
fallback cannot just be deleted: firmware only echoes the prefix when
the command exceeds four characters including it
(simple_repeater/MyMesh.cpp, strlen(command) > 4 && command[2] == '|'),
so a very short command legitimately answers without one. It now
resolves only when exactly one command is pending, and surfaces the
ambiguity otherwise.
2. A late reply no longer overwrites unsaved edits. The settings screen
applied a late `get` payload unconditionally, so a reply arriving after
its timeout could revert a field the user had typed into while waiting.
It now applies only when nothing is dirty, and says which happened.
3. _expiredCommands is pruned on timeout as well as on receive. It was
pruned only in handleResponse, so a run of commands that all time out
with no traffic coming back accumulated records until disposal.
The reviewer described 3 as unbounded growth. It is bounded at 256 by the
prefix token space, and stale records could never be mis-attributed
because handleResponse prunes before matching, so the severity was
overstated. Fixed anyway; it is nearly free.
A fourth finding, that the new l10n strings are untranslated in the other
17 locales, is rejected. That is this project's established pipeline:
strings are authored in app_en.arb, gen-l10n emits English fallbacks, and
untranslated.json tracks the gap, which it now does for these keys. Every
string in the app arrived this way, so it is not a defect this branch
introduces.
The ambiguity test was verified to fail against the pre-fix code.
handleResponse looked up the reply's prefix, and when that matched nothing
pending it fell through to "first pending command for this repeater". So a
straggler from an expired command could complete an unrelated in-flight
one, reporting one command's output as another command's result.
The service already carries a correlation token in every command and every
reply echoes it. The fallback discarded that. Now a reply that carries a
prefix is only ever matched to the prefix's owner; if there is no owner it
goes to the unmatched handling added in #528, where an expired command is
still named. Only a reply with no prefix at all, which has no correlation
token to honour, may fall back to matching by repeater.
Not reachable from today's callers: all four pass retries: 1 and the
settings refresh awaits each command, so two are never in flight to one
repeater. It becomes live the moment anything issues concurrent commands,
which any retry work would.
The two negative tests were verified to fail against the old logic and
pass against the new, so they are a real guard rather than a restatement.
The two preservation tests pass either way by design.
_registerPending is extracted so the test seam and the real send path
cannot drift apart.
Part of epic #473, stacked on #528, #529 and #531.
The timer ran timeoutMs while the message printed
(timeoutMs / 1000).ceil(), so every window in (4000, 5000] announced
"timeout after 5 seconds". The owner's 0-hop window was 4074 ms and fired
at 4.07 s while claiming 5, which is what made the behaviour look
arbitrary rather than deterministic: the number shown was never the
number used.
The service now throws a typed RepeaterCommandTimeout carrying the window
that was actually armed, formatted to one decimal. Every existing caller
already stringifies the error, so all of them inherit an honest figure
without being touched; the CLI screen additionally renders it through a
new localized string rather than the generic error wrapper.
Tests pin that 4074 reports 4.1 rather than 5, that the new 28748 ms
budget reports 28.7 rather than 29, and that two windows inside the same
second no longer collapse to the same text, which was the defect's
signature.
Part of epic #473, stacked on #528 and #529.
The CLI timeout used calculateTimeout, which mirrors the firmware's
calcDirectTimeoutMillisFor and estimates ONE-WAY delivery. A CLI command
is a request, an execution and a reply, so the budget was structurally
short.
Measured against rpt-01 on 910.525 MHz / SF7 / BW 62.5k / CR 4:5, where
the old window was 4074 ms: 27 commands sent, 12 replies matched, and 4
of those 12 arrived after the client had already given up, at 6.17 s,
8.28 s, 15.52 s and 20.33 s against a median of 2.75 s.
calculateCliTimeout sums four terms, each with a source rather than a
chosen value:
outbound leg calculateTimeout, which is what it actually models
cliReplyDelayMs 600, firmware CLI_REPLY_DELAY_MILLIS, unconditional
reply leg the reply is a second packet the ACK formula omits
retrieval budget replies are pull-based; the radio raises MSG_WAITING
and the app must ask, granting itself 5000 ms per
attempt across 3 retries
The retrieval term is derived from the sync constants rather than
restated, so the command timeout cannot drift below the layer it depends
on. That is the #530 invariant holding by construction, not by two
numbers being maintained in agreement.
Execution time is deliberately not modelled. The same verb, wifi on 30,
returned in both 2.31 s and 20.33 s, so it is not a per-command constant
that could be tabulated. The retrieval term carries that tail.
The reply leg uses physics only. The predictor is trained on
direct-message ACK latency, so asking it about a CLI reply leg would be
extrapolation; that is #534 and #535, not this change.
Tests cover the construction, the never-below-retrieval invariant, growth
with path length, coverage of the 20.33 s worst case actually observed,
and negatively that the direct-message ACK path is untouched.
Part of epic #473. Does not change the reported duration string (#531)
or the stale-prefix fallback (#532), and does nothing for commands that
draw no reply at all (#541).
A repeater reply that arrived after its command's window closed hit
`if (commandId.isEmpty) return;` in RepeaterCommandService.handleResponse
and was dropped with no log and no UI. Since the command timeout can be
shorter than the app's own message-retrieval budget, that made "the
command ran but the response was never reported" the normal outcome
rather than an edge case, and it affected every repeater screen, not
just the CLI one.
RepeaterCommandService now remembers a timed-out command's prefix for two
minutes, so a reply arriving afterwards can be attributed to the request
it answers. Replies that reach no waiting command are handed to a new
onUnmatchedResponse sink and logged through appLogger. No path through
handleResponse returns without either completing a command, surfacing the
payload, or logging why it could not.
The CLI screen renders these as a distinct history entry naming the
original command and how late it was. The settings screen applies the
value if it is a `get` reply and tells the user it arrived late.
repeater_status_screen already parsed responses independently of the
service, so it had no silent-loss path to fix.
Part of epic #473. Does not change the timeout window itself (#529),
the reported duration (#531), or the stale-prefix fallback (#532).
#456 removed enabled from the profile template but the apply path still
re-enabled via wasLive, carrying over the prior enabled state on import,
against the explicit directive that import brings in NEITHER enabled nor
disabled. Firmware already force-disables a slot on any field write (#53);
apply now passes enable=false and never re-enables. The slot is left disabled
and the operator re-enables intentionally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hiding the section left enabled features running invisibly. The action is now
'Disable experimental features': disableExperimental() turns off every
experimental flag (CoreScope observer counts, future ones) AND re-locks the
section in one atomic write. Also fixes the subtitle to say 'Build row' (the
#553 anchor), not 'version row'. Part of the #509/#553 experimental section.
Agent: QuietSnow (session 31eaba02)
Badge stays observers-primary; tooltip/long-press now read 'N observers . M
observations' so they reconcile with CoreScope's 'Observations (N)' feed (the
two are distinct metrics: 15 distinct observers vs 34 total sightings). Refresh
feedback moved from a bottom snackbar to an auto-dismissed top MaterialBanner,
out of the way of the composer. Part of #524.
Agent: QuietSnow (session 31eaba02)
Info logs at each step (0xC6 reply match, hash, CoreScope GET url + result,
stored count, message-not-found) so the app debug log shows exactly where the
badge chain breaks. Part of #524.
Agent: QuietSnow (session 31eaba02)
After a channel send, when firmware advertises 0xC6 (cap2 0x08 + ver>=22) AND
the owner enabled the feature, the connector queries the packet hash, then
CoreScope for observer_count, and stamps it on the message (background,
best-effort). Adds the coreScopeObserverCountEnabled AppSettings flag, a gated
switch in the #509 Experimental section (disabled until the radio supports the
capability), and a distinct cloud badge next to the radio-heard count. All
inert until the firmware PR (#611) lands. Completes the client side of #524.
Agent: QuietSnow (session 31eaba02)
Best-effort GET /api/packets?hash=H&groupByHash=true against a CoreScope
instance (default map.okimesh.org); returns observer_count or null. Never
throws, so the chat UI degrades silently to radio-only when offline or the
hash is unknown. Part of epic #524.
Agent: QuietSnow (session 31eaba02)
7 taps on the About version row (rolling ~3s window) reveal a neutral
'Experimental' settings category; persisted on AppSettings, survives
restart, re-hidden from a switch inside the section. Empty of toggles
for now (Fast Sync #118, CoreScope #524 land into it later).
Countdown snackbars after tap 4; already-unlocked feedback. Version-text
tap is absorbed so it counts without opening the About dialog.
Agent: QuietSnow (session 31eaba02)
Children of #471 (parent stays open for AAB hardware validation, #507).
The block list was one global unscoped list on the phone, unioned onto
every radio on connect. A stale block for one of the owner's own radios
therefore rode onto every fresh/erased radio, and clearing a radio never
stuck: any other radio still holding it re-seeded the global list on
connect, which re-pushed it back.
- #505 block_store.dart: scope keys/names by connected device key (like
the app's other stores); dropLegacyGlobal() deletes the legacy
block_keys_v1/block_names_v1 (drop-and-start-fresh, owner-approved).
No global list.
- #505 block_service.dart: load() drops the legacy global and starts
empty; loadForDevice(deviceKey) swaps the in-memory set per radio and
runs the #250 self-heal. All mutating ops serialized through a Future
chain so loadForDevice and importKeys (different connector frame
handlers, both unawaited) cannot interleave and wipe each other.
- #506 meshcore_connector.dart: the device-key hook loads the connected
radio's list, so the offload union reconciles within that one radio.
Existing radio-side firmware blocks left untouched (no auto-CLEAR on
migration, owner-approved). Tests: per-radio isolation, clear-sticks
across reconnect, legacy-drop, disconnect-clears, load/import race.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A transient upstream 503 (e.g. Hugging Face) hard-failed the model download and
surfaced it as an error (#229). The download requests (HEAD, single GET, range
GET) now go through sendModelDownloadWithRetry: 5xx/429 responses and network
exceptions retry with bounded exponential backoff (1..30s, <=5 attempts, honors
Retry-After, cancellable); terminal 4xx (e.g. 404) fail immediately. The retry
logic is a pure injectable top-level function, unit-tested (503-then-200,
persistent-503, 404-no-retry, network-exception, cancel).
Manual retry after a sustained failure is the existing "Download model" button
(re-invokes the download); no new UI added. Build of epic #422.
Follow-up to #457. Swept the em-dash character (U+2014) out of the test
tree (test descriptions and comments) and cleaned 8 em-dashes that
landed in lib comments via the #456 refactor after #457 merged, so the
tree is back to zero. Same rules: replaced with commas/colons/periods,
preserved the lone "no data" glyph placeholders, left non-English ARB
untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The cache-bust helps federated catalogs on normal servers but does NOT defeat
raw.githubusercontent's CDN (ignores query + no-cache; ~5min TTL, verified).
Comment no longer overclaims. Known issue tracked in #452.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
raw.githubusercontent sits behind a CDN with a multi-minute TTL; a catalog edit
followed by a quick re-import returned the OLD profile (reported: a removed
region still showing as a change). Append a unique query param + no-cache
headers so every catalog/profile fetch is fresh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device-agnostic write enumerator (config_profile_writes.dart): a profile ->
ordered flat writes + per-broker field maps. Skips null/empty (never clobbers),
skips jwt_token (live-minted), holds enabled out for last-write, flags the
danger set (username/password/jwt_owner/jwt_email + wifi.pwd) for #406's gate.
Observer executor (observer_apply_service.dart): flats via setFlat, brokers via
the existing saveBroker (disable-first, fields, enabled LAST, stop-on-error
partial-safe #80); reads current enabled to preserve it when a profile omits it.
Result labels name keys/slots only, never values (no secret leak).
8 enumerator tests. Executor is thin orchestration over the tested service;
end-to-end covered by #408 hardware.
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>
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>
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>
Reactions sent from MeshCore One arrived as junk text: an emoji line
followed by an 8-character token such as "dyps6yf0". Those tokens are
Crockford Base32 target-message hashes, the second line of a two-line
reaction payload we did not recognise.
Receive-side only. Offband keeps sending its own r:hhhh:ii format; their
client already parses ours, so nothing about what we transmit changes.
Wire format (confirmed against a live capture, see #378):
channel: {emoji}@[{targetSender}]\n{hash}
direct: {emoji}\n{hash}
hash: sha256(body utf8 + timestamp uint32 LE seconds)[0:5],
Crockford Base32, 8 chars, lowercase
The body is hashed without the channel "SenderName: " prefix, which is
why the sender travels in @[...] instead.
- crockford_base32.dart: encode and normalise, 12-bit accumulator so the
web target's 32-bit bitwise ops cannot truncate a 40-bit value
- pocketmesh_reaction.dart: hash and a parser mirroring the reference
implementation, with a conservative leading-emoji check so a real
message is never swallowed
- reaction_helper.dart: ReactionInfo carries a dialect; applyReaction
picks the matching hash and, for the channel form, requires an exact
sender-name match (a node name can carry emoji and variation
selectors)
- pending_reactions.dart: a reaction arriving before its target is held
and retried rather than silently dropped, bounded at 50 entries with a
15 minute TTL and a warn-level log on expiry (closes the silent-drop
path in #382)
- the channel dedup key now includes the reacting sender, so two people
sending the same emoji no longer collapse into one
- notification tray summarises the foreign format as a reaction instead
of showing the raw token
Known limitation: on channels with Smaz or Cyr2Lat enabled the wire text
differs from the text we store, so a hash computed by another client
will not match. Flagged for a decision rather than worked around.
Epic #376. Fast-follow #383 adds reactor identity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the database can't open (e.g. the native sqlite library fails to load),
every read/write silently failed and the app opened to an empty, normal-looking
screen — the user thinks their history was wiped (SAFELANE §6 violation).
Probe the storage layer in main() before any store reads
(BlobStore.verifyReadWrite). On failure, a StorageHealthService one-way latch
records it, and a persistent, non-dismissable banner is shown above the whole
app: history is not lost, storage is unavailable, messages are NOT being saved,
restart after fixing. Cross-platform (probe goes through drift, covers web too).
Tests: service state/latch + banner show/hide widget test. Gemini-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#363 pins the DB to one directory and migrates ONE prior store in, but a user
who ran differently-built copies can have data split across several stores.
On startup (native only, after the prefs->drift migration) this discovers
every store on the machine and unions its bulk blobs (messages by id, contacts
by public key) into the current one, so nothing shows as a gap. Sources are
read-only and never deleted.
Not a one-shot: instead of a permanent flag it tracks each store's signature
(mtime+size), so a store that a stray older build later creates or grows is
re-merged rather than stranded. A store over a 200 MB guard, or one that
cannot be read, is skipped and NOT recorded as done, so it retries later.
Identity dedup is key-order independent. sqlite3 (dart:ffi) stays out of the
web build via a conditional import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three findings from the adversarial review (standards#145), all real, all
fixed:
- Move-then-close inside the 400ms debounce lost the final position. The
service now setPreventClose(true) and saves on onWindowClose before
destroying the window, so the closing frame is always persisted.
- _isReachable mixed a display's visible ORIGIN with its total SIZE when
visibleSize was null, producing a rect offset from the real area. It now
uses the visible pair when both are known, else the total pair. Negative
visiblePosition (a monitor left of/above primary) is handled by intersect().
- The window listener and debounce timer are now released in onWindowClose.
flutter analyze clean, dart format clean, 509 tests pass.
The stock Flutter desktop runner never saved the window frame, so the window
always reopened at a default position and size (#349). Adds
WindowGeometryService (window_manager + screen_retriever): it saves the frame
on move/resize, debounced, and restores it on launch.
Restore is clamped against the connected displays. A window last placed on a
monitor that is now unplugged must not reopen off-screen where it cannot be
grabbed; if the saved frame does not overlap any display by at least 80px on
both axes it is discarded and the window opens at the default. A too-small or
undecodable saved value also falls through to the default (logged, not
swallowed).
Gated on PlatformInfo.isDesktop, so it is a no-op on mobile and web. Scoped
past just Windows deliberately: window_manager is desktop-only and the same gap
exists on Linux/macOS, so all three get the fix rather than Windows needing an
extra guard.
Known limitation, not addressed here: without a native runner change to start
the window hidden, restore repositions AFTER the window is shown, so there can
be a brief flash at the default position before it jumps to the saved frame.
Eliminating that needs a windows/runner edit; flagged for the owner to judge
during hardware test.
flutter analyze clean, dart format clean, tests pass. Windows-run verification
is the owner's (geometry is a real-desktop behaviour).
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.
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>
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>
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.
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>
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>
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.
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>
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>
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>