App Store guideline 2.3.10 bars naming other marketplaces in an app. Other platforms keep the link.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner answered standard algorithms on build 41171670352, and it moved to Ready to Submit with no document. The answer is recorded on #958. The next build shows whether Apple still asks.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 41171670352 validated and uploaded 1.7.0 (41171670352), so the trigger has done its job. testflight.yml is back to manual dispatch only. The plan records what that run proved.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A push trigger keyed to this exact branch, for the first real upload. The apple-signing Environment admits this branch only for this run, and the trigger comes out once the lane is proven.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The assertion stayed green with the flag off, and run 41171197215 listed sqlite3.framework and llamadart.framework, so the plan's hypothesis was wrong. The flag is gone, the assertion still guards every build, and it is proven able to fail against an empty app. The plan now says so, and records the run ID as the build number. Negative steps discard their output so their error lines do not annotate a passing job.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ios-signed.yml cannot run on a pull request, so its output check is proven in the compile job with canned output and no secrets: exit 0 with an error, with mixed output, with no success line, and a non-zero exit.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both call ios-signed.yml. The release job deliberately does not need the ios job, so an Apple failure cannot block Android and desktop releases. Neither has a pull_request trigger.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reusable workflow in the apple-signing Environment. It checks secrets, profile, frameworks, version and signature, then validates and uploads with altool, and stops on anything it cannot confirm. There is no dry-run input. The build number is the run ID, because the two callers count run numbers separately. Review verdicts are on #1013.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iPhone-only and the Giphy define are in. Native assets are deliberately not enabled yet, so this push shows whether the assertion goes red without them, as the plan requires. The next commit enables them.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rotation now names -WorkDir with a new folder, and the script comment reports the legacy-format reason instead of stating it as fact. Verdicts are on #1012.
Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apple refused app.offband.meshcore as not available and the owner's team does not hold it. The owner created the App Store Connect record under the .ios ID, so the runbook, TestFlight guide and Xcode project now match. Android, macOS and Linux keep their ID.
Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The script already takes -WorkDir; the runbook now says to pass it on every command and use that folder throughout.
Epic #1012, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner request. Apple account documents stay out of the public repository.
Epic #1012, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apple portal steps checked against Apple's help pages (App ID, App Store profile, new app record, availability, DSA, API access). Paste-ready PowerShell for the script; rotation table. One open item tagged in the session record: the least-privileged API key role that can upload builds.
Epic #1012, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
new-csr, make-p12, set-secrets and selftest. PowerShell 7 only, openssl from Git for Windows (mingw64, MSYS argument rewriting disabled). Keys live outside the repo and are never overwritten; the certificate must match the key; the .p12 is packed in the legacy format and read back; secrets are piped on stdin, never printed or put on a command line. The macOS proof job now runs the script's own selftest and imports its output.
Tested locally: selftest passes incl. refusal of a mismatched certificate; legacy format confirmed; CSR subject correct; new-csr refuses to overwrite a key; set-secrets stops before gh on a missing file.
Epic #1012, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Throwaway self-signed certificate generated on the runner each run; no key in the repo. Legacy and OpenSSL 3 default .p12 both packed; legacy must import with key paired, a wrong password must be refused, and the default's result is recorded as a notice.
Epic #1012, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes the Environment rule that would refuse the first signed run, adds IPA signature and altool validation checks, aligns the privacy goal with the CoreScope choice, and marks rounds 1 to 3 done. Verdicts are on #958.
Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finding 6 and owner round 2 now use app.offband.meshcore.ios, after Apple refused the original ID.
Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four Epics: Windows signing toolchain proven on a Mac runner, signed IPA and TestFlight lane, App Review package, Integration Testing. Every finding cites the research on #958.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Android 11 and below the BLE plugin refuses to scan while system Location is off. The Scan button caught that error and only logged it, so the user saw nothing; one tapped Scan 172 times. A failed scan now shows a persistent message: a specific one when Location is off, a plain generic one for anything else, never the raw exception. The detail still goes to the log, and Scan stays pressable.
New owner-approved strings scanner_scanFailedLocationOff and scanner_scanFailedGeneric; other locales fall back to English via untranslated.json.
Tests: each message present on its failure, absent on success, still shown after 10 s, and Scan retries after a failure. Each check was made to fail first against a broken build (persist false; log-only; misclassification).
Plan by EmeraldBrook on #1033.
Agent: BrightDog (session 38fb0e08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Route into the app on return from settings only when the scanner is the current route again, the same condition the connection listener uses. A popping route is outside the navigator's present set, so the settings route does not block it.
The share tests now wait frame by frame on their own condition instead of a fixed 2-second window, and the persistence checks were re-proven to fail with persist: false.
Correct a stale comment: the Experimental unlock is on the Device Info Build row, not About.
Agent: BrightDog (session 38fb0e08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A radio that will not connect is exactly when a user needs the log, and
the only way to it ran through device Settings, which needs a connected
radio. The scanner now has a settings icon that opens an app-only
settings screen: App Settings, Debug (Share logs, the logging switch,
both log viewers) and About.
Navigation only. How logs are written is untouched: Share logs calls the
same LogExport.shareLogs the log screens use, on the same on-disk file.
The device Settings screen keeps every category. Its About pane and the
app-level Debug rows moved into AboutView and DebugLogTiles, which both
screens now share; Device CLI, Serial capture and Mesh topology need the
radio and stay in device Settings only.
Export failures now persist until dismissed and are logged: "No log
available to export" was a 4-second snackbar, and a share or save that
threw had no handler at all. New owner-approved string:
debugLog_exportFailed. Other locales fall back to English per
untranslated.json.
Tests pin both sides with the radio disconnected: Share logs hands the
on-disk file to the share sheet, About shows the version, no device
category or radio row appears, both error messages persist, and device
Settings still has everything. Each check was made to fail against a
deliberately broken build first.
Part of Epic #693.
Agent: BrightDog (session 38fb0e08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v1.7.0 release run failed in release-signed.yml's linux job with the #929 glibc header clash (once_flag / pthread_once) on the ubuntu-26.04 image. #929 pinned build.yml's linux job to ubuntu-24.04 but never touched release-signed.yml, whose linux job still ran on ubuntu-latest. The release job needs all three platforms, so no GitHub release could publish.
Child of release tracker #1005.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bump pubspec to 1.7.0+68 and add the four release-gate docs (CHANGELOG [1.7.0], GitHub release notes, Play what's-new at 403/500, Discord post). Content: per-message storage with the one-way schema 1 to 2 upgrade, radio-profile isolation, sync-is-truth contacts, custom contact names, region scoping, the Settings restructure, CLI for stock 1.18 companions, and the bounded BLE reconnect.
The release notes, Play text, and Discord post carry the #953 disclosure: earlier versions cannot open the converted database. Firmware caveats stated per feature: regions need companion 1.16+ or current Offband firmware (verified in offband-v1.5.0-beta7 and v1.6.0-beta1); CLI needs stock 1.18; the 0xC5 badge settings stay hidden until Offband firmware ships them (f3d30a88 is in no firmware tag yet).
Build task under release tracker #1005. Part of epic #312.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The companion "CLI console" entry was gated only on the target being a
companion. A pre-1.18 radio, including Offband before the absorb, refuses a
type-3 send: companion-v1.17.1 CMD_SEND_TXT_MSG accepts only plain and
CLI_DATA and answers anything else with ERR_CODE_UNSUPPORTED_CMD
(companion-v1.18.0 adds CLI_COMMAND). The connector only logs error
frames, so the command waited out its mesh timeout and the console showed
the #891 row, which blames the target for a limit of the user's own radio.
- Gate: DeviceCliScreen.offersRemoteConsole requires a companion target
AND a connected radio that speaks the 1.18 CLI (allowsCliFrame, the same
check that gates the local Device CLI; type-3 send and frame 66 shipped
together in 1.18). Disconnected or unclassified offers nothing. The chat
menu uses it.
- Fail fast: RemoteCliTransport fails the command at once with
CliConsoleUnsupported when the radio refuses the send. The window arms
only when the text frame is written (the onAttempt hook, after the path
update) and closes on the command's SENT, which on 1.18 carries an
expected-ack of 0. So an unrelated unknown-command refusal before the
write, or a DM's SENT, cannot be pinned on this command. Dispose
releases a command still waiting.
- Console: a refused send shows "<command>: not sent. Your radio's
firmware doesn't support remote CLI." (owner-approved), not the timeout
row.
Tests: the gate (positive and negative across populations, roles, and
disconnected); fail-fast on the refusal, ignoring other codes, errors
after the radio accepted the send, a DM's SENT, an error before the write,
and dispose; the console row.
Reviewed by a one-off Sonnet adversarial pass (Gemini budget down). Its
Medium finding, that the window armed at run() before the write, is fixed
here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A companion that has not granted us remote CLI queues our command as a
plain message and never answers, so from the console's side the silence
is as likely a permission answer as a lost packet. The console showed the
same bare "no reply in N s" row it uses for the local device, which reads
as "the radio is down".
The remote console now shows: "<command>: no reply in N s. This radio may
not allow remote CLI from you, or is unreachable. Not retried." The local
console keeps its plain frame-timeout row. The send itself was already a
single attempt (#999), so "Not retried" states what happened.
- device_cli_screen: the timeout row picks the remote string when the
console targets a contact. A transportOverride test seam lets a test
drive a deterministic timeout; the screen owns and disposes it.
- l10n: deviceCli_remoteNoReply (owner-approved wording).
Tests: the remote timeout renders the full interpolated string; the local
timeout renders its exact string and never the remote permission hint.
Reviewed by a one-off Sonnet adversarial pass; its test gaps are fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extend the one CLI console (device_cli_screen, F2 #883/#884) to target a
permitted companion over the mesh (TXT type 3, #888/#889), reusing the
console rather than adding a screen or a chat-borne command path. Opened
from a "CLI console" action on a companion contact; command and reply
both live in the console.
- cli_console_transport.dart: CliConsoleTransport with a local and a
remote implementation, and one CliConsoleTimeout the console catches.
The remote transport sends with retries: 1 (no auto-retry - an
unpermitted companion queues the command and never replies, so a retry
only spams the mesh) and correlates the reply via the receivedFrames
listener, gated to CLI_DATA so a chat message or inbound command from
the same contact can't complete it (Sonnet review).
- device_cli_screen: optional remoteContact; "Remote CLI" + contact name.
- chat_screen: companion-only "CLI console" entry (Contact.isCompanion).
Also corrects #890: CLI and chat are separate surfaces. The receive
pipeline filed every contact frame into the conversation regardless of
TXT type, so a companion's CLI reply (type 1) and an inbound command
(type 3) leaked into the chat thread. A guard now drops CLI-typed
messages from the message pipeline - the console is their only surface -
and #890's in-chat command bubble is removed.
Review (Sonnet, one-off, Gemini budget down): redact secret reply text
in the remote path's late/unmatched log, matching LocalCliService;
dispose cancels the subscription; document that remote truncation is not
detectable post-reassembly.
Tests: transport round-trip / sender-filter / timeout / no-retry /
CLI_DATA gate; Contact.isCompanion; remote console title; and the
pipeline keeping CLI (type 1 and 3) out of the conversation while a plain
message still enters.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Upstream 1.18 lets a permitted contact send a remote-CLI command (TXT
type 3) to run on our radio. Before this, _parseContactMessage only knew
plain (0) and CLI_DATA (1), so a type-3 frame was warn-dropped as an
unknown type and never surfaced.
- meshcore_connector: accept txtTypeCliCommand in _parseContactMessage,
skip SMAZ decode for it (like CLI_DATA), and flag the Message
isCliCommand. Add @visibleForTesting parseContactMessageForTest.
- message model: new isCliCommand field threaded through the
constructor and copyWith.
- message_store: persist and read isCliCommand; skip SMAZ on read for it.
- chat_screen: label an inbound command bubble "Command for your radio"
and exclude it from the incoming-translate action.
- l10n: chat_remoteCliCommand.
test/connector/inbound_cli_command_test.dart: type-3 accepted+flagged,
type-1 isCli-not-command, plain-neither.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1 target split. The command service now picks the message type by
the target contact's role: a companion (chat) gets TXT_TYPE_CLI_COMMAND
(type 3), a repeater/room server keeps the universal CLI_DATA (type 1)
it has always used. One line in the send path; the reply handling,
correlation and timeout model (which already carries the 600 ms
companion reply hold) are unchanged and serve both.
Companion replies come back as CLI_DATA (verified: MyMesh.cpp companion),
so the existing contact-agnostic reply listener routes them to
handleResponse unchanged; a UI entry point to open the CLI console for a
companion is the remaining downstream piece.
Tests: a real send proves companion -> type 3 and repeater -> type 1,
driven deterministically off the send frame. Full suite green (1432);
Gemini review: test race fixed, the reply-path concern refuted against
the source-verified CLI_DATA reply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1. When sending a CLI command to a remote peer, the message type
is chosen by the contact's ROLE (owner ruling): a companion (chat) gets
the upstream-1.18 command type (3); a repeater or room server keeps the
universal CLI_DATA type (1) it has always used. Decided by role, not
firmware version: a Contact carries no version, the user types the
commands (passthrough), and a non-1.18 companion silently drops the
command type (surfaced as no reply, #891).
Adds txtTypeCliCommand (3) and cliTxtTypeForContactType(), and
parameterizes buildSendCliCommandFrame with the txt type (default
CLI_DATA, so the existing repeater path stays byte-identical). A later
refinement could pin a peer's version via a `ver` probe on connect.
Tests: the role mapping and the frame's type byte + command passthrough.
Full suite green (1430); Gemini 2.5-pro review: one naming nit taken.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1. Upstream 1.18 gates remote CLI (TXT type 3) on contact flag
0x10 (isRemoteCLIAllowed): the receiving radio checks it, so setting it
on a contact grants that contact permission to run CLI on this radio.
Adds the constant, Contact.remoteCliAllowed, a remoteCli param to
setContactFlags (same set/clear pattern as the telemetry flags, reading
a fresh device snapshot first), and an opt-in SwitchListTile in the
shared contact settings dialog (default off). Written with the existing
add/update-contact frame; the flags byte already rides it.
Tests: the 0x10 bit on the write-path frame (set/clear/coexist) and the
getter. Full suite green (1424); Gemini 2.5-pro review: no defects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner test 2026-10-26: Stop, then a BLE connect to another radio 6 s
later. The Stop disconnect was still releasing the T1000-E; every
connect path only refused connecting and connected, so the new connect
started during disconnecting. The disconnect's tail then cleared the
new link's device, name and state: the status stuck, and the frame
size stayed at 20 bytes with MTU 176, which left the channel composer
unusable.
- disconnect() tracks the one in progress; a connect (BLE, USB, TCP)
waits for it, and a second disconnect joins it. A manual caller
joining an automatic disconnect still suppresses the retry.
- A superseded attempt logs its own radio's name, not whatever name the
newer connect has written by then.
Watched failing first: the TCP connect started before "Disconnect
complete". Full suite 1720 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 2026-10-25 the client retried an unreachable T1000-E about every
14 s for 7.7 hours, and no other connect could start meanwhile (#995).
- connect() reset the attempt counter at the start of every automatic
attempt, so the backoff never grew and nothing gave up. A
ReconnectPolicy now owns the count: 5 attempts or 3 minutes, and only
a user connect, a manual disconnect or a successful link resets it.
- A connect the user starts, BLE, USB or TCP, abandons an automatic
attempt in flight instead of being refused. A generation check after
each await in the BLE connect stops an abandoned attempt before it
writes shared state.
- Refusals are logged and shown, never silent (the USB screen dropped
taps without a trace).
- The scanner status bar shows the attempt with a Stop button, and
"Couldn't reach" with Retry once the retry gives up.
Tests watched failing first: the attempt count (1, expected 2), the
give-up, the TCP refusal, the silent BLE refusal, and the three screen
cases. Full suite 1719 pass, analyze clean.
Epic #996, design on #995.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three defects turned a 13-hop 3-byte-hash message into a 39-hop badge:
- log-RX ingress stored the width-stripped hop count; now stores the raw packed path-len byte, the same encoding fromFrame keeps
- the repeat merge substituted the path BYTE length into the packed field; mergePathLenRaw now compares decoded hop counts and never sees a byte length
- fromFrame read the V3 path_len field signed, so 3-byte-width values (>= 0x80) collapsed into the flood sentinel; now unsigned with 0xFF kept as the direct marker
Rendering now groups path hashes by the packet's own declared width (ChannelMessage.effectivePathHashWidth), with the radio's device-wide mode only as the legacy fallback, so the path screen, chat via-line and route-reply all agree with the badge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_customNames was only ever added to. Rename contact X on radio A, switch
to radio B which also holds X, and B showed A's label, breaking the
owner's decision that custom names are per radio.
- The hydrate clears the map before loading the connected radio's names.
- disconnect() clears it with the contact list, so no label outlives
the link it belongs to (Gemini finding).
T37 (switch) and T37b (disconnect) both failed with the previous radio's
"Chuck" before the fix and pass after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the pre-PR adversarial gate, which earned its keep: this is the worst instance of the exact leak the epic exists to prevent, and I had missed it.
toShareUri and toChannelShare both read the .name getter, so sharing a renamed contact put the user's private label in the payload. A share payload becomes the RECEIVED name on every device that imports it, so the label would land as another operator's ground truth and overwrite a signed advert name. Worse than sending it to our own radio, because it propagates outward instead of stopping at one device.
Both now send receivedName. The static build helpers keep their name parameter, since the device also shares its OWN name through them with no Contact involved.
KNOWN LIMIT now documented in the guard, because the near-miss matters more than the fix. T14 matches the pattern name-colon-identifier-dot-name, so it structurally CANNOT see an intra-class read, where the getter is reached as a bare name with an implicit this. That is precisely where this bug was living. Reverting the fix was confirmed to leave T14 green while T36 failed, so the limit is measured, not assumed. Widening the regex to catch a bare name is not possible without flooding on the legitimate parameter pass-through in the same file, where the static helpers forward a name argument correctly. So cross-class reads are guarded by T14, intra-class reads behaviourally by T36 and T11, and contact.dart joins the watched list for the cross-class case.
Three tests added. T36 falsified before being trusted.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1505 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebase brought in Feature #853's scoping gate, which requires every storage key in lib/ to carry a per-radio or global verdict with a reason. contact_customname_ was undeclared, so the gate failed the build. That gate did its job: a new key cannot land unnoticed.
Declared per-radio, prefix + device key + contact key, matching its neighbour in the same store. The reason records why it is not in contact_store, since that is the decision most likely to be undone by someone later: contact_store is rebuilt from radio contact-sync data, so a save after a sync would wipe the rename.
Also renames the fixture arg in #940's radio_isolation_matrix_test, which the 750.1 compile break caught the same way #754's test was caught two rebases ago.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1502 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebase onto dev brought in #754's per-radio discovered-contacts test, whose Contact fixture used name:. The 750.1 compile break caught it, which is what that design was for: a new write site arriving from another branch announces itself instead of silently holding the wrong string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 750.4 of the contact-naming plan: the UI. Rename sits on the per-contact sheet, above the destructive Block tile, and the sheet shows what the node calls itself whenever a custom name is set.
The dialog shows the node's own name at the moment of the decision, and only while it differs from what is typed, so the user can see the name they are overriding without having to remember it and the same string never appears twice. It also says plainly that the rename is local and that adverts keep the node's own name up to date, so nobody has to guess what renaming costs them.
Empty and whitespace-only are normalized to a clear in the dialog as well as in the connector and the store. Three layers enforce it independently so none is load-bearing and the dialog never hands a blank downstream to be cleaned up.
A 32-character cap keeps tiles readable. Counted in characters, not bytes: a custom name never crosses the wire, so #636's grapheme truncation does not apply and a byte cap would mean 'shorter for CJK' for no reason.
Thirteen tests. T29 and T30 came from the pre-PR review and I had them wrong first: they accepted an empty string, which IS the defect the review found, so they would have passed against a broken save path. Tightened to assert null strictly, then falsified: reverting the normalization makes both fail where before they passed.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1335 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 750.3 of the contact-naming plan. The custom name lives in contact_settings_store keyed by pubkey, not in contact_store, so the live Contact objects have to be reattached to it. Two paths drop it otherwise, and both fail silently.
Cache hydrate rebuilds every Contact from contact_store, which does not hold the custom name on purpose, so a restart would lose every rename the user made. Radio contact-sync replaces an existing Contact with one built from the device frame, where no custom name exists.
Adds a _customNames map as the in-memory mirror, _withCustomName to reattach, and _loadCustomNames to batch the load. Batched for the reason already documented on the cyr2lat loader: a notify per contact rebuilds the tree twice per contact and each rebuild walks the contact list.
Applied at five sites: cache hydrate and the four contact-sync rebuilds. Paths that copyWith from an EXISTING contact need nothing, because copyWith preserves customName unless clearCustomName is passed, which is why the advert handler from 750.1 already behaves.
setContactCustomName is the write path. Null, empty and whitespace all clear, matching Contact.name and the store, so a user who deletes the field's text cannot end up with a blank name and no way back.
Eleven tests. T35 was falsified before being trusted: dropping the hydrate mapping made it fail alone. T16 to T22 guard behaviour 750.1 already delivered and passed on arrival, so they are regression guards rather than watched failures, and the file says so.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1324 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 750.2 of the contact-naming plan: the radio boundary. The radio's contact table is the shared record of what a node calls itself, and firmware has a single char name[32] per contact with nowhere to put an override, so a private label must never reach it.
This was the hole the type system cannot see. After 750.1 renamed the field, every frame-builder call site still read .name and compiled, which meant they were silently sending the custom name. T11 demonstrated it live before the fix: the custom name was present in the emitted frame bytes.
Four connector sites and the stock config export now send receivedName. The export matters for a second reason: a stock config file is an interchange format other operators import, where a name reads as the node's own, so exporting one user's label would hand it to everyone who loads the file.
T14 is a source-reading guard, because nothing else can hold this. It fails if any watched file passes a Contact display name into a name field, and separately if the frame-builder call-site count changes. Its allowlist is keyed by path, not by line content, so an exception in one file cannot excuse the same line in another.
Correction to the plan's own count: analyze caught that stock_config_import_service reads a StockContact parsed from an imported file, not our Contact, so that site was never wrong. The real number of Contact-typed write sites is eight, not the nine recorded on #750. The reason is in the code and in the guard's allowlist.
Eight tests. T11, T33 and T14 were each falsified before being trusted: reverting the export line alone failed both T33 assertions and the source guard independently.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1313 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 750.1 of the contact-naming plan: model and storage. Stock MeshCore has a per-node CUSTOM NAME field and keeps the original underneath; Offband could not rename a contact at all.
Contact gains receivedName (the name the mesh gave us, wire and storage) and customName (the user's label, client-side only). name becomes a getter returning customName ?? receivedName, so all 85 existing display reads stay correct and a future read cannot forget. Empty and whitespace-only custom names count as unset at both the getter and the store, so clearing the field cannot leave a contact blank with no way back.
The constructor and copyWith take receivedName, which made the rename a compile error at every write site. The compiler enumerated 23 of them across 5 lib files and 11 test files; none was found by reading. The advert handler is the one that matters: receivedName now updates while customName is untouched, so an advert improves accuracy without moving what the user sees. That was the defect making a client-held name impossible.
customName is deliberately NOT in contact_store. That record is rebuilt from radio contact-sync data where no custom name exists, so a save after a sync would wipe it. It lives in contact_settings_store keyed by pubkey hex, which a sync cannot reach.
Both stores persist receivedName, never the getter. The plan's correction on #750 found that persisting the getter is one-way data loss: the custom name would be read back as the received name and the real one destroyed with no advert able to restore it. The ?? 'Unknown' fallback is kept with a comment, since the rename is exactly the edit that drops a trailing fallback and would crash the load for a legacy row.
19 tests, positive and negative. T31 and T32 were falsified before being trusted: breaking the persist and the fallback made exactly those two fail and nothing else.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1305 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A long-lived session drifted hundreds of contacts above radio truth (#838):
the client promoted every overheard advert into the persisted contact list
using only the radio's auto-add type policy, applying neither the radio's hop
limit nor its capacity, and ignoring its eviction/full pushes. The firmware
fires NEW_ADVERT (0x8A) and the raw RX firehose (0x88) even for adverts it
rejects, so those became phantom contacts the next manual sync had to prune.
Option A, sync is truth: the persisted contact list is populated only by the
contact sync stream. Both live-advert paths now feed discovery only; a new
advert whose type the radio would auto-add nudges a debounced incremental
reconcile so a contact the radio actually stored still surfaces without a
manual sync, while one it rejected simply never returns in the stream. The
existing-contact path/GPS refresh and the reconnect full-sync prune are
unchanged. The reconcile debounce (and the sibling channels-changed debounce)
is cancelled on both disconnect paths so a timer scheduled against one radio
can't fire against the next (pre-PR Gemini review finding).
Epic #842. Build #850.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No preview is sent while a slot transfer runs, so neither can take the other's refusal. A final SLOT_SET reply that is not 0 is no longer read as a commit. Back during a read or save now says why it is held. Comments now cite meshcore-firmware f3d30a88 on firmware-base, which merged 3e788e2a with the contract files unchanged. The wider attribution fix is #991.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The badge's player reads d=, o=, b= positionally, in that order, so RTTTL from elsewhere that lists them otherwise (owner's Take On Me: o=5,d=8,b=160,b=160) is refused as a stray colon, and firmware's 21-column words do not say why. When the header is the only fault, the editor now says the badge reads d=, o=, b= in that order, each once, and offers Fix order. The fix refuses to pick between two different values for one key, and must itself pass rtttlProblem, so it never hides a second problem.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds the owner-approved parity design (docs/initiatives/standalone/975-settings-restructure/tones-screen-parity-mockups.html) against meshcore-firmware 3e788e2a:
- #986: the six event tones (DM, Channel, Room, New contact, Ack, Sent) on Buzzer and tones, one shared picker with a play button per row (0x0F/0x10/0x11). DM is one tone for every direct message.
- #970: all eight custom slots listed; the editor reads the stored tune back (0x12), checks typing with a port of firmware's rtttlProblem(), and saves in transport-sized chunks (0x0A), restarting once if displaced.
- #971: Radio tone in the channel sheet and chat menu, Default naming what it plays.
- #987: Screen (text sizes, Screen off, time zone with a phone-matched suggestion) and Battery (full point, set here, back to auto) under Node Settings, gated on caps2 0x40.
Caps2 0x10 is now TUNES (slots, channel tone, event tones, preview). Also corrects the mockup's zone count: 40 zones plus not set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner-approved 2026-10-25 with four decisions: Screen and Battery under Node Settings, a separate play button, no way back to radio default, and the Menus and lists / Messages labels. Built against meshcore-firmware 3e788e2a. Covers #970, #971, #986, #987.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>