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>
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>
Owner's call: 'a few of you asked how to chip in, so I put up a page'
reads as responsive/humble and implies traction, vs. asking for money.
Community-request framing fits Discord (talking to the folks who asked);
release-notes donation line stays broader-audience.
Part of #160. Agent: SapphireCompass (session 8d755b5e)
Owner's call: include the live web URLs and sequence the main promotion
(which deploys offband.app) BEFORE the release tag, so offband.app is up
and verified before the notes go public. Avoids a pointless second push
just to add URLs.
Restored offband.app in the platform list, changelog, and Discord link.
Part of #160. Agent: SapphireCompass (session 8d755b5e)
- Release notes: added a Support Offband donate section (offband.org/donate,
verified live), framed toward funding iOS.
- Discord: fully rewritten, richer per-feature copy + real links (download/
release, project site, donate). Was thin and link-less.
- Web handling: offband.app is NOT deployed (404) and untested, so pulled the
live web claim from the platform list, changelog, and Discord; replaced with
'browser version coming soon'. dev.offband.app works but is not a release URL.
Gate passes; no em-dashes in the new copy.
Part of #160. Agent: SapphireCompass (session 8d755b5e)
Hero image (SHA-pinned raw URL, resolves now + at release) at the top;
removed the pinned-rail and FEM-LNA placeholders per Ben (one deliberate
hero, no empty slots).
Agent: SapphireCompass (session 8d755b5e)
Reordered + marketed per direction: platforms-first (Android/Windows/
Linux/Web now shipped every release), then the interface redesign as the
headline (left rail, pinned rail, in-place channel nav), storage, GIFs
(cross-client tap-to-view + Tenor render), a big FEM/LNA callout, deeper
diagnostics (persisted rxTime, honest path units), and performance.
Added <!-- SCREENSHOT: ... --> placeholders at the hero, pinned-rail,
and FEM-LNA spots for Ben to drop captures into the GitHub release
editor (real screenshots must be captured from the running app; not
faking them).
No em-dashes.
Part of #160. Agent: SapphireCompass (session 8d755b5e)
Marketing version 1.2.0 (Ben's call: minor bump for a storage-engine
migration + nav overhaul, not a patch). versionCode 61 (next after 60,
current on Play).
- pubspec 1.1.2-rc.3+60 -> 1.2.0+61
- CHANGELOG [1.2.0] section
- release-notes/1.2.0.md (GitHub release body)
- play/1.2.0.txt (498/500 chars)
- discord/1.2.0.md
Release-gate passes (all four texts present, play within limit). No
em-dashes in the new copy. Headlines: drift/SQLite storage migration
(auto-migrates existing data), new nav shell, cross-client GIF links.
First release cut end-to-end through the #312 pipeline.
Part of Play epic #160. Agent: SapphireCompass (session 8d755b5e)
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>
Follow-up to #348 (now merged): dev has drift, but release-signed.yml
had no build_runner step, so the next v* release would fail its
android/windows/linux builds on the missing offband_database.g.dart.
Adds `dart run build_runner build --delete-conflicting-outputs` after
`flutter pub get` in the three jobs that build (android, windows,
linux). gate and release don't compile Dart, so no codegen there.
Matches the step #348 added to build.yml/flutter_dart/deploy-web. This
one couldn't ride #348 because release-signed.yml had diverged on dev
(#342/#346) and editing the stale copy caused a merge conflict — see
#348 discussion.
Part of epic #312. Agent: SapphireCompass (session 8d755b5e)
First live signing run (#345) failed at APK cert verification. The APK
signed correctly, but the label parse (grep "Signer #1 certificate
SHA-256 digest" | awk) returned empty on the runner's apksigner, whose
output labels that line differently than the local build-tools. Empty
!= expected produced a false mismatch. The release correctly did NOT
publish (fail-safe held).
Now extracts by shape: the cert SHA-256 is the only 64-hex string in
the output (SHA-1 is 40, MD5 is 32), so grep -ioE '[0-9a-f]{64}' is
label-independent. Verified against a real signed APK (e7da8cd5…).
Echoes the raw apksigner + keytool output for diagnosability, and
hardens the AAB parse the same way (it was skipped last run, so its
runner format is still unconfirmed — the echo will show it).
Part of epic #312. Agent: SapphireCompass (session 8d755b5e)
My previous commit added the codegen step to this branch's OLD
android-signed release-signed.yml. But dev has since replaced that file
with a 5-job pipeline (#342/#346), so editing the stale copy made both
sides diverge -> PR #348 went CONFLICTING and CI stopped running.
release-signed.yml only runs on v* tags, never on this PR, so it does
not belong in this PR at all. Restored to the merge-base version so it
merges cleanly (dev's version wins). The codegen for the release
pipeline will be added to DEV's release-signed.yml in a follow-up, AFTER
this merges (build_runner isn't a dep on dev until then).
The PR-relevant codegen (build.yml, flutter_dart.yml, deploy-web.yml) +
the .g.dart untracking are unchanged and still green the PR.
For #335 / PR #348. Agent: SapphireCompass (session 8d755b5e)
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 gate introduced in this PR correctly FAILED on its own PR in 8s:
pubspec is rc.3, which had a CHANGELOG section but no notes files. That
is the gate working exactly as intended, demonstrated live.
Fix is the work we needed regardless: author rc.3's three per-audience
notes. This also seeds dev so every future dev PR passes the gate (the
current version now has all four files), and the rc.3 backfill branch
will carry these to build the actual release.
DRAFTS for owner review — the prose is mine, the judgement is Ben's.
Play blurb is 403/500 chars.
Part of epic #312, plan #321. Agent: SapphireCompass (session 8d755b5e)
Both findings from the adversarial review, verified before applying.
1. Draft-then-publish (was: create-then-upload). Creating the release
first made it publicly visible with zero assets until uploads
finished; a stalled upload or dead runner would strand a partial
public release — the exact failure this pipeline exists to prevent.
Now: create --draft → upload → edit --draft=false, which
`gh release create --help` documents as the correct pattern. Both
the create and retry branches end published, so a run retried after
a mid-way death can't get stuck as a draft. gh flags verified via
--help.
2. Verify the AAB certificate, not just the APK. The APK check was a
proxy; the AAB is what Play actually receives. apksigner can't read
an AAB, so this uses keytool -printcert -jarfile and pins the same
e7da8cd5… fingerprint. Verified locally: built a debug AAB, confirmed
the extraction command yields 765fb469… matching the known debug
fingerprint, so the check would correctly FAIL a debug-signed AAB.
Part of epic #312, plan #321. Agent: SapphireCompass (session 8d755b5e)
Today a tag builds and signs an APK/AAB but creates no release and
attaches nothing. That is the same partial-release failure mode as
rc.2/rc.3 (Play-only, no changelog). This closes it.
release-signed.yml becomes a complete release pipeline off a v* tag:
gate -> release-notes gate (backstop; fails in seconds)
android -> signed APK + AAB, cert-verified (environment-gated)
windows -> zipped Release build
linux -> tarred bundle
release -> create GitHub release, body = release notes, attach all four
Security invariant preserved: only the android job references the
release-signing Environment, so only it receives the keystore. The
windows/linux/release jobs never see it. Still no pull_request trigger.
The release job runs ONLY after every build passes, so a signing
failure yields no release rather than an empty one. Published directly
because the notes were reviewed in the cut PR.
Four-file notes gate (.github/scripts/release-gate.sh), run on every PR
(release-gate.yml) and as job one of the tag workflow:
CHANGELOG.md section for the pubspec version
release-notes/<v>.md non-empty -> GitHub release body
play/<v>.txt non-empty, <=500 chars (Play limit)
discord/<v>.md non-empty
play and discord are human-pasted; CI validates, never sends. Any
missing/oversize file fails the build, so a noteless release (rc.2/rc.3)
is unmergeable.
Gate script lives in .github/scripts/ because /scripts is in
.git/info/exclude (would not commit). Verified locally: fail surfaces
all four problems at once, pass is clean, 500 passes, 501 fails.
Backfills the combined rc.2/rc.3 CHANGELOG entry the two versions
shipped without.
release-notes/, play/, discord/ seeded with README conventions.
No ref input needed: the rc.3 backfill branch will carry these workflow
files, so tagging that branch runs them at that commit.
Part of epic #312, plan #321. Agent: SapphireCompass (session 8d755b5e)
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.
The guard read pubspec.lock with a newline-sensitive regex, so it passed on
LF (CI) and failed on CRLF (Windows) for the same, correct assets. A guard
that is itself platform-fragile is worse than none. Normalise CRLF->LF before
matching.
Surfaced by the 290+306+335 integration merge, where the lockfile came through
with CRLF endings.
Rebased onto dev after #331 landed. Brings the signing workflow in line
with the rest of the matrix:
- Pinned to Flutter 3.44.1, matching the bench and every other
workflow. A release artifact in particular must not be built by
whatever `stable` happens to point at that day, which was the whole
argument for #331.
- Generates dart_defines.json and passes --dart-define-from-file, so
the signed release does not ship with a dead GIF picker.
- Cleanup step now removes dart_defines.json alongside the keystore
material, since it holds the Giphy key.
Verified that a job pinned to an environment still receives repository
secrets (precedence is org < repo < environment, environment winning on
a name collision), so the repo-scoped GIPHY_API_KEY reaches this job
while the keystore secrets stay environment-scoped behind the reviewer
gate.
Part of epic #312, plan #321 (Phase 5).
Rejecting only CN=Android Debug catches the #111 fallback but not a
DIFFERENT key being substituted, which breaks cross-channel updates
against Play just as badly. Now asserts the exact fingerprint.
Established from artifacts users have already installed, with no access
to the keystore password (reading a certificate off a signed APK needs
none):
release b55 (2026-07-06) e7da8cd5...
release b58 (2026-07-10) e7da8cd5...
local release build e7da8cd5...
Three independent sources agree, and the owner confirmed the same
strycher-personal.jks is enrolled for Play App Signing, so this is the
fingerprint that must hold for a GitHub download to update over a Play
install.
Found while establishing it: published release b59 (2026-07-20) is
DEBUG-SIGNED (765fb469, CN=Android Debug), unlike b55 and b58. That is
#111 occurring in production. Filed separately.
Part of epic #312, plan #321 (Phase 5).
CI has only ever produced debug-signed Android artifacts, because
build.gradle.kts:63-70 falls back to signingConfigs "debug" when
key.properties is absent, which is exactly the CI condition (#111).
Debug-signed builds cannot be installed over a Play install or over a
properly signed GitHub release, so users hit "App not installed".
Adds a separate workflow that signs with the real keystore.
Security posture, given this repo is PUBLIC and the keystore is the one
credential that cannot be replaced if leaked:
- No pull_request trigger. A PR, including from a fork, must never run
a job that can read these secrets.
- Pinned to the release-signing Environment, which carries a required
reviewer and only accepts main or v* tags. The signing secrets are
scoped to that environment, NOT to the repo, so a workflow that
omits the environment key cannot read them at all.
- Keystore decoded to disk only for the build, removed in a step with
if: always() so a failed build leaves nothing behind.
- Secrets passed via env: and printf'd into a file, never placed on a
command line where they would appear in process listings, and never
echoed.
Includes an apksigner check that fails the job if the built APK carries
CN=Android Debug. A green build alone does not prove release signing,
because the gradle fallback is silent; that check is what makes this
verifiable rather than assumed.
The keystore is PKCS12 despite its .jks extension, and PKCS12 cannot
carry a key password distinct from the store password, so one secret
correctly populates both fields.
Part of epic #312, plan #321 (Phase 5).
Publishes the Flutter web client. offband.org stays the Hugo marketing
site in OffbandMesh/offband-site, a separate Pages project, untouched.
push to main -> --branch=main -> production -> offband.app
push to dev -> --branch=dev -> dev tier -> dev.offband.app
Direct upload only. Cloudflare's dashboard "Connect to Git" must not be
enabled for this project; it would create a second git-integrated
project racing this workflow. Matches how offband-site deploys.
Because direct-upload projects cannot configure production branch
controls in the Cloudflare dashboard, the "never auto-deploy arbitrary
branches to prod" requirement from #122 is enforced by the workflow's
trigger list instead. That is the safety mechanism, and it is better
placed here than in dashboard state: reviewable and version controlled.
Builds via build_pipe rather than a plain `flutter build web`, because
build_pipe appends ?v=<version> to the bootstrap and manifest files.
Without it, browsers serve stale bundles after a deploy.
Two fixes folded in:
- pubspec.yaml build_pipe build_command did not pass
--dart-define-from-file, so a deployed site would have shipped a
dead GIF picker even though #331 wired the key into build.yml. The
deploy path and the CI path used different build commands.
- Retired the inherited zjs81 Workers deploy: removed deploy.yml and
wrangler.toml (which targeted THEIR account's "meshcore" project)
and dropped the now-dead `deploy` script from package.json. A root
wrangler.toml carrying Workers config can also interfere with
`wrangler pages deploy`.
Verified locally by running the real build with the bench toolchain:
build_pipe accepts the new flag, cache busting is applied
(flutter_bootstrap.js?v=e2019703...), output is 46 files with a 10.2 MB
largest asset (Pages limits are 25 MiB per asset and 20000 files), and
the Giphy key is confirmed compiled into main.dart.js.
Not verifiable locally: whether the repo's CLOUDFLARE_API_TOKEN carries
Pages:Edit scope. The workstation token is DNS-only and 403s on the
Pages API. A 403 on first deploy would indicate wrong scope; it fails
safely.
Part of epic #312, plan #321 (Phase 4).
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.