Merge pull request #518 from OffbandMesh/dev

release: promote 1.4.0+65 to main
main v1.4.0
Strycher 2 months ago committed by GitHub
commit 0c405bb659
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Fail if an em-dash (U+2014) appears in source-authored text.
The em-dash reads as an AI tell, so it is kept out of the public repo (#457,
#462, #464). This guard runs in CI (the `analyze` job) and can also be wired
into a local pre-commit hook.
In scope:
- lib/**/*.dart and test/**/*.dart (code comments + string literals)
- lib/l10n/app_en.arb (English UI copy)
Out of scope (never flagged):
- Generated files: *.g.dart, lib/l10n/app_localizations*.dart
- Non-English ARB (lib/l10n/app_*.arb except app_en.arb): an em-dash may be
legitimate target-language punctuation.
- The lone "no data / not available" glyph placeholder: a dash that is the
entire quoted value, e.g. `return '—';`, `"key": "—"`, `text: '—'`. That is
a deliberate UI element, not prose.
Exit status: 0 when clean, 1 when any in-scope em-dash is found (each printed
as file:line: <content>).
"""
from __future__ import annotations
import os
import sys
EM_DASH = "—"
ROOTS = ("lib", "test")
def _has_prose_emdash(line: str) -> bool:
"""True if the line has an em-dash that is NOT a lone glyph placeholder.
The lone "no data" glyph is a dash that is the entire quoted value (`'—'`
or `"—"`). Strip those tokens first, so a line carrying both a glyph and a
real prose em-dash is still flagged.
"""
stripped = line.replace("'" + EM_DASH + "'", "").replace('"' + EM_DASH + '"', "")
return EM_DASH in stripped
def _in_scope(path: str) -> bool:
base = os.path.basename(path)
if base.endswith(".g.dart") or base.startswith("app_localizations"):
return False
if base.endswith(".dart"):
return True
# ARB: only the English source is in scope.
return base == "app_en.arb"
def find_violations(roots=ROOTS):
hits = []
for root in roots:
if not os.path.isdir(root):
continue
for dirpath, _dirs, filenames in os.walk(root):
for name in filenames:
if not (name.endswith(".dart") or name.endswith(".arb")):
continue
path = os.path.join(dirpath, name)
if not _in_scope(path):
continue
with open(path, encoding="utf-8") as fh:
for lineno, line in enumerate(fh, 1):
if _has_prose_emdash(line):
hits.append((path.replace(os.sep, "/"), lineno, line.rstrip()))
return hits
def main() -> int:
roots = tuple(sys.argv[1:]) or ROOTS
hits = find_violations(roots)
if not hits:
print("check_no_emdash: OK (no em-dash characters in source)")
return 0
print(
"check_no_emdash: found the em-dash character (U+2014) in source. "
"Replace it with a comma, colon, or period (see #464):",
file=sys.stderr,
)
for path, lineno, content in hits:
print(f" {path}:{lineno}: {content}", file=sys.stderr)
print(f"\n{len(hits)} em-dash(es) found.", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

@ -14,6 +14,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Guard against em-dash characters (#464)
run: python3 .github/scripts/check_no_emdash.py
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:

@ -2,6 +2,40 @@
All notable changes to Offband Meshcore. Pre-releases are tagged `-beta.N` / `-rc.N`.
## [1.4.0] - 2026-07-30
A follow-up to the 1.3.0 production launch: settings for headless devices, a
resend action for channel messages, and a batch of per-radio data-correctness
fixes.
### Added
- Button and buzzer settings for headless devices, capability-gated so they only
appear on radios that report support (#483).
- "Send Again" resend on channel messages, with the direct-message resend action
relabeled to match (#513).
- Device Info shows the second offband capabilities byte, so more capability bits
are visible at a glance (#480).
- `@[name]` self-mention matching is now ASCII case-insensitive, so a mention
matches regardless of case (#486).
### Fixed
- Block list is scoped per radio again: an accidental global block list was dropped,
so blocking on one radio no longer leaks to another (#505, #506).
- Per-radio in-memory caches are cleared on reconnect, so data from a previously
connected radio cannot linger into the next session (#472).
- Translation-model downloads retry transient 5xx errors with backoff instead of
failing outright, and the backoff tracks actual elapsed time (#425).
- `@[name]` mentions are treated as a verbatim wire token (no stray trim), matching
the reference contract (#497).
- Routed replies are sent at the contact's path-hash width instead of width 1
(#495).
### Under the hood
- CI now guards against em-dash characters in `lib/` and `test/` (#464).
## [1.3.0] - 2026-07-30
The first Offband release published to the Google Play production track. A large

@ -0,0 +1,15 @@
🔧 **Offband Meshcore 1.4.0** is up, a focused follow-up to the 1.3.0 production launch.
🔘 **Button and buzzer settings for headless devices.** On radios that report support, configure the button and buzzer straight from settings.
🔁 **"Send Again" on channel messages.** Resend a channel message in one tap, with the DM resend action relabeled to match.
🔎 **More capability detail in Device Info**, so more of your radio's capability bits are visible at a glance.
🛠️ **Fixes:** the block list is scoped per radio again (an accidental global list is gone), per-radio caches clear on reconnect so nothing lingers from another radio, translation-model downloads retry on transient errors, and routed replies use the correct path width.
**📥 Get it on Google Play:** search **Offband MeshCore**, or https://play.google.com/store/apps/details?id=app.offband.meshcore
💻 **Desktop (Windows / Linux) and Android APK:** https://github.com/OffbandMesh/meshcore-client/releases/tag/v1.4.0
🌐 **Or run it in your browser:** https://offband.app
💚 Thanks as always to the OKIMesh community (https://okimesh.org/). Offband is free and open source and always will be. If you'd like to help keep it going: https://offband.org/donate

@ -58,6 +58,7 @@ import '../utils/battery_utils.dart';
import '../utils/platform_info.dart';
import 'meshcore_uuids.dart';
import 'meshcore_protocol.dart';
import 'offband_device_ui.dart';
import 'caplog_reassembler.dart';
class DirectRepeater {
@ -294,7 +295,11 @@ class MeshCoreConnector extends ChangeNotifier {
String? _firmwareVersion;
String? _deviceModel;
int? _offbandCaps;
int? _offbandCaps2;
bool? _femLnaEnabled;
ButtonMatrix? _buttonMatrix;
DeviceNotifyScope? _deviceNotifyScope;
String? _deviceUiError;
int _pathHashByteWidth = 1;
CompanionRadioStats? _latestRadioStats;
Stopwatch? _airtimeBumpStopwatch;
@ -610,6 +615,154 @@ class MeshCoreConnector extends ChangeNotifier {
/// null on older firmware that sends a shorter frame.
int? get offbandCaps => _offbandCaps;
/// Second `offband_caps` bitfield (frame offset 84, firmware #508). Null when
/// the frame is too short, which means "no byte-2 capabilities" and is never
/// an error: every radio predating #508 reports null here. (#480)
int? get offbandCaps2 => _offbandCaps2;
/// Whether this radio exposes a configurable button-action matrix (#474).
bool get supportsButtonMatrix => firmwareSupportsButtonMatrix(_offbandCaps2);
/// Whether this radio exposes a settable device notification scope (#475).
bool get supportsNotifyScope => firmwareSupportsNotifyScope(_offbandCaps2);
/// Whether the radio can actually be QUERIED, as opposed to merely reporting
/// that it has the feature. False until firmware lands the get/set command,
/// so no frame is emitted that nothing will answer. The distinction is real
/// and user-visible: a T1000-E advertises the buzzer capability today but
/// cannot yet be read or written by the app.
bool get supportsDeviceUiCommand =>
deviceUiCommandLanded && (supportsNotifyScope || supportsButtonMatrix);
/// Last button matrix read from the radio, or null if not read yet.
ButtonMatrix? get buttonMatrix => _buttonMatrix;
/// Last device notification scope read from the radio. The user can change
/// this out of band by triple-pressing the device, so it is re-read on every
/// device-info refresh rather than cached across a connection. (#475)
DeviceNotifyScope? get deviceNotifyScope => _deviceNotifyScope;
/// The radio's own reason for refusing the last write, or null. Held until
/// the next request so the UI can show it persistently rather than as a
/// flash: an error the user cannot finish reading is not surfaced (§6).
String? get deviceUiError => _deviceUiError;
void clearDeviceUiError() {
if (_deviceUiError == null) return;
_deviceUiError = null;
notifyListeners();
}
/// Ask the radio for its button matrix. No-op unless the capability bit is
/// set, so an unsupported radio never sees `0xC5`.
Future<void> requestButtonMatrix() async {
if (!supportsDeviceUiCommand || !supportsButtonMatrix) return;
await sendFrame(buildButtonMatrixGetFrame());
}
/// Assign [action] to [sequence]. The radio echoes the pair on success or
/// replies with a reason; the local matrix is updated only from that reply,
/// never optimistically, so the UI can never show an assignment the device
/// rejected.
Future<void> setButtonAction(
ButtonSequence sequence,
ButtonAction action,
) async {
if (!supportsDeviceUiCommand || !supportsButtonMatrix) return;
_deviceUiError = null;
await sendFrame(buildButtonMatrixSetFrame(sequence, action));
}
/// Ask the radio for its current notification scope.
Future<void> requestNotifyScope() async {
if (!supportsDeviceUiCommand || !supportsNotifyScope) return;
await sendFrame(buildNotifyScopeGetFrame());
}
/// Set the device notification scope. As with the matrix, local state follows
/// the device's reply rather than the request.
Future<void> setNotifyScope(DeviceNotifyScope scope) async {
if (!supportsDeviceUiCommand || !supportsNotifyScope) return;
_deviceUiError = null;
await sendFrame(buildNotifyScopeSetFrame(scope));
}
/// Re-read whatever headless-UI state this radio supports. Called after every
/// device-info reply so a scope changed by triple-press on the device is
/// never displayed stale, and so frame-arrival order does not matter. (#475)
/// Drop everything read from the PREVIOUS radio.
///
/// These values are per-device. Capability bits refresh from every
/// device-info frame, but the values behind them do not, so without this a
/// reconnect can show one radio's notification scope as another's.
void _clearDeviceUiState() {
_buttonMatrix = null;
_deviceNotifyScope = null;
_deviceUiError = null;
}
void _reconcileDeviceUi() {
if (supportsButtonMatrix) requestButtonMatrix();
if (supportsNotifyScope) requestNotifyScope();
}
/// Single entry point for `0xC5`: both surfaces and the shared error sub-code
/// ride one command byte, so the matrix parser is tried first (it owns 0x7F)
/// and the scope parser handles what is left.
void _handleDeviceUiReply(Uint8List frame) {
// Parse ONCE and hand the result down. Parsing here to route and again in
// the handler lets the two copies drift, so a later edit to one could drop
// a valid frame.
final matrix = parseButtonMatrixReply(frame);
if (matrix != null) {
_handleButtonMatrixReply(matrix);
return;
}
final scope = parseNotifyScopeReply(frame);
if (scope != null) _handleNotifyScopeReply(scope);
}
void _handleButtonMatrixReply(OffbandUiReply reply) {
if (reply.isError) {
_deviceUiError = reply.errorMessage;
_appDebugLogService?.warn(
'Button config refused: ${reply.errorMessage}',
tag: 'DeviceUI',
);
notifyListeners();
return;
}
if (reply.matrix != null) {
_buttonMatrix = reply.matrix;
} else if (reply.setSequence != null && reply.setAction != null) {
final held = _buttonMatrix;
if (held == null) {
// A device-confirmed write with nothing to fold it into. Never drop it
// silently: re-read so the client matches what the device just did.
requestButtonMatrix();
} else {
_buttonMatrix = held.withAssignment(
reply.setSequence!,
reply.setAction!,
);
}
}
notifyListeners();
}
void _handleNotifyScopeReply(OffbandUiReply reply) {
if (reply.isError) {
_deviceUiError = reply.errorMessage;
_appDebugLogService?.warn(
'Notification scope refused: ${reply.errorMessage}',
tag: 'DeviceUI',
);
} else {
_deviceNotifyScope = reply.scope;
}
notifyListeners();
}
/// Whether the connected firmware speaks the `0xC1` GPS extension, i.e. it
/// advertised the Offband-fork `offband_caps` byte. Stock MeshCore omits it,
/// so 0xC1 is suppressed there rather than pinged blindly. (#144)
@ -2615,14 +2768,18 @@ class MeshCoreConnector extends ChangeNotifier {
_maybeStartInitialChannelSync();
}
/// Keep [BlockService]'s notion of "me" in sync with the connected node, so
/// it can refuse a self-block and self-heal one that already exists, the
/// union pull can land before self-info arrives, so healing matters (#250).
/// (Re)load the connected radio's per-radio block list and keep BlockService's
/// notion of "me" in sync. Called when the device key is learned (device-info)
/// and on disconnect/reset (null). loadForDevice sets the store scope + self
/// key synchronously, so an unawaited call is safe against a concurrent block
/// LIST dump; it also runs the #250 self-heal. Per-radio scoping is what stops
/// a block on one radio leaking to another and re-infecting a cleared radio
/// (#471).
void _applySelfKeyToBlockService() {
final service = _blockService;
if (service == null) return;
final key = _selfPublicKey;
unawaited(service.setSelfKey(key == null ? null : pubKeyToHex(key)));
unawaited(service.loadForDevice(key == null ? null : pubKeyToHex(key)));
}
void _resetConnectionHandshakeState() {
@ -2636,11 +2793,34 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null;
_hasReceivedDeviceInfo = false;
// Drop the previous radio's in-memory history before a new connection loads
// its own. These caches are keyed by channel index / contact key, not by
// radio, so without this a radio switch would keep showing the prior
// radio's channel and DM history (a new radio's empty store cannot
// overwrite them). On-disk stores are already per-radio (device+PSK); this
// is purely the runtime cache. Repopulated on connect by
// loadAllChannelMessages and _loadMessagesForContact. (#472)
_channelMessages.clear();
_conversations.clear();
_loadedConversationKeys.clear();
_resetSyncProgressState();
_bleInitialSyncStarted = false;
_pathHashByteWidth = 1;
}
@visibleForTesting
void resetConnectionHandshakeStateForTest() =>
_resetConnectionHandshakeState();
@visibleForTesting
Map<int, List<ChannelMessage>> get channelMessagesForTest => _channelMessages;
@visibleForTesting
Map<String, List<Message>> get conversationsForTest => _conversations;
@visibleForTesting
Set<String> get loadedConversationKeysForTest => _loadedConversationKeys;
void _resetSyncProgressState() {
_pendingInitialChannelSync = false;
_pendingInitialContactsSync = false;
@ -3470,10 +3650,14 @@ class MeshCoreConnector extends ChangeNotifier {
// Update any in-flight retries so they use the new path override
_retryService?.updatePendingContact(_contacts[index]);
// If setting a specific path (not flood, not auto), also sync with device
// If setting a specific path (not flood, not auto), also sync with device.
// pathLen from the override dialog is a BYTE count; convert to a true hop
// count at the contact's width so the wire path_len packs correctly (#279).
if (pathLen != null && pathLen >= 0 && pathBytes != null) {
final w = contact.pathHashWidth < 1 ? 1 : contact.pathHashWidth;
final hops = w > 0 ? pathBytes.length ~/ w : pathBytes.length;
appLogger.info('Sending path to device...', tag: 'Connector');
await setContactPath(contact, pathBytes, pathLen);
await setContactPath(contact, pathBytes, hops, hashWidth: w);
appLogger.info('Path sent to device', tag: 'Connector');
}
@ -3505,6 +3689,7 @@ class MeshCoreConnector extends ChangeNotifier {
contact,
Uint8List.fromList(resolved.pathBytes),
resolved.hopCount,
hashWidth: resolved.hashWidth,
);
}
@ -4655,6 +4840,9 @@ class MeshCoreConnector extends ChangeNotifier {
case respCodeOffbandCaplog:
_handleOffbandCaplogFrame(frame);
break;
case respCodeOffbandDeviceUi:
_handleDeviceUiReply(frame);
break;
case respCodeSelfInfo:
debugPrint('Got SELF_INFO');
_handleSelfInfo(frame);
@ -5017,6 +5205,21 @@ class MeshCoreConnector extends ChangeNotifier {
static bool? parseFemLnaState(Uint8List frame) =>
frame.length >= 84 ? frame[83] != femLnaBypass : null;
/// Second `offband_caps` byte, at frame offset **84** (firmware #508).
///
/// Deliberately NOT adjacent to byte 1 at offset 82: offset 83 is already the
/// FEM LNA state byte, and every field here is read at a FIXED ABSOLUTE
/// offset, so inserting byte 2 next to byte 1 would shift the FEM state and
/// make shipped clients misread a bitmask as the LNA toggle. Firmware appends
/// byte 2 at the end of the frame for exactly that reason
/// (`OffbandConfigProtocol.h`, firmware PR #515).
///
/// Null on any firmware predating #508, which sends a shorter frame. Null
/// means "no byte-2 capabilities", never an error. Bounds-checked like its
/// siblings, so a truncated or hostile short frame never indexes OOB.
static int? parseOffbandCaps2(Uint8List frame) =>
frame.length >= 85 ? frame[84] : null;
void _handleDeviceInfo(Uint8List frame) {
if (frame.length < 4) return;
if (_shouldGateInitialChannelSync) {
@ -5067,16 +5270,25 @@ class MeshCoreConnector extends ChangeNotifier {
// FEM LNA state rides one byte past the caps byte on v16+ (#304). Primary
// read on connect, a 0xC3 GET is only the fallback.
_femLnaEnabled = parseFemLnaState(frame);
// Second caps byte rides at offset 84, past the FEM state byte (#480).
_offbandCaps2 = parseOffbandCaps2(frame);
// Capability-gated features are invisible when a bit is clear, which looks
// identical to a bug. Log the raw inputs so "the toggle didn't appear" can
// be told apart from "this radio says it can't". (#304)
_appDebugLogService?.info(
'Offband caps=0x${(_offbandCaps ?? 0).toRadixString(16).padLeft(2, '0')} '
'caps2=${_offbandCaps2 == null ? 'absent' : '0x${_offbandCaps2!.toRadixString(16).padLeft(2, '0')}'} '
'verCode=${_firmwareVerCode ?? 0} frameLen=${frame.length} '
'femLnaByte=${_femLnaEnabled == null ? 'absent' : (_femLnaEnabled! ? '1' : '0')} '
'femCapable=$supportsOffbandFemLna blockCapable=$supportsOffbandBlock',
tag: 'Device',
);
// Byte-2 caps just landed. Drop the PREVIOUS radio's device-UI values,
// then pull this one's, so a scope changed by triple-pressing the device is
// re-read and one radio's configuration is never shown as another's.
// (#474/#475)
_clearDeviceUiState();
_reconcileDeviceUi();
// Caps just landed; (re)evaluate GPS polling in case a `gps=1` custom-var
// frame arrived before this device-info reply set support. (#144)
_reconcileGpsPolling();
@ -6143,19 +6355,67 @@ class MeshCoreConnector extends ChangeNotifier {
return 'Channel $channelIndex';
}
/// True when [text] carries a canonical mention of this node: the `@[Name]`
/// Case-fold ASCII `A-Z` only, leaving every other code unit byte-exact.
///
/// Deliberately NOT `String.toLowerCase()`, which applies full Unicode case
/// mapping. Firmware #510 implements the same match rule with a byte-wise
/// fold that does not do Unicode mapping, so a node name carrying any
/// non-ASCII character would get 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 (#475): both sides fold
/// ASCII only, so non-ASCII names compare case-sensitively. (#486)
/// Public so that anything deciding "is this the same name?" uses the SAME
/// equivalence as [mentionsName]. Deduplicating with `String.toLowerCase()`
/// instead silently drops candidates: `'É'` and `'é'` collapse under
/// `toLowerCase()` but stay distinct under this fold, so one of two real
/// contacts would disappear from the mention list while remaining matchable.
static String foldAscii(String s) => _foldAscii(s);
static String _foldAscii(String s) {
final units = s.codeUnits;
final folded = List<int>.filled(units.length, 0);
for (var i = 0; i < units.length; i++) {
final unit = units[i];
folded[i] = (unit >= 0x41 && unit <= 0x5A) ? unit + 0x20 : unit;
}
return String.fromCharCodes(folded);
}
/// True when [text] carries a canonical mention of [selfName]: the `@[Name]`
/// form the composer inserts and the chat renders as a chip (#235).
///
/// Matched case-insensitively, since a hand-typed mention need not match the
/// advert's casing. Bare `@Name` is deliberately NOT matched: it false-
/// positives on ordinary text and cannot be delimited for names containing
/// spaces.
bool _mentionsSelf(String text) {
final name = _selfName?.trim();
if (name == null || name.isEmpty) return false;
return text.toLowerCase().contains('@[${name.toLowerCase()}]');
/// ⚠ CROSS-REPO CONTRACT (client #475, firmware #510). The firmware
/// implements this exact rule against `NodePrefs::node_name` so that both
/// sides agree on what "Self" means for the device notification scope.
/// Changing it in any way (accepting a bare `@name`, restoring Unicode
/// folding, anchoring the match at a word boundary) is a BREAKING cross-repo
/// change that ships only in an aligned client + firmware build pair, never
/// unilaterally.
///
/// ⚠ THE NAME IS COMPARED VERBATIM. Owner ruling 2026-08-01 (#497): the
/// `@[name]` token carries the advert name BYTE-FOR-BYTE, unnormalised. No
/// trimming, no Unicode normalisation. Leading and trailing whitespace are
/// part of a name's identity, and every other hop is already verbatim, so a
/// `.trim()` here makes this node stop recognising mentions of itself. That
/// is what stopped mentions beeping. Do not reintroduce it.
///
/// The properties firmware must match, none obvious from the rule name: an
/// empty name matches nothing (it does not fall through to matching
/// everything); the match is a plain substring `contains`, neither anchored
/// nor word-boundary aware; folding is ASCII-only per [_foldAscii] and is the
/// ONLY normalisation applied to either side.
///
/// Bare `@Name` is deliberately NOT matched: it false-positives on ordinary
/// text and cannot be delimited for names containing spaces.
static bool mentionsName(String text, String? selfName) {
final name = selfName;
// Emptiness is probed on a trimmed copy; the COMPARISON uses the raw name.
if (name == null || name.trim().isEmpty) return false;
return _foldAscii(text).contains('@[${_foldAscii(name)}]');
}
bool _mentionsSelf(String text) => mentionsName(text, _selfName);
void _maybeNotifyChannelMessage(
ChannelMessage message, {
String? channelName,

@ -502,6 +502,32 @@ bool firmwareSupportsOffbandBlock(int? offbandCaps, int? firmwareVerCode) =>
/// >= 17, the version that introduced it.
const int offbandCapCaplog = 0x20;
/// `offband_caps` BYTE 2 bits (device-info frame offset 84, firmware #508).
///
/// Bit assignment CONFIRMED by firmware (FuchsiaCreek, 2026-08-01): bit 0 is
/// the notification scope, bit 1 is the button matrix. This is the reverse of
/// the order the two epics were filed in, so do not infer it from issue
/// numbers.
///
/// `offbandCap2NotifyScope` is set **only where `PIN_BUZZER` is defined**, the
/// same principle as FEM LNA gating on `canControlLoRaFemLna()`. Heltec V4 and
/// RAK4631 have no buzzer and will never advertise it. Gate on the BIT ONLY,
/// never on model or version code: it is a per-unit answer.
///
/// Bit 1 is still unclaimed pending the #509 firmware PR.
const int offbandCap2NotifyScope = 0x01;
const int offbandCap2ButtonMatrix = 0x02;
/// True iff this radio advertises a configurable button-action matrix (#474).
/// False whenever byte 2 is absent, which is every radio predating #508 and is
/// never an error.
bool firmwareSupportsButtonMatrix(int? offbandCaps2) =>
offbandCaps2 != null && (offbandCaps2 & offbandCap2ButtonMatrix) != 0;
/// True iff this radio advertises a settable device notification scope (#475).
bool firmwareSupportsNotifyScope(int? offbandCaps2) =>
offbandCaps2 != null && (offbandCaps2 & offbandCap2NotifyScope) != 0;
bool firmwareSupportsOffbandCaplog(int? offbandCaps, int? firmwareVerCode) =>
offbandCaps != null &&
(offbandCaps & offbandCapCaplog) != 0 &&

@ -0,0 +1,318 @@
import 'dart:typed_data';
/// Headless-device UI configuration: the button-action matrix (#474) and the
/// device notification scope (#475).
///
/// ⚠ THE COMMAND DOES NOT EXIST ON THE WIRE YET. Firmware confirmed
/// (FuchsiaCreek, 2026-08-01) that nothing currently reads or writes either
/// surface: the capability bit is discoverable, but the scope changes only by
/// triple-pressing the device, and the button matrix is unwritten. Firmware
/// asked for a client-specified shape and is building to the spec below, so
/// this file is the proposal, kept in one place so a landed contract is a
/// constant change rather than a rewrite.
///
/// Until firmware lands it, [MeshCoreConnector.supportsDeviceUiCommand] is
/// false and no frame is ever emitted. The client can see that a radio supports
/// the feature without being able to query it, which is exactly what the UI
/// reports.
///
/// ONE command byte covers both epics, sub-code selects the surface, so #510
/// does not need a second allocation. 0xC5 is the next free code after 0xC0
/// config, 0xC1 GPS, 0xC2 block, 0xC3 FEM LNA, 0xC4 caplog.
///
/// Deliberately NOT folded into `CMD_OFFBAND_CONFIG` 0xC0: that command is
/// observer-only (its backend compiles only under `OFFBAND_OBSERVER` and the
/// client gates it on `WIFI_OBSERVER_SUPPORT`), so extending it would make the
/// feature unreachable on headless trackers, the exact boards it exists for.
///
/// Companion-API only, NEVER on the mesh: it changes only this node's own
/// button handling and buzzer, touching no forwarding, relay, or advert path.
///
/// scope GET `[0xC5][0x01]` -> `[0xC5][0x01][scope]`
/// SET `[0xC5][0x02][scope]` -> echo on success
/// matrix GET `[0xC5][0x03]`
/// -> `[0xC5][0x03][supportedActions][n]([sequence][action]) * n`
/// SET `[0xC5][0x04][sequence][action]` -> echo on success
/// error `[0xC5][0x7F][reason]`
/// Whether the `0xC5` get/set command exists in shipped firmware.
///
/// TRUE: the client renders the real controls and speaks the canonical `0xC5`
/// contract published in the firmware registry.
///
/// Owner instruction 2026-08-01: the app is where this gets configured. The
/// client is not the thing holding the feature back, so the controls are live
/// and code to the contract as written.
///
/// Until the firmware handler answers, a read shows its loading row and a write
/// is not confirmed, because state only ever follows the device's reply and is
/// never assumed from the request. Nothing is faked and nothing is written
/// locally that the radio has not acknowledged.
const bool deviceUiCommandLanded = true;
const int cmdOffbandDeviceUi = 0xC5;
const int respCodeOffbandDeviceUi = 0xC5;
const int offbandUiScopeGet = 0x01;
const int offbandUiScopeSet = 0x02;
const int offbandUiMatrixGet = 0x03;
const int offbandUiMatrixSet = 0x04;
/// Shared error sub-code. The reply carries a reason byte rather than the
/// generic `[RESP_CODE_ERR][ERR_CODE_ILLEGAL_ARG]` pair, because both epics
/// require the user to be shown *why* an assignment was refused: "this board
/// has no buzzer" and "unknown action" are different answers and a single
/// illegal-arg code cannot express them.
const int offbandUiErr = 0x7F;
/// Why the device refused a write. Unknown values are preserved and surfaced
/// verbatim rather than collapsed into a generic failure, so firmware can add
/// reasons without the client hiding them.
enum ButtonConfigError {
unsupportedAction(0x01, 'This radio does not support that action'),
unknownSequence(0x02, 'This radio does not recognise that button sequence'),
noBuzzer(0x03, 'This radio has no buzzer'),
noGps(0x04, 'This radio has no GPS'),
malformed(0x05, 'The radio could not read the request');
const ButtonConfigError(this.code, this.message);
final int code;
final String message;
static String describe(int code) {
for (final e in ButtonConfigError.values) {
if (e.code == code) return e.message;
}
// Never swallow an unrecognised reason: show the raw code so a newer
// firmware's error is still actionable rather than invisible.
return 'The radio refused the change (reason 0x'
'${code.toRadixString(16).padLeft(2, '0')})';
}
}
/// A button press sequence. Values are the wire encoding.
///
/// Long-press sequences are deliberately absent: firmware reserves long-press
/// under 8 seconds for CLI rescue and 8 seconds or more for power off, and
/// making either reassignable would let a user lock themselves out of a
/// screenless device with no way back.
enum ButtonSequence {
single(0x00, 'Single press'),
double(0x01, 'Double press'),
triple(0x02, 'Triple press'),
quadruple(0x03, 'Quadruple press');
const ButtonSequence(this.code, this.label);
final int code;
final String label;
static ButtonSequence? fromCode(int code) {
for (final s in ButtonSequence.values) {
if (s.code == code) return s;
}
return null;
}
}
/// An action assignable to a sequence. The device advertises which of these it
/// actually supports; the client never offers one the radio did not claim.
enum ButtonAction {
none(0x00, 'Unassigned'),
sendAdvert(0x01, 'Send advert'),
toggleGps(0x02, 'Toggle GPS'),
cycleNotifyScope(0x03, 'Cycle notification scope'),
batteryBeep(0x04, 'Battery / status beep');
const ButtonAction(this.code, this.label);
final int code;
final String label;
/// Bit position in the device's supported-actions mask.
int get mask => code == 0 ? 0 : 1 << (code - 1);
static ButtonAction? fromCode(int code) {
for (final a in ButtonAction.values) {
if (a.code == code) return a;
}
return null;
}
}
/// Device notification scope (#475). Governs whether the DEVICE buzzer sounds.
///
/// Distinct from the app's per-channel [ChannelNotifyMode], which governs
/// whether the PHONE notifies. Same vocabulary deliberately; they are
/// complementary and must never be merged or made to shadow each other.
enum DeviceNotifyScope {
all(0x00, 'All', 'Beep for any received message'),
self(0x01, 'Self', 'Beep only for direct messages and @[mentions]'),
none(0x02, 'None', 'Never beep');
const DeviceNotifyScope(this.code, this.label, this.description);
final int code;
final String label;
final String description;
static DeviceNotifyScope? fromCode(int code) {
for (final s in DeviceNotifyScope.values) {
if (s.code == code) return s;
}
return null;
}
}
/// The device's current button configuration, as reported by a GET.
class ButtonMatrix {
/// Sequence to action, only for sequences the device reported.
final Map<ButtonSequence, ButtonAction> assignments;
/// Bitmask of actions this specific radio can perform. Derived at runtime by
/// firmware from compiled-in hardware (no buzzer, no GPS), so it is a
/// per-unit answer and must never be inferred from model or version.
final int supportedActions;
const ButtonMatrix({
required this.assignments,
required this.supportedActions,
});
bool supports(ButtonAction action) =>
action == ButtonAction.none || (supportedActions & action.mask) != 0;
/// Actions this radio will accept, always including "Unassigned" so any
/// sequence can be cleared.
List<ButtonAction> get availableActions =>
ButtonAction.values.where(supports).toList();
ButtonMatrix withAssignment(ButtonSequence seq, ButtonAction action) =>
ButtonMatrix(
assignments: {...assignments, seq: action},
supportedActions: supportedActions,
);
}
Uint8List buildButtonMatrixGetFrame() =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiMatrixGet]);
Uint8List buildButtonMatrixSetFrame(
ButtonSequence sequence,
ButtonAction action,
) => Uint8List.fromList([
cmdOffbandDeviceUi,
offbandUiMatrixSet,
sequence.code,
action.code,
]);
Uint8List buildNotifyScopeGetFrame() =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiScopeGet]);
Uint8List buildNotifyScopeSetFrame(DeviceNotifyScope scope) =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiScopeSet, scope.code]);
/// Outcome of a `0xC5` / `0xC6` reply. Exactly one of the payload fields is
/// non-null; [errorMessage] is set when the device refused.
class OffbandUiReply {
final int command;
final int sub;
final ButtonMatrix? matrix;
final ButtonSequence? setSequence;
final ButtonAction? setAction;
final DeviceNotifyScope? scope;
final String? errorMessage;
const OffbandUiReply({
required this.command,
required this.sub,
this.matrix,
this.setSequence,
this.setAction,
this.scope,
this.errorMessage,
});
bool get isError => errorMessage != null;
}
/// Parse a `0xC5` button-matrix reply. Null if this is not one.
///
/// Every length check is explicit: a truncated or hostile frame yields null or
/// an error reply, never an out-of-range index.
OffbandUiReply? parseButtonMatrixReply(Uint8List frame) {
if (frame.length < 2 || frame[0] != respCodeOffbandDeviceUi) return null;
final sub = frame[1];
if (sub == offbandUiErr) {
return OffbandUiReply(
command: respCodeOffbandDeviceUi,
sub: sub,
errorMessage: ButtonConfigError.describe(
frame.length >= 3 ? frame[2] : 0x00,
),
);
}
if (sub == offbandUiMatrixGet) {
if (frame.length < 4) return null;
final supported = frame[2];
final count = frame[3];
final assignments = <ButtonSequence, ButtonAction>{};
for (var i = 0; i < count; i++) {
final base = 4 + (i * 2);
// Stop at the real end of the frame rather than trusting the count byte.
if (base + 1 >= frame.length) break;
final seq = ButtonSequence.fromCode(frame[base]);
final action = ButtonAction.fromCode(frame[base + 1]);
// An unknown sequence or action from newer firmware is skipped rather
// than guessed at; the rows the client does understand still render.
if (seq != null && action != null) assignments[seq] = action;
}
return OffbandUiReply(
command: respCodeOffbandDeviceUi,
sub: sub,
matrix: ButtonMatrix(
assignments: assignments,
supportedActions: supported,
),
);
}
if (sub == offbandUiMatrixSet) {
if (frame.length < 4) return null;
return OffbandUiReply(
command: respCodeOffbandDeviceUi,
sub: sub,
setSequence: ButtonSequence.fromCode(frame[2]),
setAction: ButtonAction.fromCode(frame[3]),
);
}
return null;
}
/// Parse a `0xC6` notification-scope reply. Null if this is not one.
OffbandUiReply? parseNotifyScopeReply(Uint8List frame) {
if (frame.length < 2 || frame[0] != respCodeOffbandDeviceUi) return null;
final sub = frame[1];
// The shared 0x7F error sub-code is owned by parseButtonMatrixReply, which
// the dispatcher tries first, so it is deliberately not handled twice here.
if (sub != offbandUiScopeGet && sub != offbandUiScopeSet) return null;
if (frame.length < 3) return null;
final scope = DeviceNotifyScope.fromCode(frame[2]);
if (scope == null) {
// A scope the client does not know is an error the user can see, not a
// silent fallback to some default that would misreport the device.
return OffbandUiReply(
command: respCodeOffbandDeviceUi,
sub: sub,
errorMessage:
'The radio reported an unknown notification scope '
'(0x${frame[2].toRadixString(16).padLeft(2, '0')})',
);
}
return OffbandUiReply(
command: respCodeOffbandDeviceUi,
sub: sub,
scope: scope,
);
}

@ -724,6 +724,8 @@
"chat_messageCopied": "Message copied",
"chat_messageDeleted": "Message deleted",
"chat_retryingMessage": "Retrying message",
"chat_sendingAgain": "Sending again",
"message_sendAgain": "Send Again",
"chat_retryCount": "Retry {current}/{max}",
"@chat_retryCount": {
"placeholders": {

@ -2668,6 +2668,18 @@ abstract class AppLocalizations {
/// **'Retrying message'**
String get chat_retryingMessage;
/// No description provided for @chat_sendingAgain.
///
/// In en, this message translates to:
/// **'Sending again'**
String get chat_sendingAgain;
/// No description provided for @message_sendAgain.
///
/// In en, this message translates to:
/// **'Send Again'**
String get message_sendAgain;
/// No description provided for @chat_retryCount.
///
/// In en, this message translates to:

@ -1451,6 +1451,12 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get chat_retryingMessage => 'Опитваме се отново.';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Опитай отново $current/$max';

@ -1449,6 +1449,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get chat_retryingMessage => 'Versuche es erneut.';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Versuche $current/$max';

@ -1421,6 +1421,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get chat_retryingMessage => 'Retrying message';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Retry $current/$max';

@ -1448,6 +1448,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get chat_retryingMessage => 'Reintentando…';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Reintentar $current/$max';

@ -1453,6 +1453,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get chat_retryingMessage => 'Tentative de récupération.';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Essai $current/$max';

@ -1456,6 +1456,12 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get chat_retryingMessage => 'Újrapróbálási üzenet';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Újrapróbál $current/$max';

@ -1450,6 +1450,12 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get chat_retryingMessage => 'Riprovo';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Riprova $current/$max';

@ -1387,6 +1387,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get chat_retryingMessage => '再試行メッセージ';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return '$current / $max 回目';

@ -1382,6 +1382,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get chat_retryingMessage => '재시도 메시지';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return '$current/$max 시도';

@ -1438,6 +1438,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get chat_retryingMessage => 'Poging opnieuw.';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Poging opnieuw $current/$max';

@ -1460,6 +1460,12 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get chat_retryingMessage => 'Ponawianie wiadomości';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Próba $current/$max';

@ -1447,6 +1447,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get chat_retryingMessage => 'Tentando novamente';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Tentar $current/$max';

@ -1448,6 +1448,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get chat_retryingMessage => 'Повтор отправки сообщения';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Попытка $current/$max';

@ -1438,6 +1438,12 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get chat_retryingMessage => 'Pokus o obnovenie';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Skúsiť $current/$max';

@ -1436,6 +1436,12 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get chat_retryingMessage => 'Ponovni poskus.';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Ponovit $current/$max';

@ -1430,6 +1430,12 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get chat_retryingMessage => 'Försöker igen';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Försök igen $current/$max';

@ -1442,6 +1442,12 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get chat_retryingMessage => 'Спроба відновлення.';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return 'Повторна спроба $current/$max';

@ -1368,6 +1368,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get chat_retryingMessage => '正在重试消息';
@override
String get chat_sendingAgain => 'Sending again';
@override
String get message_sendAgain => 'Send Again';
@override
String chat_retryCount(int current, int max) {
return '重试 $current/$max';

@ -6,28 +6,52 @@ const int recentAttemptDiversityWindow = 2;
class PathSelection {
final List<int> pathBytes;
/// TRUE hop count (number of hops), or -1 for flood. NOT a byte count.
/// `pathBytes.length == hopCount * hashWidth` for a routed selection.
final int hopCount;
/// Bytes per hop hash (1..3). Defaults to 1 (legacy single-byte). Sent on the
/// wire packed with the hop count via `encodePathLen`. Without this, routed
/// sends went out at width 1 and a 2-byte route was read as twice as many
/// 1-byte hops, routing to the wrong nodes (#279).
final int hashWidth;
final bool useFlood;
const PathSelection({
required this.pathBytes,
required this.hopCount,
this.hashWidth = 1,
required this.useFlood,
});
}
/// Resolves the path to send to [contact], as a (bytes, true-hop-count, width)
/// triple. The contact's own `pathHashWidth` is the single width authority for
/// every non-flood branch, so an override, a device path, and a path-history
/// retry all encode at the width the route was actually captured at.
PathSelection resolvePathSelection(
Contact contact, {
PathSelection? selection,
bool forceFlood = false,
}) {
final width = contact.pathHashWidth < 1 ? 1 : contact.pathHashWidth;
// Hops for a byte array at [width]; integer division tolerates a malformed
// length rather than throwing.
int hopsFor(List<int> bytes) =>
width > 0 ? bytes.length ~/ width : bytes.length;
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
final bytes = contact.pathOverrideBytes ?? Uint8List(0);
return PathSelection(
pathBytes: contact.pathOverrideBytes ?? Uint8List(0),
hopCount: contact.pathOverride!,
pathBytes: bytes,
hopCount: hopsFor(bytes),
hashWidth: width,
useFlood: false,
);
}
@ -36,10 +60,13 @@ PathSelection resolvePathSelection(
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
// Path-history retry: reuse the bytes, but derive hops/width from THIS
// contact so the wire encoding matches the contact's configured width.
if (selection != null && selection.pathBytes.isNotEmpty) {
return PathSelection(
pathBytes: selection.pathBytes,
hopCount: selection.hopCount,
hopCount: hopsFor(selection.pathBytes),
hashWidth: width,
useFlood: false,
);
}
@ -47,6 +74,7 @@ PathSelection resolvePathSelection(
return PathSelection(
pathBytes: contact.path,
hopCount: contact.pathLength,
hashWidth: width,
useFlood: false,
);
}

@ -1297,11 +1297,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final seen = <String>{};
// Recent senders in this channel, keyed to their most recent timestamp.
// Names are carried VERBATIM: the `@[name]` token must byte-match the
// advert name the device stores, whitespace included (#497). Emptiness is
// tested on a trimmed copy, but the raw name is what gets kept.
final recentTime = <String, DateTime>{};
for (final message in connector.getChannelMessages(_currentChannel)) {
if (message.isOutgoing) continue;
final name = message.senderName.trim();
if (name.isEmpty || name == 'Unknown') continue;
final name = message.senderName;
final probe = name.trim();
if (probe.isEmpty || probe == 'Unknown') continue;
final existing = recentTime[name];
if (existing == null || message.timestamp.isAfter(existing)) {
recentTime[name] = message.timestamp;
@ -1311,14 +1315,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
candidates.add(
MentionCandidate(name: name, recent: true, lastSeen: time),
);
seen.add(name.toLowerCase());
// Same equivalence as mentionsName, or a real contact silently
// vanishes from the list while still being matchable. (#497)
seen.add(MeshCoreConnector.foldAscii(name));
});
// Known contacts not already present as a recent sender.
// Known contacts not already present as a recent sender. Same rule: the
// contact's name is kept raw so the inserted token matches the device.
for (final contact in connector.allContacts) {
final name = contact.name.trim();
if (name.isEmpty) continue;
if (!seen.add(name.toLowerCase())) continue;
final name = contact.name;
if (name.trim().isEmpty) continue;
if (!seen.add(MeshCoreConnector.foldAscii(name))) continue;
candidates.add(MentionCandidate(name: name, recent: false));
}
return candidates;
@ -1606,6 +1613,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
void _retryChannelMessage(ChannelMessage message) {
context.read<MeshCoreConnector>().sendChannelMessage(
_currentChannel,
message.text,
);
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_sendingAgain),
);
}
void _showMessageActions(ChannelMessage message) {
final translationService = context.read<TranslationService>();
final canTranslateMessage =
@ -1648,6 +1666,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_showMessagePathInfo(message);
},
),
// Offer resend on an outgoing channel message until its
// ack/repeat-back arrives (status becomes sent). Channel sends are
// not auto-retried, so this is the only recovery path. (#256)
if (message.isOutgoing &&
message.status != ChannelMessageStatus.sent)
ListTile(
leading: const Icon(Icons.refresh),
title: Text(context.l10n.message_sendAgain),
onTap: () {
Navigator.pop(sheetContext);
_retryChannelMessage(message);
},
),
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(

@ -1586,7 +1586,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (message.isOutgoing && message.status == MessageStatus.failed)
ListTile(
leading: const Icon(Icons.refresh),
title: Text(context.l10n.common_retry),
title: Text(context.l10n.message_sendAgain),
onTap: () {
Navigator.pop(sheetContext);
_retryMessage(message);
@ -1643,7 +1643,7 @@ class _ChatScreenState extends State<ChatScreen> {
connector.sendMessage(_resolveContact(connector), message.text);
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_retryingMessage),
content: Text(context.l10n.chat_sendingAgain),
);
}

@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../connector/meshcore_connector.dart';
import '../../connector/offband_device_ui.dart';
/// Settings for a headless device's physical UI: the button-action matrix
/// (#474) and the device notification scope (#475).
///
/// The whole pane is capability-gated. A radio that does not advertise the bit
/// gets no screen and never sees the command, so stock and older firmware
/// degrade silently with nothing shown and no error.
///
/// English-only for now, matching the serial-capture pane (#430); localization
/// is a follow-up rather than a blocker on shipping the capability.
class DeviceUiView extends StatefulWidget {
const DeviceUiView({super.key});
@override
State<DeviceUiView> createState() => _DeviceUiViewState();
}
class _DeviceUiViewState extends State<DeviceUiView> {
@override
void initState() {
super.initState();
// Re-read on open as well as on device-info: the user may have changed the
// scope by triple-pressing the device since the last refresh.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final c = context.read<MeshCoreConnector>();
c.requestButtonMatrix();
c.requestNotifyScope();
});
}
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final showButtons = connector.supportsButtonMatrix;
final showScope = connector.supportsNotifyScope;
// A radio advertising neither bit has no button and no buzzer to
// configure, so it gets nothing. The tile that leads here is gated the
// same way, so this is a belt-and-braces guard rather than a path a
// user can reach. (#474 negative test)
if (!showButtons && !showScope) {
return const SizedBox.shrink();
}
// The radio advertises the capability but shipped firmware has no
// get/set command yet, so it can be detected and not queried. Say that
// plainly instead of spinning forever on a read that never returns.
if (!connector.supportsDeviceUiCommand) {
return ListView(
children: [
const _SectionHeader('Supported by this radio'),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Text(
'This radio reports that it supports '
'${showScope && showButtons
? 'notification scope and button actions'
: showScope
? 'the notification scope'
: 'button actions'}.\n\n'
'Reading and changing it from the app needs a firmware '
'update that is still in progress, so there is nothing to '
'set here yet.',
),
),
if (showScope)
const ListTile(
leading: Icon(Icons.touch_app_outlined),
title: Text('Change it on the device'),
subtitle: Text(
'Triple-press the button to cycle All, Self, then None. '
'The default is All.',
),
),
],
);
}
return ListView(
children: [
if (connector.deviceUiError != null)
_ErrorBanner(
message: connector.deviceUiError!,
onDismiss: connector.clearDeviceUiError,
),
if (showScope) ...[
const _SectionHeader('Device notification scope'),
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
'Controls whether this radio\'s buzzer sounds. This is '
'separate from per-channel notifications, which control '
'whether this app notifies you.',
),
),
..._scopeTiles(connector),
const Divider(height: 24),
],
if (showButtons) ...[
const _SectionHeader('Button actions'),
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
'Assign what each button press does. Long press is reserved '
'by the firmware for CLI rescue and power off, so it cannot '
'be reassigned.',
),
),
..._buttonTiles(connector),
],
],
);
},
);
}
List<Widget> _scopeTiles(MeshCoreConnector connector) {
final current = connector.deviceNotifyScope;
if (current == null) {
return [
const ListTile(
leading: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
title: Text('Reading scope from the radio'),
),
];
}
// Plain ListTiles rather than RadioListTile: the Radio group API is
// deprecated in this Flutter version and the replacement needs a
// RadioGroup ancestor, which buys nothing for three mutually exclusive
// rows.
return DeviceNotifyScope.values
.map(
(scope) => ListTile(
leading: Icon(
scope == current
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: scope == current
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(scope.label),
subtitle: Text(scope.description),
selected: scope == current,
onTap: () => connector.setNotifyScope(scope),
),
)
.toList();
}
List<Widget> _buttonTiles(MeshCoreConnector connector) {
final matrix = connector.buttonMatrix;
if (matrix == null) {
return [
const ListTile(
leading: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
title: Text('Reading button configuration from the radio'),
),
];
}
// Only actions the radio said it can perform are offered, so a board with
// no buzzer or no GPS never shows a choice it would reject. (#474)
final actions = matrix.availableActions;
return ButtonSequence.values.map((seq) {
final assigned = matrix.assignments[seq] ?? ButtonAction.none;
return ListTile(
title: Text(seq.label),
subtitle: Text(assigned.label),
trailing: DropdownButton<ButtonAction>(
value: actions.contains(assigned) ? assigned : ButtonAction.none,
onChanged: (value) {
if (value != null) connector.setButtonAction(seq, value);
},
items: actions
.map(
(a) => DropdownMenuItem<ButtonAction>(
value: a,
child: Text(a.label),
),
)
.toList(),
),
);
}).toList();
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.title);
final String title;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
);
}
/// Persistent error banner. Stays until dismissed rather than flashing, per the
/// error-visibility rule: an error the user cannot finish reading is not a
/// surfaced error.
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.message, required this.onDismiss});
final String message;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error_outline, color: scheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: TextStyle(color: scheme.onErrorContainer),
),
),
IconButton(
icon: Icon(Icons.close, color: scheme.onErrorContainer),
onPressed: onDismiss,
),
],
),
);
}
}

@ -21,6 +21,7 @@ import 'settings/app_settings_view.dart';
import 'settings/message_settings_view.dart';
import 'settings/observer_settings_view.dart';
import 'settings/blocked_view.dart';
import 'settings/device_ui_view.dart';
import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart';
import 'serial_capture_screen.dart';
@ -257,6 +258,40 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
],
// Button and buzzer: device UI config (#474/#475). Owner-placed
// under Node Settings, directly above the public key.
//
// Gated STRICTLY on the capability bits. A radio without the
// hardware has no button to configure, so it gets no tile, no
// screen and no command. This is the negative test in #474:
// "A device that does not advertise the bit shows no screen and
// the client emits no command."
if (connector.supportsButtonMatrix ||
connector.supportsNotifyScope) ...[
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.radio_button_checked_outlined),
title: const Text('Button and buzzer'),
subtitle: const Text(
'Assign button actions and set when this radio beeps',
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(
title: const Text('Button and buzzer'),
centerTitle: true,
),
body: const DeviceUiView(),
),
),
);
},
),
],
if (connector.selfPublicKey != null) ...[
const Divider(height: 1),
Padding(
@ -409,11 +444,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Capability-gated controls vanish silently when a bit is clear,
// which is indistinguishable from a bug. Surface the raw inputs so
// "missing feature" can be diagnosed without enabling logging. (#304)
// caps2 is shown as "absent" rather than omitted: on a radio running
// byte-2 firmware the whole point is telling "the radio sent it" and
// "the radio is too old to send it" apart at a glance, without
// opening the debug log. All-bits-zero is a valid present value.
// (#480)
if (connector.offbandCaps != null)
_buildInfoRow(
l10n.settings_infoOffbandCaps,
'0x${connector.offbandCaps!.toRadixString(16).padLeft(2, '0')}'
' (v${connector.firmwareVerCode ?? 0})'
' · caps2 '
'${connector.offbandCaps2 == null ? 'absent' : '0x${connector.offbandCaps2!.toRadixString(16).padLeft(2, '0')}'}'
'${connector.supportsOffbandFemLna ? ' · FEM LNA' : ''}'
'${connector.supportsOffbandBlock ? ' · block' : ''}',
),

@ -2,11 +2,15 @@ import 'package:flutter/foundation.dart';
import '../storage/block_store.dart';
/// App-global block list, backed by [BlockStore] and exposed to the UI.
/// Per-radio block list, backed by [BlockStore] and exposed to the UI.
///
/// Source of truth for the client: DMs + adverts are filtered by public key,
/// channel posts by resolved key or claimed name. Always active regardless of
/// firmware; the firmware offload (Epic B) mirrors this, never replaces it.
///
/// The set is scoped to the connected radio and swapped by [loadForDevice] on
/// connect; a block set on one radio never leaks to another, and clearing a
/// radio stays cleared (#471). There is no global list.
/// See `docs/architecture/block-contract-as-built.md`.
class BlockService extends ChangeNotifier {
BlockService({BlockStore? store}) : _store = store ?? BlockStore();
@ -22,6 +26,19 @@ class BlockService extends ChangeNotifier {
/// (the pull side), so a synced key never echoes back to the radio.
void Function(String keyHex, bool blocked)? firmwareSync;
/// Serializes all state-mutating operations so they can't interleave. The
/// connector fires [loadForDevice] (on connect) and [importKeys] (from the
/// block LIST dump) without awaiting, and they land in different frame
/// handlers; without this chain, loadForDevice's clear+reload could wipe keys
/// importKeys just added, or vice-versa (#471). Runs each op strictly after
/// the previous one settles; failures don't stall the chain.
Future<void> _opChain = Future<void>.value();
Future<T> _serialize<T>(Future<T> Function() op) {
final result = _opChain.then((_) => op());
_opChain = result.then((_) {}, onError: (_) {});
return result;
}
String? _selfKeyHex;
/// The connected node's own public key, injected by the connector once it is
@ -34,27 +51,29 @@ class BlockService extends ChangeNotifier {
return self != null && publicKeyHex.toLowerCase() == self;
}
/// Called by the connector when the self key is learned (or cleared). If the
/// self key is already stored, blocked before this guard existed, or pulled
/// in from the radio's list, drop it here and tell the radio to remove it.
Future<void> setSelfKey(String? publicKeyHex) async {
final key = publicKeyHex?.toLowerCase();
/// Call once during app startup. Drops the legacy unscoped global list
/// (#471, drop-and-start-fresh) and starts empty; the real per-radio list
/// loads via [loadForDevice] when a radio connects.
Future<void> load() => _serialize(() async {
await _store.dropLegacyGlobal();
_blockedKeys.clear();
_blockedNames.clear();
_selfKeyHex = null;
notifyListeners();
});
/// (Re)load the block list for the connected radio, keyed by [deviceKeyHex]
/// (null on disconnect). Called by the connector when the device key is
/// learned or changes. Swaps the in-memory set so a block set on one radio
/// never leaks to another, and runs the #250 self-heal for this radio's own
/// key. Serialized against [importKeys] so a concurrent block LIST dump can
/// neither be wiped by the reload nor wipe it (#471).
Future<void> loadForDevice(String? deviceKeyHex) => _serialize(() async {
final key = deviceKeyHex?.toLowerCase();
final normalized = (key == null || key.isEmpty) ? null : key;
final changed = normalized != _selfKeyHex;
_store.setPublicKeyHex = normalized ?? '';
_selfKeyHex = normalized;
// Heal even when the key is unchanged, a self-block can appear after the
// key is already known (a union pull racing self-info, or a stale store),
// and an unchanged-key early return would strand it.
final healed = normalized != null && _blockedKeys.remove(normalized);
if (healed) firmwareSync?.call(normalized, false);
if (changed || healed) notifyListeners();
// Everything above is synchronous, so a caller that doesn't await this
// still observes consistent state immediately; only the write is deferred.
if (healed) await _store.saveKeys(_blockedKeys);
}
/// Load persisted state. Call once during app startup.
Future<void> load() async {
await _store.dropLegacyGlobal();
_blockedKeys
..clear()
..addAll(await _store.loadKeys());
@ -62,8 +81,14 @@ class BlockService extends ChangeNotifier {
..clear()
..addAll(await _store.loadNames());
await _pruneExpiredNames();
// Self-heal: never keep the connected radio's own key in its own list
// (blocking yourself silently hides your own traffic, #250).
if (normalized != null && _blockedKeys.remove(normalized)) {
await _store.saveKeys(_blockedKeys);
firmwareSync?.call(normalized, false);
}
notifyListeners();
}
});
Set<String> get blockedKeys => Set.unmodifiable(_blockedKeys);
Map<String, int> get blockedNames => Map.unmodifiable(_blockedNames);
@ -74,27 +99,28 @@ class BlockService extends ChangeNotifier {
bool isNameBlocked(String name) =>
_blockedNames.containsKey(name.trim().toLowerCase());
Future<void> block(String publicKeyHex) async {
Future<void> block(String publicKeyHex) => _serialize(() async {
final key = publicKeyHex.toLowerCase();
if (isSelf(key)) return;
if (!_blockedKeys.add(key)) return;
await _store.saveKeys(_blockedKeys);
firmwareSync?.call(key, true);
notifyListeners();
}
});
Future<void> unblock(String publicKeyHex) async {
Future<void> unblock(String publicKeyHex) => _serialize(() async {
final key = publicKeyHex.toLowerCase();
if (!_blockedKeys.remove(key)) return;
await _store.saveKeys(_blockedKeys);
firmwareSync?.call(key, false);
notifyListeners();
}
});
/// Merge keys learned from the firmware block list into the local set WITHOUT
/// echoing them back to the radio (used by the connect-time union pull).
/// Never removes, the union only adds.
Future<void> importKeys(Iterable<String> keysHex) async {
/// Never removes, the union only adds. Serialized against [loadForDevice] so
/// a reload can't wipe these imports and vice-versa (#471).
Future<void> importKeys(Iterable<String> keysHex) => _serialize(() async {
var changed = false;
for (final k in keysHex) {
final key = k.toLowerCase();
@ -106,21 +132,21 @@ class BlockService extends ChangeNotifier {
if (!changed) return;
await _store.saveKeys(_blockedKeys);
notifyListeners();
}
});
Future<void> blockName(String name) async {
Future<void> blockName(String name) => _serialize(() async {
final n = name.trim().toLowerCase();
if (n.isEmpty || _blockedNames.containsKey(n)) return;
_blockedNames[n] = DateTime.now().millisecondsSinceEpoch;
await _store.saveNames(_blockedNames);
notifyListeners();
}
});
Future<void> unblockName(String name) async {
Future<void> unblockName(String name) => _serialize(() async {
if (_blockedNames.remove(name.trim().toLowerCase()) == null) return;
await _store.saveNames(_blockedNames);
notifyListeners();
}
});
/// Name-only blocks older than this are pruned on load (self-cleaning).
static const Duration _nameBlockTtl = Duration(days: 30);
@ -128,24 +154,25 @@ class BlockService extends ChangeNotifier {
/// Promote a name-only block to a durable pubkey block once we learn the
/// identity behind it (observed via an advert or a DM). No-op if the name
/// isn't name-blocked.
Future<void> maybePromote(String name, String publicKeyHex) async {
final n = name.trim().toLowerCase();
if (n.isEmpty || !_blockedNames.containsKey(n)) return;
final key = publicKeyHex.toLowerCase();
_blockedNames.remove(n);
// Your own name resolving to your own key must not promote into a
// self-block, drop the name block and stop (#250).
if (isSelf(key)) {
await _store.saveNames(_blockedNames);
notifyListeners();
return;
}
final added = _blockedKeys.add(key);
await _store.saveNames(_blockedNames);
await _store.saveKeys(_blockedKeys);
if (added) firmwareSync?.call(key, true);
notifyListeners();
}
Future<void> maybePromote(String name, String publicKeyHex) =>
_serialize(() async {
final n = name.trim().toLowerCase();
if (n.isEmpty || !_blockedNames.containsKey(n)) return;
final key = publicKeyHex.toLowerCase();
_blockedNames.remove(n);
// Your own name resolving to your own key must not promote into a
// self-block, drop the name block and stop (#250).
if (isSelf(key)) {
await _store.saveNames(_blockedNames);
notifyListeners();
return;
}
final added = _blockedKeys.add(key);
await _store.saveNames(_blockedNames);
await _store.saveKeys(_blockedKeys);
if (added) firmwareSync?.call(key, true);
notifyListeners();
});
/// Drop name-only blocks that never linked to a pubkey within [_nameBlockTtl].
Future<void> _pruneExpiredNames() async {

@ -35,6 +35,78 @@ class TranslationDownloadCancelled implements Exception {
String toString() => 'Download canceled.';
}
/// Max attempts (initial + retries) for a transient model-download failure. #229
const int kTranslationDownloadMaxAttempts = 5;
/// HTTP statuses worth retrying on a model download: server overload / 5xx and
/// 429. Terminal 4xx (e.g. 404) are not retried. #229
bool isRetryableDownloadStatus(int statusCode) =>
statusCode == 429 || (statusCode >= 500 && statusCode <= 599);
/// Exponential backoff for download [attempt] (1-based): 1, 2, 4, 8, 16 s capped
/// at 30 s; a larger server `Retry-After` (seconds) wins, also capped. No jitter
/// (single-client download, no thundering-herd concern). #229
Duration translationDownloadBackoff(int attempt, {int? retryAfterSeconds}) {
final exp = (1 << (attempt - 1)).clamp(1, 30);
final seconds = (retryAfterSeconds != null && retryAfterSeconds > exp)
? retryAfterSeconds.clamp(1, 30)
: exp;
return Duration(seconds: seconds);
}
int? _retryAfterHeaderSeconds(Map<String, String> headers) =>
int.tryParse(headers['retry-after']?.trim() ?? '');
/// Sends the request built by [buildRequest] on [client], retrying transient
/// failures (5xx / 429 responses and network exceptions) with bounded
/// exponential backoff. Terminal responses (2xx, 3xx, non-retryable 4xx) are
/// returned for the caller to validate; a sustained failure throws after
/// [maxAttempts]. Cancellable via [isCancelled]. Deps injected → unit-testable.
/// #229
Future<http.StreamedResponse> sendModelDownloadWithRetry(
http.Client client,
http.Request Function() buildRequest, {
required Future<void> Function(Duration) sleep,
required bool Function() isCancelled,
int maxAttempts = kTranslationDownloadMaxAttempts,
}) async {
var attempt = 0;
while (true) {
attempt++;
if (isCancelled()) throw const TranslationDownloadCancelled();
try {
final response = await client.send(buildRequest());
if (isRetryableDownloadStatus(response.statusCode) &&
attempt < maxAttempts) {
await response.stream.drain<void>();
appLogger.warn(
'Model download HTTP ${response.statusCode}; retry $attempt/$maxAttempts',
);
await sleep(
translationDownloadBackoff(
attempt,
retryAfterSeconds: _retryAfterHeaderSeconds(response.headers),
),
);
continue;
}
return response;
} on TranslationDownloadCancelled {
rethrow;
} on Exception catch (e) {
if ((e is http.ClientException || e is TimeoutException) &&
attempt < maxAttempts) {
appLogger.warn(
'Model download error ($e); retry $attempt/$maxAttempts',
);
await sleep(translationDownloadBackoff(attempt));
continue;
}
rethrow;
}
}
}
class TranslationService extends ChangeNotifier {
final AppSettingsService _appSettingsService;
final TranslationFileStore _fileStore;
@ -205,7 +277,10 @@ class TranslationService extends ChangeNotifier {
int? totalSize;
bool supportsRange = false;
try {
final headResponse = await headClient.send(http.Request('HEAD', uri));
final headResponse = await _sendWithRetry(
headClient,
() => http.Request('HEAD', uri),
);
totalSize = headResponse.contentLength;
supportsRange =
headResponse.headers['accept-ranges']?.contains('bytes') == true;
@ -257,13 +332,38 @@ class TranslationService extends ChangeNotifier {
});
}
Future<http.StreamedResponse> _sendWithRetry(
http.Client client,
http.Request Function() buildRequest,
) => sendModelDownloadWithRetry(
client,
buildRequest,
sleep: _cancellableBackoff,
isCancelled: () => _cancelDownloadRequested,
);
/// Backoff wait that aborts promptly when the user cancels the download.
Future<void> _cancellableBackoff(Duration total) async {
const step = Duration(milliseconds: 250);
var waited = Duration.zero;
while (waited < total && !_cancelDownloadRequested) {
final remaining = total - waited;
final chunk = remaining < step ? remaining : step;
await Future<void>.delayed(chunk);
waited += chunk;
}
}
Future<DownloadedModelFile> _downloadSingle({
required Uri uri,
required String fileName,
}) async {
final client = http.Client();
try {
final response = await client.send(http.Request('GET', uri));
final response = await _sendWithRetry(
client,
() => http.Request('GET', uri),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw StateError('Model download failed: HTTP ${response.statusCode}');
}
@ -338,9 +438,11 @@ class TranslationService extends ChangeNotifier {
required int start,
required int end,
}) async {
final request = http.Request('GET', uri);
request.headers['Range'] = 'bytes=$start-$end';
final response = await client.send(request);
final response = await _sendWithRetry(client, () {
final request = http.Request('GET', uri);
request.headers['Range'] = 'bytes=$start-$end';
return request;
});
if (response.statusCode != 206) {
await response.stream.drain<void>();
throw StateError(

@ -3,29 +3,62 @@ import 'dart:convert';
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
/// Persistence for the app-global block list.
/// Per-radio persistence for the block list.
///
/// Deliberately **global**, unlike the other stores it is NOT scoped by the
/// connected device key. A block is "I don't want to see this person," keyed by
/// their public key, and must hold regardless of which radio is connected.
/// See `docs/architecture/block-contract-as-built.md`.
/// Scoped by the connected device key (first 10 hex chars), like the app's
/// other stores. A block belongs to the radio it was set on: the app is the
/// enforcer and registry even on firmware that can't offload, and clearing a
/// radio must stay cleared. There is intentionally **no** global list; a
/// global list unioned across radios re-infects a cleared radio (#471).
///
/// The pre-#471 build stored one unscoped global list (`block_keys_v1` /
/// `block_names_v1`). That is dropped, not migrated (owner decision, #471):
/// the feature is new and the global list was the source of the stray
/// self-block. See `docs/architecture/block-contract-as-built.md`.
class BlockStore {
static const String _keysKey = 'block_keys_v1';
static const String _namesKey = 'block_names_v1';
static const String _keysPrefix = 'block_keys_v1';
static const String _namesPrefix = 'block_names_v1';
/// First 10 hex chars of the connected device key. Empty when no radio is
/// connected; loads/saves are no-ops in that state.
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get _keysKey => '$_keysPrefix$publicKeyHex';
String get _namesKey => '$_namesPrefix$publicKeyHex';
/// Delete the legacy unscoped global list. Idempotent, cheap after the first
/// run (the guarded reads avoid a prefs rewrite when the keys are absent).
/// Drop-and-start-fresh: the global list is not migrated onto any radio.
Future<void> dropLegacyGlobal() async {
final prefs = PrefsManager.instance;
if (prefs.get(_keysPrefix) != null) {
appLogger.info('Dropping legacy global block key list (#471)');
await prefs.remove(_keysPrefix);
}
if (prefs.get(_namesPrefix) != null) {
appLogger.info('Dropping legacy global block name list (#471)');
await prefs.remove(_namesPrefix);
}
}
/// Blocked public keys (lowercased hex).
/// Blocked public keys (lowercased hex) for the current radio.
Future<Set<String>> loadKeys() async {
if (publicKeyHex.isEmpty) return {};
final list = PrefsManager.instance.getStringList(_keysKey) ?? const [];
return list.map((k) => k.toLowerCase()).toSet();
}
Future<void> saveKeys(Set<String> keys) async {
if (publicKeyHex.isEmpty) return;
await PrefsManager.instance.setStringList(_keysKey, keys.toList());
}
/// Name-only blocks: lowercased claimed name -> first-blocked epoch millis.
/// The timestamp feeds the promote-and-prune age-out (Epic A / A7).
Future<Map<String, int>> loadNames() async {
if (publicKeyHex.isEmpty) return {};
final raw = PrefsManager.instance.getString(_namesKey);
if (raw == null || raw.isEmpty) return {};
try {
@ -38,6 +71,7 @@ class BlockStore {
}
Future<void> saveNames(Map<String, int> names) async {
if (publicKeyHex.isEmpty) return;
await PrefsManager.instance.setString(_namesKey, jsonEncode(names));
}
}

@ -5,6 +5,17 @@ import 'byte_count_input.dart';
/// A candidate name for `@`-mention autocomplete.
class MentionCandidate {
/// The advert name EXACTLY as the device holds it, including any leading or
/// trailing whitespace.
///
/// ⚠ CROSS-REPO CONTRACT (client #497, firmware #510). The `@[name]` token
/// carries the advert name BYTE-FOR-BYTE, unnormalised: no trimming, no
/// Unicode normalisation, no case folding when composing it. Whitespace is
/// part of a name's identity, and every other hop (entry, firmware memcpy,
/// advert encode, advert parse, contact record) is already verbatim. A
/// `.trim()` here changes the identity of the addressee and makes the
/// mention unmatchable on the device, which is exactly the bug that stopped
/// mentions beeping. Do not "tidy" this.
final String name;
/// True when this name is a recent sender in the current channel.
@ -13,11 +24,23 @@ class MentionCandidate {
/// Most-recent time this name was seen (recent candidates only).
final DateTime? lastSeen;
final String? _label;
/// Display form. Defaults to the raw name, NOT a trimmed copy.
///
/// Trimming here would render two contacts whose names differ only by
/// surrounding whitespace as identical rows, giving the user no way to tell
/// which one they are about to address, while the inserted tokens differ.
/// That hides exactly the identity #497 exists to preserve, so the display
/// stays honest to what will be sent.
String get label => _label ?? name;
const MentionCandidate({
required this.name,
String? label,
required this.recent,
this.lastSeen,
});
}) : _label = label;
}
/// One row in the autocomplete dropdown.
@ -248,8 +271,11 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
}
_matches = combined
.map(
// label is display, insert is the wire token. They are deliberately
// different fields: the token must carry the raw name verbatim while
// the list may show a tidied one. (#497)
(c) => _Entry(
label: c.name,
label: c.label,
icon: c.recent ? Icons.history : Icons.person_outline,
insert: '@[${c.name}] ',
),

@ -0,0 +1,3 @@
Offband Meshcore 1.4.0
A follow-up to the 1.3.0 launch. New button and buzzer settings for headless devices, a one-tap Send Again on channel messages, and more capability detail in Device Info. Fixes: the block list is scoped per radio again, per-radio caches are cleared on reconnect so nothing lingers from another radio, translation-model downloads retry on transient errors, and routed replies use the correct path width.

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.3.0+64
version: 1.4.0+65
environment:
sdk: ^3.9.2

@ -0,0 +1,34 @@
## Offband Meshcore 1.4.0
A focused follow-up to the 1.3.0 production launch: new settings for headless
devices, a resend action on channel messages, and a set of per-radio
data-correctness fixes.
### New
- **Button and buzzer settings for headless devices.** On radios that report
support, you can now configure the button and buzzer right from settings,
capability-gated so the controls only show where they apply (#483).
- **"Send Again" on channel messages.** Resend a channel message in one tap, with
the direct-message resend action relabeled to match (#513).
- **More capability detail in Device Info.** The second offband capabilities byte is
now shown, so more of your radio's capability bits are visible at a glance (#480).
- **Case-insensitive self-mentions.** An `@[name]` mention of yourself now matches
regardless of case (#486).
### Fixed
- **Block list is per-radio again.** An accidental global block list was dropped, so
blocking someone on one radio no longer carries over to another (#505, #506).
- **Clean slate on reconnect.** Per-radio in-memory caches are cleared on reconnect,
so data from a previously connected radio can't linger into the next session
(#472).
- **Sturdier translation-model download.** Transient server errors now retry with
backoff instead of failing the download outright (#425).
- **Mention and routing correctness.** `@[name]` mentions are handled as a verbatim
wire token, and routed replies are sent at the contact's path-hash width instead
of width 1 (#497, #495).
Offband is free and open source and always will be. If you would like to help keep
it going, there is a donation page at https://offband.org/donate. Thank you for being
part of it.

@ -0,0 +1,68 @@
// #472: switching radios must not show the previous radio's history.
//
// The per-radio in-memory caches (_channelMessages, _conversations,
// _loadedConversationKeys) are keyed by channel index / contact key, not by
// radio. _resetConnectionHandshakeState() runs at the start of every
// connection, so it must clear them; otherwise a new radio whose store is empty
// for a channel cannot overwrite the stale entry and keeps rendering the prior
// radio's history (including its outgoing messages). On-disk stores are already
// per-radio (device+PSK), so this is purely the runtime cache.
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/models/channel_message.dart';
import 'package:meshcore_open/storage/prefs_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
PrefsManager.reset();
await PrefsManager.initialize();
});
test(
'connection reset clears the previous radio in-memory caches (#472)',
() {
final connector = MeshCoreConnector();
// Seed the caches as if a prior radio's history had loaded.
connector.channelMessagesForTest[0] = [
ChannelMessage(
senderName: 'PrevRadio',
text: 'history from the other radio',
timestamp: DateTime.fromMillisecondsSinceEpoch(1000),
isOutgoing: true,
status: ChannelMessageStatus.sent,
),
];
connector.conversationsForTest['deadbeef00'] = [];
connector.loadedConversationKeysForTest.add('deadbeef00');
expect(connector.channelMessagesForTest, isNotEmpty);
expect(connector.conversationsForTest, isNotEmpty);
expect(connector.loadedConversationKeysForTest, isNotEmpty);
// A new connection begins.
connector.resetConnectionHandshakeStateForTest();
expect(
connector.channelMessagesForTest,
isEmpty,
reason: 'channel history cache must be dropped on reconnect',
);
expect(
connector.conversationsForTest,
isEmpty,
reason: 'DM conversation cache must be dropped on reconnect',
);
expect(
connector.loadedConversationKeysForTest,
isEmpty,
reason: 'loaded-conversation markers must be dropped so DMs reload',
);
},
);
}

@ -0,0 +1,123 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
/// Cross-repo contract tests for the `@[name]` self-mention rule (client #486,
/// firmware #510). Firmware implements the identical rule, so any change that
/// breaks one of these breaks agreement with the device and must ship in an
/// aligned build pair.
void main() {
group('@[name] matching', () {
test('matches the canonical bracketed form', () {
expect(
MeshCoreConnector.mentionsName('hey @[Ben] you there', 'Ben'),
isTrue,
);
});
test('bare @name does not match', () {
expect(
MeshCoreConnector.mentionsName('hey @Ben you there', 'Ben'),
isFalse,
);
});
test('matches as a plain substring, not anchored or word-bounded', () {
// Deliberate: the rule is `contains`, and firmware must agree.
expect(MeshCoreConnector.mentionsName('x@[Ben]y', 'Ben'), isTrue);
});
test('the name is compared VERBATIM, whitespace included (#497)', () {
// Owner ruling 2026-08-01: @[name] is a wire token carrying the advert
// name byte-for-byte. A name with surrounding spaces is a DIFFERENT
// name, and trimming it here is what stopped mentions beeping.
expect(MeshCoreConnector.mentionsName('yo @[Ben]', ' Ben '), isFalse);
expect(
MeshCoreConnector.mentionsName('yo @[ Ben ]', ' Ben '),
isTrue,
);
expect(MeshCoreConnector.mentionsName('yo @[Ben ]', 'Ben '), isTrue);
expect(MeshCoreConnector.mentionsName('yo @[Ben]', 'Ben '), isFalse);
});
test('empty or whitespace-only self-name matches nothing', () {
expect(MeshCoreConnector.mentionsName('yo @[Ben]', ''), isFalse);
expect(MeshCoreConnector.mentionsName('yo @[Ben]', ' '), isFalse);
expect(MeshCoreConnector.mentionsName('yo @[Ben]', null), isFalse);
});
test('a name that is not mentioned does not match', () {
expect(MeshCoreConnector.mentionsName('yo @[Alice]', 'Ben'), isFalse);
expect(
MeshCoreConnector.mentionsName('no mentions here', 'Ben'),
isFalse,
);
});
});
group('ASCII-only case folding (owner decision 2026-07-31, #486)', () {
test('ASCII names match case-insensitively in both directions', () {
expect(MeshCoreConnector.mentionsName('yo @[BEN]', 'ben'), isTrue);
expect(MeshCoreConnector.mentionsName('yo @[ben]', 'BEN'), isTrue);
expect(MeshCoreConnector.mentionsName('yo @[BeN]', 'bEn'), isTrue);
});
test('non-ASCII names compare case-sensitively', () {
// The deliberate divergence from String.toLowerCase(): firmware folds
// bytes and cannot do Unicode case mapping, so the client must not
// either, or the two sides disagree on the same message.
expect(MeshCoreConnector.mentionsName('yo @[Érik]', 'Érik'), isTrue);
expect(MeshCoreConnector.mentionsName('yo @[érik]', 'Érik'), isFalse);
expect(MeshCoreConnector.mentionsName('yo @[ÉRIK]', 'érik'), isFalse);
});
test('ASCII folding still applies around non-ASCII characters', () {
// "Ben-Érik": the ASCII half folds, the accented character does not.
expect(
MeshCoreConnector.mentionsName('yo @[BEN-Érik]', 'ben-Érik'),
isTrue,
);
expect(
MeshCoreConnector.mentionsName('yo @[BEN-érik]', 'ben-Érik'),
isFalse,
);
});
test('non-letter ASCII is untouched by the fold', () {
expect(MeshCoreConnector.mentionsName('yo @[Node_7]', 'node_7'), isTrue);
expect(MeshCoreConnector.mentionsName('yo @[N0DE-7]', 'n0de-7'), isTrue);
});
test(
'names containing spaces work, which bare @name could not delimit',
() {
expect(
MeshCoreConnector.mentionsName('yo @[Base Station]', 'base station'),
isTrue,
);
},
);
});
group('foldAscii is the single equivalence rule (Gemini review, #497)', () {
test('ASCII case collapses, non-ASCII case does not', () {
// Dedup used String.toLowerCase(), which collapses these; matching uses
// foldAscii, which keeps them distinct. One of two real contacts would
// have silently vanished from the mention list while staying matchable.
expect('É'.toLowerCase() == 'é'.toLowerCase(), isTrue);
expect(
MeshCoreConnector.foldAscii('É') == MeshCoreConnector.foldAscii('é'),
isFalse,
);
expect(
MeshCoreConnector.foldAscii('Ben') ==
MeshCoreConnector.foldAscii('ben'),
isTrue,
);
});
test('folding leaves non-ASCII bytes untouched', () {
expect(MeshCoreConnector.foldAscii('Érik'), equals('Érik'));
expect(MeshCoreConnector.foldAscii('BEN'), equals('ben'));
});
});
}

@ -0,0 +1,104 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
void main() {
/// Device-info frame with the three additive tail bytes at their fixed
/// absolute offsets: caps byte 1 at 82, FEM LNA state at 83, caps byte 2 at
/// 84 (firmware #508 / PR #515).
Uint8List deviceInfo({
required int length,
int caps1 = 0,
int femByte = 0,
int caps2 = 0,
}) {
final frame = Uint8List(length);
if (length >= 1) frame[0] = respCodeDeviceInfo;
if (length >= 83) frame[82] = caps1;
if (length >= 84) frame[83] = femByte;
if (length >= 85) frame[84] = caps2;
return frame;
}
group('offband_caps byte 2, device-info offset 84 (#480)', () {
test('reads byte 84 when the frame is long enough', () {
expect(
MeshCoreConnector.parseOffbandCaps2(
deviceInfo(length: 85, caps2: 0x01),
),
equals(0x01),
);
expect(
MeshCoreConnector.parseOffbandCaps2(
deviceInfo(length: 85, caps2: 0xFF),
),
equals(0xFF),
);
});
test('a present byte 2 of zero is still present, not absent', () {
// "No byte-2 capabilities set" and "firmware predates byte 2" are
// different states; only the second is null.
expect(
MeshCoreConnector.parseOffbandCaps2(deviceInfo(length: 85, caps2: 0)),
equals(0),
);
});
test('null on firmware predating #508 (frame stops at 84)', () {
expect(
MeshCoreConnector.parseOffbandCaps2(deviceInfo(length: 84)),
isNull,
);
});
test('null on a short or truncated frame, never an OOB index', () {
for (final length in [0, 1, 4, 81, 82, 83, 84]) {
expect(
MeshCoreConnector.parseOffbandCaps2(deviceInfo(length: length)),
isNull,
reason: 'frame length $length must yield null, not throw',
);
}
});
});
group('byte 2 does not disturb the fields before it', () {
// This is the regression the offset-84 choice exists to prevent: byte 2 is
// appended at the END of the frame, not adjacent to byte 1, because the FEM
// state byte already occupies 83 and every field is read at a fixed
// absolute offset.
final frame = deviceInfo(
length: 85,
caps1: offbandCapFemLna | offbandCapBlock,
femByte: 1,
caps2: 0x03,
);
test('caps byte 1 still reads at offset 82', () {
expect(
MeshCoreConnector.parseOffbandCaps(frame),
equals(offbandCapFemLna | offbandCapBlock),
);
});
test('FEM LNA state still reads at offset 83', () {
expect(MeshCoreConnector.parseFemLnaState(frame), isTrue);
});
test('byte-1 gate predicates are unaffected by byte 2', () {
final caps1 = MeshCoreConnector.parseOffbandCaps(frame);
expect(firmwareSupportsOffbandFemLna(caps1), isTrue);
expect(firmwareSupportsOffbandBlock(caps1, 15), isTrue);
});
test('all three tail fields are independent', () {
final onlyCaps2 = deviceInfo(length: 85, caps2: 0x02);
expect(MeshCoreConnector.parseOffbandCaps(onlyCaps2), equals(0));
expect(MeshCoreConnector.parseFemLnaState(onlyCaps2), isFalse);
expect(MeshCoreConnector.parseOffbandCaps2(onlyCaps2), equals(0x02));
});
});
}

@ -0,0 +1,213 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/connector/offband_device_ui.dart';
void main() {
group('capability gating on caps byte 2 (#474/#475)', () {
test('requires the explicit bit', () {
expect(firmwareSupportsButtonMatrix(offbandCap2ButtonMatrix), isTrue);
expect(firmwareSupportsNotifyScope(offbandCap2NotifyScope), isTrue);
});
test('false when the other feature bit is set but not this one', () {
expect(firmwareSupportsButtonMatrix(offbandCap2NotifyScope), isFalse);
expect(firmwareSupportsNotifyScope(offbandCap2ButtonMatrix), isFalse);
});
test('absent byte 2 is unsupported, not an error', () {
expect(firmwareSupportsButtonMatrix(null), isFalse);
expect(firmwareSupportsNotifyScope(null), isFalse);
expect(firmwareSupportsButtonMatrix(0x00), isFalse);
});
test('the two bits do not collide', () {
expect(offbandCap2ButtonMatrix & offbandCap2NotifyScope, equals(0));
});
});
group('request frames', () {
test('command bytes do not collide with the shipped fork commands', () {
final used = {
cmdOffbandGps,
cmdOffbandBlock,
cmdOffbandFemLna,
cmdOffbandCaplog,
};
expect(used.contains(cmdOffbandDeviceUi), isFalse);
});
test('matrix get and set encode as documented', () {
expect(buildButtonMatrixGetFrame(), equals([0xC5, 0x03]));
expect(
buildButtonMatrixSetFrame(
ButtonSequence.double,
ButtonAction.sendAdvert,
),
equals([0xC5, 0x04, 0x01, 0x01]),
);
});
test('scope get and set encode as documented', () {
expect(buildNotifyScopeGetFrame(), equals([0xC5, 0x01]));
expect(
buildNotifyScopeSetFrame(DeviceNotifyScope.self),
equals([0xC5, 0x02, 0x01]),
);
});
});
group('button matrix GET parse', () {
test('reads the supported mask and the assignment rows', () {
// supported = advert | gps, two rows: single->none, double->advert
final mask = ButtonAction.sendAdvert.mask | ButtonAction.toggleGps.mask;
final reply = parseButtonMatrixReply(
Uint8List.fromList([0xC5, 0x03, mask, 2, 0x00, 0x00, 0x01, 0x01]),
);
expect(reply, isNotNull);
expect(reply!.isError, isFalse);
final m = reply.matrix!;
expect(m.assignments[ButtonSequence.single], ButtonAction.none);
expect(m.assignments[ButtonSequence.double], ButtonAction.sendAdvert);
expect(m.supports(ButtonAction.sendAdvert), isTrue);
expect(m.supports(ButtonAction.toggleGps), isTrue);
expect(m.supports(ButtonAction.batteryBeep), isFalse);
});
test('unassigned is always offered so a sequence can be cleared', () {
final m = ButtonMatrix(assignments: const {}, supportedActions: 0);
expect(m.supports(ButtonAction.none), isTrue);
expect(m.availableActions, contains(ButtonAction.none));
});
test('a lying count byte cannot read past the frame', () {
// count says 5 rows, only one is present.
final reply = parseButtonMatrixReply(
Uint8List.fromList([0xC5, 0x03, 0xFF, 5, 0x00, 0x01]),
);
expect(reply, isNotNull);
expect(reply!.matrix!.assignments.length, equals(1));
});
test('unknown sequence or action codes are skipped, not guessed', () {
final reply = parseButtonMatrixReply(
Uint8List.fromList([0xC5, 0x03, 0xFF, 2, 0x7E, 0x01, 0x01, 0x7E]),
);
expect(reply!.matrix!.assignments, isEmpty);
});
test('truncated GET yields null rather than throwing', () {
for (final f in [
<int>[0xC5],
<int>[0xC5, 0x03],
<int>[0xC5, 0x03, 0x00],
]) {
expect(parseButtonMatrixReply(Uint8List.fromList(f)), isNull);
}
});
test('a foreign command byte is not claimed', () {
expect(
parseButtonMatrixReply(Uint8List.fromList([0xC3, 0x01, 0x00])),
isNull,
);
expect(
parseNotifyScopeReply(Uint8List.fromList([0xC3, 0x01, 0x00])),
isNull,
);
});
});
group('error replies carry the device reason', () {
test('known reason codes map to their message', () {
final reply = parseButtonMatrixReply(
Uint8List.fromList([0xC5, 0x7F, 0x03]),
);
expect(reply!.isError, isTrue);
// Firmware-owned reason codes (FuchsiaCreek, 2026-08-01): 1 unsupported
// action, 2 unknown sequence, 3 no buzzer, 4 no GPS, 5 malformed.
expect(reply.errorMessage, ButtonConfigError.noBuzzer.message);
expect(ButtonConfigError.noBuzzer.code, equals(0x03));
expect(ButtonConfigError.unknownSequence.code, equals(0x02));
expect(ButtonConfigError.noGps.code, equals(0x04));
expect(ButtonConfigError.malformed.code, equals(0x05));
});
test(
'an unknown reason is surfaced with its raw code, never swallowed',
() {
final reply = parseButtonMatrixReply(
Uint8List.fromList([0xC5, 0x7F, 0x5A]),
);
expect(reply!.isError, isTrue);
expect(reply.errorMessage, contains('0x5a'));
},
);
test('the shared error sub-code is owned by one parser, not both', () {
// Both surfaces ride 0xC5, so 0x7F must be handled exactly once or an
// error would be processed twice by the dispatcher.
final frame = Uint8List.fromList([0xC5, 0x7F, 0x04]);
expect(parseButtonMatrixReply(frame)!.isError, isTrue);
expect(parseNotifyScopeReply(frame), isNull);
});
});
group('notification scope parse', () {
test('reads each scope', () {
for (final s in DeviceNotifyScope.values) {
final reply = parseNotifyScopeReply(
Uint8List.fromList([0xC5, 0x01, s.code]),
);
expect(reply!.scope, equals(s));
}
});
test('an unknown scope is an error, not a silent default', () {
final reply = parseNotifyScopeReply(
Uint8List.fromList([0xC5, 0x01, 0x40]),
);
expect(reply!.isError, isTrue);
expect(reply.scope, isNull);
expect(reply.errorMessage, contains('0x40'));
});
test('truncated scope reply yields null', () {
expect(parseNotifyScopeReply(Uint8List.fromList([0xC5, 0x01])), isNull);
});
});
group('SET echo', () {
test('confirmed assignment folds into the held matrix', () {
final reply = parseButtonMatrixReply(
Uint8List.fromList([0xC5, 0x04, 0x02, 0x03]),
);
expect(reply!.setSequence, ButtonSequence.triple);
expect(reply.setAction, ButtonAction.cycleNotifyScope);
final before = ButtonMatrix(
assignments: const {ButtonSequence.triple: ButtonAction.none},
supportedActions: 0xFF,
);
final after = before.withAssignment(reply.setSequence!, reply.setAction!);
expect(
after.assignments[ButtonSequence.triple],
ButtonAction.cycleNotifyScope,
);
expect(after.supportedActions, equals(0xFF));
});
});
group('long press is not assignable', () {
test('no sequence models a long press', () {
// Firmware reserves long press for CLI rescue and power off. Exposing it
// would let a user lock themselves out of a screenless device.
expect(ButtonSequence.values.length, equals(4));
expect(
ButtonSequence.values.map((s) => s.label).join(' ').toLowerCase(),
isNot(contains('long')),
);
});
});
}

@ -0,0 +1,119 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/models/path_selection.dart';
Contact _contact({
required int pathLength,
required int pathHashWidth,
List<int> path = const [],
int? pathOverride,
List<int>? pathOverrideBytes,
}) {
return Contact(
publicKey: Uint8List(32),
name: 'R',
type: advTypeRepeater,
pathLength: pathLength,
pathHashWidth: pathHashWidth,
path: Uint8List.fromList(path),
pathOverride: pathOverride,
pathOverrideBytes: pathOverrideBytes == null
? null
: Uint8List.fromList(pathOverrideBytes),
lastSeen: DateTime.utc(2026),
);
}
void main() {
const pathLenOffset = 35; // 1 cmd + 32 pubKey + 1 type + 1 flags
group('resolvePathSelection width (#279)', () {
test('device path reports true hops + the captured width', () {
// Bandit 2026-08-01: a 6-hop 2-byte route (12 path bytes). It must NOT be
// reported as 12 hops nor sent at width 1.
final bytes = [
0xC6, 0x5C, 0x64, 0x7A, 0x75, 0xC9, //
0x73, 0x60, 0xF6, 0x9F, 0xFB, 0x97,
];
final r = resolvePathSelection(
_contact(pathLength: 6, pathHashWidth: 2, path: bytes),
);
expect(r.useFlood, isFalse);
expect(r.hopCount, 6);
expect(r.hashWidth, 2);
expect(r.pathBytes.length, 12);
});
test('end to end: the device path encodes as 0x46, not 0x06', () {
final bytes = [
0xC6, 0x5C, 0x64, 0x7A, 0x75, 0xC9, //
0x73, 0x60, 0xF6, 0x9F, 0xFB, 0x97,
];
final r = resolvePathSelection(
_contact(pathLength: 6, pathHashWidth: 2, path: bytes),
);
final frame = buildUpdateContactPathFrame(
Uint8List(32),
Uint8List.fromList(r.pathBytes),
r.hopCount,
hashWidth: r.hashWidth,
);
expect(frame[pathLenOffset], 0x46);
expect(pathHopCount(frame[pathLenOffset]), 6);
expect(pathHashSizeBytes(frame[pathLenOffset]), 2);
});
test('override: one 2-byte hop is 1 hop at width 2, not 2 hops', () {
// The dialog stores pathOverride as a BYTE count (2); the selection must
// still resolve to a single 2-byte hop.
final r = resolvePathSelection(
_contact(
pathLength: 1,
pathHashWidth: 2,
pathOverride: 2,
pathOverrideBytes: [0xC6, 0x5C],
),
);
expect(r.hopCount, 1);
expect(r.hashWidth, 2);
});
test('legacy 1-byte net is unchanged', () {
final r = resolvePathSelection(
_contact(pathLength: 3, pathHashWidth: 1, path: [0xAA, 0xBB, 0xCC]),
);
expect(r.hopCount, 3);
expect(r.hashWidth, 1);
});
test('flood override passes through as flood', () {
final r = resolvePathSelection(
_contact(pathLength: 0, pathHashWidth: 2, pathOverride: -1),
);
expect(r.useFlood, isTrue);
expect(r.hopCount, -1);
});
test('path-history retry derives width from the contact', () {
final retry = const PathSelection(
pathBytes: [0xC6, 0x5C, 0xA1, 0xB2],
hopCount: 99, // stale/ambiguous; must be recomputed
useFlood: false,
);
final r = resolvePathSelection(
_contact(
pathLength: 2,
pathHashWidth: 2,
path: [0x00, 0x00, 0x00, 0x00],
),
selection: retry,
);
expect(r.pathBytes.length, 4);
expect(r.hopCount, 2);
expect(r.hashWidth, 2);
});
});
}

@ -2,27 +2,53 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/services/block_service.dart';
import 'package:meshcore_open/storage/block_store.dart';
/// In-memory stand-in so the service can be exercised without SharedPreferences.
/// In-memory, scope-aware stand-in so the service can be exercised without
/// SharedPreferences. Keys/names are stored per device scope (#471).
class _FakeBlockStore implements BlockStore {
Set<String> keys = {};
Map<String, int> names = {};
final Map<String, Set<String>> _keys = {};
final Map<String, Map<String, int>> _names = {};
bool legacyDropped = false;
@override
Future<Set<String>> loadKeys() async => {...keys};
String publicKeyHex = '';
@override
Future<void> saveKeys(Set<String> value) async => keys = {...value};
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
@override
Future<Map<String, int>> loadNames() async => {...names};
Future<void> dropLegacyGlobal() async => legacyDropped = true;
Set<String> keysFor(String scope) => {...?_keys[scope]};
@override
Future<Set<String>> loadKeys() async =>
publicKeyHex.isEmpty ? {} : {...?_keys[publicKeyHex]};
@override
Future<void> saveKeys(Set<String> value) async {
if (publicKeyHex.isEmpty) return;
_keys[publicKeyHex] = {...value};
}
@override
Future<Map<String, int>> loadNames() async =>
publicKeyHex.isEmpty ? {} : {...?_names[publicKeyHex]};
@override
Future<void> saveNames(Map<String, int> value) async => names = {...value};
Future<void> saveNames(Map<String, int> value) async {
if (publicKeyHex.isEmpty) return;
_names[publicKeyHex] = {...value};
}
}
void main() {
const selfKey = 'aa11bb22cc33dd44';
const otherKey = '99ff88ee77dd66cc';
// Radios (their own keys double as the per-radio scope + self key).
const radioA = 'a1a1a1a1a1a1a1a1';
const radioB = 'b2b2b2b2b2b2b2b2';
// Contacts to block.
const bob = '99ff88ee77dd66cc';
const cara = '4444555566667777';
late _FakeBlockStore store;
late BlockService service;
@ -37,118 +63,150 @@ void main() {
});
group('self-block guard (#250)', () {
test('block() refuses the self key and pushes nothing', () async {
await service.setSelfKey(selfKey);
test('block() refuses the connected radio\'s own key', () async {
await service.loadForDevice(radioA);
pushes.clear();
await service.block(selfKey);
await service.block(radioA);
expect(service.isBlocked(selfKey), isFalse);
expect(store.keys, isNot(contains(selfKey)));
expect(service.isBlocked(radioA), isFalse);
expect(store.keysFor('a1a1a1a1a1'), isNot(contains(radioA)));
expect(pushes, isEmpty);
});
test('block() still blocks a normal key', () async {
await service.setSelfKey(selfKey);
test('block() still blocks a normal contact', () async {
await service.loadForDevice(radioA);
await service.block(otherKey);
await service.block(bob);
expect(service.isBlocked(otherKey), isTrue);
expect(pushes, contains((key: otherKey, blocked: true)));
expect(service.isBlocked(bob), isTrue);
expect(pushes, contains((key: bob, blocked: true)));
});
test('importKeys() skips self so the radio cannot re-import it', () async {
await service.setSelfKey(selfKey);
test('importKeys() skips the self key from the radio dump', () async {
await service.loadForDevice(radioA);
await service.importKeys([selfKey, otherKey]);
await service.importKeys([radioA, bob]);
expect(service.isBlocked(selfKey), isFalse);
expect(service.isBlocked(otherKey), isTrue);
expect(service.isBlocked(radioA), isFalse);
expect(service.isBlocked(bob), isTrue);
});
test('setSelfKey() heals an existing self-block and sends REMOVE', () async {
// Blocked before the guard existed (or pulled in before self-info landed).
await service.block(selfKey);
expect(service.isBlocked(selfKey), isTrue);
pushes.clear();
test(
'loadForDevice heals a stale self-block already in the store',
() async {
// Radio A's stored list contains A's own key (pre-guard / stale).
store.saveKeysForTest('a1a1a1a1a1', {radioA, bob});
await service.setSelfKey(selfKey);
await service.loadForDevice(radioA);
expect(service.isBlocked(selfKey), isFalse);
expect(store.keys, isNot(contains(selfKey)));
expect(pushes, contains((key: selfKey, blocked: false)));
});
expect(service.isBlocked(radioA), isFalse, reason: 'self healed');
expect(service.isBlocked(bob), isTrue);
expect(pushes, contains((key: radioA, blocked: false)));
},
);
test('maybePromote() will not promote a name into a self-block', () async {
await service.setSelfKey(selfKey);
await service.loadForDevice(radioA);
await service.blockName('me');
pushes.clear();
await service.maybePromote('me', selfKey);
await service.maybePromote('me', radioA);
expect(service.isBlocked(selfKey), isFalse);
expect(service.isBlocked(radioA), isFalse);
expect(service.isNameBlocked('me'), isFalse);
expect(pushes, isEmpty);
});
});
test('heals a self-block even when the self key is unchanged', () async {
await service.setSelfKey(selfKey);
// Stale persisted state reloaded while the self key is already known,
// load() does not filter, so this lands a self-block behind the guards.
store.keys = {selfKey, otherKey};
await service.load();
expect(service.isBlocked(selfKey), isTrue);
pushes.clear();
// Same key as before, so an unchanged-key early return would strand it.
await service.setSelfKey(selfKey);
group('per-radio isolation + drop-and-start-fresh (#471)', () {
test('a block on radio A does not appear on radio B', () async {
await service.loadForDevice(radioA);
await service.block(bob);
expect(service.isBlocked(bob), isTrue);
expect(service.isBlocked(selfKey), isFalse);
await service.loadForDevice(radioB);
expect(
service.isBlocked(otherKey),
isTrue,
reason: 'only self is healed',
service.isBlocked(bob),
isFalse,
reason: 'radio B has its own list',
);
expect(pushes, contains((key: selfKey, blocked: false)));
});
test('repeat setSelfKey with nothing to heal pushes nothing', () async {
await service.setSelfKey(selfKey);
pushes.clear();
await service.setSelfKey(selfKey);
await service.block(cara);
expect(service.isBlocked(cara), isTrue);
expect(service.isBlocked(bob), isFalse);
expect(pushes, isEmpty);
expect(service.isSelf(selfKey), isTrue);
// Back to A: A still has bob, not cara.
await service.loadForDevice(radioA);
expect(service.isBlocked(bob), isTrue);
expect(service.isBlocked(cara), isFalse);
});
test('state is consistent synchronously when not awaited', () async {
await service.setSelfKey(selfKey);
await service.setSelfKey(null);
await service.block(selfKey);
test('clearing a radio stays cleared across a reconnect', () async {
await service.loadForDevice(radioA);
await service.block(bob);
await service.unblock(bob);
expect(service.isBlocked(bob), isFalse);
pushes.clear(); // ignore the legitimate ADD/REMOVE above
// Fire-and-forget, as the connector does via unawaited().
final future = service.setSelfKey(selfKey);
expect(service.isSelf(selfKey), isTrue);
expect(service.isBlocked(selfKey), isFalse);
await future;
expect(store.keys, isNot(contains(selfKey)));
// Connect another radio, then come back to A. Nothing re-seeds bob.
await service.loadForDevice(radioB);
await service.loadForDevice(radioA);
expect(
service.isBlocked(bob),
isFalse,
reason: 'no global list to re-push the unblocked key',
);
expect(
pushes.where((p) => p.key == bob && p.blocked),
isEmpty,
reason: 'bob is never re-ADDed to the radio',
);
});
test('isSelf() is false when no self key is known', () {
expect(service.isSelf(selfKey), isFalse);
test('load() drops the legacy global list and starts empty', () async {
await service.load();
expect(store.legacyDropped, isTrue);
expect(service.blockedKeys, isEmpty);
});
test('clearing the self key stops treating the old key as self', () async {
await service.setSelfKey(selfKey);
await service.setSelfKey(null);
expect(service.isSelf(selfKey), isFalse);
test('loadForDevice also drops the legacy global list', () async {
await service.loadForDevice(radioA);
expect(store.legacyDropped, isTrue);
});
await service.block(selfKey);
expect(service.isBlocked(selfKey), isTrue);
test(
'concurrent loadForDevice + importKeys does not wipe imports (race)',
() async {
// Fire the reload without awaiting, then import from the radio's LIST
// dump immediately after; the connector does exactly this across two
// frame handlers. Serialization must order them so neither wipes the
// other; bob (from the dump) must survive.
final load = service.loadForDevice(radioA);
final import = service.importKeys([bob]);
await Future.wait([load, import]);
expect(
service.isBlocked(bob),
isTrue,
reason: 'imported key survived the concurrent reload',
);
expect(store.keysFor('a1a1a1a1a1'), contains(bob));
},
);
test('disconnect (null device) clears the in-memory set', () async {
await service.loadForDevice(radioA);
await service.block(bob);
expect(service.isBlocked(bob), isTrue);
await service.loadForDevice(null);
expect(service.blockedKeys, isEmpty);
expect(service.isSelf(radioA), isFalse);
});
});
}
extension on _FakeBlockStore {
void saveKeysForTest(String scope, Set<String> keys) => _keys[scope] = keys;
}

@ -0,0 +1,134 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:meshcore_open/services/translation_service.dart';
void main() {
final uri = Uri.parse('https://example.test/model.gguf');
http.Request get() => http.Request('GET', uri);
Future<void> noSleep(Duration _) async {}
bool never() => false;
group('isRetryableDownloadStatus', () {
test('5xx and 429 are retryable', () {
for (final s in [500, 502, 503, 504, 429]) {
expect(isRetryableDownloadStatus(s), isTrue, reason: '$s');
}
});
test('2xx / 3xx / terminal 4xx are not retryable', () {
for (final s in [200, 206, 301, 400, 403, 404]) {
expect(isRetryableDownloadStatus(s), isFalse, reason: '$s');
}
});
});
group('translationDownloadBackoff', () {
test('exponential 1,2,4,8,16 then capped at 30', () {
expect(translationDownloadBackoff(1), const Duration(seconds: 1));
expect(translationDownloadBackoff(2), const Duration(seconds: 2));
expect(translationDownloadBackoff(3), const Duration(seconds: 4));
expect(translationDownloadBackoff(4), const Duration(seconds: 8));
expect(translationDownloadBackoff(5), const Duration(seconds: 16));
expect(translationDownloadBackoff(6), const Duration(seconds: 30));
});
test('a larger Retry-After wins, also capped at 30', () {
expect(
translationDownloadBackoff(1, retryAfterSeconds: 20),
const Duration(seconds: 20),
);
expect(
translationDownloadBackoff(1, retryAfterSeconds: 999),
const Duration(seconds: 30),
);
// Smaller Retry-After does not shrink the exponential floor.
expect(
translationDownloadBackoff(4, retryAfterSeconds: 2),
const Duration(seconds: 8),
);
});
});
group('sendModelDownloadWithRetry', () {
test('retries a transient 503 then succeeds', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
return http.Response(
calls == 1 ? 'busy' : 'ok',
calls == 1 ? 503 : 200,
);
});
final res = await sendModelDownloadWithRetry(
client,
get,
sleep: noSleep,
isCancelled: never,
);
expect(res.statusCode, 200);
expect(calls, 2);
});
test('gives up after maxAttempts on persistent 503', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
return http.Response('busy', 503);
});
final res = await sendModelDownloadWithRetry(
client,
get,
sleep: noSleep,
isCancelled: never,
maxAttempts: 3,
);
expect(res.statusCode, 503);
expect(calls, 3);
});
test('does not retry a terminal 404', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
return http.Response('nope', 404);
});
final res = await sendModelDownloadWithRetry(
client,
get,
sleep: noSleep,
isCancelled: never,
);
expect(res.statusCode, 404);
expect(calls, 1);
});
test('retries a network exception then succeeds', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
if (calls == 1) throw http.ClientException('connection reset');
return http.Response('ok', 200);
});
final res = await sendModelDownloadWithRetry(
client,
get,
sleep: noSleep,
isCancelled: never,
);
expect(res.statusCode, 200);
expect(calls, 2);
});
test('aborts immediately when cancelled', () async {
final client = MockClient((req) async => http.Response('ok', 200));
expect(
() => sendModelDownloadWithRetry(
client,
get,
sleep: noSleep,
isCancelled: () => true,
),
throwsA(isA<TranslationDownloadCancelled>()),
);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.