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>
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>
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>
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>
The unresolved state claimed "Haven't heard this node's advert yet". The
condition behind it is only that the claimed name matched nothing, which is
not the same thing: a node renamed since its last advert is known to us, just
not under the name it is posting with.
Now reads "No node known by that name" with a hint that adding needs an
advert from the node, so it states what we know and what unblocks it without
asserting something we cannot check. Tracked as issue 579.
Gemini pre-PR review finding. importDiscoveredContact no-ops when the radio
is gone, and sendFrame throws on a mid-write disconnect or an unwritable BLE
characteristic. Both reached the user as a tap that appeared to work.
Checks the connection first, catches the write failure, logs it, and shows a
persistent error snackbar in either case (SAFELANE section 6: no silent
failures).
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.
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.
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.
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).
Adds three tappable rows to the About page opening in the external browser via
url_launcher: offband.org, the Google Play listing (app.offband.meshcore), and
offband.org/donate (Ko-fi + GitHub Sponsors options, live-verified). Failure
surfaces a snackbar. Completes epic #525.
Agent: QuietSnow (session 31eaba02)
About moves out of the Debug pane into a top-level 'About' category (its own
pane, not the stock dialog). Keeps the marketing version display, adds a
description + refreshed legalese (Offband, zjs81/MeshCore MIT attribution kept),
and a View licenses row (showLicensePage). Build identity stays in Device Info
(#553). #527 adds the outbound links here next. Part of epic #525.
Agent: QuietSnow (session 31eaba02)
Hiding the section left enabled features running invisibly. The action is now
'Disable experimental features': disableExperimental() turns off every
experimental flag (CoreScope observer counts, future ones) AND re-locks the
section in one atomic write. Also fixes the subtitle to say 'Build row' (the
#553 anchor), not 'version row'. Part of the #509/#553 experimental section.
Agent: QuietSnow (session 31eaba02)
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)
Owner testing: the inline badge needed pixel-accurate taps, the refresh gave no
feedback, and there was no refresh in the message action sheet. Now the badge is
a padded InkWell (real tap target), the long-press panel has a 'Refresh CoreScope
observers' action, both show a snackbar with the fetched count, and the auto-poll
is more patient (adds 90s tail steps, stabilises after 3 flat checks) so it climbs
closer to the total before you need to tap. Part of #524.
Agent: QuietSnow (session 31eaba02)
After a channel send, when firmware advertises 0xC6 (cap2 0x08 + ver>=22) AND
the owner enabled the feature, the connector queries the packet hash, then
CoreScope for observer_count, and stamps it on the message (background,
best-effort). Adds the coreScopeObserverCountEnabled AppSettings flag, a gated
switch in the #509 Experimental section (disabled until the radio supports the
capability), and a distinct cloud badge next to the radio-heard count. All
inert until the firmware PR (#611) lands. Completes the client side of #524.
Agent: QuietSnow (session 31eaba02)
Owner feedback: per-tap snackbars appeared over the version/About row and
blocked the next tap; and a 'Hide' switch sitting ON read backwards.
- Tap progress + unlock/already-unlocked now render inline under the
version number (2s auto-clear timer), so nothing overlays the tap target
or the app bottom.
- Experimental 'Hide' is now a plain action row, not a switch.
Agent: QuietSnow (session 31eaba02)
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)
Channel messages had no resend path: unlike DMs (auto-retried by
MessageRetryService), a channel send that never gets its generic ack/repeat-back
goes to failed with no recovery. Adds a "Send Again" action to the channel
long-press sheet for outgoing messages that are not yet acked (status != sent,
i.e. pending or failed), re-sending via sendChannelMessage.
Also renames the existing direct-chat "Retry" action to "Send Again" (matching
stock MeshCore's "Send Again" for cross-client familiarity); DM gating unchanged
(offered at failed, after auto-retry is exhausted). New l10n strings
message_sendAgain + chat_sendingAgain. Build of epic #256.
Ben's style rule: no em-dash character in copy, docs, or code comments
(it reads as an AI tell), and the repo is going public. Replaced the
em-dashes in code comments and English UI strings with commas, colons,
or periods, whichever reads best. User-facing strings were hand-tuned
for natural punctuation rather than a blanket comma.
Preserved the lone "no data" glyph placeholders (a standalone dash used
as a not-available indicator in status displays); those are a design
element, not prose.
Regenerated app_localizations*.dart from app_en.arb (the English
fallback for untranslated keys propagates to every locale's generated
file). Non-English ARB translations left untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini flagged two unhandled PlatformException paths (SAFELANE §6):
- isIgnoringBatteryOptimizations could throw -> banner silently never shows;
now caught + logged, leaving the banner hidden on an unknown status.
- openIgnoreBatteryOptimizationSettings could throw -> button did nothing with
no feedback; now caught + logged + a snackbar tells the user it failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On aggressive OEMs (Samsung One UI) a backgrounded app that is not exempt
from battery optimization is slept on screen-off and drops the radio
connection, with no warning. Add a persistent banner on the channels screen
that appears (Android only) when the app is not exempt, explains the risk,
and deep-links to the battery settings via
openIgnoreBatteryOptimizationSettings(). Re-checks on resume so it clears
once the user applies the change; dismissible for the session.
No restricted permission: reads own status and opens the settings screen
(ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); the Play-restricted
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS path is deliberately avoided.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Smaz is a client-side text convention with no MeshCore protocol or firmware
support (findings: GH-315). When enabled it compressed ordinary prose into
`s:`+base64, which any non-lineage client renders as garbage — the same
cross-client interop failure as the old `g:<code>` GIF token.
Phase 1 of the Smaz-removal epic (GH-314): stop sending Smaz and remove the
user-facing surface, while KEEPING decode so messages from lineage peers still
on Smaz, and any legacy compressed rows, keep rendering. Decode removal is
deferred to Phase 2 (GH-421).
Removed: the Smaz branch in prepareContact/ChannelOutboundText (Cyr2Lat branch
and the structured-payload guard preserved); connector state/API (the enabled
maps, is*/set*SmazEnabled, ensureContactSmazSettingLoaded, the warm-up and
channel loaders); the per-channel and per-contact toggles plus their
mutual-exclusion; loadSmazEnabled/saveSmazEnabled and the `*_smaz_` key prefixes
(stores kept, they also hold Cyr2Lat); l10n `channels_smazCompression` and the
orphaned `chat_compressOutgoingMessages` across 18 locales (+ regenerated
app_localizations).
Retained for Phase 2: the 5 Smaz.tryDecodePrefixed decode sites and
helpers/smaz.dart.
Safety (verified): no storage migration, the app already persists plaintext
(receive decodes before store; send stores the pre-compression text), so the
`s:` form was wire-only. ACK matching is unaffected, the expected hash derives
from prepare*'s output, so dropping compression keeps both sides hashing
plaintext.
Behavior change: the composer byte-counter now reflects raw size, so anyone who
had Smaz on can type slightly fewer chars per message.
Tests: gif_url_outbound_guard_test rewritten to pin plaintext passthrough and
decode retention. Full suite green, analyze clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a DM opens the Path screen; it now shows the RF details the app
was already receiving but discarding:
- SNR: captured from the v3 contact-msg-recv frame (was skipBytes(1)).
Firmware sends (int8)(snr_dB*4), so dB = byte/4.0 (MyMesh.cpp:512) —
the old commented-out code multiplied by 4, which was 16x wrong.
- Path type: firmware sends path_len 0xFF for a direct/routed frame and
the hop count for a flood (MyMesh.cpp:545). The app collapsed 0xFF->0,
colliding "direct" with "flood, 0 hops". Capture the distinction into
Message.isFloodRoute so the Path row reads "direct (routed)" vs
"flood, N hops".
- RSSI: row wired to Message.rssi but left null — the wire byte is a
hardcoded reserved 0 today; populated once firmware ships it (#439).
Message gains snr/rssi/isFloodRoute (nullable, additive, persisted like
rxTime). 10 tests cover scaling, the 0xFF discriminator, and copyWith.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The share helper only invoked the OS share sheet on mobile; on all desktop it
opened the containing folder, which for a temp-dir capture dumped the user into
TEMP amid unrelated files. Now:
- Android/iOS: OS share sheet (unchanged).
- Windows/macOS/Linux: native Save As dialog (file_selector) writing the file
to a user-chosen location.
- Web: browser download of the log text (no on-disk file on web).
Adds file_selector; web download via a js_interop helper behind a conditional
import. Folds in #427 (localized share strings). LogExport.shareFile keeps a
compatible signature for the serial-capture screen (#430).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route LogExport's Share tooltip, share subject, and file-logging-unavailable
snackbar through context.l10n instead of hardcoded English. Adds four keys to
app_en.arb (debugLog_shareLog, debugLog_openLogsFolder, debugLog_shareSubject,
debugLog_fileLoggingUnavailable) and regenerates all locales (English fallback
until translated). Resolves the deferred Gemini finding from #393.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No in-app signal of which binary is running has caused repeated confusion
(a debug-signed APK silently not installing over the release app, old layout
persisting with nothing to indicate the new build never landed).
BuildInfo reads GIT_SHA/GIT_BRANCH/BUILD_TIME from --dart-define with dev
fallbacks, so every build carries its own identity independent of the pubspec
version. Surfaced as its own copyable Build row in Device Info.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stores are keyed by the first 10 hex of the connected radio's public key, so
connecting a different radio silently swaps which contacts, channels, and
history you are viewing with nothing in the UI saying so. Device Info showed
the full public key but never tied it to storage.
Adds a Data scope row with the key actually in effect, plus a one-line
explanation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Advertisement Notifications" reads as ads. Retitled to "New node discovered"
/ "Notify when new repeaters or contacts are heard" across all 18 locales so
the overnight-ping setting is findable without mesh jargon.
Coverage verified, no gap found: showAdvertNotification is the only discovery
notification entry point and all three of its callers
(meshcore_connector.dart:4961, 5047, 7226) are guarded by
notificationsEnabled && notifyOnNewAdvert && !isBlocked. The batch summary is
fed solely from enqueued adverts, so it cannot fire with the toggle off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a KeepScreenAwake controller that holds a wakelock_plus lock while the
setting is on and the app is foregrounded, releasing on background, on toggle
off, and on dispose. Toggle lives in App Settings > Battery (power tradeoff),
defaults OFF, persisted as keep_screen_awake. Strings added for all 18 locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notification settings moved to the Messages category, but the App Settings
tile subtitle still advertised them. Retitled across all 18 locales to match
what the pane actually renders (appearance, translation, battery, map, Cyr2Lat).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the database can't open (e.g. the native sqlite library fails to load),
every read/write silently failed and the app opened to an empty, normal-looking
screen — the user thinks their history was wiped (SAFELANE §6 violation).
Probe the storage layer in main() before any store reads
(BlobStore.verifyReadWrite). On failure, a StorageHealthService one-way latch
records it, and a persistent, non-dismissable banner is shown above the whole
app: history is not lost, storage is unavailable, messages are NOT being saved,
restart after fixing. Cross-platform (probe goes through drift, covers web too).
Tests: service state/latch + banner show/hide widget test. Gemini-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner hardware test on an S25 FE and Windows turned up five issues. Three of
them shared one root cause.
**Map: always show names.** New mapAlwaysShowNames setting, toggled from the
map panel. Names were shown only past zoom 14; the override wins at any zoom.
Evaluated at build rather than only on camera movement, so flipping the switch
repaints immediately instead of waiting for the next pan.
**Panel footer on every screen.** Disconnect and Settings now also appear
inside a channel. The footer is app-level, so it belongs anywhere the panel
is, not just the primary views.
**Back button (three reported symptoms, one cause).** Channels, Contacts and
Map each wrapped themselves in `PopScope(canPop: !isConnected)`, so while
connected the system back press was swallowed whole. That explains all three:
an open drawer would not close, Android would not background the app, and on
Windows the pinned layout has no Scaffold drawer, so the app bar auto-implied
a back arrow whose press PopScope then discarded - a button that visibly did
nothing.
AppShell now owns back handling for every screen that uses it, in order:
close an open drawer, else pop the route (a pushed chat returns to its list),
else hand back to the OS via SystemNavigator.pop() so Android returns to the
home screen or the previous app. The three per-screen PopScope wrappers are
removed. AppShell becomes stateful to hold the scaffold key this needs.
flutter analyze clean, dart format clean, 469 tests pass. Not yet re-tested on
hardware.
A capability-gated control that doesn't appear is indistinguishable from
a broken one. Diagnosing "the FEM LNA toggle is missing" on real hardware
required a rebuild, because the caps byte was never surfaced and the app
debug log records nothing in a release build unless logging is enabled.
Adds an "Offband capabilities" row to Device Info showing the raw caps
byte, firmware version code, and which gated features it grants, e.g.
"0x02 (v16) - block". That turned an unexplained missing toggle into a
one-look answer: firmware v16 running, FEM LNA bit clear, client correct.
Also logs the same values at device-info parse for anyone who does have
logging on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the client half against the firmware as-built (#298):
- Device-info offset 83 carries the FEM LNA state on v16+, appended
unconditionally (reads 0 on non-capable boards). parseFemLnaState is
length-guarded and returns null pre-v16. Byte presence signals firmware
version, not capability — the cap BIT gates the UI.
- 0xC3 reply handler adopts the reported value verbatim: firmware returns
post-apply hardware state, not an echo, so a refused write surfaces as
truth rather than a lie.
- setFemLna / requestFemLnaState no-op unless the capability bit is set,
so a non-capable radio never sees 0xC3 traffic.
- Radio Settings toggle rendered only when the bit is set, driven by
connector state via ListenableBuilder rather than local optimistic
state, so it always shows what the radio reports.
- errCodeUnsupportedCmd documented: a mis-gated request draws [0x01][0x01],
not [0x01][0x06]. Neither is 0xC3-prefixed, so no new error path.
Gating is on the bit alone, never model or version: firmware derives it
from a runtime FEM probe, so two Heltec V4s can legitimately disagree and
rak3401 reports false by design (its SKY66122 gates LNA and PA together).
13 tests. Not hardware-validated — firmware 0xC3 is still on a branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sensors were the one advert type with no filter. advTypeSensor (4) already
existed in the protocol and Contact.typeLabelRaw already returned 'Sensor',
but ContactTypeFilter stopped at rooms, so sensor contacts could not be
isolated in either the contacts list or discovery.
- ContactTypeFilter gains `sensors`. Both exhaustive switches over it were
found by the analyzer rather than by hand: the contacts list predicate and
the search-hint text.
- discovery_screen's predicate has a `default: return false`, so it compiled
without a sensors case but would have silently shown an empty list. Added
explicitly.
- New l10n strings contacts_searchSensors and listFilter_sensors, regenerated
across all 18 locales. The 17 non-English locales fall back to the English
text and are recorded in untranslated.json, matching how existing strings
are handled.
- Persisted round-trip verified: UiViewStateService stores the filter by name
with orElse -> ContactTypeFilter.all, so older persisted values cannot throw
and `sensors` persists like the rest.
Groundwork for the Contacts filter rail (#307), but useful on its own: the
existing filter menu now offers Sensors.
flutter analyze clean, dart format clean, 445 tests pass.
The channel long-press sheet's binary Mute/Unmute tile becomes a
Notifications tile showing the current level, opening a picker with All
messages / Mentions only / Off.
The mode is read and written against the channel's PSK identity via
AppSettings.channelNotifyKey, so it survives a rename and does not follow a
reused slot (#259). Plain ListTiles with a check mark are used rather than
RadioListTile to stay clear of the Radio groupValue deprecation.
Adds channels_notifications / channels_notifyAll / channels_notifyMentionsOnly
/ channels_notifyOff to app_en.arb and regenerates l10n; the other 17 locales
fall back to English and are recorded in untranslated.json.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the DM composer with an "Unblock to message" notice bar when
the contact is blocked, so a block also stops outgoing DMs (previously
only incoming DMs/adverts were suppressed). Inline unblock restores the
composer. Adds block_composerNotice l10n string and documents the
outgoing-DM rule in block-contract-as-built.md §4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings showed the self public key truncated to 16 hex chars + "..." as
plain Text (settings_screen:262/:410), so it couldn't be shared. Show the
full key, make it selectable, and add a copy button beside it.
- _buildInfoRow gains an optional copyValue: renders the value as
SelectableText + a copy IconButton that copies the full key and shows a
"Public key copied" snackbar. Both self-pubkey rows pass the full key.
- New l10n string settings_publicKeyCopied (regenerated localizations +
untranslated.json).
Part of #234.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Connector parses ADD/REMOVE/CLEAR replies; ADD ok=0 (node store full at 32) sets
blockOffloadStoreFull (local stays authoritative), cleared on reconcile. BlockedView
shows a firmware-offload status row when supportsOffbandBlock — 'active', or a
'radio block list full' warning. Adds block_offload* l10n.
Epic B #166 / B5 #180.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New BlockedView pane (Settings category, after Privacy): lists blocked pubkeys
(contact name when resolvable, else short key) and name-only channel blocks, each
with Unblock; empty state when nothing blocked. App-local; firmware-offload
indicator deferred to Epic B. Adds block_* settings l10n.
Epic A #165 / A6 #173. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Long-press a channel post -> Block sender: resolve the claimed name to known
pubkey(s) and block each (full block across DM+adverts+all channels, multi-device
covered); if unresolved, block the name globally across ALL channels (promotes to
pubkey later, #174). Adds block_* l10n strings. A5 channel surface;
contacts/chat/discovery + unblock still to come.
Epic A #165 / A5 #172. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Add Channel "Scan QR Code" scanner now accepts channel share URIs
(meshcore://channel/add) alongside community QR JSON: it parses name +
secret, confirms, and adds the channel to the next free slot via
setChannel. Completes the channel-QR round-trip in-app, and interops with
the reference app's channel QRs.
Bumps build to +52 for the b52 combined test binary (path-hash + trace +
channel QR).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Channel.toShareUri/fromShareUri/isValidShareUri encode the reference
MeshCore app's format: meshcore://channel/add?name=<url-encoded>&secret=<hex32>.
A "Share QR Code" action in the channel menu shows the QR via the existing
QrCodeShareDialog, so it round-trips with the reference app.
Encode/decode unit-tested against both real reference examples (#test, #echo).
Scan-to-add follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Set Path" dialog parsed, built, and displayed hop prefixes as a fixed
1 byte (substring(0,2)), so on a 2-/3-byte path-hash mesh manual entry produced
wrong-width routing paths and the helper text claimed "1 byte". Make it width-
aware from connector.pathHashByteWidth.
- parsePathPrefixes(text, width, invalid): static + testable; each entry must be
EXACTLY width*2 hex chars (wrong-length / malformed go to invalid, never
silently truncated); width clamped >= 1.
- _updateTextFromContacts / display / maxLength group by the configured width;
the display substring is guarded against short keys.
- show() takes pathHashByteWidth; the chat + path-management callers pass
connector.pathHashByteWidth.
- l10n: reworded the path-entry helper / example / instructions to be width-
agnostic (dropped the "2 hex / 1 byte / first byte" claims). The 17 non-English
locales keep their prior strings, flagged for re-translation (English fixed).
analyze clean; 6 parsePathPrefixes tests (incl. exact-length + clamp). Gemini v1
BLOCKER (unguarded substring) + MAJOR (silent truncation) fixed; v2 MAJORs were
the pre-existing refresh button (out of scope), v2 MINOR (1-byte example) fixed.
Closes#155
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Offband 0xC1 GPS query: 1-byte request -> RESP 0xC1 ASCII reply
(enabled/detected/active/fix/baud/lat/lon/alt_cm/sats/time), parsed into
OffbandGpsStatus (lat/lon /1e6). New GPS-status section in the Location dialog
(live-fix vs stored banner + coords/sats/alt/time). Validated against RedCreek's
feat/gps-state-query test firmware.
#140: the per-minute GPS poll now uses the lightweight 0xC1 query instead of
CMD_APP_START (which re-walked channels + re-drained messages = the BLE re-init
flood); a live fix updates self-lat/lon. Validated on b42 (APP_START ~1/min ->
~0, 0xC1 polls clean every 60s, channel re-walk gone).
Gemini (fix-then-ship): atomic completer-claim in _handleOffbandGps to shrink
the stale-reply race; full elimination needs a protocol sequence id, but the
poll shares in-flight via the re-entrancy guard and the query button is disabled
during a request, so concurrent requests don't occur in practice. The hardcoded
snackbars Gemini flagged in _editLocation are pre-existing l10n debt, not
introduced here; all new strings are localized.
Closes#135Closes#140
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parse the device-info reply's readable strings (build date / model / firmware
version, NUL-terminated after the 8-byte header) and show firmwareVersion +
deviceModel in Settings → Device Info. Raw strings are logged on connect.
End-anchored field mapping (deviceInfoFields) keeps the version correct on
partial frames.
Gemini (fix-then-ship): removed the unused deviceBuildDate field/getter (dead
state) — the build date stays in the connect log via the raw strings, and
deviceInfoFields still parses + tests it.
Closes#134
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Reply long-press drops the cursor into the composer (post-frame focus). (#131)
- "Reply with route" sends `@[sender] ↩ N hops · via <path>`, byte-truncated with `…`, Cyr2Lat-aware. (#106)
Gemini (fix-then-ship): the unawaited `sendChannelMessage` in `_sendRouteReply` mirrors the established `_sendMessage` convention — failure surfaces via the visible message's delivery status, not the returned Future; holistic channel-send error handling is tracked in #133 per maintainer decision. O(n^2) path truncation is negligible on the <=160-byte payload; the `w<1` guard is a defensive div-by-zero (pathHashByteWidth is 1-3).
Closes#131Closes#106
Part of #132
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Clock setting (System / 12h / 24h) in App Settings; chat times follow it + locale.
- Channel tile: always-on metadata row - time + hops on inbound, time + cell_tower
heard count + status on outbound - decoupled from the message-tracing toggle. The
via-path stays behind tracing.
- Drop the auto reply-to block and the most-recent-sender guess; @mentions now
render inline in the message text.
- Direct chat: same clock-aware time formatting.
Closes#37
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>