fix/1140-apple-app-names
main
dev
feat/1109-macos-signing
feat/2069-indicator-tags
fix/1128-channel-key-history
feat/1129-sponsor-prompt
fix/1118-factory-reset-delete-on-send
fix/1122-notify-logging
fix/1896-client-slot0-public
feat/1054-factory-reset
fix/1111-osm-identity
feat/523-land-indicators
feat/1108-macos-artifact
feat/1014-ios-app-review
feat/1013-ios-signed-lane
feat/1068-radio-channel-notify
docs/1091-epic-chain
docs/1053-play-automation
fix/1065-vercode-floors
feat/1012-ios-signing-toolchain
docs/958-ios-appstore-plan
fix/1033-scan-location-message
feat/693-offline-app-settings
fix/1009-release-linux-2404
release/1.7.0
epic/870-console-gating
feat/759-fake-radio-integration
feat/758-fake-radio-ci
feat/757-fake-radio-behaviors
feat/756-fake-radio-core
epic/870-remote-cli-permission
feat/842-sync-is-truth
feat/975-settings-restructure
feat/966-clear-stale-history
feat/965-stale-blob-sweep
docs/962-epic-chain
docs/962-reclamation-plan
fix/943-952-storage-migration
feat/940-isolation-integration
feat/939-scope-key-lifecycle
feat/938-blocked-per-radio
feat/937-storage-service-migration
feat/936-scoping-harness
docs/853-isolation-plan
fix/754-discovery-per-radio
feat/920-message-rows
chore/904-docs-proof
feat/919-blob-isolate
fix/927-ci-spike-flag
feat/917-storage-save-path
chore/904-docs-only-ci
docs/857-interop-plan
feat/844-per-radio-notify
fix/812-land-814-815-on-dev
epic/816-integration
epic/814-discover-regions
epic/813-region-protocol
epic/815-channel-scope
docs/812-region-scoping-plan
release/1.6.0
fix/793-log-composer-budget
feat/703-forced-resync
docs/755-plan-flash-fix
docs/755-fake-radio-plan
docs/750-contact-naming-plan
feat/702-lossless-serial
feat/668-contact-sync-guard
feat/610-contact-card-receive
fix/717-linux-web-mtu
feat/638-top-toast
fix/589-late-reply-log-only
fix/663-triage-internal-actors
fix/652-release-prerelease-flag
feat/619-contact-identity-uri
fix/581-ack-adoption
epic/568-export-import
fix/580-caplog-partial
release/1.5.0-beta.1
task/501-deviceui-logging
chore/542-assets2
chore/542-assets
docs/519-claudemd-web
release/1.4.0
feat/483-button-buzzer-ui
fix/497-mention-verbatim
fix/279-send-width
docs/478-docs-home-research
release/1.3.0
fix/443-battery-optimization-warning
fix/447-close-labeler
fix/445-windows-sqlite3-native-assets
feat/429-companion-sync
fix/395-dm-channel-send-wedge
feat/427-localize-log-share
feat/393-app-log-share
fix/share-icon-android
fix/389-back-nav
feat/385-storage-health
release/1.2.2
feat/367-store-consolidation
chore/365-cut-1.2.1
fix/363-pin-db-path
feat/351-contact-settings-menu
feat/349-window-geometry
feat/355-migration-merge
integration/290-306-335
release/1.1.2-rc.3-allplatforms
fix/309-path-len-decode
chore/298-path-logging
fix/285-ingest-timelog
fix/252-block-outgoing-dm
feat/64-observer-config
feat/batch1-ux
chore/offband-rebrand
v1.8.0
v1.7.0
v1.6.0
v1.5.0-beta.1
v1.4.0
v1.3.0
v1.2.2
v1.2.1
v1.2.0
v1.1.2-rc.3-allplatforms
v1.1.2-rc.3
v1.1.2-rc.2
v1.0.0
observer-g2-rc3
observer-g2-rc4
observer-g2-rc5
observer-g2-rc6
observer-g2-rc7
observer-g2-rc8
observer-g2-rc9
preview-1.8.0-1
v1.1.0-rc.1
v1.1.0-rc.2
v1.1.0-rc.3
v1.1.2-beta.1
v1.1.2-beta.2
v1.1.2-beta.3
v1.1.2-ble-test
v1.1.2-queuediag
v1.1.2-rc.1
v1.1.2-rc.1-b45-nearbyrep
v1.1.2-rc.1-b46-pathhash
v1.1.2-rc.1-b47-tracewidth
v1.1.2-rc.1-b48-repeatertrace
v1.1.2-rc.1-b49-maptrace
v1.1.2-rc.1-b50-tracefix
v1.1.2-rc.1-b51-originstrip
v1.1.2-rc.1-b52-channelqr
v1.1.2-rc.1-b53-viarepeater
v1.1.2-rc.1-b54-rxfed
v1.1.2-rc.1-b55-topology
v1.1.2-rc.1-b57-routeux
v1.1.2-rc.1-b58-toporeadout
v1.1.2-rc.3-b59-pathlen
${ noResults }
81 Commits (65b91cffdf58c8c9bb6de3bb5f6bb292cb60f554)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
65b91cffdf |
feat(#1129): sponsor window on the first launch after an install or upgrade
SponsorPromptGate wraps the first screen and, once it has drawn, shows a Support Offband dialog: Sponsor (opens offband.org/donate), Not now, and Don't show again. It shows once per installed version and build, saved before the dialog opens so a crash can't loop it. Hidden on iOS, as the About screen's Donate link is (#1051). A link that fails to open shows the About screen's persistent "Could not open the link". Both keys are app-wide and classified in scoping_inventory.dart. English strings only; untranslated.json regenerated. Tests: 10 widget tests plus the wiring through the gate. Each of five guards (iOS, Don't show again, same version, the gate's call, the failure message) was removed in turn and a test failed. Full suite 2074 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
4 days ago |
|
|
96ed46afd1 |
fix(#1118): factory reset deletes the app data on send, no reconnect check
Owner decision 2026-11-07: drop the wait. With the box ticked, the radio's app data is deleted as soon as the reset is sent and the link is closed, the same way Reboot just reboots. The pending record, the key check on the next connect, and its complete / kept / did-not-happen messages are gone. A failed delete still shows a banner that stays until dismissed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
4 days ago |
|
|
4f76ac73f9 |
Merge remote-tracking branch 'origin/dev' into feat/1054-factory-reset
# Conflicts: # lib/l10n/app_en.arb # lib/l10n/app_localizations.dart # lib/l10n/app_localizations_bg.dart # lib/l10n/app_localizations_de.dart # lib/l10n/app_localizations_en.dart # lib/l10n/app_localizations_es.dart # lib/l10n/app_localizations_fr.dart # lib/l10n/app_localizations_hu.dart # lib/l10n/app_localizations_it.dart # lib/l10n/app_localizations_ja.dart # lib/l10n/app_localizations_ko.dart # lib/l10n/app_localizations_nl.dart # lib/l10n/app_localizations_pl.dart # lib/l10n/app_localizations_pt.dart # lib/l10n/app_localizations_ru.dart # lib/l10n/app_localizations_sk.dart # lib/l10n/app_localizations_sl.dart # lib/l10n/app_localizations_sv.dart # lib/l10n/app_localizations_uk.dart # lib/l10n/app_localizations_zh.dart # untranslated.json |
6 days ago |
|
|
eb76acd323 |
fix(#1063): keep per-slot message state from crossing channel identities
Persisted channel history has been PSK-keyed since #194; the remnants that could still cross-wire on a slot reassignment were in-memory and legacy. Epic B of #1061, stacked on #1062: - _completeChannelSync's identity diff now drops the slot's in-memory conversation and reloads it from the store, which reads the NEW identity's own history through the (now fail-closed) resolver. - setChannel's reassignment clear purges only the LEGACY slot-index keys (new clearLegacySlotHistory), so the outgoing occupant's PSK-keyed history survives and comes back if the channel is re-added. True deletes (deleteChannel) keep the full both-key clear. - A WRITE that falls back to the slot-index key now logs an error with the orphaning consequence spelled out; post-#1062 live ingress cannot reach it. - The #193-era comment claiming history is slot-keyed is corrected and the setChannel/resolver interplay documented. Tests: 2 connector tests (in-memory drop on identity change, kept on same identity) + 5 store tests (legacy purge, PSK history survival, no adoption onto the new occupant, true delete). Issue: #1063 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
4d20965d9e |
feat(#1057): factory reset the connected radio, delete its app data only once proven
Settings > device gets a red Factory Reset tile next to Reboot. The confirm dialog warns that the radio's identity, contacts, channels and settings are erased, with "Also delete this radio's data stored in the app" ticked by default (owner D1=C, D2=A). Wire: stock CMD_FACTORY_RESET (51) + "reset", since firmware ver code 7. Refused, with a persistent message and nothing sent, when no radio is connected or the version is unknown or below 7. The radio disables its link before formatting, so the client never hears the result. The app data therefore waits for proof: a pending record (device, old key prefix, the checkbox) is saved, the frame sent and the session ended so it does not auto-reconnect into a wiped radio. On the next SELF_INFO from the same device, a new key means the wipe worked and, if ticked, every perRadio surface of the old prefix is deleted across drift, prefs and message rows, plus its notify modes. The same key means it did not happen: nothing is deleted. A different device decides nothing. A failed delete keeps the record and retries next connect. The wipe is driven by scoping_inventory.dart, so a per-radio store added later is covered without editing it. An app-root banner reports the outcome, including the BLE note to remove the old OS pairing before reconnecting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 week ago |
|
|
bd6b578136 |
feat(#890): accept and render inbound remote-CLI commands
Upstream 1.18 lets a permitted contact send a remote-CLI command (TXT type 3) to run on our radio. Before this, _parseContactMessage only knew plain (0) and CLI_DATA (1), so a type-3 frame was warn-dropped as an unknown type and never surfaced. - meshcore_connector: accept txtTypeCliCommand in _parseContactMessage, skip SMAZ decode for it (like CLI_DATA), and flag the Message isCliCommand. Add @visibleForTesting parseContactMessageForTest. - message model: new isCliCommand field threaded through the constructor and copyWith. - message_store: persist and read isCliCommand; skip SMAZ on read for it. - chat_screen: label an inbound command bubble "Command for your radio" and exclude it from the incoming-translate action. - l10n: chat_remoteCliCommand. test/connector/inbound_cli_command_test.dart: type-3 accepted+flagged, type-1 isCli-not-command, plain-neither. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
2 weeks ago |
|
|
f2cf677893 |
feat(#750): declare the custom-name key in the scoping inventory
The rebase brought in Feature #853's scoping gate, which requires every storage key in lib/ to carry a per-radio or global verdict with a reason. contact_customname_ was undeclared, so the gate failed the build. That gate did its job: a new key cannot land unnoticed. Declared per-radio, prefix + device key + contact key, matching its neighbour in the same store. The reason records why it is not in contact_store, since that is the decision most likely to be undone by someone later: contact_store is rebuilt from radio contact-sync data, so a save after a sync would wipe the rename. Also renames the fixture arg in #940's radio_isolation_matrix_test, which the 750.1 compile break caught the same way #754's test was caught two rebases ago. Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1502 tests passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
3a0ad8f273 |
feat(#750): separate what a node calls itself from what the user calls it
Task 750.1 of the contact-naming plan: model and storage. Stock MeshCore has a per-node CUSTOM NAME field and keeps the original underneath; Offband could not rename a contact at all. Contact gains receivedName (the name the mesh gave us, wire and storage) and customName (the user's label, client-side only). name becomes a getter returning customName ?? receivedName, so all 85 existing display reads stay correct and a future read cannot forget. Empty and whitespace-only custom names count as unset at both the getter and the store, so clearing the field cannot leave a contact blank with no way back. The constructor and copyWith take receivedName, which made the rename a compile error at every write site. The compiler enumerated 23 of them across 5 lib files and 11 test files; none was found by reading. The advert handler is the one that matters: receivedName now updates while customName is untouched, so an advert improves accuracy without moving what the user sees. That was the defect making a client-held name impossible. customName is deliberately NOT in contact_store. That record is rebuilt from radio contact-sync data where no custom name exists, so a save after a sync would wipe it. It lives in contact_settings_store keyed by pubkey hex, which a sync cannot reach. Both stores persist receivedName, never the getter. The plan's correction on #750 found that persisting the getter is one-way data loss: the custom name would be read back as the received name and the real one destroyed with no advert able to restore it. The ?? 'Unknown' fallback is kept with a comment, since the rename is exactly the edit that drops a trailing fallback and would crash the load for a legacy row. 19 tests, positive and negative. T31 and T32 were falsified before being trusted: breaking the persist and the fallback made exactly those two fail and nothing else. Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1305 tests passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
5b061924c7 |
feat(#966): mock-ups, a confirmation that states the scope, and the eligibility bug it found
The owner asked why there were no UI mock-ups to review. There was no good answer: this Epic's card was built and installed on both his machines without him ever seeing the design, and the plan had no step requiring otherwise. So the plan now has B0: mock-ups first, owner-approved before any of it is built, as a committed HTML file beside the plan in the format his firmware initiative already uses. epic-b-storage-mockups.html is that file: eleven numbered variants with real Material 3 tokens, a 360-wide pane, example values from his own install, and the behaviour to build as bullets under each. Owner-approved 2026-10-23. The confirmation (B1a, variants 8 to 10). It states how many copies, roughly how much space, how many messages are NOT affected, and that it cannot be undone, with the count on the action button so the scope is the last thing read. Cancel, a tap outside and back all decline. Nothing eligible opens no dialog at all, because a dialog that only says zero is not worth opening. That needed a preview pass: verify every eligible key and total it without deleting, so the numbers are real rather than estimated. Verification now runs twice for a confirmed clear, once to preview and once under the key lock. That is the cost of saying what will go before it goes. approximateBytes is named for what it is: the sum of blob lengths, not what VACUUM returns, because page rounding moves the real figure; the dialog says "about" and the result reports what actually changed. Writing the preview found a real bug, and it was about to bite him. The "skip anything stamped this pass" guard exists so installing an update never deletes a blob on sight, and it was being applied to the button as well. Every install that migrated before #962 has an empty __msgrows_migrated_at, so the first press stamped everything and cleared NOTHING. His install is exactly that: 47 migrated keys, none stamped. He would have pressed the button on a 70.9 MB database with 16.6 MB of reclaimable blobs and been told "nothing to clear". Fixed as eligibleByAge, one static rule both preview and run share, so the dialog cannot promise what the sweep will not do. An explicit press includes newly stamped keys; the automatic sweep still refuses them. Three tests on the rule directly, plus one proving the automatic path refuses while the press clears. Label stays "Clear Stale History Now" rather than "Compact", owner's call and the better argument: the name generalises to clearing stale history for other reasons later, such as #963's retention limit. 14 widget tests and 34 storage tests. Full suite 1,502 pass, 1 skipped. analyze clean, format clean. Task: meshcore-open-gan Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
3168c413c9 |
feat(#966): a durable audit of what the sweep deleted
The owner asked whether the delete and compact are logged or silent. Checked rather than assumed, and the answer was bad on two counts: - A delete logged only a COUNT. "Cleared 40" names nothing, so there was no record of WHICH keys were reclaimed. Refusals were already logged properly, with the key and up to three missing records; deletions were not. - All of it goes through appLogger, which is gated on appDebugLogEnabled. That is off by default and off on his install, so on a normal install an irreversible delete left no trail at all. Both fixed. The summary now names the cleared and kept keys, and the sweep also writes a durable entry into the database itself, under __msgrows_reclaim_audit: timestamp, the cleared keys in full, the refused keys, and the byte counts either side. In the database because that survives restarts and the logging toggle, and can be read back without the app running, which is also how it gets verified on hardware. Key lists are stored in full. Truncating them would recreate the "Cleared 40" problem the entry exists to solve. Capped at the last 5 sweeps instead, which is generous given a key is cleared once and then has no blob to consider again, so this is close to a once-per-install operation. A failure to write the audit is logged and tolerated rather than failing the sweep: the deletes already happened and were each verified, so losing the record is worse than nothing but far better than reporting a correct sweep as broken. An unreadable audit likewise starts a fresh list and is logged, never silently replaced. scoping_inventory gained the key, globalOnPurpose: one sweep spans every radio and the entries name store keys that already carry the radio. Six tests: a sweep records which keys it deleted; the entry survives a fresh store object over the same database, so it is really on disk; refusals are recorded too; a sweep that did nothing writes no entry; the cap drops the oldest and keeps the newest; and an unreadable audit does not stop a sweep. Lands on Epic B's branch rather than Epic A's, where the sweep lives, to avoid restacking B over a changed A for a change that reaches dev through both either way. Origin is #965's engine. Full suite 1,489 pass, 1 skipped. analyze clean, format clean. Task: meshcore-open-gan Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
84c2dbd9da |
fix(#965): act on the Gemini gate findings
Three findings, all addressed. One was a real data-loss hole. 1. CRITICAL, fixed. missingFromRows trusted the converter's own output as the measure of the converter. A callback that silently dropped a record, rather than throwing, would have had every record it DID produce found present, returned "nothing missing", and the blob holding the only copy of the dropped one would have been deleted. The migration gate has always guarded this with an independent countJsonArrayElements in a separate compute; the verify path I added did not, which left the two halves of the same rule unequal. It now does the same cross-check and refuses as unverifiable on a mismatch. New test: a converter that drops its last row is refused with missing == null, not missing == []. 2. Measured, not redesigned. VACUUM takes an exclusive lock, and the review called it a UI freeze. It is not: drift_flutter runs SQLite in a spawned isolate (_isolateEntrypoint -> DriftIsolate.inCurrent, verified in the package), so database access queues behind it while the UI thread stays free. That is a stall on history-backed reads, not an ANR, which is the distinction that matters for an app whose storage work began as an ANR investigation. It is also close to once per install, since a cleared key has no blob to consider again. The duration is now logged, because 0.6 s on the owner's 70.9 MiB database is a number worth watching in the field rather than assuming. 3. Fixed, and the review was right about why. The non-history-key test asserted only that the isHistoryKey helper returns false, which would still pass if run() stopped calling it: the loop visits keys that HAVE rows, and contacts and friends normally have none, so they were never reachable anyway. Those keys now get a planted row and a 99-day-old timestamp, so the prefix guard inside run() is the only thing standing between them and a delete. Also carried over from the previous round: the check-then-delete window is closed by BlobStore.reclaimVerifiedHistoryBlob, which holds the per-key lock across both the verify and the delete and so also serialises the sweep against a migration or a blob merge on the same key. Two tests cover it, including that a second call on a blobless key reports neither a delete nor a problem, which is what makes a repeat sweep silent. On the owner's database 7 of 47 aged keys are in that state, having migrated through the empty-blob path. 18 tests in the file, full suite 1,472 pass, 1 skipped. Re-verified against a copy of the owner's real database after the changes: blob keys: 477, migrated history keys aged: 47 cleared 40, refused 0, 17362944 bytes returned (16.56 MiB) kept keys missing: 0, byte-changed: 0 integrity_check: ok, rows 29950 -> 29950 Task: meshcore-open-5vt Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
1b38c674e4 |
feat(#965): per-key migration timestamps and the stale-blob sweep
Epic A of #962. The engine only; the manual action is #966. A1 wrote the diagnosis as failing tests first, and they were red for the right reason: no sweep existed and six BlobStore methods did not. Also lands a control test asserting that DELETE alone does not shrink the database, which passes today and must keep passing, since it is the entire reason a VACUUM step exists. A2, the timestamps. A second blob, __msgrows_migrated_at, keyed by store key. Deliberately NOT a reshape of __msgrows_migrated: that marker is read on every save and every load through a cache, so changing its shape would put a migration of its own on the hottest storage path. Owner decision 2026-10-22. An install that migrated before this ships has no timestamp, so backfillMigratedAt stamps it on FIRST SIGHT and the sweep skips anything it stamped this pass: updating the app can never delete a blob on the spot, and every key gets the full 30 days measured from when we first knew about it. A3, the sweep. Eligibility is age plus a fresh verification, never the marker alone, which is the difference between "we recorded that this migrated" and "we just confirmed it". missingFromRows returns null for "could not tell" and an empty list for "verified nothing missing", and those are deliberately different values: collapsing them is how the only copy of an unreadable history gets deleted. A4, one VACUUM, only when something was deleted. A failure there is logged and tolerated: the deletes already happened and were each verified, so it costs space and not data, and the next sweep retries it. The comparison rule now lives in ONE place, _absentFrom, used by both the migration gate and the sweep. The gate still decodes its own blob, because a second compute over multiple megabytes on the migration path would cost more than the duplication saves, so the rule is shared even though the decode is not. Wired into main.dart's existing post-frame callback, beside the #906 exit-info collector, for the same reason: storage hygiene must never be on the path to a usable screen. The catchError sits at that fire-and-forget call site rather than inside run(), so #966's button still gets a failure it can report. scoping_inventory gained the new key, because the #853 gate caught it exactly as designed. Its verdict is globalOnPurpose for the same reason as the marker: it is keyed BY store keys that already carry the radio. 15 tests, the plan's whole acceptance bar: the 29-day and 30-day boundaries; one missing row refuses AND the same key clears once its rows are complete, which is what proves the gate discriminates rather than never deleting; an undecodable blob refuses; an unstamped key is stamped not deleted; an unmigrated key is untouched; contacts, discovered_contacts and path_history_ are asserted non-candidates BY NAME; a DM key sweeps through the DM converter; and every kept blob is byte-identical across the sweep and VACUUM with the row count unchanged. A5, verification against a copy of the owner's real database through the production sweep: blob keys: 470, migrated history keys aged: 47 cleared 40, refused 0, 17367040 bytes returned (16.56 MiB) kept keys missing: 0, byte-changed: 0 integrity_check: ok, rows 29949 -> 29949 47 aged and 40 cleared because the other 7 migrated through the empty-blob path and never had a blob to reclaim. Full suite 1,469 pass, 1 skipped, up from 1,454. analyze clean, format clean. Task: meshcore-open-5vt Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
5f79c6e1db |
fix(#943,#952): act on the Gemini gate findings
Two of five findings were real.
Log the downgrade refusal BEFORE the path lookup and the throw. onUpgrade runs
inside drift's beforeOpen, so it fires on whichever isolate issued the first
query, and whoever catches the exception decides what the user sees. A
downgrade that only stopped background work, with nothing in the log, would be
the quiet failure this branch exists to replace. The log line now lands first,
so the record exists however the exception is handled.
Strengthen the recovery bench. It asserted row COUNTS, so a converter
producing the right number of rows with wrong contents would have passed.
It now also checks that every identified blob record has a row carrying its
identity, and that no payload is empty, both derived from the raw blob rather
than from the converter under test so the check is not circular. Records with
no messageId are not identity-checked, because reproducing the `x:` fallback
key here would make it circular again; the count assertion still covers them.
Verified on a copy of the owner's database with the trap reconstructed (the
6,308-message channel removed from the marker plus one planted row the blob
does not contain): the key migrates, and all 6,308 identities are present.
Then verified the new assertion can FAIL, by planting an empty payload the
migration does not overwrite:
Expected: empty
Actual: WhereIterable<String>:['{}']
Three findings were rejected, with reasons in the PR body: the duplicate
merge-key collapse is pre-existing and unchanged by this diff, the claimed
migration/blob-save race cannot occur because of the re-check inside the
per-key lock, and the path_provider call is bounded and already proven.
Task: meshcore-open-lkp
Agent Mail: TealGlen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
3 weeks ago |
|
|
b08b42bff8 |
fix(#943): refuse a newer schema loudly instead of rewriting the version
Owner decision, 2026-10-22: downgrades stay unsupported, and must fail deliberately rather than silently. Before this, onUpgrade guarded on `from < 2`, so a database from a FUTURE schema fell straight through it. The open succeeded, and drift then stamped user_version back down to this build's 2, because it writes schemaVersion whenever the versions differed. The database's only record of having been on a newer build was destroyed, and a later run of that newer build would re-run its own upgrade against tables already in their new shape. onUpgrade now throws OffbandSchemaDowngrade when `from > to`, naming both schemas, saying the upgrade is one-way, and giving the database path when it can be resolved (path_provider has no web implementation, so the path is best-effort and the message stands without it). Not made to work, deliberately. Once a key is on rows its blob is frozen, so an older build would read stale history and write into the blob while this one reads rows, orphaning everything it received. Supporting a downgrade would mean dual-writing the blob on every save, which is the cost #917 exists to remove. The two tests that documented the old silent behaviour now assert the refusal, including that user_version is left at 3 on a file-backed database, which is the whole point of throwing. Three more guard the upgrade direction: an equal version takes neither branch, a fresh database still lands on 2, and a real 1 to 2 upgrade still carries its blobs across, since that is the path every existing install takes. Release-note disclosure and rollout staging are tracked separately. Task: meshcore-open-3e1 Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
eef192ea7f |
fix(#952): the migration gate asserts a superset, not equal totals
Four of the owner's store keys could never migrate to rows. Each held rows whose merge_key is absent from its blob (1, 3, 7 and 5 of them), and the readback gate compared the key's TOTAL row count against the number of distinct records in the blob. Those two numbers can never be brought level again: nothing removes the extra rows, and the frozen blob can never come to contain them. Every attempt refused, for good. His two largest channels were among them, 6,313 and 5,648 messages, so they kept re-encoding 3.77 MB and 3.55 MB of history on every save. That is the cost #920 removed and they were not getting it. No data loss: the blob stays complete and authoritative for a refused key. The gate asserted the wrong invariant. What it exists to catch is a record from the blob failing to reach a row, which is a SUBSET question. Equality became wrong the moment rows could legitimately hold messages the frozen blob does not, which is true for every migrated key the instant a new message arrives. - messageMergeKeys(storeKey) reads back the stored identities, and the gate now requires every deduped blob key to be among them. Rows beyond that are logged and accepted rather than treated as corruption. - The refusal log names how many records are missing and samples three, which is more use than the two totals it printed before. - The four keys need no healing migration: they migrate normally at next access, pinned by "a key stuck under the old gate recovers on next access". The step can still fail, and both halves of the loop are proved: a BlobStore whose insert silently drops a row is refused and stays on the blob, and a lossy converter is still refused before anything is written. Renamed the test file, since it now guards the gate rather than describing the trap it was written to demonstrate. Task: meshcore-open-lkp Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
e2b8970f1f |
fix(#943): establish what governs a downgrade, and correct the claim in code
#943 says drift "refuses to open a database whose user_version is newer than the binary's schemaVersion". That is wrong, and the real mechanism points somewhere else. drift has no newer-schema refusal. It compares the stored user_version against schemaVersion for INEQUALITY and routes both directions into onUpgrade ("Schema version upgrades and downgrades will both be run here"). The pre-#917 build declared schema 1 and no migration getter at all, so it got drift's default onUpgrade, which throws. That is why the owner's old install would not start: not a version check, but a missing strategy, so it never reached the blobs it would have found. The consequence matters for the decision. This build does NOT throw on a downgrade: the guard is `from < 2`, so a database from a future schema opens silently, and drift then stamps user_version back down to 2, erasing the only record that it has been through a newer build. The next schema bump inherits a silent failure rather than #943's loud one. - schema_downgrade_test.dart, six tests: a newer version reports as hadUpgrade rather than being refused; a bare strategy throws (the #943 failure); this build opens a schema-3 database without complaint; and it rewrites the version backwards. Two more pin that an equal version takes neither branch and that a fresh database still lands on 2, so the downgrade cases are genuinely testing the downgrade path. - Corrects the stale claim in offband_database.dart, which still told readers a downgrade finds its history. The merged plan was corrected in 4b8f5731; this is the same correction in the code it describes. Also lands migration_refusal_trap_test.dart for #952, found while gathering this evidence: four of the owner's store keys hold rows the blob does not, so the readback gate's equality check refuses them on every attempt, for good. Five tests, including the clean-key control and a check that no message is lost. Fix deliberately not included; #952 carries the decision. The three decisions in #943 are untouched and remain the owner's. Task: meshcore-open-3e1 Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
a475cc21b4 |
task(#937): B2+B3 path history and delivery observations per radio
Both scoped by the device-key prefix, and the legacy untagged data deleted per the owner's decision 1 rather than orphaned. **#854 path history: per radio AND out of SharedPreferences.** Two changes in one migration, because doing them separately means two migrations over the same data. The key was prefix + contact, so a route measured by a 1 W handheld was applied to a repeater and the reverse. It now lives in drift as prefix + device key + contact. The move out of prefs is not gold-plating: that one store held 2,092 keys and 86% of a 4.28 MB file, and every prefs write on Windows re-encodes the whole file (#923). This resolves #924 and makes every unrelated settings write cheaper in proportion. **#856 delivery observations: per radio, staying in prefs.** A repeater's 45-second deliveries were training the ACK-timeout model a 1 W handheld then used. One small key, 3,091 bytes and 14 entries on the owner's install, so unlike path history there is no cost argument for moving it and a move would be churn. **The legacy purge is chunked, and that is measured rather than guessed.** Each prefs `remove` re-encodes and rewrites the whole file, so I measured the real sequence against the owner's live prefs before designing it: 2,092 keys, 12.7 s of total work, mean 6.1 ms, but 32 ms for the first removal and under 1 ms by the last, because the file collapses from 4.28 MB to 45 KB as it goes. I had assumed ~57 s and was wrong; it is tractable. One unbroken pass would still block for 12.7 s, well past the 5 s watchdog, which is the exact failure #917 just fixed. So it yields every 20 removals. It runs after the startup loads so it never delays first paint, and it is idempotent: no writes once there is nothing left, so running it on every connect is safe. Legacy and new cannot be confused, which is what makes the purge safe: the new scheme writes to drift and the legacy keys are in prefs, so no length or shape heuristic is needed to tell them apart. That is decision 2 making decision 1 unambiguous. **Wiring:** StorageService is shared with PathHistoryService and TimeoutPredictionService, so the connector cannot own it like the other per-radio stores. It is injected from main() and fed the device key on SELF_INFO alongside them. Unset, both surfaces load nothing and never write, so a read before the handshake cannot serve another radio's data (#754 precedent). **Tests: Epic A's bleed tests inverted, 11 of them.** Every expectation that demanded the bleed now demands isolation, and the before/after is the git history of one file. Plus the purges: prefix-only, idempotent, and the scoped data surviving the purge of the unscoped. **Not done, and flagged rather than dropped:** the plan's B2 also said "bound what persists to what PathHistoryService actually caches". That bound existed to stop the prefs file growing. In drift a write costs one row and growth costs nothing per write, so the bound would now delete regenerable route history for no benefit. Skipping it is a judgement, not an oversight, and it is called out here and in the PR. The inventory's known-holes set is empty for the first time: {854, 855, 856} at Epic B's start, then {854, 856} after B1, now none. An Epic A sanity assertion that holes must exist failed on that and was corrected, because the invariant that matters is conditional: if a hole is declared it must name an issue, and empty satisfies it. Citadel: meshcore-open-0z3, meshcore-open-z8t Refs #937, #854, #856, #924, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
a7882b62e0 |
task(#937): B1 delete the dead pending_messages API
#855 was filed as "pending messages are stored globally, not per radio". It is not a scoping bug. `loadPendingMessages`, `savePendingMessages` and `clearPendingMessages` had no callers anywhere in lib/ or test/, and the key was absent from the owner's live prefs. The `_pendingMessages` field in message_retry_service.dart is an unrelated in-memory list that happens to share the name. So the surface is DELETED, not scoped: three methods and the key constant. Scoping it per radio would have been three careful changes and a migration decision, for an API nothing calls. This is the finding that justified consolidating #854, #855 and #856 into one Epic instead of landing three PRs. Nobody reading #855 on its own would have gone looking for callers, because the issue already told them what the fix was. Removed with it: - the inventory row, replaced by a comment saying the surface is GONE rather than reclassified, so the history is readable - A2's round-trip test, whose job was to prove the thing being deleted actually worked. It served that and then became a test of nothing - 855 from the known-holes expectation, which now reads {854, 856}; that set shrinking to empty is what closing Epic B looks like What still guards it: the inventory's "declared but vanished from source" check fires if `pending_messages` ever comes back undeclared. Citadel: meshcore-open-li1 Refs #937, #855, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
9ffa9a3c0b |
task(#936): A1 scoping inventory, with a gate that already caught four things
Feature #853's acceptance bar is that every store has "a verified scoping verdict and no hole remains". Until now, verifying that meant a human re-reading an audit in an issue body, which is how three separate bleeds (#754, #854, #856) survived earlier rounds of bleed fixes. `scoping_inventory.dart` is that audit in code: every persistence surface with perRadio / globalOnPurpose / knownHole, a mandatory reason, and the file that owns it. A reason is required because an unexplained verdict is how a settled surface gets re-opened as a hole later. The test makes it a gate. It scans lib/ for the shape every key in this repo uses, a `const String` with a lower snake_case value, and fails when one is neither a declared surface nor an explicitly justified non-key. It also checks the reverse: a surface declared here but gone from the source. That one will fire when Epic B deletes `pending_messages`, which is intended. **It caught four things on its first two runs**, which is the argument for building it before Epic B rather than after: - `channel_cyr2lat_` and `contact_cyr2lat_` were never audited. #853 listed channel_settings_store as one surface; it owns two keys. - `los_error_elevation_unavailable`, a third error sentinel I had not found by hand. - `__msgrows_migrated`, declared by me and then reported missing, because my pattern required a letter first and internal markers start with underscores. The gate caught my own declaration bug. Scope of the scan, stated so nobody mistakes it for total: it would miss a key built inline with no constant, and a key that is not snake_case. Scanning every string literal in lib/ was tried and rejected, because it fires on asset paths and log tags, and a check that cries wolf gets worked around. That is the #927 lesson, which is also why there are three tests that prove this gate can fail rather than assuming it. Citadel: meshcore-open-z36 Refs #936, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
a331075a8b |
fix(#754): scope discovered contacts per radio
Discovered contacts (nodes heard but not added) were stored under one global 'discovered_contacts' blob shared by every radio, so one radio's discoveries showed up while another was connected (#754, Feature #853). Scope the store by the connected device-key prefix like contact_store and the others; the connect block sets the key and the existing startup load clears + reloads the list for that radio. An unset key loads nothing and never writes, so it can't read or clobber the legacy shared blob. No migration: the legacy blob is orphaned and each radio's list self-heals as it re-hears adverts. Part of radio-profile isolation Feature #853. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 weeks ago |
|
|
28cdba8cb3 |
task(#920): B2 both message stores read and write rows
A save is now one upsert per message in the list being saved, and costs the same with 50,000 messages of history as with 50. That is the Feature's "done when", and it is the part #919 could not deliver. Also gone: a delete is a single-row DELETE instead of rewriting the whole history, and a load reads only its own key's rows, ordered and pageable in SQLite. Load order matters and is deliberate. Rows are consulted first, but ONLY when rows actually exist. A key marked migrated with no rows still falls through to the legacy slot-index and unscoped-key adoption (#194, #306), because those run on blobs; short-circuiting on the marker alone would strand history stored under an older key. Adoption then hands its result to the migration, so the key ends up on rows either way. The blob path stays as the fallback for a key whose migration was refused. Refused keys keep working exactly as they did after #919: off the UI isolate, merge-not-overwrite, failing closed on an undecodable blob. Tested, including that a refused key does not accumulate half a history in rows. One unreadable row now costs one message. On the blob a single bad byte took the whole conversation with it. Clear paths had to learn about rows: a cleared channel or conversation that only dropped its blob would have stayed fully readable. clearAll also sweeps row-only keys, which no longer appear in a blob key listing. `encodeHistoryForTest` is new on both stores. The #919 tests built their blob fixtures by saving through the store and reading the blob back, which cannot work once saves write rows, and hand-written fixtures are what rotted the first time. They now call the stores' own encoder directly. Tests: 24 added, through the public store API only, because that is what an existing install hits on its first launch after this ships. Covered: blob adoption on first load and on first save, the windowed-save truncation case (#343), per-radio and per-contact scoping (#472), delete, clear, clearAll on row-only keys, a refused migration still serving reads and writes, and the payload being the stores' own encoding. Suite: 1,344 pass, analyze clean, format clean. Citadel: meshcore-open-tfq Refs #920, #917, #334 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
d20d567bba |
task(#920): B3 blob-to-rows migration with a readback gate that can fail
Per key, once: count the blob's records, convert it to rows off the UI isolate, insert them in one transaction, read the stored count back, and only then mark the key as reading from rows. **The blob is never deleted**, not even on a verified match. Rows become the read and write path; the blob is left untouched. Reclaiming that space is a separate owner-gated step, because the moment the delete runs is the moment a mistake stops being recoverable, and a downgrade to an older build still finds its history where it looks for it. Every failure leaves the blob authoritative and says so loudly: an undecodable blob, a converter that throws, a count mismatch, a failed insert. None of them retries, because a count that did not match once will not match on a loop. The migrated set is its own marker key, not "this key has rows". Those are different statements: a key can have rows and an incomplete migration at the same time, and treating rows as proof of completeness is how half a history would read as a whole one. An unreadable marker treats every key as unmigrated, which is the safe direction because the blobs are all still there. The first version of the readback gate was a check that could not fail. It compared the stored row count against the number of rows the converter produced, so a converter that silently dropped records matched itself and passed. Caught by a test built to drop a record. The blob's element count is now measured by a separate decode that does nothing but count, so the converter cannot report its own homework: `rows.length` must equal that count, and the stored count must equal the distinct identities. Both can fail independently, and together they close the loop from blob element to stored row. Duplicate identities in a source blob are deduped rather than refused. The old merge should not have produced any, but the legacy slot-index adoption path (#194) could have, and that must not read as data loss. Tests: 15, nine of them failure paths. The lossy-converter case is the one that caught the broken gate. Citadel: meshcore-open-x9q Refs #920, #917, #334 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
4d4d44a61a |
task(#920): B1 message_records table and the per-message row API
Schema 2 adds one row per message beside StoredBlobs, which stays exactly as it is. A save becomes an upsert of one row, so its cost stops depending on how much history is already stored. That is what #919 could not do: it moved the whole-history re-encode to another isolate, it did not remove it. Shape, and why it is this shape: store_key the EXISTING blob key, byte-identical merge_key the stores' existing _mergeKey identity timestamp_ms INTEGER, so ORDER BY and LIMIT run in SQLite payload the per-message JSON the stores already emit PK (store_key, merge_key), index (store_key, timestamp_ms) Reusing the blob key means every per-radio and per-channel scoping rule (#194, #472) carries over untouched, and the migration becomes a per-key operation with an exact record count to verify against. The payload is JSON rather than a column per field. A Message has 25 fields including three maps, two byte arrays and the translation set; hand-mapping them would mean trusting a new field-by-field converter against 41 MB of real history on its first run, and #355 cost 574 messages the last time a storage migration was hurried. With a payload column the migration is a pure re-key, the stores' existing encoders are reused verbatim, and a new message field needs no second migration. The trade is that message fields are not SQL-queryable; nothing asks for that today, and columns can be added beside the payload later without another data migration. Flagged for the owner on the issue before building it. The row API lives on BlobStore rather than in a second class because both APIs must share one OffbandDatabase: opening it twice is an error on the web backends, and a second singleton would be one mistake away from doing that. `limit` with no `offset` returns the NEWEST n, still oldest-first, because that is what a chat screen opens with. Tests: 18. The row set covers store-key scoping (a leak there would be a history bleed between radios), merge-key upsert, paging, per-row delete, and rows coexisting with a retained blob. The schema set builds a database that looks like one an older build left behind, v1 table with real data and user_version 1, and proves the upgrade runs, creates a usable table, leaves the blobs byte-identical and lands the index on both an upgraded and a fresh database. Citadel: meshcore-open-g6j Refs #920, #917, #334 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
1ed7581a51 |
task(#919): A3 both explicit delete paths off the UI isolate
Deleting one message rewrites the entire blob, so on the owner's largest
channel a delete blocked the UI for as long as a save did, right after the
user tapped something. Both delete paths now go through
`mergeOffThread`.
Two call shapes, one helper. `mergeOffThread`'s second argument is renamed
`incomingJson` to `payload`, because the callback decides what that string
means: the incoming batch when saving, the target merge key when deleting.
The method only carries it across the boundary.
`dropFrom{Channel,Dm}HistoryJson` return null when the target is not in the
history, so nothing is written at all. Re-encoding 5 MB to change nothing is
exactly the cost this Epic exists to stop paying, and the old code did it on
every miss.
`MessageStore.removeMessage` also gains per-key serialisation it never had:
it was a read-modify-write outside `BlobStore.synchronized`, so a save
landing after a delete could resurrect the deleted message. There is a test
for that race.
Tests: 8 added in their own file, including both stores' public delete API,
an absent target, an absent blob and a corrupt blob left byte-identical.
Citadel: meshcore-open-z7c
Refs #919, #917, #334
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
3 weeks ago |
|
|
a2f172098c |
task(#919): A2 DM save merges off the UI isolate
`MessageStore.saveMessages` had the same read-modify-write as the channel store: decode the whole DM history, rebuild every record, re-encode it, all on the UI isolate, for every single message that arrives. Applies A1's helper. Only the incoming batch is encoded on the UI isolate; the decode, merge and re-encode go to another isolate via `mergeDmHistoryJson`, a top-level function so `compute` can send it. The five JSON helpers it needs are now static for the same reason. `mergeDmHistoryJson` throws on undecodable stored JSON rather than swallowing it, which `mergeOffThread` turns into a refused write. That is the same refusal the inline code made, and it is what stops a 5 MB history being replaced by the one message that happened to arrive. Tests: 6 added. Fixtures encode through the store's own encoder, never a hand-written map, because a hand-rolled fixture rots the moment a non-null field joins the schema. Includes a save round trip proving the second save merges rather than overwrites (#343). Citadel: meshcore-open-8sg Refs #919, #917, #334 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
4ddb6a4358 |
feat(#919): move the channel-history merge off the UI isolate
BlobStore.mergeOffThread runs the whole read-modify-write through compute(): stored JSON and incoming JSON in, merged JSON out, so only strings cross the isolate boundary and the object graph never does. Keeps the per-key serialisation that stops concurrent saves clobbering each other. channel_message_store.saveChannelMessages now encodes only the INCOMING messages on the UI isolate (normally one message, measured 0.002 ms) and hands the 5.38 MB decode-merge-encode to another isolate, where it measured 134 ms per save and crossed Android's 5s input-dispatch watchdog in a burst (#334). Fails closed: a stored blob that will not decode throws out of the merge, is logged, and the key is NOT written, which is the same refusal the inline code made. compute runs inline on web, which has no isolates; that is documented behaviour, not a silent fallback, and #920 removes the cost for every platform. _messageToJson, _messageFromJson, _mergeKey, _repeatToJson and _repeatFromJson became static so the top-level merge can call them; none touched instance state. 9 new tests cover merge equivalence, timestamp ordering, empty/absent blobs, the throw on corrupt JSON, fail-closed leaving the blob byte-identical, and three concurrent saves all surviving. Fixtures go through the store's own encoder rather than hand-written maps, after a hand-rolled fixture rotted on a non-null field. Refs #334, #917 Agent: TealGlen (session c61ce804) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
d83cbfae0c |
feat(#829): per-channel region-scope store (Epic #815 C1)
ChannelRegionScopeStore persists a channel-index -> region-name map per device (first 10 hex of the device key, matching the other channel stores). set/clear/scopeFor, null = unscoped. Tests cover round-trip, per-device and per-channel isolation, persistence, and no-device-key guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
4 weeks ago |
|
|
1329f3d3c5 |
fix(#673): never save over contacts the app could not read
ContactStore.loadContacts returned an empty list when the stored JSON failed to decode, and the next save wrote the in-memory list over the stored contacts. A read failure was treated as "no contacts". A decode failure is now logged as an error, the raw value is kept under contacts<key>.unreadable-<timestamp>, and saves to that key are refused (and logged) until a load succeeds. Other radios' keys are unaffected. Tests cover the round trip, the failure report, the raw copy, the refused save, recovery after a good load, and another radio's key; with the refusal disabled the save test fails. Epic #668. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 month ago |
|
|
f5441e527c |
fix(#505, #506): per-radio block storage, drop the accidental global list
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> |
3 months ago |
|
|
b96f20140f |
chore(#457): remove em-dash characters from source
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> |
4 months ago |
|
|
f0d0ca0b27 |
feat(#420): remove Smaz send-side compression (Phase 1)
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> |
4 months ago |
|
|
f935a48ef0 |
feat(#438): show path type + SNR on the message Path screen
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> |
4 months ago |
|
|
4fd5f76a13 |
feat(#297): send a stable per-install client_id in CMD_APP_START
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> |
4 months ago |
|
|
e452ab4805 |
feat(#383): track who reacted, not just the count
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> |
4 months ago |
|
|
d68c6dfdd8 |
fix(#385): surface storage-init failure with a persistent warning
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> |
4 months ago |
|
|
61b0eab423 |
feat(#367): consolidate all found message stores into the pinned DB
#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> |
4 months ago |
|
|
b1fb227b4c |
fix(#363): keep sqlite3 (dart:ffi) out of the web build
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> |
4 months ago |
|
|
e45946a3d3 |
fix(#363): pin the drift DB to one fixed directory, migrate old locations in
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> |
4 months ago |
|
|
8af0e0d5dc |
fix(#355): merge prefs into drift on migration instead of discarding
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> |
4 months ago |
|
|
b7584bd0c2 |
chore(#352): generate drift code in CI instead of committing it
Replaces the stopgap (
|
4 months ago |
|
|
53f87f51e3 |
build(#335): commit the drift generated code so CI can analyze/build
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. |
4 months ago |
|
|
3ff272ad8c |
fix(#343): close the data-loss holes found by Gemini review
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/. |
4 months ago |
|
|
2fefb6aa91 |
fix(#343): keep full message history; window only in memory
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. |
4 months ago |
|
|
fa470c1442 |
fix(#335): store the drift DB in app-support, not OneDrive-synced Documents
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. |
4 months ago |
|
|
4cf2c632df |
Merge branch 'feat/335-drift-storage' into integration/290-306-335
# Conflicts: # lib/storage/contact_discovery_store.dart # lib/storage/contact_store.dart # lib/storage/message_store.dart |
4 months ago |
|
|
dd67391a0e |
feat(#335): switch bulk stores over to drift
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. |
4 months ago |
|
|
a2c7302aad |
feat(#335): migrate bulk data from SharedPreferences into drift
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. |
4 months ago |
|
|
847a28df3e |
feat(#335): prove drift/SQLite-WASM works in a real browser
Step 1 gate, web half. Building is not evidence, so this was run in a browser. Assets: sqlite3.wasm (748 KB) and drift_worker.js (355 KB), both taken from the SAME drift-2.34.2 release so they are built against each other. Validated before use - the wasm has correct WebAssembly magic (0061 736d) and carries SQLite 3.53.3; the worker is genuine compiled Dart JS. Note the filename: the release ships `drift_worker.js`, not the `drift_worker.dart.js` the docs name. The database URI was corrected to match, which would otherwise have been a silent 404 at runtime. Browser result, served over HTTP with COOP/COEP and Content-Type: application/wasm: Using WasmStorageImplementation.opfsLocks PROBE-OK open+write+read PROBE-OK 6MiB round-trip (localStorage cap is 5MiB) drift selected OPFS, the top storage tier, and round-tripped 6 MiB - data the current localStorage-backed web build physically cannot hold. No exceptions. Adds web/_headers for Cloudflare Pages: forces application/wasm on the wasm asset and sets COOP/COEP for cross-origin isolation. Verified to ship into build/web. Without COOP/COEP drift still works, falling back to IndexedDB, which is slower but still unbounded next to localStorage - a performance tier, not a correctness requirement. web_probe/ is a standalone entrypoint that exercises ONLY the drift stack, so a pass or fail is unambiguous without BLE and the rest of the app in the way. It is not part of the app build. Still no user data touched and no store switched over. The migration is the next step. flutter analyze clean, dart format clean, 497 tests pass. |
4 months ago |
|
|
19858698ee |
feat(#335): add drift and prove it opens on the targets buildable here
Step 1 of #335, deliberately before any data migration: prove the store works before trusting it with message history. Adds drift + drift_flutter, a key/value StoredBlobs table mapping 1:1 onto the existing SharedPreferences keys, and verifyReadWrite() as a runtime probe. Key/value rather than relational tables on purpose: it maps directly onto the current store interfaces, so callers do not change and the migration can be verified row-for-row against the old keys. Relational schemas come later, once this is proven and querying is actually wanted. Verified here: - 3 tests pass, including a 6 MiB round-trip - larger than the 5 MiB localStorage cap that makes the web build impossible today - flutter analyze clean - Windows release build succeeds - Web release build compiles Two findings from the gate that a plan-on-paper would have missed: 1. sqlite3_flutter_libs 0.6.0+eol is a DEPRECATED no-op. From sqlite3 v3.x it is unnecessary and drift_flutter already covers it, so it is not a direct dependency here. 2. The web build COMPILES BUT WOULD FAIL AT RUNTIME: drift needs sqlite3.wasm and drift_worker.dart.js shipped in web/, and neither is present. Per drift docs they are downloaded from GitHub releases, version-matched to pubspec.lock (drift 2.34.2, sqlite3 3.5.0), and the server must serve .wasm as Content-Type: application/wasm. Not done here - downloading files needs explicit human approval. Also noted: the drift_dev CLI does not compile at these versions (allSchemaEntities missing from the drift3_preview GeneratedDatabase), so its asset tooling is unavailable. build_runner code generation is unaffected. No user data is touched. No store is switched over. The migration is the next step and remains gated on the web assets question. |
4 months ago |
|
|
30ab360d66 |
fix(#306): surface silent storage failures per SAFELANE 6
The stores violated the no-silent-failures rule, and that is why tonight's
diagnosis took as long as it did. Two classes:
1. Ten catch blocks swallowed decode errors and returned an empty collection.
`catch (_) { return []; }` means a corrupt or unreadable store presents to
the caller as "no data", so the user sees no contacts, no channels or no
history with nothing anywhere explaining why. Indistinguishable from data
loss. Every one now logs what failed, with the store named, before
returning.
2. ChannelMessageStore._storageKey fell back from the PSK-identity key to the
legacy slot-index key without a word. That fallback is correct before a
channel list exists, but once history has been migrated to the PSK key it
reads a DIFFERENT key and returns empty. That is exactly #333: a user's 566
Public messages were intact on disk while the app showed nothing, with no
error, no parse failure and no log line to follow.
It now warns when it falls back while a resolver is installed, since that
combination specifically means the channel list was not loaded first.
Had either of these been loud, #333 would have been a one-line log read
instead of a multi-hour investigation that looked like data loss.
flutter analyze clean, dart format clean, 494 tests pass.
|
4 months ago |