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)
Unifies the previously-duplicated send timestamps (frame builder vs outgoing
message each called now() separately) into one monotonic-per-channel value, so
every channel send has a unique (ts, channel_idx) key. That is the client-side
guarantee the 0xC6 correlation needs (VioletBarn caught that AES-128-ECB
determinism would otherwise make two same-second messages a wrong-hash query).
Adds transient onAirHash + coreScopeObserverCount to ChannelMessage. Part of #524.
Agent: QuietSnow (session 31eaba02)
Builder/parser for the client-issued 0xC6 CMD_OFFBAND_PKT_HASH query (per
firmware #611 contract) and firmwareSupportsPktHash (cap2 0x08 + ver >= 22).
Inert until firmware advertises the capability. Part of epic #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)
Owner feedback: per-tap snackbars appeared over the version/About row and
blocked the next tap; and a 'Hide' switch sitting ON read backwards.
- Tap progress + unlock/already-unlocked now render inline under the
version number (2s auto-clear timer), so nothing overlays the tap target
or the app bottom.
- Experimental 'Hide' is now a plain action row, not a switch.
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)
Channel messages had no resend path: unlike DMs (auto-retried by
MessageRetryService), a channel send that never gets its generic ack/repeat-back
goes to failed with no recovery. Adds a "Send Again" action to the channel
long-press sheet for outgoing messages that are not yet acked (status != sent,
i.e. pending or failed), re-sending via sendChannelMessage.
Also renames the existing direct-chat "Retry" action to "Send Again" (matching
stock MeshCore's "Send Again" for cross-client familiarity); DM gating unchanged
(offered at failed, after auto-retry is exhausted). New l10n strings
message_sendAgain + chat_sendingAgain. Build of epic #256.
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.
Switching radios showed the previous radio's channel history (including
its outgoing messages) on the new radio. The in-memory caches
_channelMessages, _conversations, and _loadedConversationKeys are keyed
by channel index / contact key, not by radio, and were never cleared on
a switch; a new radio's empty store could not overwrite them
(_loadChannelMessages only writes on a non-empty read). On-disk stores
are already per-radio (device+PSK since #277), so no re-keying or
migration is needed.
Clear the three caches in _resetConnectionHandshakeState (runs at the
start of every connect). loadAllChannelMessages and _loadMessagesForContact
repopulate from the new radio's store. Adds a regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner placement. Node Settings order is now node name, location, GPS
enable, Button and buzzer, public key.
Epic: #474, #475
Agent: CalmBay (session d14220d9)
A radio without the caps bits has no button and no buzzer to configure,
so it gets nothing: no tile, no screen, no command.
I had changed the tile to appear whenever a radio was connected and
added a diagnostics pane showing its raw caps byte 2. Nobody asked for
that. I added it on my own initiative because a silently absent screen
looked like a broken client, and it broke the negative test #474
specifies verbatim:
"A device that does not advertise the bit shows no screen and the
client emits no command."
"Settings screen appears ONLY when the device advertises the
capability bit"
That negative test is the reason client code ships ahead of firmware:
cross-compatibility with radios that lack the bits can only be exercised
if the client is out in front. My change would have made it fail on
something I invented rather than on anything real.
The tile is now gated on supportsButtonMatrix || supportsNotifyScope.
The pane keeps a matching guard as belt and braces, not a reachable
path. The no-command-emitted half was already correct and is unchanged.
Full suite 740 pass, analyze clean, format clean.
Epic: #474, #475
Agent: CalmBay (session d14220d9)
Three defects. One found by the review, one found while verifying it, one
the review reported at the wrong severity.
1. The device-info reconcile was in the WRONG FUNCTION. It sat inside the
`gps:1`/`gps:0` custom-var handler instead of `_handleDeviceInfo`, so
the headless-UI state was never pulled when caps byte 2 actually
landed. It worked only because the pane re-requests when opened, and
the previous commit message's claim that it re-reads on device-info
refresh was false as written. Now fires where caps2 is parsed.
2. Device-UI state was never cleared between radios. Capability bits
refresh from every device-info frame but the values behind them do
not, so a reconnect could display one radio's notification scope as
another's. Cleared explicitly before re-reading. The review did not
find this one.
3. A confirmed matrix write was silently discarded when no matrix was
held: `_buttonMatrix?.withAssignment(...)` resolves to null and drops
a change the device had already applied. Now re-reads instead.
Review rated this High on the grounds that a user could set an action
before the initial read returned. That path is not reachable: the
dropdown only renders once a matrix exists, so the user cannot fire a
write first. The defect is real, the severity was not.
Also applied the review's structural finding: the dispatcher parsed each
frame to route it and the handler parsed it again. Parsed once and passed
down, so the two copies cannot drift and drop a valid frame.
Full suite 740 pass, analyze clean, format clean.
Epic: #474, #475
Agent: CalmBay (session d14220d9)
Client UI for the button-action matrix (#474) and the device notification
scope (#475), under Settings > Node Settings.
Speaks the canonical 0xC5 contract published by firmware in
OffbandConfigProtocol.h: one command byte, sub-code selects the surface
(0x01/0x02 scope get/set, 0x03/0x04 matrix get/set, 0x7F error). Not
folded into 0xC0, which is observer-only and would make the feature
unreachable on the headless trackers it exists for.
- Notification scope All/Self/None, labelled as this radio's buzzer and
kept distinct from the per-channel app notify mode. Re-read on open and
on every device-info refresh so a scope changed by triple-pressing the
device is never shown stale.
- Button actions per press sequence, assignable only from the action set
the DEVICE reports via its supported-actions mask, so a board with no
buzzer or no GPS never offers a choice it would refuse. Single press
defaults to unassigned.
- Long press is deliberately not assignable. Firmware owns it for CLI
rescue and power off, and remapping it could leave a screenless board
unrecoverable.
- Failures show the device's own reason via the firmware-owned reason
codes, not a generic error, and the banner persists until dismissed.
An unrecognised reason is surfaced with its raw code rather than
swallowed.
- State only ever follows the device's reply, never the request, so the
UI can never show an assignment the radio rejected and nothing is
faked or stored unacknowledged.
- A radio advertising neither capability bit gets a diagnosis, its raw
caps byte 2 and an explicit statement that no command will be sent,
rather than a blank screen that is indistinguishable from a bug.
caps2 bit assignment is firmware-confirmed: 0x01 notification scope, set
only where PIN_BUZZER is defined, 0x02 button matrix. That is the reverse
of the order the epics were filed in, so it cannot be inferred from issue
numbers.
21 protocol tests: gating, frame encoding, a lying count byte, unknown
sequence/action/scope codes, truncated and foreign frames, reason-code
mapping, and that no sequence models a long press.
NOT YET EXERCISED AGAINST A DEVICE. The firmware 0xC5 handler is written
but unmerged, so until it answers, a read shows its loading row and a
write is not confirmed. Hardware validation of the pair is the owner's
gate and has not happened.
Full suite 740 pass, analyze clean, format clean.
Epic: #474, #475
Agent: CalmBay (session d14220d9)
Two defects the adversarial review found, both verified before accepting.
1. `label` trimmed the name for display while the token stayed raw, so
two contacts differing only by surrounding whitespace rendered as
identical rows with no way to tell which one was about to be
addressed. That hides the exact identity #497 exists to preserve.
Display now defaults to the raw name.
2. Candidate deduplication used String.toLowerCase() while matching uses
the ASCII fold. Verified empirically: 'É' and 'é' compare equal under
toLowerCase and unequal under foldAscii, so of two real contacts one
silently vanished from the mention list while remaining matchable.
foldAscii is now public and is the single equivalence rule used for
both dedup and matching.
Regression tests added for both.
Full suite 719 pass, analyze clean, format clean.
Agent: CalmBay (session d14220d9)
The byte was only observable in the app debug log, which makes
validating byte-2 firmware a log hunt instead of a glance.
Renders as "caps2 absent" or "caps2 0xNN" on the existing Offband caps
row, added by #304 for exactly this purpose. "absent" is shown rather
than the row being omitted, because telling "the radio sent byte 2"
apart from "the radio is too old to send it" is the whole point, and
all-bits-zero is a valid present value.
Unblocks hardware validation of firmware PR #515 (#481).
Epic: #474
Agent: CalmBay (session d14220d9)
Adds parseOffbandCaps2 alongside the existing tail-byte helpers, a
_offbandCaps2 field plus getter, and byte 2 in the device-info
capability log line.
Byte 2 sits at offset 84, deliberately NOT adjacent to byte 1 at 82:
offset 83 is already the FEM LNA state byte and every tail field is read
at a fixed absolute offset, so an adjacent insert would shift the FEM
state and make shipped clients misread a bitmask as the LNA toggle.
Firmware appends it at the end of the frame for that reason.
Absence is "no byte-2 capabilities", never an error, so any radio
predating the firmware change reads null and behaves unchanged.
No bit constants yet: byte-2 bits 0 and 1 are earmarked for firmware
epics but neither is claimed, so nothing gates on them here.
Wire contract from OffbandMesh/meshcore-firmware PR #515 (branch
feat/508-caps-byte2, commit 7664c29f). That PR is open pending this
client-side validation, tracked at #481.
Epic: #474
Agent: CalmBay (session d14220d9)
Owner ruling 2026-08-01. Mention autocomplete trimmed the contact name
when building the @[...] token while the device stores and matches it
byte-for-byte, so any name with leading or trailing whitespace was
unmentionable and never beeped. Confirmed on the wire: the contact
record keeps the 0x20, the outgoing mention drops it.
@[...] is a wire token, not display text. Entry, firmware memcpy,
advert encode, advert parse and the contact record are all verbatim by
design; this trim was the only transformation applied to a node name
anywhere, and it changed the identity of the addressee.
- MentionCandidate now separates the two concerns: `name` is raw and
goes on the wire, `label` is display-only and may be tidied. The
const constructor is preserved, so existing const call sites still
compile.
- The candidate builder keeps sender and contact names raw. Emptiness is
probed on a trimmed copy; the stored value is untouched.
- _mentionsSelf drops its own trim as a direct consequence: a raw token
requires a raw comparison, or this node stops recognising mentions of
its own name.
- Contract clause written at both the insertion site and _mentionsSelf,
stating the token is byte-for-byte and that a future .trim() tidy-up
is forbidden. The contract was silent on raw vs normalised, which is
why both sides were reasonable and incompatible.
Deliberately out of scope per the ruling: no entry-side trim in
settings_screen. It fixes no deployed name and would silently alter
deliberate spacing.
Test updated to assert the new truth: ' Ben ' does NOT match @[Ben],
and DOES match @[ Ben ]. Full suite 732 pass, analyze clean.
Agent: CalmBay (session d14220d9)
_mentionsSelf folded with String.toLowerCase(), which applies full
Unicode case mapping. Firmware adopts this same match rule with a
byte-wise fold that does not, so a node name carrying any non-ASCII
character could produce one self-mention verdict on the client and the
opposite on the device for the same message. A silent wrong answer, not
a visible failure.
Owner decision 2026-07-31: both sides fold ASCII A-Z only, so non-ASCII
names compare case-sensitively.
Extracts the rule into a testable static (mentionsName) and documents it
as a cross-repo contract: widening it, whether by accepting a bare
@name, restoring Unicode folding, or anchoring the match, is a breaking
change that ships only in an aligned client and firmware build pair.
Behaviour change: notifications for non-ASCII node names go from
case-insensitive to case-sensitive. Deliberate, per the decision above.
Epic: #475
Agent: CalmBay (session d14220d9)
resolvePathSelection returned no width and, on the override branch, a byte
count in place of a hop count. preparePathForContactSend then called
setContactPath without a width, so encodePathLen packed mode bits 00 and a
2-byte route went out as twice as many 1-byte hops (path_len 0x06 for a
6-hop 2-byte route instead of 0x46). The radio routed on wrong 1-byte
prefixes, confirmed on Bandit's 2026-08-01 log and via CoreScope. Direct
and flood were immune because neither uses the path bytes.
PathSelection now carries hashWidth and a true hop count on every branch,
using the contact's pathHashWidth as the single width authority. Both
setContactPath call sites thread it. Also addresses the override timeout
inflation half of #299 (hopCount was a byte count feeding calculateTimeout).
Gemini review found two override/history width edge cases -> split to #494.
Refs #299
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Profile is now a container of capability-scoped sections (schema_version 2):
- wifi (WifiConfig) — any wifi device
- mqtt (MqttSection = region + status_interval + brokers) — observer capability
so MQTT is defined once and shared by observer / observer-repeater /
observer-companion, never redone per device. Future radio/repeater/companion/
display sections slot in alongside.
Parser reads the sectioned YAML and rejects the old flat v1 layout with a clear
message. Enumerator reads from sections but emits the SAME firmware keys, so
apply/diff/screens are unchanged. 45 config-profile tests updated + green.
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>
Gemini pass 2 flagged that the banner shrinks the viewport while the "no
channels" / "no results" empty-states used a fixed MediaQuery.height - N
SizedBox, pushing the message below the fold. Replace both with
LayoutBuilder + SingleChildScrollView + ConstrainedBox(minHeight:
constraints.maxHeight) so they center in the actual available space (banner
or not) and stay pull-to-refresh scrollable. Removes the fragile -200/-300
magic numbers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini flagged two unhandled PlatformException paths (SAFELANE §6):
- isIgnoringBatteryOptimizations could throw -> banner silently never shows;
now caught + logged, leaving the banner hidden on an unknown status.
- openIgnoreBatteryOptimizationSettings could throw -> button did nothing with
no feedback; now caught + logged + a snackbar tells the user it failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface the warning on the connection/device-selection screen so the user
can fix battery settings before connecting, not only after landing on the
channels screen. Reuses BatteryOptimizationBanner (Android-only, self-checking).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On aggressive OEMs (Samsung One UI) a backgrounded app that is not exempt
from battery optimization is slept on screen-off and drops the radio
connection, with no warning. Add a persistent banner on the channels screen
that appears (Android only) when the app is not exempt, explains the risk,
and deep-links to the battery settings via
openIgnoreBatteryOptimizationSettings(). Re-checks on resume so it clears
once the user applies the change; dismissible for the session.
No restricted permission: reads own status and opens the settings screen
(ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); the Play-restricted
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS path is deliberately avoided.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Firmware #465 now writes RX RSSI into reserved2 (byte[3]) of the v3
contact-msg-recv frame as a clamped int8 dBm, 0 when unset
(MyMesh.cpp:550-551). Read it in the parse instead of skipping: gate on
!= 0 (RSSI is always negative for a real RX, so 0 = no data), null for
device-composed outgoing messages.
reserved1 (byte[2]) is untouched — that's #429's outgoing flag; RSSI
lives in byte[3] after the res1/res2 collision fix (#464/#465).
The Message.rssi field, persistence, param-passing, and the (hidden-
while-null) RSSI row on the Packet Path screen were all staged in #438,
so this is just the wire read + 3 gate tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Top-level tabs (Contacts/Channels/Map) no longer auto-imply an AppBar
leading. They build via appBarBuilder(pinned): no leading when the nav
panel is pinned (desktop), the drawer hamburger when transient (mobile).
Detail screens are untouched and keep their working back button.
Removes the unused hideBackButton constructor param (declared, passed
true at quick-switch call sites, never read) and all its call sites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Smaz is a client-side text convention with no MeshCore protocol or firmware
support (findings: GH-315). When enabled it compressed ordinary prose into
`s:`+base64, which any non-lineage client renders as garbage — the same
cross-client interop failure as the old `g:<code>` GIF token.
Phase 1 of the Smaz-removal epic (GH-314): stop sending Smaz and remove the
user-facing surface, while KEEPING decode so messages from lineage peers still
on Smaz, and any legacy compressed rows, keep rendering. Decode removal is
deferred to Phase 2 (GH-421).
Removed: the Smaz branch in prepareContact/ChannelOutboundText (Cyr2Lat branch
and the structured-payload guard preserved); connector state/API (the enabled
maps, is*/set*SmazEnabled, ensureContactSmazSettingLoaded, the warm-up and
channel loaders); the per-channel and per-contact toggles plus their
mutual-exclusion; loadSmazEnabled/saveSmazEnabled and the `*_smaz_` key prefixes
(stores kept, they also hold Cyr2Lat); l10n `channels_smazCompression` and the
orphaned `chat_compressOutgoingMessages` across 18 locales (+ regenerated
app_localizations).
Retained for Phase 2: the 5 Smaz.tryDecodePrefixed decode sites and
helpers/smaz.dart.
Safety (verified): no storage migration, the app already persists plaintext
(receive decodes before store; send stores the pre-compression text), so the
`s:` form was wire-only. ACK matching is unaffected, the expected hash derives
from prepare*'s output, so dropping compression keeps both sides hashing
plaintext.
Behavior change: the composer byte-counter now reflects raw size, so anyone who
had Smaz on can type slightly fewer chars per message.
Tests: gif_url_outbound_guard_test rewritten to pin plaintext passthrough and
decode retention. Full suite green, analyze clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_confirmDanger awaited showDialog then called _apply unconditionally; if the
screen was disposed while the dialog was open, _apply's setState would throw.
Guard with && mounted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
ConfigProfileImportScreen: browse the curated catalog (default OffbandMesh
profiles.json) or enter any source URL (tail-detection routes catalog vs
single profile), then open the #406 preview/apply screen with the fetched
profile. Surfaces skipped-entry counts and fetch/parse errors inline.
Observer settings gains an 'Import config profile' entry that opens it with the
live ObserverConfigService. Completes the observer chain (#404-407); ready for
the #408 hardware test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- config_profile_diff.dart: pure current->new diff builder (add/change,
drops no-ops, flags danger + secret rows). 4 tests.
- splitProfileWrites: partitions writes into safe vs credential/identity for
the two-tier apply; a mixed broker splits, enabled rides the safe half only.
- ConfigProfilePreviewScreen: full sub-screen — reads current state, renders
the diff (amber overwrites, red danger section), normal Apply for plain
config + a separate red gate (with confirm dialog listing exactly which
credential/identity values change) for the danger set. Secrets masked.
Re-diffs after apply for partial-save recovery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Tapping a DM opens the Path screen; it now shows the RF details the app
was already receiving but discarding:
- SNR: captured from the v3 contact-msg-recv frame (was skipBytes(1)).
Firmware sends (int8)(snr_dB*4), so dB = byte/4.0 (MyMesh.cpp:512) —
the old commented-out code multiplied by 4, which was 16x wrong.
- Path type: firmware sends path_len 0xFF for a direct/routed frame and
the hop count for a flood (MyMesh.cpp:545). The app collapsed 0xFF->0,
colliding "direct" with "flood, 0 hops". Capture the distinction into
Message.isFloodRoute so the Path row reads "direct (routed)" vs
"flood, N hops".
- RSSI: row wired to Message.rssi but left null — the wire byte is a
hardcoded reserved 0 today; populated once firmware ships it (#439).
Message gains snr/rssi/isFloodRoute (nullable, additive, persisted like
rxTime). 10 tests cover scaling, the 0xFF discriminator, and copyWith.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini review (standards#145) flagged that a burst of 0x91 pushes (e.g. a
bulk channel edit on the device) would each trigger a full getChannels
re-sync. getChannels already guards concurrent syncs (_isSyncingChannels),
so there is no request flooding, but sequential re-syncs after each completes
would repeatedly clear+repopulate the channel list (UI flicker). Coalesce the
burst with a 400ms debounce so it settles into a single re-sync. Timer is
cancelled in dispose().
Citadel: meshcore-open-p12
Client half of the coordinated firmware+client change (firmware wadamesh#50,
verified against source).
Part A: handle push code 0x91 (pushCodeChannelsChanged) by re-polling
getChannels(force: true), so channels added/removed on the device appear in the
client without a reconnect.
Part B: read reserved1 bit0 of the V3 contact and channel message frames as an
outgoing flag. A message composed on the device (DM or channel) now renders as
sent-by-me (isOutgoing: true) instead of received. The channel self-echo guard
is bypassed for outgoing so device-composed channel messages are not dropped.
Removed the dead upstream hasPath/path-bytes branch in ChannelMessage.fromFrame
(no firmware, stock or wadamesh, appends path bytes to this frame; verified).
Backward-compatible: old firmware sends 0 (received, as today); an old client
ignores the bit.
Citadel: meshcore-open-p12
Gemini adversarial review flagged the getter's clamp: the floor used the
ATT_MTU minimum (23) where it should use the writable minimum (20 = 23-3),
overstating the cap by 3 for tiny MTUs; and an unknown MTU defaulted to
maxFrameSize (172), the wrong direction for a safety cap. Floor at 20 and
default an unknown MTU conservatively. No behaviour change on radios that
grant MTU >= 175 (they hit the maxFrameSize short-circuit).
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>
Gemini review finding (medium): _handleErrorFrame only failed the caplog
download on RESP_CODE_ERR; control ops (enable/disable/erase) also get
RESP_CODE_ERR when the device is busy (firmware rejects non-STATUS ops while a
stream is in flight), so their completers hung 5s then threw a misleading
TimeoutException. Now fail the pending ack/status completers fast with
CaplogBusyException. analyze clean; full suite 643 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the received CHUNK-frame count to CaplogTruncatedException and the
on-screen error ("received X of Y bytes in N chunks"). This diagnostic
pinpointed the near-full BLE truncation: 94 chunks (all frames arrived) but
each full frame 3 bytes short = BLE MTU clipping 176-byte caplog frames to
173. Firmware chunk-size cap for BLE tracked with TopazHill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From Ben's testing:
- The buffer readout went stale while idle (STATUS poll only ran during
capture). Split the poll from the elapsed tick: the STATUS poll now runs
continuously while the screen is on a connected caplog device (stops only on
disconnect/dispose); the 1s elapsed tick stays capture-only.
- An empty download now shows "Buffer is empty - nothing to download" instead
of saving a 0-byte file.
analyze clean; full suite 643 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frame decoder capped companion frames at 172, but the firmware's real
MAX_FRAME_SIZE is 176 (BaseSerialInterface.h, "+4 for transport codes").
Full-size caplog CHUNK frames (176 B) were silently rejected; only the final
sub-172 partial chunk survived, so a download reassembled just the last chunk
(e.g. "received 95 of 5141"). Caplog is the first feature to use full frames,
so nothing exposed this before.
- usb_serial_frame_codec.dart: usbSerialMaxPayloadLength 172 -> 176.
- serial_capture_screen.dart: erase-on-start (Start / Start&Reboot) for a clean
session ("didn't start at 0").
- 3 decoder regression tests; 113 tests total green; analyze clean.
Root cause confirmed with firmware (TopazHill): firmware streams the full
buffer correctly (wire trace 5175/5175); the client decoder dropped oversized
chunks. My earlier firmware-ring hypothesis was wrong.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the reboot-test failure (support latched "unsupported" after the
reconnect window) and reworks the boot-log UX per Ben.
- meshcore_protocol.dart: offbandCapCaplog (0x20) + firmwareSupportsOffbandCaplog
(0x20 bit AND FIRMWARE_VER_CODE >= 17), mirroring the block-cap pattern.
- meshcore_connector.dart: supportsOffbandCaplog getter.
- serial_capture_screen.dart: gate support on the static cap bit (reactive via
the connector), not a one-shot STATUS probe, so it never latches "unsupported"
after a reboot. Derive capturing state from device STATUS so an auto-resumed
capture (post firmware #428) shows STOP not START. Cancel timers on disconnect,
re-query STATUS on reconnect. New red "Start & Reboot" (no timer) boot-log flow.
- 4 cap-gate unit tests; 18 caplog tests total green; analyze clean.
Root cause confirmed with firmware (TopazHill): caplog cap bit (0x20) is
advertised statically across reboots; the client's probe raced the reconnect
window and latched. Firmware #428 (persist flag + boot capture) is the other half.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Confirmation-gated Reboot action on the serial-capture screen so an operator
can enable capture, reboot the radio, and record the boot log for retrieval
(the #428 boot-log flow). Available while capturing; uses the existing
connector.rebootDevice(). Full boot-log capture needs firmware retained-enable
(#428); the client affordance lands now.
flutter analyze clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2b UI. SerialCaptureScreen drives the 0xC4 caplog control + download
from the prior commits: probe support (STATUS) on open, start/stop capture
(default 5-min window or until stopped) with live elapsed + auto-stop +
buffer fill, download to a file handed to LogExport.shareFile, and erase.
Reached from Settings > Debug.
English-only strings for now (localization follow-up, mirrors LogExport
#427); capture duration is screen-local (persisting it is a small follow-up).
flutter analyze clean on all touched files; 14 connector unit tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the 0xC4 caplog protocol with the companion control sub-codes the
firmware exposes (the companion has no CLI; #395 CLI verbs are repeater-
only). Firmware #417/#408.
- meshcore_protocol.dart: request sub-codes ENABLE/DISABLE/ERASE/STATUS +
builders; ACK / STATUS parsers (CaplogAck, CaplogDeviceStatus).
- meshcore_connector.dart: setDeviceCaplogEnabled / eraseDeviceCaplog /
getDeviceCaplogStatus; 0xC4 response routing split so ACK (0x10) and
STATUS (0x11) dispatch to own completers, download stream unchanged.
- 5 unit tests for builders + parsers; analyze clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the client half of serial-capture (#430): the protocol layer
to download the device's serial-capture buffer over the companion link.
- meshcore_protocol.dart: cmdOffbandCaplog / respCodeOffbandCaplog = 0xC4
(NOT 0xC3, which collides with cmdOffbandFemLna; see firmware #406),
START/CHUNK/END sub-codes + request builder.
- caplog_reassembler.dart: pure START/CHUNK*/END reassembly state machine
with truncation detection, unit-tested in isolation.
- meshcore_connector.dart: downloadCaplog() + 0xC4 frame dispatch + fast
busy-reject on RESP_CODE_ERR while awaiting START.
- 9 unit tests passing; flutter analyze clean.
Integration test gated on the firmware 0xC4 fix merging. Not pushed
(human-test gate).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- LogExport.shareLogs/shareFile now resolve ScaffoldMessenger and l10n strings
off the context BEFORE the flush await, and _exportFile takes them as values
(no BuildContext use across the async gap; avoids a deactivated-ancestor crash
on Back-during-flush).
- Web download: append the anchor to the document before click() (Firefox needs
a connected anchor) and revoke the object URL on a delay (synchronous revoke
can abort the download in Safari/iOS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The share helper only invoked the OS share sheet on mobile; on all desktop it
opened the containing folder, which for a temp-dir capture dumped the user into
TEMP amid unrelated files. Now:
- Android/iOS: OS share sheet (unchanged).
- Windows/macOS/Linux: native Save As dialog (file_selector) writing the file
to a user-chosen location.
- Web: browser download of the log text (no on-disk file on web).
Adds file_selector; web download via a js_interop helper behind a conditional
import. Folds in #427 (localized share strings). LogExport.shareFile keeps a
compatible signature for the serial-capture screen (#430).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route LogExport's Share tooltip, share subject, and file-logging-unavailable
snackbar through context.l10n instead of hardcoded English. Adds four keys to
app_en.arb (debugLog_shareLog, debugLog_openLogsFolder, debugLog_shareSubject,
debugLog_fileLoggingUnavailable) and regenerates all locales (English fallback
until translated). Resolves the deferred Gemini finding from #393.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the BLE screen's on-disk log export into lib/utils/log_export.dart and
reuse it on the App-log screen, so both log screens have one consistent Share
action: OS share sheet on mobile, open logs folder on desktop. The shared file
already holds the app log and BLE frames combined.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address 3 review findings (all confirmed real): thread section context through
the optional-field accessors so nested errors name the broker (brokers[i]."port"),
reject broker ports outside 1..65535, and reject negative integer fields
(status_interval, jwt_refresh) via a shared _optUint. +3 tests (14 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the yaml dependency and parseConfigProfile(): YAML -> ConfigProfile
(#402). Strict by design since profiles are untrusted input (#139) — unknown
keys, wrong types, out-of-range/duplicate broker slots, and unknown
transport/auth values all throw ConfigProfileFormatException with a
user-facing message. Only keys present populate the model. 11 unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Typed model for importable device config profiles (feature #136): WifiConfig,
BrokerConfig (6 slots), region/status-interval, and a ConfigProfile container.
All fields nullable so apply engines write only the keys a profile sets.
Key names + wire encoding mirror the firmware ConfigSchema (transport/auth as
string names, wifi.pwd write-only). ConfigKeys centralizes the key strings so
the parser (#403) and per-device apply engines never hard-code them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No in-app signal of which binary is running has caused repeated confusion
(a debug-signed APK silently not installing over the release app, old layout
persisting with nothing to indicate the new build never landed).
BuildInfo reads GIT_SHA/GIT_BRANCH/BUILD_TIME from --dart-define with dev
fallbacks, so every build carries its own identity independent of the pubspec
version. Surfaced as its own copyable Build row in Device Info.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stores are keyed by the first 10 hex of the connected radio's public key, so
connecting a different radio silently swaps which contacts, channels, and
history you are viewing with nothing in the UI saying so. Device Info showed
the full public key but never tied it to storage.
Adds a Data scope row with the key actually in effect, plus a one-line
explanation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wadamesh keys its history watermark (last_delivered_seq) by client_id. We
sent none, so we shared the empty-string slot with every other MeshCore
client on the machine: whichever connected first drained the device history
ring and the next app got NO_MORE_MESSAGES for frames it never received.
cid_len is 6 by necessity, not preference. Stock reads cmd_frame[1..7] as
reserved with the app name at a fixed offset 8; Wadamesh reads the name at
2 + cid_len. Only 6 puts the name at 8 on both, so one frame serves both
firmwares with no firmware change. Covered by app_start_frame_test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Groups the list by kind: configuration (Node, Radio, Radio Stats, Privacy,
Contacts, Blocked, Messages, Observer, App Settings), then readout (Device
Info), then rarely-used Actions, then Debug.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Advertisement Notifications" reads as ads. Retitled to "New node discovered"
/ "Notify when new repeaters or contacts are heard" across all 18 locales so
the overnight-ping setting is findable without mesh jargon.
Coverage verified, no gap found: showAdvertNotification is the only discovery
notification entry point and all three of its callers
(meshcore_connector.dart:4961, 5047, 7226) are guarded by
notificationsEnabled && notifyOnNewAdvert && !isBlocked. The batch summary is
fed solely from enqueued adverts, so it cannot fire with the toggle off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extracts the #262 notify-mode selector out of channels_screen into a shared
widgets/channel_notify_mode.dart (key, icon, label, dialog) and points both
entry points at it, so the Channels list and the in-channel menu cannot drift.
Same PSK-keyed storage; no new settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a KeepScreenAwake controller that holds a wakelock_plus lock while the
setting is on and the app is foregrounded, releasing on background, on toggle
off, and on dispose. Toggle lives in App Settings > Battery (power tradeoff),
defaults OFF, persisted as keep_screen_awake. Strings added for all 18 locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The App Settings pane carried a Debug card holding only the app-debug-logging
switch, while the Debug category already owned the log viewers. Moved the
switch next to the App debug log viewer it controls and dropped the now-empty
Debug card from App Settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notification settings moved to the Messages category, but the App Settings
tile subtitle still advertised them. Retitled across all 18 locales to match
what the pane actually renders (appearance, translation, battery, map, Cyr2Lat).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review caught a data-loss defect in the reactor-name
fallback. For a reaction in a room from an author not in the local
contacts, _resolveContactSenderName returns null and the chain fell
through to reactionContact?.name. But reactionContact is the room server,
not the reactor, so every unknown-author reaction was attributed to the
room's display name. Two distinct unknown authors then both resolved to
that one name, so applyReaction's per-reactor dedup treated the second as
a duplicate and dropped it, count and all.
Fall back to the per-author hex (from the frame's own fourByteRoomContact
Key) instead, which is unique per reactor. In a true 1:1 that hex is
empty and the contact genuinely is the reactor, so its name stays the
correct fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reactions stored only a Map<String,int> of emoji to count, so "who
reacted" was unanswerable and MeshCore One's tap-to-see-who had nothing
to show against. This captures the reactor for every reaction, both our
own r: format and the PocketMesh / MeshCore One format.
Data layer only. The tap-a-badge-to-see-who UI is a separate follow-on;
this makes the data available and does not change any screen.
Additive by design. A new reactionSenders field (Map<String,List<String>>,
emoji -> reactor names) sits alongside the existing count map, serialised
under a new JSON key. Both maps are always written. An older build reading
a newer store ignores the unknown key and still gets correct counts from
reactions; it never hits the hard `value as int` cast that would fail the
whole message-list load (the #355 data-loss shape). Records written before
this field load with an empty sender map, so their counts survive with
names simply absent.
- ChannelMessage + Message: new reactionSenders field, constructor,
copyWith
- both stores: serialise the new key; deserialise via a shared
ReactionHelper.reactionSendersFromJson that returns empty for a missing
key and skips malformed entries rather than throwing
- applyReaction: records the reactor and dedups per reactor per emoji.
This is persistent dedup (survives restart), unlike the connector's
in-memory processed-set. A pre-existing count with no sender list is
incremented from its stored value, not recomputed from the partial
list, so old counts are preserved.
- connector: threads the reactor name into all reaction paths. Channel
uses the frame sender; a room resolves the author via
fourByteRoomContactKey; an outgoing reaction is attributed to self.
- pending queue: retry now hands the stored reactor to its callback
Tests: reactor capture, two-reactor count, same-reactor no-double-count,
pre-#383 count preservation, and JSON round-trip + backward-compat +
malformed-entry handling for the new field. Full suite 604 passing.
Epic #376.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The button used Icons.ios_share for all mobile, so Android showed the iOS
share glyph. Split by platform: Android -> Icons.share, iOS -> Icons.ios_share,
desktop -> Icons.folder_open (reveal the saved logs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two of three Gemini findings held up against the code; the third did not.
Accepted, message-swallow risk: the emoji check looked only at the first
rune against a range list that included arrows, so a multi-line message
beginning with an arrow and ending in eight Crockford characters would
have been consumed and displayed as a reaction whose "emoji" was the
whole sentence. Ben's own capture has U+2192 mid-sentence in ordinary
channel traffic, so this was reachable, not theoretical.
- drop U+2190-U+21FF and U+2934-U+2935; arrows carry the Unicode Emoji
property but read as punctuation in prose
- cap the emoji segment at 8 runes, which is clear of the longest ZWJ
sequence and nowhere near a sentence. This is the guard that holds
regardless of how the range list evolves.
Accepted, dedup: the contact path keyed on target hash plus emoji only.
A room server is many-party over the 1:1 transport, so two members
sending the same emoji collapsed into one and the count stuck at 1, the
same defect already fixed on the channel path. The reacting author
prefix is now part of the key; in a true 1:1 it is empty and the key is
unchanged.
Rejected, room-server sender matching: the review claimed getSenderName
resolves to the room itself and that room text carries a "Sender: "
prefix. Neither is true. _resolveContactSenderName resolves the author
via fourByteRoomContactKey to the real per-sender contact name, and room
message text is stored bare with the author in its own field. One real
sub-case survives: an author who is not in our contacts resolves to null
and will not match, which degrades to queue-then-expire with a warn log.
Also rejected: switching @[ lookup from first to last occurrence. The
reference implementation uses the first, and diverging risks mismatching
payloads it accepts.
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>
AppShell decided top-level vs detail by `selectedIndex != null`, but pushed
detail screens (a channel chat, the LOS map) also set selectedIndex to keep the
bottom bar visible. So Back from inside a channel backgrounded/left the app
instead of popping to the channel list.
Decouple the two concerns: add `isTopLevel` (default true), separate from
`selectedIndex`. Detail screens pass `isTopLevel: false` (keep the bar, but Back
pops). The decision is extracted into a pure `AppShell.backAction`
(drawer -> close; detail with a route below -> pop; else -> background), with an
assert that a detail is actually poppable. Unit tests cover the matrix; widget
tests exercise the real system-Back -> PopScope -> pop wiring.
Follow-up #390 tracks the separate AppBar back-arrow path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the database can't open (e.g. the native sqlite library fails to load),
every read/write silently failed and the app opened to an empty, normal-looking
screen — the user thinks their history was wiped (SAFELANE §6 violation).
Probe the storage layer in main() before any store reads
(BlobStore.verifyReadWrite). On failure, a StorageHealthService one-way latch
records it, and a persistent, non-dismissable banner is shown above the whole
app: history is not lost, storage is unavailable, messages are NOT being saved,
restart after fixing. Cross-platform (probe goes through drift, covers web too).
Tests: service state/latch + banner show/hide widget test. Gemini-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#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>
The VACUUM INTO snapshot import pulled dart:ffi via package:sqlite3, which
does not compile on web. Isolate it behind a conditional import (native impl
+ web stub); the migration is native-only and never runs on web.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DB directory came from getApplicationSupportDirectory(), which on Windows
is derived from the executable's Company/Product version metadata. Two builds
with different metadata (or a future rebrand) resolved DIFFERENT %APPDATA%
folders and read DIFFERENT databases, so a user's history appeared to vanish
when they ran a different build.
Pin the desktop DB to a constant path (Windows: %APPDATA%\Offband MeshCore;
Linux pinned too, its support dir derives from the exe name). On first run at
the pinned location, migrate an existing DB in via SQLite VACUUM INTO (a
consistent snapshot that is safe even under a concurrent writer), choosing the
DB from the known canonical locations first and falling back to a bounded
scan of the app-data roots (no name blocklist) so a DB under an unknown
folder is still found. On any snapshot failure it aborts cleanly, leaving the
source intact and opening a fresh DB - never a raw file copy of a live WAL DB.
Adds sqlite3 as a direct dependency (pinned to drift 2.34.2's resolved 3.5.0).
Tests cover source selection, the folder-agnostic scan, and the snapshot
happy + abort paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-contact settings dialog (compression + telemetry grants) was
reachable only from a contact's chat via ellipsis -> Contact settings.
Extract it to a shared showContactSettingsDialog() and add a "Contact
settings" entry as the first item of the contact long-press/right-click
menu, so it opens the same surface from both places.
chat_screen's _showContactSettings now delegates to the shared dialog;
its _buildInfoRow stays (still used by the contact-info dialog).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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).
The migration's already-present branch called prefs.remove(key) and threw
the prefs copy away when drift already held the key. A build that writes to
SharedPreferences (a non-drift build, or any in-between test build) collects
new messages there, so the next drift run silently gapped that history out.
It cost 574 real channel messages, recovered from backups.
Union the prefs copy into drift by element identity (messageId, else
publicKey, else canonical JSON), keeping drift's live copy on a collision and
appending prefs-only elements. Verify the merged write before removing the
source. New `merged` counter in the report.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the stopgap (53f87f5) that committed
lib/storage/drift/offband_database.g.dart to green CI. Restores the
repo's "generated files are not committed" convention:
- dropped the .gitignore negation; the .g.dart is gitignored + untracked
(git rm --cached, working copy kept)
- added `dart run build_runner build --delete-conflicting-outputs`
immediately after `flutter pub get` in every Dart job: flutter_dart
(analyze), build.yml (all 6), release-signed (android-signed),
deploy-web (before build_pipe)
- keeps the drift_dev 2.34.0 / drift 2.34.2 / build_runner ^2.15.1 pins
from the stopgap for deterministic generation
Verified locally on the bench toolchain (3.44.1): deleted the working
.g.dart, ran build_runner from scratch (regenerated cleanly), confirmed
the generated file passes `dart format --set-exit-if-changed` and full
`flutter analyze --fatal-infos --fatal-warnings` is clean with the file
generated (not tracked).
For #335 / PR #348. Agent: SapphireCompass (session 8d755b5e)
CI failed on every job: offband_database.g.dart (drift's build_runner output)
was gitignored and no workflow runs build_runner, so analyze and all six
platform builds hit "Target of URI hasn't been generated". drift is the first
code in this repo needing codegen, which is why dev's CI never had to.
The workflows are not matrixed - six separate build jobs plus analyze, web
deploy and release-signed, each with its own `flutter pub get`. Adding a
codegen step to all of them is wide and easy to miss one. Instead the generated
file is committed (drift supports this): one file, works across every job and
workflow with no CI edits, and reproducible.
drift_dev is pinned (2.34.0) alongside the already-pinned drift, so a future
regeneration produces the same file rather than drifting from the committed
copy. Regenerated against the pinned versions before committing.
Follow-up worth having: a CI check that regenerates and diffs, so a schema
change without regen fails loudly rather than shipping a stale .g.dart.
Adversarial review (standards#145) of the storage/migration surface found four
real defects, all data-integrity. All confirmed against the code and fixed; no
false positives.
BLOCKER - concurrent save race. saveChannelMessages / saveMessages are
read-modify-write with an await gap, so two saves to the same key raced and the
second clobbered the first, silently losing messages (e.g. a message and its
delivery ack arriving together). BlobStore now provides synchronized(key, ...),
a per-key operation chain; every RMW - merge-save, remove, and the load-path
legacy migration - runs through it. Different keys stay concurrent. New test
fires two concurrent saves and asserts the union survives.
BLOCKER - non-atomic DB relocation. The documents->support move copied and
deleted each file (.sqlite/-wal/-shm) in turn, so a failure after the main file
moved stranded the DB across two locations and corrupted it. Now copies all
files, verifies each by size, and only then deletes the sources; on any failure
it rolls back the destination and leaves the original intact.
MAJOR - load-path legacy migration raced save. The #194 index->PSK adoption did
a blind write to the PSK key that could clobber a save that landed first. It now
runs under the key lock and MERGES (union) instead of overwriting, so both the
adopted history and any fresh message survive.
MINOR - channel merge key lacked a sender. Two senders posting identical text at
the same timestamp without a messageId would collide and lose one. The key now
includes the sender, matching MessageStore.
flutter analyze clean, dart format clean, 509 tests pass. Full Gemini log in
docs/llm-consultations/.
The persisted store was being overwritten with the windowed in-memory list, so
any channel or DM with more than _messageWindowSize (200) messages lost
everything older than the newest 200 on the first save after load. Observed
live: Public went 232 -> 201 in one session on a build that already had the
#333 fix, so this was not the race - it was windowing truncating the store.
This is the slow-erosion cause behind the whole 566 -> 231 -> 206 -> 201
history.
saveChannelMessages / saveMessages now MERGE into the persisted set instead of
overwriting: upsert by message identity so older persisted messages are kept,
new ones added, and the in-memory copy wins for edits/reactions/status. If the
existing history fails to decode, the save aborts loudly rather than merging
into an empty base and truncating (SAFELANE 6).
Deletion is now an explicit path - removeChannelMessage / removeMessage - since
the app deletes individual messages. Routing delete through the merging save
would resurrect them; the connector's deleteChannelMessage / deleteMessage now
call the explicit remove.
Windowing stays for display and memory; it no longer dictates what is stored.
Tests: 250-message history survives a 200-window save; new message appends;
edit is captured not duplicated; delete does not resurrect. 508 pass, analyze
and format clean.
Cost: a save now re-reads and re-encodes the channel/contact history. Cheap
with drift's per-key writes (#335); a future append-only schema removes even
that.
drift_flutter's native default is getApplicationDocumentsDirectory(), which on
Windows resolves to the user's Documents folder - redirected into OneDrive on
most machines (verified: drift_flutter 0.3.1 connect.dart). A live SQLite file
syncing to OneDrive risks lock contention and corruption, and it is simply the
wrong place for app data.
The DB now opens in getApplicationSupportDirectory() (%APPDATA% on Windows),
the same place SharedPreferences already lives, so all app data sits together.
Existing installs already have a DB in the old location, so opening a fresh one
there would abandon their migrated history. _appSupportDatabaseDirectory()
relocates it once on first run: it moves offband_store.sqlite and its -wal/-shm
sidecars from the documents dir to the support dir before drift opens, and
never deletes a source without a successful copy. If relocation fails it is
logged loudly and a fresh DB is created, with the migration re-running from
SharedPreferences rather than silently losing anything.
Web is unaffected: path_provider has no web backend and drift ignores
databaseDirectory there, using OPFS/IndexedDB.
Adds `path` as a direct dependency (was transitive).
flutter analyze clean, tests pass.
Wires the migration into startup and points the four bulk stores at drift.
SharedPreferences keeps the settings, which is what it is for.
Startup runs the migration after prefs are up and BEFORE any store reads, and
awaits it. Letting stores race a half-finished migration is precisely the
shape of #333, where a storage path silently chose the wrong key and 566 real
messages read as empty.
Converted: message_store, channel_message_store, contact_store,
contact_discovery_store. All four now have ZERO prefs get/set for bulk data.
Three safety properties, deliberately built in:
1. readWithPrefsFallback - if a key is somehow not in drift, the prefs copy is
still served rather than reading as empty. It logs a WARNING when it fires,
because a fallback during normal operation means the migration is
incomplete and someone needs to know. Silence here is what made #333 look
like data loss.
2. deleteEverywhere / keysWithPrefix span BOTH backends. A clear that only
removed the drift row would leave a pre-migration prefs copy to reappear
through the fallback, resurrecting deleted history.
3. The legacy-key migrations inside the stores now check both backends and
only touch prefs when a legacy key actually exists. This also carries the
#306 fix into this branch: the unconditional prefs.remove was still present
here, since this branched from dev rather than from the #306 work.
Verified: analyze clean, format clean, 504 tests pass, Windows and web both
build. Migration rehearsed earlier against a copy of a real 7 MB store: 69
keys, 5.21 MB, zero failures.
NOT yet run against live data - that happens on first launch of this build,
and the owner should have a prefs backup before that.
Moves message history, contacts and discovered contacts out of the settings
store. Settings stay in SharedPreferences, which is what it is for.
Ordering is the whole safety argument: WRITE, VERIFY BY READING BACK, and only
then remove the source. #333 was a storage path that chose a key silently and
made 566 real messages read as empty; deleting before verifying would make
that class of mistake permanent instead of cosmetic. On any failure the source
is left intact and the error is logged - never a silent drop (SAFELANE 6).
Idempotent by construction: a key already present in drift is not overwritten,
so re-running is a no-op. If an older build re-writes a migrated key into
prefs, the migrated copy wins and the stale prefs copy is discarded rather
than promoted.
Rehearsed against a COPY of a real 7 MB store, as the plan required before
touching live data:
REHEARSAL: 69 migrated, 0 failed, 5.21 MB, 69 bulk keys expected
Every key checked for exact length, confirmed removed from prefs, and every
settings key confirmed untouched. The live store was never opened.
Two things the tests caught that review would not have:
1. getString THROWS on a non-string value rather than returning null, so a
non-string under a bulk prefix was counted as a migration FAILURE. It now
type-checks with prefs.get() and skips. Alarming falsely is its own bug.
2. The `contacts` prefix was checked against the real store rather than
assumed: it matches only the 6 bulk contact blobs, and correctly does NOT
match contact_unread_count*.
Not yet wired into app startup - that is the switchover, and it is deliberately
a separate commit so this can be reviewed on its own.
flutter analyze clean, dart format clean, 504 tests pass.
Step 1 gate, web half. Building is not evidence, so this was run in a browser.
Assets: sqlite3.wasm (748 KB) and drift_worker.js (355 KB), both taken from
the SAME drift-2.34.2 release so they are built against each other. Validated
before use - the wasm has correct WebAssembly magic (0061 736d) and carries
SQLite 3.53.3; the worker is genuine compiled Dart JS.
Note the filename: the release ships `drift_worker.js`, not the
`drift_worker.dart.js` the docs name. The database URI was corrected to match,
which would otherwise have been a silent 404 at runtime.
Browser result, served over HTTP with COOP/COEP and Content-Type:
application/wasm:
Using WasmStorageImplementation.opfsLocks
PROBE-OK open+write+read
PROBE-OK 6MiB round-trip (localStorage cap is 5MiB)
drift selected OPFS, the top storage tier, and round-tripped 6 MiB - data the
current localStorage-backed web build physically cannot hold. No exceptions.
Adds web/_headers for Cloudflare Pages: forces application/wasm on the wasm
asset and sets COOP/COEP for cross-origin isolation. Verified to ship into
build/web. Without COOP/COEP drift still works, falling back to IndexedDB,
which is slower but still unbounded next to localStorage - a performance tier,
not a correctness requirement.
web_probe/ is a standalone entrypoint that exercises ONLY the drift stack, so
a pass or fail is unambiguous without BLE and the rest of the app in the way.
It is not part of the app build.
Still no user data touched and no store switched over. The migration is the
next step.
flutter analyze clean, dart format clean, 497 tests pass.
Step 1 of #335, deliberately before any data migration: prove the store works
before trusting it with message history.
Adds drift + drift_flutter, a key/value StoredBlobs table mapping 1:1 onto the
existing SharedPreferences keys, and verifyReadWrite() as a runtime probe.
Key/value rather than relational tables on purpose: it maps directly onto the
current store interfaces, so callers do not change and the migration can be
verified row-for-row against the old keys. Relational schemas come later, once
this is proven and querying is actually wanted.
Verified here:
- 3 tests pass, including a 6 MiB round-trip - larger than the 5 MiB
localStorage cap that makes the web build impossible today
- flutter analyze clean
- Windows release build succeeds
- Web release build compiles
Two findings from the gate that a plan-on-paper would have missed:
1. sqlite3_flutter_libs 0.6.0+eol is a DEPRECATED no-op. From sqlite3 v3.x it
is unnecessary and drift_flutter already covers it, so it is not a direct
dependency here.
2. The web build COMPILES BUT WOULD FAIL AT RUNTIME: drift needs sqlite3.wasm
and drift_worker.dart.js shipped in web/, and neither is present. Per drift
docs they are downloaded from GitHub releases, version-matched to
pubspec.lock (drift 2.34.2, sqlite3 3.5.0), and the server must serve .wasm
as Content-Type: application/wasm. Not done here - downloading files needs
explicit human approval.
Also noted: the drift_dev CLI does not compile at these versions
(allSchemaEntities missing from the drift3_preview GeneratedDatabase), so its
asset tooling is unavailable. build_runner code generation is unaffected.
No user data is touched. No store is switched over. The migration is the next
step and remains gated on the web assets question.
Measured, not guessed. Cumulative per-code frame timing named the owner:
frame handler cumulative (2002ms total sync): code=3 2002ms/105calls
code=3 is RESP_CODE_CONTACT, and it accounted for ALL of the synchronous
frame time at ~19ms per contact. That is why a per-call 100ms threshold never
fired: no single call is slow, the volume is.
The 19ms is _handleContact calling notifyListeners() for every contact. Each
notification is a synchronous full-tree rebuild, and each rebuild walks the
contact list, so the cost grows with the address book. The channel handler
directly alongside already guards its notify with _isLoadingChannels; the
contact handler had no equivalent.
Notifications are now throttled to one per 250ms while _isLoadingContacts is
set, and remain immediate outside a pull so single adverts still update live.
Throttled rather than suppressed so the sync progress bar keeps advancing. The
existing notifyListeners() when the pull completes still fires, so the final
state cannot be stale.
Scope of what this accounts for, stated honestly: measured synchronous frame
work was ~2s per report window and the pull showed ~4s total, against a ~30s
observed freeze. This removes the largest measured contributor. If the stall
persists, the remainder is outside the frame handlers and the cumulative
counters will show frame time has dropped, which narrows it further.
flutter analyze clean, dart format clean, 494 tests pass.
The conversation-load metric was misread, and nearly caused a fix to the wrong
thing. It reported store=30138ms across 300 contacts, which looked like
loadMessages costing ~100ms each. It is not:
- The device-wide fallback key `messages_<dev>` does not exist on the
reporting machine, and only 11 per-contact message keys exist in total, so
loadMessages does three in-memory map lookups and returns empty.
- storeWatch spans an `await`, so it measures WALL time including event-loop
queueing, not work. merge, which is synchronous and has no await, measures
0ms - consistent with the isolate being busy elsewhere while those awaits
wait their turn.
So the 30s is ~300 awaits each waiting for a busy isolate, not 30s of storage
work. The metric label now says so, rather than inviting the same misreading.
The per-call 100ms threshold cannot see the actual shape here: a handler that
takes 40ms and runs 234 times owns ~9s of isolate time and never trips it.
Frame handlers now accumulate synchronous time per frame code and report the
top codes by total, which names the owner regardless of per-call cost.
Diagnostics only, no behaviour change.
flutter analyze clean, dart format clean, 494 tests pass.
The stores violated the no-silent-failures rule, and that is why tonight's
diagnosis took as long as it did. Two classes:
1. Ten catch blocks swallowed decode errors and returned an empty collection.
`catch (_) { return []; }` means a corrupt or unreadable store presents to
the caller as "no data", so the user sees no contacts, no channels or no
history with nothing anywhere explaining why. Indistinguishable from data
loss. Every one now logs what failed, with the store named, before
returning.
2. ChannelMessageStore._storageKey fell back from the PSK-identity key to the
legacy slot-index key without a word. That fallback is correct before a
channel list exists, but once history has been migrated to the PSK key it
reads a DIFFERENT key and returns empty. That is exactly #333: a user's 566
Public messages were intact on disk while the app showed nothing, with no
error, no parse failure and no log line to follow.
It now warns when it falls back while a resolver is installed, since that
combination specifically means the channel list was not loaded first.
Had either of these been loud, #333 would have been a one-line log read
instead of a multi-hour investigation that looked like data loss.
flutter analyze clean, dart format clean, 494 tests pass.