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 }
1078 Commits (cbd4550ca446234aca9aa426a85aefdc416fb04a)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
c296e258ea |
fix(#1132): keep the rotation SET gate as it was, pin the reconnect reset
Applies the Gemini review: the rotation gate change was outside this issue's scope, so rotation SET stays on supportsIndicators. Adds a test that a new radio's device-info drops the previous radio's advert tags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 days ago |
|
|
0416885842 |
fix(#1132): gate display and LED on their own advert tags
caps2 0x04 INDICATORS answers display AND LED with one bit, so a board with a real display and no controllable LED showed the Status LED switch, and the radio refused the write with [0xC5][0x7F][0x06] (firmware #2065: T096, T1000-E, QCC badge). Firmware PR #2080 (merged on firmware-base, c835aeca) adds advert tags 0x04 TAG_DISPLAY_CONTROL and 0x05 TAG_LED_CONTROL. The precedence comes from the header, in the same shape as channel notify: - the advert carries either tag: the tags decide, the bit is ignored - the advert answers with neither tag: the bit answers both - no advert (pre-0x1C radio, or not answered yet): the bit answers both The Display block and the Status LED switch are now gated independently. The Indicators row shows when either one is present. Each SET is refused locally unless its own capability is present. There is no GET-and-read-the-refusal probe. Client half of OffbandMesh/meshcore-firmware#2069. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 days ago |
|
|
74176fb07d |
fix(#1128): log every hidden-history check, run or skipped
The owner's first radio log showed the key-change lines but nothing for the hidden-history check, so "ran and found nothing" could not be told from "did not run". Every channel sync now logs either the check's result (keys checked, channels on the radio, keys deleted) or why it was skipped: erased slots, unconfirmed slots, a partial sync, no device info, or a write in flight. Also makes the key-change test helper wait for each set-channel frame in real time: the history move can decode on another isolate, so a fixed number of microtask turns raced it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
4 days ago |
|
|
f12d5064c5 |
fix(#1128): a key change asks keep or delete; no history stays hidden
Owner ruling: a channel's message history either stays or goes, never kept hidden for a later re-add. - Edit Channel asks Keep or Delete when the key changes (hashtag rename, new private key). Cancel writes nothing. Same-key renames ask nothing. - The answer is applied once the radio confirms the clear. Keep moves the rows (copy, verify, delete only what was copied, repeat) and the phone notify mode; Delete removes the old key's history. - After a sync that saw every slot, confirmed them all, found none erased and had no write in flight, history no channel on the radio shows is deleted, each key logged. Restore's case (erased slots) and partial syncs delete nothing; a radio with no channels deletes nothing. - A different channel over a slot is settled by that sync, since 1.7.0 firmware may move the occupant rather than wipe it. - Keep history that could not land (add refused, move failed) is spared for the connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
4 days ago |
|
|
c512c39a6e |
feat(#1129): time the sponsor window by use, not by release
Owner's rules, 2026-11-09, after a research pass on prompt timing (Google's in-app review guidance, Apple's 3-per-365-days cap, and donation prompts in other open-source apps): - first shown on the 3rd launch, and no sooner than 7 days after the first - Not now, or closing it, sleeps 30 days - Sponsor sleeps 90 days - Never Show Again (renamed from "Don't show again") ends it - upgrades no longer bring it back on their own - the text adds that support is always available from About in Settings or at offband.org/donate Test builds (GIT_SHA defined) skip every wait and show it once per commit, even after Never Show Again, so each test build can be checked. Tests: 16, each rule with its own test; six guards removed in turn (3 launches, 7 days, the sleep check, the 90-day Sponsor wait, Never, once per commit) and each failed a test. Full suite 2079 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
4 days ago |
|
|
10479ed079 |
feat(#1129): tell test builds of one version apart by commit
Test builds share a version and build number, so the sponsor window would show only for the first of them. A build passed --dart-define=GIT_SHA=<sha> is now known as 1.8.0+69@<sha>; a build without it (CI releases, whose build number always changes) stays 1.8.0+69. Tests: two commits of one version each show it once; the gate test passes with and without GIT_SHA defined, and failed with the old fixed expectation when it was defined. Full suite 2076 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
4 days ago |
|
|
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 |
|
|
8709351d57 |
fix(#1122): log the slot lookup's own source, survive a version read
Review findings on the logging commit: - The notify decision line said "live list" or "cached list" by repeating the lookup. The lookup now reports which list answered, so the line cannot drift from it. - Reading the app version at startup can throw. It now logs the error and keeps "unknown" instead of failing startup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
5 days ago |
|
|
43ec4d2fa3 |
fix(#1122): log every notify-mode save and notify decision
The exported log could not show what the client did when a channel's phone notify mode was set, or why a channel message notified, so #1121 could not be diagnosed from a log. - Every notify-mode save logs radio, slot, channel, a one-way PSK fingerprint, old mode with its source, new mode, and the read-back. A save refused for a missing radio key or PSK logs as an error. - Any change to the notify table, by any path, logs before and after. Settings that fail to parse at startup, or fail to write, log errors. - The notify dialog and the channel action sheet log what they show. - Every incoming channel message logs its notify decision: which channel the slot held, from the live or cached list, the mode and where it came from, and posted or suppressed with the reason. - NotificationService queue, batch, suppress and failure paths go to the app log instead of debugPrint. - The app version is logged at startup and on every connect. - appLogger no longer drops messages when the in-app log view is off; the log file always receives them, as connector lines already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
5 days ago |
|
|
6b8356fc87
|
Merge pull request #1119 from OffbandMesh/feat/1054-factory-reset
feat(#1057): factory reset the connected radio |
5 days ago |
|
|
ee01c19175 |
test(#1117): pin the restore plan's rules directly
Gemini review of #1117, findings verified against the code and firmware. Kept: - planChannelRestore was tested only through the connector. There are now 11 direct tests: Public to slot 0, nothing else there, a live channel never overwritten, a channel already on the radio left out, erased slots cleared first, no slot used twice, no key written twice, erased and empty cache entries never written, and no-room channels reported. - Removed a Public-first sort that guarded nothing: nothing but Public is ever placed in slot 0, so the order cannot matter (a mutation dropping it passed every test, which is how it showed). Justified, not changed: - "A second add right after Public displaces slot 0 uses a stale list": the re-read starts synchronously in the write's finally, before the write returns, so the table stays busy and the add gets the busy message. - "Public is refused on new firmware when slot 0 is occupied": unreachable there. Public can't be deleted, and boot puts a missing Public into the first empty slot. So Public missing with slot 0 occupied happens only on a full table, where nothing has room. On older firmware the fallback keeps slot 0's channel from being overwritten. - "Restoring Public over an erased slot 0 overwrites a recoverable record": erased flash holds nothing. A cached channel from slot 0 is re-homed elsewhere, not lost. Full suite 2045 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
5 days ago |
|
|
e592eed80b |
fix(#1117): restore to radio places by identity, reports what landed
Restore to radio (#1060) replayed each cached channel into its remembered slot, in place, and reported how many frames it sent. On the firmware shipping next that is refused for every erased slot (an erased record reads as occupied, #1865), for slot 0 unless it is Public, and for Public anywhere else (#1896). It would still have said it restored them. planChannelRestore (lib/helpers/channel_restore_plan.dart) now places by identity: - a cached channel whose key is already live on the radio is left out - Public goes to slot 0 - any other channel goes to its remembered slot when that slot is free or erased and not slot 0, otherwise to the lowest free slot - a live channel is never a destination, so nothing is overwritten - an erased slot is cleared before it is written Every write goes through _writeChannelSlot and waits for the radio's OK or ERR. The result is what the radio confirmed. The screen says "The radio confirmed all N channel(s)", or "confirmed X of Y. Not restored: ...", persistently. Waiting for each answer also paces the writes by the radio itself, which replaces the fixed 10 ms gap. The comment claiming a confirmed count was impossible is gone. The confirm dialog lists only the channels the radio is missing, and no longer warns that it replaces what the slots hold, because it doesn't. The #1060 restore tests are rewritten to the new contract, keeping what they protected (no erased entry written back, nothing cached is refused, the key travels with the channel, writes are paced), and adding: slot 0 for Public only, clear-before-write, nothing live rewritten or overwritten, refusals reported by name. Mutation-checked with this commit and the previous one: not skipping slot 0 fails 5 tests; never clearing first fails 1; counting sends as confirmed fails 1; allowing a Public delete fails 2; a one-# hashtag rule fails 1. Full suite 2034 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
5 days ago |
|
|
7e5a6de8d0 |
fix(#1117): slot 0 is Public's, on every add path
The firmware shipping next (firmware-base, #1896) refuses any channel but Public in slot 0, even while it is empty, refuses Public in any other slot, and refuses clearing Public. Every refusal is ERR_CODE_BAD_STATE, the same code as an occupied slot, so the client has to avoid them, not interpret them. Owner rulings, 2026-10-28: slot 0 "should always remain public", "make sure Public can't be deleted", "Of course add the real public". - decideChannelSlot takes a required isPublic. Public goes to slot 0 when it is free; nothing else ever does. The fallback that lets Public take another free slot is reached only when slot 0 holds some other channel, which happens only on older firmware. - The shared allocator takes the key being written and decides from it, so Join Public, a typed private key, a QR share and a hashtag join all follow the rule, and a second Public is refused on every one of them. - planChannelImport follows the same rule for a stock config file. - Deleting Public is refused at the attempt with "Public can't be removed from the radio. It always stays in slot 0." The control stays live. - Edit Channel locks Public's key, and refuses typing Public's key into another channel while Public is on the radio. - A hashtag join of "public" (leading # and spaces and trailing spaces dropped, any case: the device's namesPublic rule) adds the real Public, not sha256("#public"). The #1022 test that asserted Public has no claim on slot 0 is replaced by tests for the rule above. On older firmware, slot 0 is no longer handed to other channels, so one slot of 40 goes unused there. That follows the owner's ruling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
5 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 |
|
|
8e345d8b63 |
fix(#1111): name Offband in tile requests and show the OSM credit on every map
The OSMF tile policy requires a distinct, stable User-Agent naming the app and visible OpenStreetMap attribution. Every tile request sent upstream's 'flutter_map (com.meshcore.open)', and no map showed any credit. All six maps share one tile provider, which now carries 'OffbandMeshCore (app.offband.meshcore; +https://offband.org)'; the area download adds caller headers to it instead of replacing it. Web sends none, since the browser sets its own. Each map gets '© OpenStreetMap contributors' as the last child of its stack, linking to the OSM copyright page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
5d563906c6 |
fix(#1025): a channel write never starts its own re-read
Second Gemini round on Epic #1021, findings verified against the code. Kept, fixed: - A writer's re-read called _syncChannels directly, so with two writes overlapping, the first to finish started a sync while the second still waited on the same reply queue. The trigger was restoreChannelsToRadio, the one write path the UI did not gate. Writers now only request the re-read; the last write to finish runs it, once. Nested writes (an edit's clear and add) and batches re-read once, at the end. - Restore to radio refuses with the busy message while the table is busy, like every other channel write. With that, no write path can overlap a sync. Justified, not changed: - "A write slips in between a batch ending and its deferred sync starting": calling an async function runs synchronously up to its first await, and _syncChannels sets _isSyncingChannels before any await, so no tap can land in between. - "A disconnect leaves the write counter stuck": every write decrements in finally, and _writeChannelSlot returns within the 5 s reply timeout even after the queue is cleared. A sync deferred meanwhile runs when that write ends. - "A deferred sync is dropped if a sync is already running": reachable only when a write overlaps a running sync, which no write path allows now. Mutation-checked: a writer starting its own re-read fails the new overlapping-writes test. Full suite 1963 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
085843f758 |
fix(#1025): no sync shares the reply queue with a channel write
Gemini review findings on Epic #1021, after verifying each against the code. Kept, fixed: - A sync started while a channel write waits for its reply shares the ordered reply queue with it, so the sync's empty-slot ERR could be taken as the write's answer. The trigger is real even though the review named a path that doesn't exist: pull-to-refresh, the sync button, the 400 ms channel-changed debounce, and dev's 3 s failed-sync retry all call getChannels at any time. getChannels now defers while a write is in flight, and the last write to finish runs it once. The writers' own re-reads call _syncChannels directly. - Config import and community delete hold the guard across their whole loop (batchChannelWrites), so a deferred sync cannot start between two writes. - A key change whose clear lands but whose add fails used to report "did not confirm the change", understating that the channel is now gone. It returns ChannelWrite.clearedNotAdded, with a message saying the slot is empty and to add the channel again. Justified, not changed: - A reply arriving after the 5 s timeout, once the waiter has been dropped, shifts the queue by one. Keeping the waiter instead would shift it permanently whenever a reply is truly lost. Firmware answers CMD_SET_CHANNEL synchronously, far inside 5 s, and a disconnect clears the queue, so dropping is the smaller risk. - Claimed crash on removeAt(0) with an empty queue: both handlers check isEmpty first. Mutation-checked: removing the deferral fails 2 tests; returning the add's own result fails 1. Full suite 1962 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
bf8602512a |
fix(#1025): reconcile the epic with dev's PSK-keyed history
Rebasing Epic #1021 onto dev met #1063, which keys channel history by the channel's PSK. On a slot reassignment it keeps the old channel's history, so re-adding that channel brings it back, and only a delete removes history outright. #1026's write path had cleared history on every change; the rebase resolution moves it onto dev's rules. A confirmed reassignment (an add, or an edit to a new key) purges only the slot's legacy keys and the in-memory entry. A confirmed delete removes history outright. All three still happen only after the radio's OK. Nothing in dev's suite caught a reassignment true-deleting the old channel's history: with that mutation applied, all tests passed. A new test (channel_write_history_identity_test.dart) drives the real PSK-keyed store and fails 2 of 4 under it. The Edit Channel dialog now checks for a busy channel table before #1103's identity check. Mid-sync the list is empty, so the identity check closed the dialog and blamed a changed channel. The busy check keeps the dialog and the user's edits open and gives the real reason. #1060's restoreChannelsToRadio now counts as a channel write in flight while it runs, so an add made during a restore is refused instead of choosing a slot the restore is about to fill (in the rebased #1026 commit). Full suite 1954 + 4 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
661c39dd0d |
test(#1024): every channel refusal shows, persists, and is absent on success
Part A of the #1021 test child. Widget tests (test/helpers/channel_slot_ui_test.dart) for all six refusal messages: busy, all slots full (with the count), Public already on the radio, and the three unconfirmed-write outcomes (occupied, rejected, no reply). Each one shows on its own failure, is still on screen 10 seconds later (past the 4 second toast default), and none shows when the action is allowed. Connector test: a channel sync that fails restores the cached list with hasLoadedChannels false, and the table still reads as not busy, so an add gets a free slot. This is the lockout a hasLoadedChannels gate would have caused on a flaky link, driven through the real sync rather than asserted. The "Public is already on this radio" check moves from the channels screen into channel_slot_ui.dart, so all six messages live in one tested place. Mutation-checked: dropping persist: true fails all 7 persistence tests; gating the table on hasLoadedChannels fails the failed-sync test. Full suite 1805 pass, analyze clean. Part B (on-device) is the owner's hardware pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
c8de4203f7 |
fix(#1026): wait for the radio's reply before a channel write counts
setChannel sent CMD_SET_CHANNEL and moved on without the radio's OK or ERR, then cleared the slot's message history on the strength of having sent it. Firmware with OffbandMesh/meshcore-firmware#1864 refuses a different channel over an occupied slot with ERR_CODE_BAD_STATE and keeps that channel intact, so the client would have deleted the history of a channel the radio still holds. Connector: - setChannel, deleteChannel and the new editChannel wait for the reply, using the existing generic-ack queue (each pending entry can now carry a completer), and return ChannelWrite: ok, occupied (BAD_STATE), rejected, or noReply (timeout, send failure, or not connected). - History is cleared only after an OK. A refusal or no reply keeps it. - A timed-out waiter leaves the queue, so a late or lost reply cannot be matched to the next command. - isChannelTableBusy covers an unanswered write as well as a sync. Without that, a second add made while the first is waiting would pick the same slot, because the list does not show the first one yet. - editChannel does clear-then-add for a new key. #1864 firmware refuses a new key written in place (owner ruling on that epic: changing a key is clear-then-add), so the edit dialog would have broken once it shipped. A rename is still a single write. Callers: - Every add, edit and delete path waits for the result and shows its success toast only after an OK; otherwise a persistent message says what happened. The two places the channels screen cleared history itself now do it only after an OK. - Edit and delete refuse, with the busy message, while the table is busy. - Config import and community delete write one channel at a time and re-read the list once at the end. A re-read after each write would put the next write inside a sync, where its reply cannot be told from the sync's. Import counts only confirmed channels; refused ones are listed as skipped. - Creating a community: the dialog closes before the write, so the wait cannot invite a second tap, and a refused channel's message is no longer replaced by the "created" toast (a #1022 regression, since a newer toast replaces the visible one). Tests: 12 connector tests driving synthetic OK / BAD_STATE / NOT_FOUND / no reply. Mutation-checked: clearing before the reply fails 3, a new key written in place fails 1, and a timed-out waiter left queued fails 1. Full suite 1793 pass, analyze clean. The refusal path cannot be exercised on a real radio until #1864 ships; the OK path can be tested on hardware now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
9a5cd0caf7 |
fix(#1023): every add-channel path chooses its slot at the write
The channels screen was fixed in #1022, but two more surfaces chose a channel slot on their own and had the same race. Community QR scanner: - A channel share link picked its slot before the confirm dialog and wrote it after, so a sync during the confirmation (setChannel itself starts one) could leave it pointing at an occupied slot. The slot is now chosen after the user confirms, and a refusal keeps the screen open with the reason showing. - Joining a community skipped the public channel silently when the radio was full and still showed the green "joined" toast. A refusal now explains itself, and the green toast shows only when the channel was actually written. - Its private slot finder is deleted. Config import: - planChannelImport takes a required isSyncing. Mid-sync the connector's channel list is empty, so the planner would have written every incoming channel from slot 0 up, over Public. It now plans nothing and reports each channel as skipped with a new channelsSyncing reason. The parameter is required so a caller cannot forget it and get the unsafe behavior by default. Both screens now share allocateChannelSlotOrExplain (lib/helpers/channel_slot_ui.dart), so the "which slot is free" question has a single answer. channels_noFreeSlot is retired; its only caller moved to the shared message. Tests: two planner tests for the syncing case, mutation-checked (with the guard disabled both fail). Full suite 1781 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
0506738925 |
fix(#1022): stop channel adds overwriting occupied slots, including Public
Adding #qcchams and #qcchhv from the phone lost Public, from the client and the radio. Three faults combined, all client-side; the firmware's CMD_SET_CHANNEL is a plain indexed write by design and did what it was told. 1. The slot was chosen when the add dialog opened (_showAddChannelDialog computed nextIndex before showDialog and closed over it), so it was stale by the time Add was pressed. 2. The connector empties its channel list for the duration of a sync and refills it slot by slot, so a dialog opened mid-sync saw every slot as free. 3. _findNextAvailableIndex answered 'no free slot' with return 0, which is where Public normally lives, turning a full radio into a silent overwrite of Public. The decision moves into a pure helper, decideChannelSlot, called at the moment of each write and never earlier. It refuses while isSyncingChannels is true and refuses when full, returning a named reason rather than slot 0. Deliberately isSyncingChannels and not hasLoadedChannels: the latter stays false after a FAILED sync while the cached list is restored into use, so gating on it would disable adding a channel permanently on a flaky link. Add Public no longer hardcodes slot 0, which it wrote with no occupancy check. Public is identified by its PSK, not its index, so it takes a free slot like any other channel, and is refused if it is already present. Owner ruling: block the unsafe write but keep the control live, since a greyed-out button is itself a silent failure. Every refusal explains itself and passes persist: true, because showDismissibleSnackBar defaults to a 4 second toast that SAFELANE 6 rules out for an error. A refused add leaves the dialog open so the user can retry. Mutation-tested rather than trusted: restoring return 0 fails 2 tests, and removing the sync guard fails 4, including the exact reported scenario. Full suite 1779 pass. New strings are English-only with the regenerated untranslated.json, matching the convention of every recent string-adding commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
3364895a9f |
feat(#523): display mode, status LED and rotation over 0xC5 0x1A/0x1B
Lands the #523 indicator controls, separated from the factory reset (#1057) that happened to share their branch. #1057 is destructive, untested and the owner did not ask for it, so it stays behind rather than riding along. Rotation is 0xC5 0x1A GET / 0x1B SET, gated on TAG_DISPLAY_ROTATION in the 0x1C advert, with no caps-bit fallback and no probing. It is NOT 0x18/0x19: those are per-channel channel notify, and an earlier build that put rotation there corrupted silently, because the firmware's 0x19 handler validates a channel-notify mode and nothing else. The hazard commit 89f1768e is not in this history at all; this is the post-fix state only. Display mode and the status LED read device-info bytes 86 and 85. Nothing reads byte 87, which contract section 2 retired permanently. Provenance, since the route matters: cherry-picked ea09d2f3 with -m 1. That commit is the merge of the #523 work into the #1095 advert work, and the rotation fix lives in its merge resolution rather than in a commit of its own, so its first-parent diff is exactly what #523 adds on top of what dev already carries. The result is 956 insertions across 28 files, matching ea09d2f3's own stat, and offband_indicators.dart, indicators_view.dart and indicators_connector_test.dart are byte-identical to the old branch tip. Adversarial review (gemini-2.5-pro) found a crash, fixed here: the rotation control force-unwrapped displayRotation, but the advert tag that ENABLES the control arrives before the rotation GET reply that supplies the value, so the pane threw on any slow link. It now takes an empty selection, matching the display-mode control beside it, which was already null-safe. Proved by probe: restoring the unwrap yields "Null check operator used on a null value" and turns 2 of the new widget tests red. The review's second finding, a read reply overwriting a newer confirmed write, is real but deliberately NOT fixed here. The obvious guard is to drop read replies while a write is outstanding, and _sendIndicatorSet sets that flag with no timeout and no cleanup, unlike _awaitDeviceUi which clears it in a finally. That guard would wedge rotation reads for the rest of the session after any SET that went unanswered, which is worse than the stale value it prevents. Giving _sendIndicatorSet the same timeout discipline is the real fix and is follow-up work, not something to change minutes before a cut. flutter analyze clean, 1945 tests pass and 1 skipped, including 25 indicators connector tests and 4 new widget tests. Not hardware-tested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
6 days ago |
|
|
626ce7ac52 |
fix(#1079): brace the cancelled-trace return in the contact menu
Wrapping the menu in a scroll view re-indented this line, so dart format split the if statement across two lines and CI's --fatal-infos analyze flagged curly_braces_in_flow_control_structures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
90fe0d7c93 |
chore(#1106): merge dev, remove the temporary TestFlight push trigger
Brings feat/1014-ios-app-review up to date with dev for PR #1049. Conflicts: the app name in TESTFLIGHT_GUIDE.md and tools/ios-signing/README.md keeps "Offband MeshCore" (#1050); channels_screen.dart takes dev's channel menu (new radio sound tile) and re-applies the #1075 scroll wrapper. testflight.yml is dispatch-only again, as the push trigger was only for the owner's TestFlight pass (#1047). REVIEW-NOTES encryption line and PLAN finding 14 updated to match what App Store Connect shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
cc00375d28 |
fix(#1103): lock a hashtag channel's PSK in Edit Channel, derive on rename
User report (Jeremy AA4KY, iOS; one codebase so the fix is platform-wide): the Edit Channel dialog was type-blind, offering a free-text PSK field and a random-PSK die for every channel. A hashtag channel's key IS its name, so hand-editing it silently diverges the channel from everyone else on that hashtag. - hashtag: key read-only and copyable (copy icon replaces the die), derived live from the name while renaming, exactly as adding that hashtag would. Save refuses a name that stops being a hashtag or carries a double '#' (a '##name' hashes differently), inline error. - community hashtag: name and key both read-only (community-secret derived), key copyable. - private/public/communityPublic: unchanged. - Save also refuses, with a persistent error, when the slot's live occupant no longer matches the channel the dialog opened on, so a background sync replacing the slot mid-edit (the 1060 class) cannot be overwritten by a stale dialog (review finding). Policy helpers are unit-tested; with both guards behaviorally disabled 2 tests fail. Suite 1903 passed / 1 skipped. English strings only; untranslated.json regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
6 days ago |
|
|
bd0744e472 |
fix(#1060): drop erased records on load, and pace the restore writes
Adversarial review (gemini-2.5-pro) on the push candidate raised five findings. Two were real and are fixed here; the other three are justified in the PR body. Storage is an untrusted source too. The guards already on this branch stop an erased (all-0xFF) record being adopted from the WIRE, but a build from before they existed could have persisted one, and loadCachedChannels applied no filter. _cachedChannels is a fallback identity source for _findChannelByIndex and is re-saved by the unread paths, so such a record comes back as the channel for live traffic and outlives every radio. It is dropped at the load boundary, which protects every consumer at once. It carries nothing recoverable: the name is U+FFFD and the PSK is all-ones. restoreChannelsToRadio sent its writes in an unpaced loop. On BLE sendFrame prefers write-without-response, so its await returns when the frame reaches the OS and not when the radio has taken it: a multi-channel restore could overrun the radio and lose channels with no error raised anywhere. The USB branch of sendFrame already spaces its own frames for exactly this reason. Same 10ms gap between consecutive channel writes. The restore result is documented as written and NOT confirmed. Counting confirmations after the re-sync was tried and reverted: getChannels returns once the first request is away rather than once the sync has finished, so it would have read a list the radio had not answered for and reported zero every time. The erased-slot banner is re-derived on every sync and stays up if writes did not land, so it remains the authoritative signal. Both new guards proved against planted defects rather than assumed: removing the load filter turns 2 tests red, and removing the pacing yields "Expected: a value greater than or equal to <20>, Actual: <0>". flutter analyze clean, 1896 tests pass, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
7 days ago |
|
|
f5fd17f6ff |
Merge remote-tracking branch 'origin/dev' into feat/1068-radio-channel-notify
# Conflicts: # lib/connector/meshcore_connector.dart |
7 days ago |
|
|
383ed5f9ee |
fix(#1060): erased slots never overwrite the channel list, and radio notify follows the channel
Two findings from the owner's field report: a Channels screen full of replacement characters, and a channel he had silenced chirping again. An erased record is not an empty one. Erased flash reads back 0xFF and Channel.isEmpty tests for 0x00, so a slot the radio had LOST was adopted as a channel, rendered with U+FFFD, and then persisted over the client's own good cached list. One missing validation gate, two symptoms. - Channel.isErased / isUnusable: an all-0xFF record, or a name that did not decode, is never adopted. - A sync that sees an erased slot KEEPS the cached list, reusing the fail-closed branch #1062 already had for unconfirmed holes. - restoreChannelsToRadio() writes the cache back to the original slots with their PSKs, behind a confirmed banner action. Radio notify was keyed by slot, not identity. #544 landed after #1061 and keyed its in-memory map by slot index; the slot-identity handler cleared the slot's messages but not its notify mode, so a channel that moved slots handed its mode to the new occupant and lost its own. - _channelNotifyModes is keyed by PSK hex, matching channelNotifyModesByRadio (#848) and the PSK-keyed history (#194). - A reply whose slot does not resolve to a confirmed channel is dropped. - setChannelNotify takes a Channel and resolves its CURRENT slot at send time, so a stale handle cannot address the wrong one; unresolvable is a visible refusal, not a silent write. Both guards proved against planted defects: forcing isErased false turns 5 tests red including the cache overwrite, and restoring the slot-keyed map yields Expected: null, Actual: RadioChannelNotify.off. flutter analyze clean, 1885 tests pass. New strings English-only with untranslated.json regenerated. Task: meshcore-open-75d Agent: CalmBay Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
7 days ago |
|
|
655e396183 |
feat(#1100): set and surface the radio's BLE pairing PIN (cmd 37)
Stock-parity gap: the companion radio persists a pin set over CMD_SET_DEVICE_PIN (37) and reports the configured value in device info bytes 4..7, but the client exposed neither. - cmdSetDevicePin + buildSetDevicePinFrame ([cmd][pin x4 LE]). - parseDevicePin reads the device-info field; deviceBlePin getter (0 = firmware build default, null until reported), nulled on each new connection handshake; setDevicePin validates with firmware's own rule (0 or 100000-999999) and sends like the other config writes. - Settings tile in the device Actions card, pin shown in PLAIN text (owner ruling 2026-11-06: no masking, parity with other mesh clients). Edit dialog with inline validation and Reset to default; persistent re-pair notice on success, persistent error when the send fails. English strings only; untranslated.json regenerated. Gemini gate on the diff: fixed the unsaveable prefill of CLI-set out-of-range pins, the failed-send success lie, and a controller leak; double-tap idempotence and the short-frame fallback justified in the PR. Negative evidence: mutating the encoding, the parse offset, or the validation fails 4 of the 7 new tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
ad319721ca |
fix(#1063): close the adversarial-review findings on the 1061 stack
Gemini gate (gemini-2.5-pro) on the pre-push stack, four findings: - Held channel message frames are now keyed per radio: verifying the unbounded-growth finding exposed that frames held from radio X would have flushed into radio Y's conversations after a radio switch. A parked radio's frames flush when that radio next completes a sync. No cap is added: held frames are popped messages, dropping one is guaranteed loss, and growth is traffic-bounded (<=172 bytes/frame). - _loadChannelMessages merges messages that arrived during the disk read instead of clobbering the list, so the slot-identity reload cannot hide a message the held-frame flush just filed (disk was never at risk: saves merge by identity, #343). - sendChannelMessage refuses the write for a slot this connection has not confirmed (the persisted copy would fall back to the legacy slot-index key and be orphaned), and the chat screen surfaces a persistent error at every send path instead of greying anything. New English UI string; untranslated.json regenerated (#1063). Three integration tests cover the fixes (#1064); with the guards behaviorally disabled exactly those three fail (+3 -3). Full suite 1807 passed / 1 skipped. OUTCOMES.md records the gate and dispositions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
06dd15fa9b |
test(#1064): end-to-end slot-shuffle scenarios + slot-level ingress hold
Integration tests for Feature #1061 across Epics A (#1062) and B (#1063): the #1060 bug-validation scenario (wipe + re-import with shuffled slots: list matches device, no misfiled or mis-rendered message, notify modes evaluate against the right PSKs), a manual device-side reorder (history follows the channel identity to its new slot), and a flaky sync mid-reconnect (held traffic files only after a confirming sync). Writing them exposed one gap: _flushHeldChannelFrames documented that a frame for a still-unconfirmed slot re-holds, but the handler only gated on the list-level barrier, so after a partial sync such a message would file by raw index against an unknown identity and persist under the legacy index key. _handleIncomingChannelMessage now re-holds it until a sync confirms the slot. Test plumbing: channelNotifyProbeForTest observes the notify decision (slot, label, PSK, mode) without reaching the NotificationService singleton; wireMessageStoreForTest wires the message store the way the SELF_INFO handshake does. Issue: #1064 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week 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 |
|
|
4c259d9873 |
docs(#1097): record the verified firmware mode encoding and why 0x18/0x19 corrupted silently
Read from firmware src/helpers/ChannelNotify.h on epic/606-channel-notify rather than taken from Agent Mail: kChanNotifyUnset = 0, All = 1, Mentions = 2, Off = 3, Count = 4. The client enum already matched byte for byte, so nothing changes in behaviour; this records that it was checked against the source, since taking numbers from mail is what caused the collision in the first place. The contract names the modes All / Self / None plus 'follow the device setting'. That is the same encoding in different words: Self is mentions, follow is unset. Worth saying so explicitly, because two vocabularies for one wire byte is how a later session talks itself into a renumber. Also records WHY the rotation collision was silent rather than refused: firmware validates a SET with 'stored < kChanNotifyCount' and nothing else, so every rotation value from 0 to 3 passes as a valid notify mode and writes to the channel at that slot index. Slot 0 is Public. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
1 week ago |
|
|
5dc968ee5b |
feat(#1095): the 0xC5 0x1C capability advert, and nothing at offset 87
Realigns the client to the merged contract. Firmware docs/offband_info_frame_contract.md revision 2 and OffbandConfigProtocol.h, both on firmware-base; the header is the registry and wins on any disagreement. Wire numbers claimed by firmware PR #2000, merged 28785ebf at 2026-11-04 08:34:29Z. Nothing here was built before that merge, per contract section 2. Three features each computed "the next free byte" of upstream's device-info frame as 87 and each was right in isolation. A collision at a frame offset is silent misparsing, not a refused command: everything compiles, every test passes, and it only shows up on hardware as one feature appearing broken. #1095 the advert client #1096 offset 87 removed, with a guard #1097 channel notify re-gated One request on connect, zero payload, after device-info has landed and so well past MTU negotiation. The client asks and the device never pushes: _att_mtu is 23 until onMTUChange fires, so a pushed advert would be truncated to a 20-byte payload as the normal case. A tag's presence IS the capability. No bitmap, no probing each feature's sub-code. len 0 means supported with nothing to report; an absent tag means the board lacks the feature and is neither an error nor a zero value. Unknown tags are kept and skipped by len, because a client built tonight has to stay correct against firmware emitting tags it has never heard of. TAG_CONTINUE is honoured, bounded at 8 rounds and refused a cursor that does not advance: an unbounded request loop driven by device data is worse than a truncated capability list. [0xC5][0x7F][0x05] to the zero-payload request means the radio predates the advert, so capabilities fall back to the device-info offsets that shipped before it. That reason is overloaded on firmware (malformed payload on a known sub-code returns it too), which is exactly why the request carries no payload. The refusal names no sub-code, so it is claimed only while an advert request is outstanding AND no write is: a write owns its own refusal. Channel notify gating is PRECEDENCE, not OR, and this part needed a ruling. Both caps2 0x80 and TAG_CHAN_NOTIFY are merged claims and the contract does not say which wins. Both single-source readings break on a real radio: tag-only takes the control away from 4e5a6c85, already flashed on the T1000-E, which has the bit and predates 0x1C; bit-only loses the control when the bit is retired under contract section 4. So an advert, once it arrives, is authoritative and the bit is ignored, and with no advert the bit answers, which is what shipped before. Raised with FuchsiaCreek before coding rather than worked around (Agent Mail 1449); one expression to invert if the ruling differs. Rotation and ACK capacity gain their gates here but not their features: #1098 and #1099 follow. Rotation is tag-gated with no caps-bit fallback, deliberately -- 0x1A/0x1B were claimed in the same merge as the advert, so no firmware can rotate but not advertise -- and explicitly NOT on caps2 0x04 INDICATORS, which means something else. parseOffbandCaps3, offbandCaps3Offset and offbandCaps3MinFrameLength are gone rather than left unused, so a later change cannot pick them back up. The replacement is behavioural: a device-info frame with every bit set at offset 87 must produce identical capability answers to one that has no byte 87 at all, the raw caps byte included. The getter list alone was a hole -- a leak into a bit with no getter yet moved nothing the test could see -- found by planting such a read and watching the test pass, then comparing the raw byte as well. Each new guard was made to fail before being trusted: the unknown-tag skip (parser dropping unknown tags, 2 red), the precedence rule (gate rewritten as an OR, 1 red), the offset-87 guard (a read reintroduced, 1 red after the strengthening above). flutter analyze clean. 1869 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
1 week ago |
|
|
c88ae55057 |
fix(#1057): drop the FIRMWARE_VER_CODE floor on factory reset
Owner ruling: no FIRMWARE_VER_CODE gating, caps-bit recognition only. The reset no longer refuses below ver code 7. A radio that cannot reset keeps its identity, so the pending record resolves as "not reset" on the next connect and no app data is touched. Removes FactoryResetRefusal.unsupported and its string. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 week ago |
|
|
21fa162d4a |
Merge remote-tracking branch 'origin/feat/1063-message-state-hardening' into feat/1068-radio-channel-notify
# Conflicts: # lib/connector/meshcore_connector.dart |
1 week ago |
|
|
1169b365d5 |
feat(#1068): read radio modes on connect, and the Radio sound control
Epics B3 and C of #544, so there is something testable on hardware. B3 reads the radio's mode for every reported slot, hooked to _cleanupChannelSync(completed: true). Deliberately only on completed: the failure branch restores a CACHED channel list, and addressing a slot from a cache is the guessed-slot error this must not make. When #1061 lands its stricter confirmed-list barrier this moves onto it. C adds 'Radio sound' as its OWN control beside the phone's notifications, on the Channels sheet and the channel chat menu, gated on caps2 0x80. The existing control is relabelled 'Phone notifications' so neither is mistaken for the other. Neither reads the other: that independence is pinned by the A2 tests. C3, the owner's call: the row shows what the channel will ACTUALLY do, not the stored byte. A set mode decides for itself; an unset one reads 'Follows the radio: <scope>'; and a device scope of None shows as silent even on a channel set to All, because telling a user with a muted radio that a channel is on 'All messages' is the confusion this feature exists to end. Null composes to a reading state, never to an invented mode. Proved that check can fail: removing the None-override branch fails the test that asserts it. Restored. Strings are English-only with untranslated.json regenerated, per the l10n rule. flutter analyze clean; full suite 1809 passed. |
1 week ago |
|
|
17e694caed |
fix(#1079): scroll long menus and keep links tappable beside mentions
#1075: the contact, channel, message and channel-sender menus were plain Columns in a sheet capped at 9/16 of the screen, so items past the cap were clipped. A room server's Delete Contact was unreachable on an iPhone. They now scroll, and the sheet can grow to the safe area. #1074: a message with an @mention or contact card was built as rich text whose plain segments were never linkified. Those segments now go through LinkifySpan with the same options, style and confirm dialog as Linkify. Three new widget tests fail without the fix and pass with it. Epic #1078. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 week ago |
|
|
12154c61f2 |
fix(#1063): close the adversarial-review findings on the 1061 stack
Gemini gate (gemini-2.5-pro) on the pre-push stack, four findings: - Held channel message frames are now keyed per radio: verifying the unbounded-growth finding exposed that frames held from radio X would have flushed into radio Y's conversations after a radio switch. A parked radio's frames flush when that radio next completes a sync. No cap is added: held frames are popped messages, dropping one is guaranteed loss, and growth is traffic-bounded (<=172 bytes/frame). - _loadChannelMessages merges messages that arrived during the disk read instead of clobbering the list, so the slot-identity reload cannot hide a message the held-frame flush just filed (disk was never at risk: saves merge by identity, #343). - sendChannelMessage refuses the write for a slot this connection has not confirmed (the persisted copy would fall back to the legacy slot-index key and be orphaned), and the chat screen surfaces a persistent error at every send path instead of greying anything. New English UI string; untranslated.json regenerated (#1063). Three integration tests cover the fixes (#1064); with the guards behaviorally disabled exactly those three fail (+3 -3). Full suite 1807 passed / 1 skipped. OUTCOMES.md records the gate and dispositions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
3d40da28f2 |
test(#1064): end-to-end slot-shuffle scenarios + slot-level ingress hold
Integration tests for Feature #1061 across Epics A (#1062) and B (#1063): the #1060 bug-validation scenario (wipe + re-import with shuffled slots: list matches device, no misfiled or mis-rendered message, notify modes evaluate against the right PSKs), a manual device-side reorder (history follows the channel identity to its new slot), and a flaky sync mid-reconnect (held traffic files only after a confirming sync). Writing them exposed one gap: _flushHeldChannelFrames documented that a frame for a still-unconfirmed slot re-holds, but the handler only gated on the list-level barrier, so after a partial sync such a message would file by raw index against an unknown identity and persist under the legacy index key. _handleIncomingChannelMessage now re-holds it until a sync confirms the slot. Test plumbing: channelNotifyProbeForTest observes the notify decision (slot, label, PSK, mode) without reaching the NotificationService singleton; wireMessageStoreForTest wires the message store the way the SELF_INFO handshake does. Issue: #1064 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
46255f1ef4 |
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 |
|
|
afbacbc7eb |
fix(#1062): fail-closed channel sync, ingress barrier, slot identity diff
Epic A of #1061, fixing the #1060 class: the client's slot-to-channel map could silently diverge from the device and misroute notifications and conversations. - A slot the device never answers for is UNKNOWN: tracked, surfaced via a persistent channels-screen banner with Retry, and never backfilled from the previous list. Lookups for it fail closed, including notification evaluation, which now suppresses instead of defaulting to noisy. - Ingress barrier: live channel traffic holds until this connection's list is confirmed, then flushes. Pull-based message frames are never dropped (the radio already popped them); lossy raw sniffs stay bounded. Raw-RX no longer matches the offline cache. - Per-slot unread counts survive a sync only when the slot keeps the same PSK; identity changes are listed in a dismissible notice and logged. - A sync with unconfirmed slots does not overwrite the last fully-confirmed offline cache; a sync that fails on a live connection retries itself. New strings English-only per the #1021-era ruling; regenerated l10n + untranslated.json committed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
77f16755e7 |
feat(#1068): radio per-channel notify contract, caps bit, caps byte 3, tests
Epic A of #544, plus the B1 contract it needed to fail behaviourally rather than as a compile error. Owner directive: app and radio notifications are separate controls, separate notifications, independent settings. So this adds a RADIO-side per-channel mode and touches the app's per-channel setting not at all. - offband_channel_notify.dart: 0x18 GET / 0x19 SET, RadioChannelNotify with the 1-based encoding (0 unset, 1 all, 2 mentions, 3 off), frame builders, and a parser that DROPS an unknown mode byte rather than guessing - offbandCap2ChannelNotify = 0x80, the last bit in caps byte 2 - parseOffbandCaps3 at device-info offset 87, length-gated, never version-gated (#1065). 87 and not 85: 85 is ui_led_enabled and 86 is ui_display_mode, so reading 85 would parse the LED state as a caps mask - connector: per-radio slot-to-mode state cleared in _clearDeviceUiState, request/set shaped on requestChannelTune/setChannelTune so state follows the device's re-read reply and never the request, reply handler, and DeviceUiSurface.channelNotify for refusals 15 tests across three groups. The A2 independence group is the load-bearing one: changing the app mode emits no radio frame, a radio reply saying Off does not mute the phone, and the app setting still works on a radio with no capability at all. That is what stops the two being quietly re-merged later. Proved the checks can fail: flipping the caps-3 offset to 85 fails three tests, including the one asserting the LED and display bytes are not mistaken for byte 3. Reverted. The radio control itself is Epic C; this pane explicitly does not own a channelNotify refusal, recorded at the switch rather than left implicit. Blocked downstream, recorded on #1069: B3's read-on-connect should hang off #1061's confirmed-list barrier, and the slot-to-mode cache belongs in #1061's PSK identity-diff reset set. flutter analyze clean; full suite 1804 passed. |
1 week ago |
|
|
180c01e670 |
docs(#1065): mark the two surviving ver-code reads as a ratified exception
Owner chose Option C on 2026-11-03: keep companionRadioStats >=8 and regionScope >=13, and file the removal as its own issue. That is #1066. Comment-only. Records at all three sites that these two are KEPT, not overlooked, and why they could not be converted: - both gate STOCK companion-protocol features, and a stock reply is exactly 82 bytes, so it carries no capability byte at all; there is no bit to read on a stock radio at any version and the fork cannot make upstream advertise one - the population classifier cannot substitute: stock 1.16 (no region) and 1.17 (region) both land in stockLegacy - dropping them is not behaviour-neutral: stats would read true off a malformed 3-byte frame, region scope would emit frames to a pre-1.17 radio that ignores them, both pinned by existing tests Also records WHY they are safe for now and when they stop being safe: 8 and 13 sit below the conflated 14-22 range, but a future renumbering through 13 switches region scope off silently on hardware that has it, which is the #1065 failure again. The note tells the next reader not to quietly delete them either, because #1066 needs a decision (firmware caps bits making both features fork-only, or probing instead of predicting), not a patch. Full suite: 1739 passed. |
1 week ago |
|
|
747653af90 |
fix(#1065): drop the observer-config version gate too, bit only
Owner reaffirmed: do not depend on FIRMWARE_VER_CODE, depend on caps bits. The 0xC0 config gate ANDed OFFBAND_CONFIG_MIN_VER (14) on top of the OFFBAND_CAP_WIFI_OBSERVER bit, which left it passing at zero margin once the code rolled back onto 14. Removed; supportsConfig now takes only offbandCaps. The bit is the sounder signal: caps byte 1 exists only on fork replies, which are the only replies that carry 0xC0 at all, and a build without the observer backend compiled in leaves the bit clear whatever version it reports. minVerCode is deleted rather than left unused, so it cannot be reintroduced into a gate. Two call sites and four tests updated. Four gates now depend on caps bits alone: block, caplog, packet hash, observer config. Those are all of the readers in the conflated 14-22 range. Full suite: 1739 passed. |
1 week ago |
|
|
92d5cd212b |
fix(#1065): stop three capability gates reading FIRMWARE_VER_CODE
The block list, caplog and packet-hash gates each checked the correct caps bit and then ANDed a fork contract number on top (>=15, >=17, >=22). With the code rolling back to 14 to resolve the 1.18.0 collision, all three returned false on firmware that genuinely has the feature, while the capability bit advertised support the whole time. Silent capability loss. Owner ruling (AM 1401): no dependency rides on FIRMWARE_VER_CODE. The floors were never what protected stock radios, which is why removing them is safe: - the connector parses both caps bytes ONLY when the population classifier allows fork frames, and nulls them otherwise (meshcore_connector.dart :7924-7938), so a stock reply yields null caps and every caps-anchored gate is false regardless of any floor - the classifier keys on the device-info frame SHAPE plus a dual-form version string, never on the ver code (device_population.dart:107-129), so a fork device at 14 still classifies as offband and works - a caps bit is self-dating anyway: firmware predating a feature cannot set that feature's bit, and caps byte 2 only exists from the version that added offset 84 Drops the now-unused parameter from all three signatures rather than ignoring it, and rewrites the POPULATION INVARIANT note to say no caps-anchored gate may read the ver code. Left alone deliberately: supportsCompanionRadioStats >=8 and firmwareSupportsRegionScope >=13 have NO caps bit to fall back on and are pre-split codes meaning the same thing upstream and in the fork; observer config >=14 mirrors firmware's OFFBAND_CONFIG_MIN_VER and is the one floor doing real work. Removing those would be a behaviour change, not a fix. Tests: the three gates asserted capable at ver 14 (4 assertions failed before this change), the bit still the whole gate, and the classifier proven to carry the stock protection on its own. A structural signature assertion was tried and removed: an optional positional parameter still satisfies the function type, so it passed while a deliberately reintroduced >=22 floor was in place. The behavioural assertions were verified to fail against that floor. Also adds the connector-level stock case at ver 14 to #877's test, which previously only covered a hypothetical stock device at 22. Full suite: 1738 passed. |
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 |
|
|
adce54be1c |
fix(#1052): give iOS a way out of a channel
iOS has no system Back. The chat had a hamburger in place of the back arrow, PopScope(canPop: false) switched off the edge swipe, and the keyboard covered the bottom bar with nothing to dismiss it. Add a back arrow to the channel and the line-of-sight map, keep the edge swipe on pushed screens on iOS, and dismiss the keyboard by tapping the channel's messages or dragging either chat's list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
2 weeks ago |