Owner testing: the inline badge needed pixel-accurate taps, the refresh gave no
feedback, and there was no refresh in the message action sheet. Now the badge is
a padded InkWell (real tap target), the long-press panel has a 'Refresh CoreScope
observers' action, both show a snackbar with the fetched count, and the auto-poll
is more patient (adds 90s tail steps, stabilises after 3 flat checks) so it climbs
closer to the total before you need to tap. Part of #524.
Agent: QuietSnow (session 31eaba02)
Poll now runs quick-then-slow (10/20/30/60x4s, ~5min cap) and stops early once
the count is flat for 2 checks, so it climbs to the near-final observer count
instead of stopping at a fixed cutoff. The badge is tappable to re-query on
demand (counts only ever grow), covering later re-checks. Part of #524.
Agent: QuietSnow (session 31eaba02)
Observer counts accrue over time (the packet must propagate the mesh and be
reported before CoreScope has any record), so an instant query at send+0.2s
always missed it. Now polls at ~10/30/60/120s, keeping the highest count as
observers report in; bails on disconnect. 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)
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)
Observapeater web dashboard (repeater variant w/ MQTT+WiFi tabs), the
on-device TFT UI, and the field-terminal concept panels — reference
assets for the NeonPocketMC investigation (issue 542).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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)
Reference asset for the NeonPocketMC investigation (issue 542): n30nex's
on-chip WiFi-AP web client UI, shared on the mesh channel 2026-08-06.
Stored under docs/assets so the issue can embed a stable raw URL instead
of the screenshot floating untracked in debug_logs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bump pubspec to 1.4.0+65 and add the four release-gate docs for the
follow-up to the 1.3.0 launch: button/buzzer settings for headless
devices (#483), Send Again on channel messages (#513), caps byte 2 in
Device Info (#480), and per-radio data-correctness fixes (#505/#506,
#472, #425, #495, #497).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Add .github/scripts/check_no_emdash.py and run it as the first step of
the analyze job, so it fails in seconds before the flutter setup. It
rejects the em-dash character (U+2014) in lib/ and test/ source (comments
and string literals) plus app_en.arb, excluding generated files, non-
English ARB, and the lone "no data" glyph placeholder ('—' as an entire
quoted value). Stops the regression that #457 and #462 had to sweep by
hand.
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>
Bump pubspec to 1.3.0+64 and add the four release-gate docs (CHANGELOG
[1.3.0], GitHub release notes, Play what's-new, Discord post) covering
everything since -62: the -63 store-consolidation fix plus the -64
feature batch (reactions, Observer config profiles, log/serial export,
settings reorg, path diagnostics, reliability fixes). Includes an OKIMesh
community thank-you for the first production launch.
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>