- 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>
The auto-label-closed job only stripped a stale board:* label when
board:done was ALREADY present, so a normal close (no board:done yet)
added board:done and left the prior board:in-progress/todo/testing in
place. The label was also added with GITHUB_TOKEN, which does not cascade
to the sync job, so the project Status field was never updated on close.
Rewrite the close path with actions/github-script + PROJECT_PAT to:
strip every non-board:done board:* label, ensure board:done, and set the
project Status field to Done directly via GraphQL. Enforces the same
board:* mutual exclusivity the labeled path already has.
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 flagged that only Windows got the install-time guard; Linux kept
the stock unconditional install(DIRECTORY), which errors at install time if the
native-assets dir is absent (e.g. a local build with native assets disabled).
Port the same install(CODE) EXISTS-guarded copy to linux/CMakeLists.txt.
macOS has no CMakeLists (Xcode/CocoaPods), verified — the CI enable step covers
it; the other half of the finding (presumed macos/CMakeLists.txt) does not apply.
Citadel: meshcore-open-mkl
Clean Windows builds (incl. CI) shipped no sqlite3.dll, so drift could not open
its database ("Message storage unavailable"), and the llamadart translation libs
were stranded too. Root cause: the project moved to sqlite3 v3.x, which delivers
its native lib via Dart native assets, but (a) native assets was not enabled in
CI and (b) the stock windows/CMakeLists.txt guards the native-assets install
with a configure-time `if(EXISTS)` that is false on a clean build (native assets
are built AFTER cmake configures), so the install was silently skipped. Dev
machines only worked via leftover DLLs from prior builds.
Fixes:
- windows/CMakeLists.txt: defer the native-assets copy to install time (matches
Linux's unconditional install), so a clean build bundles build/native_assets/
windows/*.dll into the runner Release dir.
- build.yml + release-signed.yml: enable native assets (flutter config
--enable-native-assets) in the windows/linux/macos jobs before build.
Verified on a clean local Windows build: Release now contains sqlite3.dll plus
the llama/ggml/mtmd set. Android (Gradle-delivered .so) and web (wasm) unchanged.
Citadel: meshcore-open-mkl
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 172->176 ceiling raise made the existing "drops oversized frames and
resyncs" test's 173-byte frame a valid length, so it buffered instead of
dropping (it was red). Derive the over-max length from the ceiling so it stays
a genuine oversized frame and still exercises drop-and-resync, confirming the
higher ceiling doesn't weaken corrupt-frame recovery. 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>
Bump pubspec to 1.2.2+63 and add the four release-gate docs (CHANGELOG
section, GitHub release notes, Play what's-new, Discord post) for the
#367/#370 desktop store-consolidation fix.
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>
Bump pubspec 1.2.0+61 -> 1.2.1+62 and add the four-file notes gate for the
1.2.1 release: CHANGELOG section, release-notes/1.2.1.md, play/1.2.1.txt
(274/500), discord/1.2.1.md. Ships the #363/#364 desktop drift DB-path fix.
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>