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 }
171 Commits (1329f3d3c5a87f80eb61bed4e61510c1ccfa91ca)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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 |
|
|
c314663623 |
fix(#671): never let a short contact sync overwrite saved contacts
A full contact sync cleared the app's list, then saved whatever the radio streamed as the whole list. The owner's RAK declared 350 contacts and streamed 106; the app kept the 106 and silently lost the rest, favorites included (#660). A full sync now leaves the local list in place and records which contacts the radio delivered. Only a complete sync (received equals the declared total) drops the contacts the radio no longer holds. A short or undeclared sync merges what arrived, removes nothing, logs an error, and retries once per connection. If the retry is short too, the connector exposes a pending decision (keep local, default, or use the radio's list) for the UI in #674. Removes the now-dead _preserveContactsOnRefresh flag. Adds test-only seams to feed frames and capture sent frames. Tests cover complete, short, undeclared, retry bound, both decisions, mid-sync adverts and incremental sync; with the old clear reinstated, 5 of them fail. Epic #668. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 month ago |
|
|
15e4917e12 |
fix(#610): apply Gemini review, harden the card parser and chip
Three accepted findings, plus a real defect the adversarial test cases flushed out. A long name overflowed the chip by 405 pixels. The name comes off a public radio channel and is attacker-controlled, so anyone could break a message list by posting a card with a long name. The label is now bounded and ellipsized, with an explicit width ceiling because a WidgetSpan hands its child unbounded width. fromChannelShare no longer scans from the first < to the last >. Two cards in one message were read as a single span running from the first key to the last name. That was reachable: the add dialog passes raw pasted text straight to it. It now takes the first well-formed card via a regex whose name class cannot cross a closing bracket. The known-contact check is O(1) against the connector's existing _knownContactKeys set, through a new isKnownContact. Scanning contacts ran per chip on every notification, and the public knownContactKeys getter copies the whole set, so neither belonged in a build method. Card-versus-mention is now carried on the match instead of inferred from text[m.start] == '<', which would mis-route the day a third bracketed pattern is added. Two findings were rejected on evidence and are justified in the PR body: a claimed missing null terminator (firmware sets it from the frame length itself) and a claimed greedy-regex failure (disproven by test). Epic #610. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
1 month ago |
|
|
3d5e655188 |
feat(#610): render a received contact card as an Add Contact chip
The receive half of contact sharing. Until now an incoming <key:type:name> rendered as raw text while the stock app showed a native Add Contact button for the identical payload, so sharing worked outbound only: a stock user could add an Offband user from a card, but not the reverse. Confirmed by the owner on hardware. Reuses the proven mention-chip mechanism in TranslatedMessageContent rather than adding new machinery. Both patterns are now collected and sorted by position, so a message carrying a mention AND a card renders both in the right order; they cannot overlap, since a mention is @[...] and a card is <...>. Tapping opens the add dialog seeded with the card rather than adding silently. A contact is an identity, so adding one stays a deliberate act with the key, name and type visible first. Two deliberate refusals: - A card whose key is already a contact renders inert, mirroring stock, which warned the owner rather than silently re-adding. - A card matching the shape but failing the parser, such as an out-of-range type, falls back to plain text. Showing an Add chip there would promise something the parser will refuse. Parsing already existed from the #611 Gemini review, so this change is rendering and dedupe only. Epic #610 under Feature #609. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
1 month ago |
|
|
2eb4868678 |
fix(#636): stop truncating names mid-codepoint on the wire
Three writers copied raw UTF-8 bytes up to a 31-byte boundary with no codepoint check, so any name crossing it was cut mid-character and invalid UTF-8 reached the radio. It was then stored that way and handed back to every client that later read the contact or channel. ASCII names were unaffected, which is why it went unnoticed. An 11-character Chinese or Japanese name is 33 bytes against a 31-byte budget, so it corrupted every time. Adds utf8TruncateToBytes, which stops on a GRAPHEME CLUSTER boundary. Codepoint-safe is not sufficient: a ZWJ emoji sequence is several codepoints joined by U+200D plus a variation selector, so a codepoint-safe cut can still leave a bare glyph, a dangling joiner or an orphaned selector. The characters package was already a dependency, and flutter/widgets re-exports it, so this costs nothing new. Fixed at all three sites: - writeCString, used for contact names and channel names - buildSetAdvertNameFrame, which sets THIS device's own advert name That last one mattered most: it is the name the whole mesh sees, the name embedded in the contact cards this device emits, and what other clients match against. Corrupt it once and it propagates outward. The strongest test sweeps every budget from 0 upward across a string mixing 1, 3 and 4-byte characters and asserts the output always decodes strictly as UTF-8, which is precisely the property that was broken. ASCII output is unchanged. Found by the standards#145 Gemini review on the #619 branch, which was told not to re-report writeCString and located the third site instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
1 month ago |
|
|
68e0d5c58e |
test(#717): pin the frame budget per platform, so this cannot regress silently
One getter decides the composer's byte budget on every platform, and it had collapsed that budget to zero on three of them before anyone noticed: Windows (#686), Linux (#717) and Web (#718). Each surfaced as a user reporting "the keyboard doesn't respond", two months and three platforms apart. Nothing exercised the getter, so nothing went red. Extracts the decision into `MeshCoreConnector.resolveFrameBudget`, a pure function of (transport, isWeb, mtuNow). The getter is now a one-line delegate and behaves identically. This is what makes the web branch testable at all: `kIsWeb` is a compile-time constant, so a VM test could never enter it and CI runs the suite on the VM only. 19 new cases, covering what we now know rather than what we guessed: - wired transports are never floored, and ignore a bogus MTU - web is exempt from the floor, and both composers stay usable at MTU 23 - native BLE keeps the #395 write-safety floor: unknown MTU yields 20, never the full frame, because defaulting an unknown link to the largest size would authorize an oversized write - the MTUs actually measured on hardware (Android 247, Windows 176) leave a usable channel composer - the original failure stays reproducible: an unreported MTU still gives a 0-byte channel budget and 4 bytes for a DM, the two numbers users reported Also adds widget tests for the #684 notice, including the disconnect case the pre-PR review caught, and the severity split between a zero and a small budget. analyze clean, 981 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
1 month ago |
|
|
63d16fd114 |
fix(#684): explain a composer that cannot carry a message, instead of a dead field
#592 was reported twice as "the keyboard doesn't respond". It never was an input bug: the byte budget had collapsed to 0, so the length limiter rejected every keystroke while the field still took focus and blinked a caret. Nothing on screen said why, and the one element that would have (the `used / max` counter) is hidden while the field is empty, which a dead field always is. Adds `minUsableComposerBytes` (16) and `isComposerBudgetUsable()` next to the budget arithmetic, and a shared `ComposerBudgetNotice` rendered above both composers. 16 is a judgement call, set above the degenerate values #592 produced (DM 4, channel 1 or 0) so the guard catches the near-misses too, not only a literal zero. A budget of 0 and a budget of 4 are described differently: one cannot send at all, the other has almost no room. Persistent inline notice rather than a toast, deliberately. The condition is a state that lasts as long as the link, not an event, so a snackbar would scroll away and leave the same silent field behind (SAFELANE 6). It renders nothing on a healthy link, so both composers include it unconditionally. Does NOT change sending or the frame budget: out of scope per the issue, and the write-safety cap from #395 stays honest. A small but nonzero budget still sends, it just says so now. The DM composer's Container moved into _buildComposerBar so a Column could sit above it; no change to the bar itself. Tests: guard assertions added to the existing budget characterization file, including the negative case that a healthy link (and the 247 / 176 MTUs measured on hardware) leaves the notice invisible. 962 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
1 month ago |
|
|
cf0332612f |
fix(#686): wire the app to the vendored Windows BLE plugin + live MTU updates
App-side half of the #592 fix; the plugin-side GattSession patch is a separate commit on packages/flutter_blue_plus_winrt. - dependency_overrides points flutter_blue_plus_winrt at the vendored copy (version 0.0.20+offband.1; interface constraint relaxed to >=8.2.2 since the Dart layer uses nothing 9.x-only - the 9.x/2.3.x upgrade was deliberately skipped, owner decision on #685). - Connector: requestMtu is now attempted on Android only. flutter_blue_plus throws "android-only" on every other platform before any platform code runs (#683), so the old call could never succeed off-Android and its failure log was noise. - Connector: subscribe to device.mtu and notifyListeners on each value. The composers read effectiveMaxFrameSize on rebuild, so this is what lets the message byte budget recover from the 20-byte floor the moment the platform reports the real MTU, instead of waiting for an unrelated rebuild. Subscription is cancelled on reconnect and disconnect. - test/connector/mtu_composer_budget_test.dart: characterizes the collapsed budgets at the unknown-MTU floor (DM 4 bytes, channel 1 or 0) - the numbers both field reports reduced to - and the healthy-link budgets. Rescued from the throwaway diagnosis branch (#683). - Regenerated plugin registrants picked up plugins missing from the committed copies (file_selector, screen_retriever, window_manager); stale on dev, refreshed by pub get after the override. analyze clean, 958 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 month ago |
|
|
9741c3399d |
fix(#575): apply pre-PR review, stop claiming unconfirmed import success
Adversarial review raised three findings. Two survived verification. Rejected: a claimed RangeError when abbreviating a short public key. The code already guards with a length check before either substring, so the crash it describes cannot occur. Rejected as described, fixed as found: contact position (0, 0) was said to be dropped on import. It is not. The frame builder writes the position block whenever lastModified is present, which it always is here, so a suppressed position still writes 0/0 and the bytes are identical either way. The hasPosition conditional was therefore doing nothing except misleading a reader, which is exactly what happened. Removed, and the behavior is pinned with a test. Accepted: the service added sections to 'applied' after sendFrame returned, which only means the frame left our side. A lost or refused frame was indistinguishable from success, so the user could be told an import worked when nothing changed on the device. A correct fix needs an awaited per-command acknowledgement in the connector, keyed on command code so concurrent commands cannot steal each other's OK. That is real connector work and is filed as #584. What is fixed here is the overclaim: 'applied' and the two counts are documented as sent rather than confirmed, and the result string now reads 'Sent N contacts and M channels to the device'. The code no longer states something it cannot know. |
2 months ago |
|
|
0047ee0c8c |
feat(#574): stock config export screen
Mirrors stock's Export Config screen: section list, Select All and Deselect All, everything checked by default. The asymmetry with import, which will start with nothing checked, is stock's own and is deliberate: opt out on the way out, opt in on the way in. A section the user ticked that could not be gathered is shown before the file is written, with its specific reason, and the user chooses whether to export anyway. Firmware-built-without-it reads differently from device-did-not-answer because only one of them is worth retrying. The screen also states plainly that this is the stock format and cannot carry Offband-only data, so importing the file back will not restore it. File naming follows stock, <name>_meshcore_config_<stamp>.json, and is sanitized for the filesystem without touching the name written inside the file, which has to round-trip exactly. Real device names in the reference corpus contain emoji and a trailing space, both covered by tests. Writing reuses LogExport for the per-platform mechanism (share sheet on mobile, Save As on desktop, download on web) rather than adding a second export path that could drift. The temp file can contain the node private key, so it is written under the app's own temp directory. The public key is abbreviated on screen; the private key is never rendered. 6 tests on the file name. |
2 months ago |
|
|
ee1ba064ac |
feat(#575): stock config import service
Applies a stock config file to the device with stock's own merge semantics, quoted from its Import Config screen: - contacts are upserted on public key, and nothing is ever deleted - channels are additive only, so a rotated PSK for a channel the user already has does not apply - importing an identity overwrites the node outright We match that behavior but not its silence. Every skipped channel comes back named, with a reason, so the UI can show it; a channel that quietly did not import is the silent no-op SAFELANE section 6 forbids. The channel merge rule is extracted as planChannelImport, a pure function, so the surprising part is testable without a radio. It treats a channel as already present when either its PSK or its name matches, which is the non-destructive reading of a rule stock states without defining. Pinning down what stock actually does is a T1 question, recorded in the doc comment. New channels take the lowest free slot, since the file carries no index. other_settings is reported as not fully writable rather than claimed as applied: buildSetOtherParamsFrame deliberately pins the auto-add-contacts byte, so manual_add_contacts cannot be written without changing app-wide behavior. Advert location policy is applied. 10 tests over the planner and the result type. |
2 months ago |
|
|
16c2b4a770 |
feat(#573): stock config export service
Gathers live device state into a StockConfig, section by section, matching stock's Export Config screen where each section is independently selectable and the file simply omits what was not ticked. A requested section that cannot be gathered is reported in the result rather than dropped, with the reason kept specific: unsupported (firmware built without identity export) is distinct from noReply and rejected, so the export screen can say the radio cannot do it instead of offering a pointless retry. Two unit traps handled explicitly: - currentFreqHz is misnamed; the value is kHz, which is also what stock's frequency field carries, so it passes through unconverted. currentBwHz really is Hz. The mismatch exists in both our state and the file. - other_settings.manual_add_contacts must be the raw device byte. The connector's _manualAddContacts is an inverted derived view of bit 0 (firmware treats a clear bit as auto-add enabled), so exporting it would have written the wrong value. The raw byte is now retained and exposed, with the inversion documented at the parse site. No behavior change. Path mapping keeps our hash count and per-hop width (#309) intact: stock encodes one hash per comma-separated element, so the width survives as element length. A flood route, an over-long declared hop count, or a width stock cannot express all yield no path rather than a guessed one. 13 tests over the pure mapping functions. |
2 months ago |
|
|
ce16a2126b |
feat(#578): connector identity export/import + auto-add hop limit
Prerequisite for the export/import epic (#568): two device values the connector could not read or write, both already exposed by firmware. Identity (CMD_EXPORT_PRIVATE_KEY 23 / CMD_IMPORT_PRIVATE_KEY 24): - adds the commands, RESP_CODE_PRIVATE_KEY 14 and the previously unhandled RESP_CODE_DISABLED 15 - both firmware commands sit behind build flags, so a radio can legitimately answer DISABLED. IdentityTransfer keeps unsupported distinct from rejected so the UI can say the radio cannot do it rather than offering a retry that can never succeed - a short PRIVATE_KEY frame reports rejected instead of handing back a truncated key that could be written to an export file - import is destructive and documented as such; the key is never logged Auto-add hop limit: - GET already returned max_hops as a third byte and we discarded it; it is now parsed and exposed - SET learns an optional maxHops. Omitting it keeps the frame two bytes, so firmware leaves the device value alone and existing callers are unchanged - clamped to 64 on our side to match the firmware clamp Adds handleFrameForTest so parse paths can be exercised without a radio, following the file's existing visibleForTesting convention. 12 new tests. |
2 months ago |
|
|
41474ca41f |
feat(#572): stock config format model + parser/serializer
Models the MeshCore stock companion config export JSON so files interchange in both directions (epic #568). Shape reverse-engineered from ten real exports across five radios; evidence recorded on #569. Format rules the parser enforces, none of them obvious: - no version field exists, so validation is by shape and every top-level section is independently optional - public_key and private_key travel as one unit - radio_settings mixes units: frequency kHz, bandwidth Hz, coding_rate a bare denominator - coordinates are JSON strings, never numbers - channels carry no index; array position is the index - out_path_list is one comma-separated hop hash per element, so hop count and hash width are both recovered rather than inferred (#309) - contact timestamps are untrusted and never sanitized - null and empty-string out_path_list stay distinct on re-encode; both mean no route, but real exports contain both and the difference is unexplained Unknown top-level keys are ignored so a future stock release cannot break the reader. Errors carry the offending JSON path for the import UI. Test fixtures are synthetic with obviously fake keys. Validated separately against the owner's ten real exports, which round-trip key-for-key; that harness reads a path outside the repo and was deliberately not committed. |
2 months ago |
|
|
d27048f94a |
fix(#678): distinguish CoreScope not-found from unreachable (#571)
CoreScopeService.fetchCounts returns a three-state CoreScopeResult { found | notFound | unreachable } instead of a nullable count. Reachable-but-empty -> notFound ('Not seen by CoreScope yet'); network/HTTP/parse error -> unreachable. Channel-chat tap-refresh banner switches on status; adaptive poll reads .counts.
Epic #677. Closes #571.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
2 months ago |
|
|
edffd10555 |
fix(#581): adopt the radio's RESP_CODE_SENT ACK hash for send correlation
The radio is authoritative for the expected-ACK hash it reports, but the client looked that hash up in a map keyed by a hash it recomputed locally. When the two disagreed the lookup missed silently (debugPrint only), the 8000ms watchdog from #395 marked the message failed, and the genuine ACK later matched nothing because every downstream map is populated only on the match path. Delivery worked the whole time. Captured on a Wadamesh HV4 TFT in #449: five DMs, all shown as errors, two with a confirmed ACK whose hash equalled the radio's RESP_CODE_SENT value exactly. Wadamesh builds the payload differently, so the client cannot predict its digest, and Offband is expected to work against stock MeshCore, Wadamesh and other forks. RESP_CODE_SENT is the reply to our own CMD_SEND_TXT_MSG, so adopt the radio's value when exactly one send awaits confirmation. Zero or several candidates keep the previous behaviour rather than guessing. Channel sends draw the same frame, so adoption is refused while one is outstanding. The lookup miss is now a warn on debugLogService instead of a bare debugPrint (SAFELANE section 6). A send-order correlation queue was developed alongside this and has been split out: its ordering premise needs a transport send mutex that does not exist yet, and three review rounds each found a fresh defect in it. This commit deliberately carries only the adoption fallback, which is what the owner's hardware test validated. Refs #449 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
2 months ago |
|
|
bd43642d72 |
fix(#580): keep a truncated caplog and offer to save/share it
A truncated caplog download used to completeError and discard every received byte, so the user got nothing from a buffer still intact on the device (a tester lost 8461/8608 bytes, 98.3%, deterministically per firmware#711 so retry can't help). Now truncation is a soft outcome: - downloadCaplog() returns a CaplogDownload (bytes + received/expected/chunks + truncated) instead of throwing; busy/timeout still throw. - serial_capture_screen still writes + LogExport.shareFile the partial bytes, marks the file "# PARTIAL ..." with the counts, and shows a non-fatal warning instead of a red error. - Button label is now platform-aware (Download & save on desktop, & share on mobile) via LogExport.actionVerb, matching the App/BLE log screens. Replaces CaplogTruncatedException with CaplogDownload. Reassembler unchanged (already keeps the bytes). Adds CaplogDownload contract tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
2 months ago |
|
|
06f7e347fd |
fix(#611): apply Gemini review, accept our own channel format on paste
Two accepted findings from the standards#145 adversarial review. Finding 4, the sharp one: the app emitted the compact <key:type:name> card but could not parse it. A user copying a card out of a channel and pasting it into Add by public key would have been rejected by the app that produced it. Adds Contact.fromChannelShare and tries it after fromShareUri in the dialog, so both real formats are accepted. The parser splits on the FIRST TWO colons and takes the remainder as the name, because names carry colons, spaces, emoji and CJK. It also finds a card embedded in a longer message, which is how one actually arrives, since people caption them. Rendering a received card as a tappable affordance remains #610; this only covers text pasted or scanned into the add flow. #610 is correspondingly smaller now. Finding 2, performance: resolveContactVerification ran an O(N) message scan from a widget build inside a ListView. Now scans newest-first, since a delivered message is overwhelmingly likely to be recent, and advert-verified contacts still return on a single comparison without touching the message list. A lastMessageAt == epoch shortcut was written, then removed after checking _setContactLastMessageAt: it maintains that field only for advTypeChat, so a key-added repeater that had been messaged would have shown the wrong badge. A cheap wrong answer is worse than a slightly slower right one, and the rejected approach is documented in place. Epic #619. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
2 months ago |
|
|
a88c24f44f |
fix(#629): put Scan contact QR on the contacts menu, not only in the key field
Owner feedback from testing: a scan button living only inside the public key field is awkward placement, because scanning is how most people will actually add someone and it should not be reachable only from inside a field they have to open first. Now in both places, routed to the right thing: - Contacts overflow menu, Scan contact QR, opens the scanner and hands the result to the add dialog already populated. Nothing to paste. - The key field keeps its scan button, which fills in place, for when the dialog is already open. Same scanner and same parser either way; only the entry point differs. The dialog gains an optional initialKeyText, and seeds it through the same handler as a paste, so a scanned link populates name and type rather than sitting there as raw text. Platform availability moves to a shared contactQrScanAvailable getter on the scanner screen, since it now has two consumers. Both the menu entry and the field button hide on Windows and Linux, where mobile_scanner has no support and the render half is the desktop path. Epic #619. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
2 months ago |
|
|
c27bef0f73 |
feat(#611): share your contact into a channel from a + picker
The composer GIF button becomes a +, opening a short picker with GIF and
My contact card. Owner decision after seeing the build: one affordance
beside the text entry rather than a button per attachable thing, because
the sendable set stays small when a channel message shares a 160-byte
payload with the Sender: prefix.
Sends the COMPACT format, not the meshcore:// URI:
<{64-hex key}:{type}:{name}>
That shape was observed on the live mesh, twice, from different senders
in #test and #hamradio four weeks apart. It costs about 75 bytes against
117 for the equivalent URI, so on a 160-byte budget the difference is
airtime rather than tidiness. The URI form stays correct for a QR, a DM,
or an out-of-band paste; this is the channel idiom.
Angle brackets are stripped from an emitted name because they are the
delimiters, and a name carrying one would truncate the payload for every
parser reading it. A colon is left alone: the name is the final field,
so a correct parser splits on the first two colons and takes the rest.
Inserts into the composer rather than sending, matching the GIF picker,
so the card can be captioned and reviewed first. It appends, so a
caption already typed is not destroyed.
Parsing a received share is #610 and is not in this change, so an
inbound card still renders as raw text for now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2 months ago |
|
|
d5a306d4a4 |
feat(#630): show how far a contact identity is confirmed
Adds a calm three-state indicator beside each contact name, per the owner steer: a green check for advert-verified, a neutral outline check for key-confirmed, a muted key for key-only. No amber and no hazard glyph, because nothing is wrong with a key-added contact. The scale reads as how much we know, never as how risky. The states are not cosmetic: - advertVerified: a signed advert arrived, so the node itself asserted its name, type and position, and the raw packet is stored, which is also what makes the contact re-shareable. - keyConfirmed: a message with this contact went through. Direct messages are encrypted with an ECDH secret derived from the contact key, and the ACK is computed over the decrypted plaintext (BaseChatMesh.cpp:442,451), so this is proof the holder of the matching private key is live. It does NOT prove the person is who the name claims. - keyOnly: someone supplied a key and nothing has confirmed it on air. Costs almost nothing to compute. Contact.isAdvertVerified falls out of the epoch last_advert_timestamp that #627 already writes, and it clears itself when a real advert rewrites the field. The advert check runs first so only an unconfirmed contact pays for a message scan, which keeps a long contact list cheap. Also fixes a defect the epoch sentinel introduced: _formatLastSeen ran the epoch through the relative formatter and claimed a key-added contact was last seen tens of thousands of days ago. It now reads Not heard yet. Epic #619. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
2 months ago |
|
|
cfe533ec18 |
feat(#629): show and scan a contact QR
Both halves of the visual exchange, sharing one format and one parser. Render: showMyContactQrDialog puts this device's own identity on screen as a QR plus the same link as selectable, copyable text. It refuses to render when not connected, since an empty key would encode a QR nobody can add. Scan: ContactQrScannerScreen validates with Contact.isValidShareUri, the same check the paste path uses, and pops the raw string. The add dialog routes a scan through the same handler as a paste, so a QR gets no separate code path. The scan affordance is gated to platforms mobile_scanner supports (Android, iOS, macOS, web). On Windows and Linux the QR half is the render side, which is the better desktop flow anyway: put your code on the big screen and let the other person scan it with a phone. Fixes a real defect found by the new test, not a test artifact: QrCodeDisplay built its QrImageView through a LayoutBuilder, which cannot answer intrinsic dimension queries, so any intrinsic-measuring parent threw. AlertDialog measures its content's max intrinsic height, so the dialog crashed on open. The QR is now bounded by a tight SizedBox, which answers the intrinsic itself. This was the widget's first real call site, so the bug had never been exercised. Entry point is provisional alongside Add by key; #632 reorganises. Epic #619. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
2 months ago |
|
|
3f427d2c41 |
feat(#628): add a contact by typing or pasting a public key
First user-reachable piece of the identity exchange. A dialog takes a public key, a name and a contact type, and hands the stub to addContactByKey. The key field also accepts a whole meshcore://contact/add link and absorbs every field from it, because that is what someone actually pastes, and a QR is only that link rendered visually. Whitespace is stripped so a key copied across a line break still works. The stub is built by round-tripping through buildShareUri and fromShareUri rather than constructing a Contact directly, so manual entry and a scanned QR cannot drift apart. One code path, one set of invariants. An informational note states plainly that the contact is not confirmed on air and that the name is whatever the user typed until the node adverts. Deliberately informational rather than a warning: nothing is wrong with a key-added contact (#630). Entry point is provisional, sitting in the contacts overflow menu so the feature is reachable. The proper add-contact surface, split away from the advert affordance, is #632 under epic #623. Five widget tests pin what reaches the connector: key, typed name, chosen type, the flood sentinel, and the epoch lastSeen that keeps the firmware replay guard from muting the contact. Also covered: pasted-link prefill, wrapped-key whitespace, an invalid key sending nothing, and the missing-name fallback. Epic #619. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
2 months ago |
|
|
9b63983033 |
feat(#627): add a contact from a key alone, with no advert
Adds MeshCoreConnector.addContactByKey, the device-side half of the identity exchange. It uses the stock CMD_ADD_UPDATE_CONTACT (command 9), whose handler creates the contact outright when the public key is unknown. Verified byte-identical to upstream meshcore-dev/MeshCore at 0679dbef, so this needs no capability gate and works on stock firmware, not only Offband builds. buildUpdateContactPathFrame gains an optional lastAdvert. It defaults to now, leaving all three existing path-update callers byte-identical, and addContactByKey passes the epoch. That epoch is the whole point. The firmware compares this field against every incoming advert with 'timestamp <= last_advert_timestamp' and silently discards the non-greater ones as replay attacks (BaseChatMesh.cpp:142-145). Advert timestamps come from the sender's clock, and two nodes on this mesh currently advertise with 2024 clocks, so stamping now would leave a key-added contact permanently deaf to its own adverts. A negative input clamps to zero rather than wrapping to a huge value, which would be the worst case for that guard. The frame is sent at full length with the 0xFF flood sentinel. The firmware length guard is only 'len >= 36' but updateContactFromFrame reads through offset 136 regardless (MyMesh.cpp:295-318), so the frame must never be trimmed. A test pins that too. Local state keeps lastSeen at the epoch so the contact reads as unverified until a real advert upgrades it (#630). Epic #619. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
2 months ago |
|
|
bbea912cb0 |
feat(#626): emit the stock contact share URI
Adds Contact.toShareUri and the static Contact.buildShareUri, the inverse of #625's parser. Emitting the reference-app format is what makes an Offband card or QR importable by a stock, non-Offband user. buildShareUri takes raw parts rather than a Contact so the local device can share its OWN identity, which is a public key plus a node name and never a Contact instance. Spaces are percent-encoded rather than emitted as '+'. Both decode to a space, and this matches Channel.toShareUri, which the codebase already documents as round-tripping with the reference app's QR (#161). Tests cover the emitted parameter shape, the companion default, and round-tripping through fromShareUri for names with spaces, emoji and reserved characters. A raw & or = in a name would otherwise truncate or forge query parameters, so that case is pinned explicitly. Epic #619. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
2 months ago |
|
|
007d0fb4f8 |
feat(#625): parse the stock contact share URI
Adds Contact.fromShareUri / isValidShareUri for the reference-app format documented in firmware docs/qr_codes.md: meshcore://contact/add?name=<url-encoded>&public_key=<64 hex>&type=<1-4> The payload is a bare public key, not a signed advert, so the parsed Contact is an identity stub: no path, no position, no rawPacket. A QR is this same URI rendered visually, so scanning will share this parser. lastSeen is deliberately the epoch rather than DateTime.now(). It maps to the firmware's last_advert_timestamp, which the advert handler compares with 'timestamp <= last_advert_timestamp' and treats as a replay attack (BaseChatMesh.cpp:142-145). Stamping now would leave the contact permanently deaf to its own adverts, since advert timestamps come from the sender's clock and two nodes on this mesh currently advertise with 2024 clocks. A regression test pins this. The fork's older meshcore://<raw advert hex> form returns null here, so callers keep routing it to the existing advert import path. Epic #619. Not yet wired to the UI: the import path needs the send half (#627) before there is anything to add. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
2 months ago |
|
|
a21fde6396 |
feat(#565): add/view a channel sender from avatar tap and long-press
Channel frames carry no key, so the sender name is resolved against known and discovered contacts. Adds resolveContactsByName (returning Contacts, which importDiscoveredContact needs) and rebuilds resolveContactKeysByName on top of it so one matching rule serves both. The avatar is the shortcut; the sender name stays inert because it sits too close to the message body to hit reliably on a phone. Long-press keeps every action it had and gains the same contact rows. A name several nodes claim lists all of them, and an unheard name says so instead of failing silently. |
2 months ago |
|
|
e145ef7481 |
fix(#528): apply the three accepted Gemini review findings
1. An unprefixed reply no longer resolves an arbitrary pending command. #532 constrained prefixed replies only; the unprefixed fallback still used firstWhere, which with two or more in flight is map-iteration order, so a caller could receive another command's output. The fallback cannot just be deleted: firmware only echoes the prefix when the command exceeds four characters including it (simple_repeater/MyMesh.cpp, strlen(command) > 4 && command[2] == '|'), so a very short command legitimately answers without one. It now resolves only when exactly one command is pending, and surfaces the ambiguity otherwise. 2. A late reply no longer overwrites unsaved edits. The settings screen applied a late `get` payload unconditionally, so a reply arriving after its timeout could revert a field the user had typed into while waiting. It now applies only when nothing is dirty, and says which happened. 3. _expiredCommands is pruned on timeout as well as on receive. It was pruned only in handleResponse, so a run of commands that all time out with no traffic coming back accumulated records until disposal. The reviewer described 3 as unbounded growth. It is bounded at 256 by the prefix token space, and stale records could never be mis-attributed because handleResponse prunes before matching, so the severity was overstated. Fixed anyway; it is nearly free. A fourth finding, that the new l10n strings are untranslated in the other 17 locales, is rejected. That is this project's established pipeline: strings are authored in app_en.arb, gen-l10n emits English fallbacks, and untranslated.json tracks the gap, which it now does for these keys. Every string in the app arrived this way, so it is not a defect this branch introduces. The ambiguity test was verified to fail against the pre-fix code. |
3 months ago |
|
|
0f7a920220 |
fix(#533): state the flood constraint instead of a branch that never fired
The flood arm read as though the model were consulted:
if (pathLength < 0) {
// Flood: trust ML, only enforce firmware formula as floor
if (mlTimeout < physicsMin) return physicsMin;
}
return mlTimeout.clamp(physicsMin, physicsMax);
It never did that. _physicsMinTimeout and _physicsMaxTimeout return the
identical expression for pathLength < 0, mirroring the firmware's
calcFloodTimeoutMillisFor, so a clamp between them cannot preserve a
prediction. Whichever way control went, flood returned 500 + 16 * airtime.
The comment described behaviour the code did not have, which is exactly
the kind of load-bearing false comment that re-causes a bug later.
Replaced with an explicit early return and the constraint written down.
Behaviour is unchanged.
Equivalence is tested, not asserted. A new group attaches a real
TimeoutPredictionService, trains it on 40-56 s delivery times so any
prediction is far from 1300, and checks flood is unmoved while a direct
path is still clamped to its ceiling, which proves the clamp is live
rather than the predictor being absent. That group was run against the
pre-change branch and passes there too, so this is a simplification and
not a behaviour change.
Deliberately NOT decided here: whether flood should ever trust a
prediction. Every round trip measured to date was 0-hop direct, so there
is no flood data to decide it from. The verification needed to answer it
is written up on the issue.
Part of epic #473, stacked on #528, #529, #531 and #532.
|
3 months ago |
|
|
99b5ea1251 |
fix(#532): a prefixed reply only completes the command that owns the prefix
handleResponse looked up the reply's prefix, and when that matched nothing pending it fell through to "first pending command for this repeater". So a straggler from an expired command could complete an unrelated in-flight one, reporting one command's output as another command's result. The service already carries a correlation token in every command and every reply echoes it. The fallback discarded that. Now a reply that carries a prefix is only ever matched to the prefix's owner; if there is no owner it goes to the unmatched handling added in #528, where an expired command is still named. Only a reply with no prefix at all, which has no correlation token to honour, may fall back to matching by repeater. Not reachable from today's callers: all four pass retries: 1 and the settings refresh awaits each command, so two are never in flight to one repeater. It becomes live the moment anything issues concurrent commands, which any retry work would. The two negative tests were verified to fail against the old logic and pass against the new, so they are a real guard rather than a restatement. The two preservation tests pass either way by design. _registerPending is extracted so the test seam and the real send path cannot drift apart. Part of epic #473, stacked on #528, #529 and #531. |
3 months ago |
|
|
75d08e73ea |
fix(#531): report the timeout window that was armed, not a rounded-up one
The timer ran timeoutMs while the message printed (timeoutMs / 1000).ceil(), so every window in (4000, 5000] announced "timeout after 5 seconds". The owner's 0-hop window was 4074 ms and fired at 4.07 s while claiming 5, which is what made the behaviour look arbitrary rather than deterministic: the number shown was never the number used. The service now throws a typed RepeaterCommandTimeout carrying the window that was actually armed, formatted to one decimal. Every existing caller already stringifies the error, so all of them inherit an honest figure without being touched; the CLI screen additionally renders it through a new localized string rather than the generic error wrapper. Tests pin that 4074 reports 4.1 rather than 5, that the new 28748 ms budget reports 28.7 rather than 29, and that two windows inside the same second no longer collapse to the same text, which was the defect's signature. Part of epic #473, stacked on #528 and #529. |
3 months ago |
|
|
a307c837bc |
fix(#529): correct the rationale comment, the tail is scheduling not execution
The comment justified not modelling command execution time by claiming `wifi on 30` does real work bringing up an interface. It does not. The firmware handler sets a persistence deadline and sprintf's its reply immediately (CommonCLI.cpp, "wifi on"), so execution is near-instant. The owner caught it: he recalled reissuing the command several times against on-screen errors, not one command taking 20 seconds. The measurement supports him. On the 20.33 s case the reply carried claimed=01:56:32 against a command sent at 01:56:26.938, and the RF frame did not reach our radio until 01:56:47.271979. So roughly 5 s to reach the repeater and be answered, then roughly 15 s in its transmit queue. The tail is scheduling on both radios. No behaviour change. The budget is unchanged and still has to tolerate a 20.33 s round trip; only the stated reason was wrong, and a wrong reason in a load-bearing comment re-causes the bug later. |
3 months ago |
|
|
83f6360f7e |
fix(#529): give repeater CLI commands their own timeout budget
The CLI timeout used calculateTimeout, which mirrors the firmware's
calcDirectTimeoutMillisFor and estimates ONE-WAY delivery. A CLI command
is a request, an execution and a reply, so the budget was structurally
short.
Measured against rpt-01 on 910.525 MHz / SF7 / BW 62.5k / CR 4:5, where
the old window was 4074 ms: 27 commands sent, 12 replies matched, and 4
of those 12 arrived after the client had already given up, at 6.17 s,
8.28 s, 15.52 s and 20.33 s against a median of 2.75 s.
calculateCliTimeout sums four terms, each with a source rather than a
chosen value:
outbound leg calculateTimeout, which is what it actually models
cliReplyDelayMs 600, firmware CLI_REPLY_DELAY_MILLIS, unconditional
reply leg the reply is a second packet the ACK formula omits
retrieval budget replies are pull-based; the radio raises MSG_WAITING
and the app must ask, granting itself 5000 ms per
attempt across 3 retries
The retrieval term is derived from the sync constants rather than
restated, so the command timeout cannot drift below the layer it depends
on. That is the #530 invariant holding by construction, not by two
numbers being maintained in agreement.
Execution time is deliberately not modelled. The same verb, wifi on 30,
returned in both 2.31 s and 20.33 s, so it is not a per-command constant
that could be tabulated. The retrieval term carries that tail.
The reply leg uses physics only. The predictor is trained on
direct-message ACK latency, so asking it about a CLI reply leg would be
extrapolation; that is #534 and #535, not this change.
Tests cover the construction, the never-below-retrieval invariant, growth
with path length, coverage of the 20.33 s worst case actually observed,
and negatively that the direct-message ACK path is untouched.
Part of epic #473. Does not change the reported duration string (#531)
or the stale-prefix fallback (#532), and does nothing for commands that
draw no reply at all (#541).
|
3 months ago |
|
|
9d690b2051 |
fix(#528): surface late repeater CLI replies instead of discarding them
A repeater reply that arrived after its command's window closed hit `if (commandId.isEmpty) return;` in RepeaterCommandService.handleResponse and was dropped with no log and no UI. Since the command timeout can be shorter than the app's own message-retrieval budget, that made "the command ran but the response was never reported" the normal outcome rather than an edge case, and it affected every repeater screen, not just the CLI one. RepeaterCommandService now remembers a timed-out command's prefix for two minutes, so a reply arriving afterwards can be attributed to the request it answers. Replies that reach no waiting command are handed to a new onUnmatchedResponse sink and logged through appLogger. No path through handleResponse returns without either completing a command, surfacing the payload, or logging why it could not. The CLI screen renders these as a distinct history entry naming the original command and how late it was. The settings screen applies the value if it is a `get` reply and tells the user it arrived late. repeater_status_screen already parsed responses independently of the service, so it had no silent-loss path to fix. Part of epic #473. Does not change the timeout window itself (#529), the reported duration (#531), or the stale-prefix fallback (#532). |
3 months ago |
|
|
3d6392f39f |
fix(#469): broker blank-field GET leaked the echoed key line
extractScalarValue required ' = ' (space-equals-space) and trimRight()'d first, so a blank field's reply 'key =' (no space after =) failed the match and the whole 'mqtt.broker.N.field =' line leaked in as the value. Split on the first '=' and trim instead: blank -> empty, values with '=' preserved. Regression tests added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 months ago |
|
|
9e3500a9a9 |
feat(#550): show observers + observations; top-banner refresh feedback
Badge stays observers-primary; tooltip/long-press now read 'N observers . M observations' so they reconcile with CoreScope's 'Observations (N)' feed (the two are distinct metrics: 15 distinct observers vs 34 total sightings). Refresh feedback moved from a bottom snackbar to an auto-dismissed top MaterialBanner, out of the way of the composer. Part of #524. Agent: QuietSnow (session 31eaba02) |
3 months ago |
|
|
63fc6a06c4 |
feat(#550): monotonic channel-send timestamp + CoreScope message fields
Unifies the previously-duplicated send timestamps (frame builder vs outgoing message each called now() separately) into one monotonic-per-channel value, so every channel send has a unique (ts, channel_idx) key. That is the client-side guarantee the 0xC6 correlation needs (VioletBarn caught that AES-128-ECB determinism would otherwise make two same-second messages a wrong-hash query). Adds transient onAirHash + coreScopeObserverCount to ChannelMessage. Part of #524. Agent: QuietSnow (session 31eaba02) |
3 months ago |
|
|
f2feb0863a |
feat(#550): 0xC6 pkt-hash protocol layer + cap2 gate
Builder/parser for the client-issued 0xC6 CMD_OFFBAND_PKT_HASH query (per firmware #611 contract) and firmwareSupportsPktHash (cap2 0x08 + ver >= 22). Inert until firmware advertises the capability. Part of epic #524. Agent: QuietSnow (session 31eaba02) |
3 months ago |
|
|
d79dfffad3 |
feat(#549): CoreScope HTTP observer-count query service
Best-effort GET /api/packets?hash=H&groupByHash=true against a CoreScope instance (default map.okimesh.org); returns observer_count or null. Never throws, so the chat UI degrades silently to radio-only when offline or the hash is unknown. Part of epic #524. Agent: QuietSnow (session 31eaba02) |
3 months ago |
|
|
567745db18 |
feat(#509): hidden 7-tap Experimental unlock + settings section
7 taps on the About version row (rolling ~3s window) reveal a neutral 'Experimental' settings category; persisted on AppSettings, survives restart, re-hidden from a switch inside the section. Empty of toggles for now (Fast Sync #118, CoreScope #524 land into it later). Countdown snackbars after tap 4; already-unlocked feedback. Version-text tap is absorbed so it counts without opening the About dialog. Agent: QuietSnow (session 31eaba02) |
3 months 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 |
|
|
09c6b911b4 |
fix(#425): retry transient 5xx on translation-model download with backoff
A transient upstream 503 (e.g. Hugging Face) hard-failed the model download and surfaced it as an error (#229). The download requests (HEAD, single GET, range GET) now go through sendModelDownloadWithRetry: 5xx/429 responses and network exceptions retry with bounded exponential backoff (1..30s, <=5 attempts, honors Retry-After, cancellable); terminal 4xx (e.g. 404) fail immediately. The retry logic is a pure injectable top-level function, unit-tested (503-then-200, persistent-503, 404-no-retry, network-exception, cancel). Manual retry after a sustained failure is the existing "Download model" button (re-invokes the download); no new UI added. Build of epic #422. |
3 months ago |
|
|
f84bcaa736 |
fix(#472): clear per-radio in-memory caches on reconnect
Switching radios showed the previous radio's channel history (including its outgoing messages) on the new radio. The in-memory caches _channelMessages, _conversations, and _loadedConversationKeys are keyed by channel index / contact key, not by radio, and were never cleared on a switch; a new radio's empty store could not overwrite them (_loadChannelMessages only writes on a non-empty read). On-disk stores are already per-radio (device+PSK since #277), so no re-keying or migration is needed. Clear the three caches in _resetConnectionHandshakeState (runs at the start of every connect). loadAllChannelMessages and _loadMessagesForContact repopulate from the new radio's store. Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 months ago |
|
|
4b337be586 |
feat(#483): Button and buzzer settings for headless devices
Client UI for the button-action matrix (#474) and the device notification scope (#475), under Settings > Node Settings. Speaks the canonical 0xC5 contract published by firmware in OffbandConfigProtocol.h: one command byte, sub-code selects the surface (0x01/0x02 scope get/set, 0x03/0x04 matrix get/set, 0x7F error). Not folded into 0xC0, which is observer-only and would make the feature unreachable on the headless trackers it exists for. - Notification scope All/Self/None, labelled as this radio's buzzer and kept distinct from the per-channel app notify mode. Re-read on open and on every device-info refresh so a scope changed by triple-pressing the device is never shown stale. - Button actions per press sequence, assignable only from the action set the DEVICE reports via its supported-actions mask, so a board with no buzzer or no GPS never offers a choice it would refuse. Single press defaults to unassigned. - Long press is deliberately not assignable. Firmware owns it for CLI rescue and power off, and remapping it could leave a screenless board unrecoverable. - Failures show the device's own reason via the firmware-owned reason codes, not a generic error, and the banner persists until dismissed. An unrecognised reason is surfaced with its raw code rather than swallowed. - State only ever follows the device's reply, never the request, so the UI can never show an assignment the radio rejected and nothing is faked or stored unacknowledged. - A radio advertising neither capability bit gets a diagnosis, its raw caps byte 2 and an explicit statement that no command will be sent, rather than a blank screen that is indistinguishable from a bug. caps2 bit assignment is firmware-confirmed: 0x01 notification scope, set only where PIN_BUZZER is defined, 0x02 button matrix. That is the reverse of the order the epics were filed in, so it cannot be inferred from issue numbers. 21 protocol tests: gating, frame encoding, a lying count byte, unknown sequence/action/scope codes, truncated and foreign frames, reason-code mapping, and that no sequence models a long press. NOT YET EXERCISED AGAINST A DEVICE. The firmware 0xC5 handler is written but unmerged, so until it answers, a read shows its loading row and a write is not confirmed. Hardware validation of the pair is the owner's gate and has not happened. Full suite 740 pass, analyze clean, format clean. Epic: #474, #475 Agent: CalmBay (session d14220d9) |
3 months ago |
|
|
7aee4007b3 |
fix(#497): apply Gemini review, one honest label and one equivalence rule
Two defects the adversarial review found, both verified before accepting. 1. `label` trimmed the name for display while the token stayed raw, so two contacts differing only by surrounding whitespace rendered as identical rows with no way to tell which one was about to be addressed. That hides the exact identity #497 exists to preserve. Display now defaults to the raw name. 2. Candidate deduplication used String.toLowerCase() while matching uses the ASCII fold. Verified empirically: 'É' and 'é' compare equal under toLowerCase and unequal under foldAscii, so of two real contacts one silently vanished from the mention list while remaining matchable. foldAscii is now public and is the single equivalence rule used for both dedup and matching. Regression tests added for both. Full suite 719 pass, analyze clean, format clean. Agent: CalmBay (session d14220d9) |
3 months ago |
|
|
44ff206b18 |
feat(#480): read offband_caps byte 2 at device-info offset 84
Adds parseOffbandCaps2 alongside the existing tail-byte helpers, a _offbandCaps2 field plus getter, and byte 2 in the device-info capability log line. Byte 2 sits at offset 84, deliberately NOT adjacent to byte 1 at 82: offset 83 is already the FEM LNA state byte and every tail field is read at a fixed absolute offset, so an adjacent insert would shift the FEM state and make shipped clients misread a bitmask as the LNA toggle. Firmware appends it at the end of the frame for that reason. Absence is "no byte-2 capabilities", never an error, so any radio predating the firmware change reads null and behaves unchanged. No bit constants yet: byte-2 bits 0 and 1 are earmarked for firmware epics but neither is claimed, so nothing gates on them here. Wire contract from OffbandMesh/meshcore-firmware PR #515 (branch feat/508-caps-byte2, commit 7664c29f). That PR is open pending this client-side validation, tracked at #481. Epic: #474 Agent: CalmBay (session d14220d9) |
3 months ago |
|
|
a48edb209f |
fix(#497): @[name] is a verbatim wire token, remove the trim
Owner ruling 2026-08-01. Mention autocomplete trimmed the contact name when building the @[...] token while the device stores and matches it byte-for-byte, so any name with leading or trailing whitespace was unmentionable and never beeped. Confirmed on the wire: the contact record keeps the 0x20, the outgoing mention drops it. @[...] is a wire token, not display text. Entry, firmware memcpy, advert encode, advert parse and the contact record are all verbatim by design; this trim was the only transformation applied to a node name anywhere, and it changed the identity of the addressee. - MentionCandidate now separates the two concerns: `name` is raw and goes on the wire, `label` is display-only and may be tidied. The const constructor is preserved, so existing const call sites still compile. - The candidate builder keeps sender and contact names raw. Emptiness is probed on a trimmed copy; the stored value is untouched. - _mentionsSelf drops its own trim as a direct consequence: a raw token requires a raw comparison, or this node stops recognising mentions of its own name. - Contract clause written at both the insertion site and _mentionsSelf, stating the token is byte-for-byte and that a future .trim() tidy-up is forbidden. The contract was silent on raw vs normalised, which is why both sides were reasonable and incompatible. Deliberately out of scope per the ruling: no entry-side trim in settings_screen. It fixes no deployed name and would silently alter deliberate spacing. Test updated to assert the new truth: ' Ben ' does NOT match @[Ben], and DOES match @[ Ben ]. Full suite 732 pass, analyze clean. Agent: CalmBay (session d14220d9) |
3 months ago |
|
|
9de6621ec9 |
feat(#486): ASCII-only case folding for the @[name] self-mention contract
_mentionsSelf folded with String.toLowerCase(), which applies full Unicode case mapping. Firmware adopts this same match rule with a byte-wise fold that does not, so a node name carrying any non-ASCII character could produce one self-mention verdict on the client and the opposite on the device for the same message. A silent wrong answer, not a visible failure. Owner decision 2026-07-31: both sides fold ASCII A-Z only, so non-ASCII names compare case-sensitively. Extracts the rule into a testable static (mentionsName) and documents it as a cross-repo contract: widening it, whether by accepting a bare @name, restoring Unicode folding, or anchoring the match, is a breaking change that ships only in an aligned client and firmware build pair. Behaviour change: notifications for non-ASCII node names go from case-insensitive to case-sensitive. Deliberate, per the decision above. Epic: #475 Agent: CalmBay (session d14220d9) |
3 months ago |
|
|
2f1048e7be |
fix(#495): send routed paths at the contact's width, not width 1
resolvePathSelection returned no width and, on the override branch, a byte count in place of a hop count. preparePathForContactSend then called setContactPath without a width, so encodePathLen packed mode bits 00 and a 2-byte route went out as twice as many 1-byte hops (path_len 0x06 for a 6-hop 2-byte route instead of 0x46). The radio routed on wrong 1-byte prefixes, confirmed on Bandit's 2026-08-01 log and via CoreScope. Direct and flood were immune because neither uses the path bytes. PathSelection now carries hashWidth and a true hop count on every branch, using the contact's pathHashWidth as the single width authority. Both setContactPath call sites thread it. Also addresses the override timeout inflation half of #299 (hopCount was a byte count feeding calculateTimeout). Gemini review found two override/history width edge cases -> split to #494. Refs #299 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 months ago |
|
|
3b39a16edd |
chore(#462): remove em-dash characters from test/ (and lib top-up)
Follow-up to #457. Swept the em-dash character (U+2014) out of the test tree (test descriptions and comments) and cleaned 8 em-dashes that landed in lib comments via the #456 refactor after #457 merged, so the tree is back to zero. Same rules: replaced with commas/colons/periods, preserved the lone "no data" glyph placeholders, left non-English ARB untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
4 months ago |