diff --git a/.github/scripts/check_no_emdash.py b/.github/scripts/check_no_emdash.py new file mode 100644 index 0000000..a5feb23 --- /dev/null +++ b/.github/scripts/check_no_emdash.py @@ -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: ). +""" + +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()) diff --git a/.github/workflows/flutter_dart.yml b/.github/workflows/flutter_dart.yml index 4076ea5..d812368 100644 --- a/.github/workflows/flutter_dart.yml +++ b/.github/workflows/flutter_dart.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c481d..379c9ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/discord/1.4.0.md b/discord/1.4.0.md new file mode 100644 index 0000000..ed2b862 --- /dev/null +++ b/discord/1.4.0.md @@ -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 diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 472896d..9ddc6e0 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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 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 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 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 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> get channelMessagesForTest => _channelMessages; + + @visibleForTesting + Map> get conversationsForTest => _conversations; + + @visibleForTesting + Set 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.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, diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 09d5901..ac3903d 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -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 && diff --git a/lib/connector/offband_device_ui.dart b/lib/connector/offband_device_ui.dart new file mode 100644 index 0000000..e5eb837 --- /dev/null +++ b/lib/connector/offband_device_ui.dart @@ -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 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 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 = {}; + 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, + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 97c4383..83b522a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index eda069c..06b1299 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index a73400e..7e27684 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -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'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 1b209c5..ae9c265 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8ad3bf9..33597a4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 9ba54d3..a3d5252 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 8ba78ec..eba4521 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 8bab96a..fb42803 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -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'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index e2b0d0d..5cd6761 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 4065395..4dd94a1 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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 回目'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 2469343..f9a1fec 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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 시도'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ebbe9ae..0dc6d77 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 75b3bef..15d3358 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 52cd3d3..ae4d686 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index ff61747..fd7434e 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 479c060..3e2938a 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -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'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 0942f71..f46ad30 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 7a79520..232c592 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -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'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 388858a..a3f4b37 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a1fea72..ab4a053 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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'; diff --git a/lib/models/path_selection.dart b/lib/models/path_selection.dart index cdb3d72..9157e41 100644 --- a/lib/models/path_selection.dart +++ b/lib/models/path_selection.dart @@ -6,28 +6,52 @@ const int recentAttemptDiversityWindow = 2; class PathSelection { final List 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 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, ); } diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 651bbc4..87799a3 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1297,11 +1297,15 @@ class _ChannelChatScreenState extends State { final seen = {}; // 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 = {}; 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 { 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 { ); } + void _retryChannelMessage(ChannelMessage message) { + context.read().sendChannelMessage( + _currentChannel, + message.text, + ); + showDismissibleSnackBar( + context, + content: Text(context.l10n.chat_sendingAgain), + ); + } + void _showMessageActions(ChannelMessage message) { final translationService = context.read(); final canTranslateMessage = @@ -1648,6 +1666,19 @@ class _ChannelChatScreenState extends State { _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( diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 791a24c..768a54f 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1586,7 +1586,7 @@ class _ChatScreenState extends State { 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 { connector.sendMessage(_resolveContact(connector), message.text); showDismissibleSnackBar( context, - content: Text(context.l10n.chat_retryingMessage), + content: Text(context.l10n.chat_sendingAgain), ); } diff --git a/lib/screens/settings/device_ui_view.dart b/lib/screens/settings/device_ui_view.dart new file mode 100644 index 0000000..95f5619 --- /dev/null +++ b/lib/screens/settings/device_ui_view.dart @@ -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 createState() => _DeviceUiViewState(); +} + +class _DeviceUiViewState extends State { + @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(); + c.requestButtonMatrix(); + c.requestNotifyScope(); + }); + } + + @override + Widget build(BuildContext context) { + return Consumer( + 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 _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 _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( + value: actions.contains(assigned) ? assigned : ButtonAction.none, + onChanged: (value) { + if (value != null) connector.setButtonAction(seq, value); + }, + items: actions + .map( + (a) => DropdownMenuItem( + 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, + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 81177da..0165466 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 { }, ), ], + // 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 { // 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' : ''}', ), diff --git a/lib/services/block_service.dart b/lib/services/block_service.dart index 9c9b081..c1318fa 100644 --- a/lib/services/block_service.dart +++ b/lib/services/block_service.dart @@ -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 _opChain = Future.value(); + Future _serialize(Future 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 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 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 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 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 get blockedKeys => Set.unmodifiable(_blockedKeys); Map get blockedNames => Map.unmodifiable(_blockedNames); @@ -74,27 +99,28 @@ class BlockService extends ChangeNotifier { bool isNameBlocked(String name) => _blockedNames.containsKey(name.trim().toLowerCase()); - Future block(String publicKeyHex) async { + Future 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 unblock(String publicKeyHex) async { + Future 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 importKeys(Iterable keysHex) async { + /// Never removes, the union only adds. Serialized against [loadForDevice] so + /// a reload can't wipe these imports and vice-versa (#471). + Future importKeys(Iterable 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 blockName(String name) async { + Future 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 unblockName(String name) async { + Future 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 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 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 _pruneExpiredNames() async { diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 7b1d7f5..5c1055f 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -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 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 sendModelDownloadWithRetry( + http.Client client, + http.Request Function() buildRequest, { + required Future 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(); + 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 _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 _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.delayed(chunk); + waited += chunk; + } + } + Future _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(); throw StateError( diff --git a/lib/storage/block_store.dart b/lib/storage/block_store.dart index 0ca7e9d..0a37924 100644 --- a/lib/storage/block_store.dart +++ b/lib/storage/block_store.dart @@ -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 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> loadKeys() async { + if (publicKeyHex.isEmpty) return {}; final list = PrefsManager.instance.getStringList(_keysKey) ?? const []; return list.map((k) => k.toLowerCase()).toSet(); } Future saveKeys(Set 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> 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 saveNames(Map names) async { + if (publicKeyHex.isEmpty) return; await PrefsManager.instance.setString(_namesKey, jsonEncode(names)); } } diff --git a/lib/widgets/mention_autocomplete.dart b/lib/widgets/mention_autocomplete.dart index e7cfc25..95af33f 100644 --- a/lib/widgets/mention_autocomplete.dart +++ b/lib/widgets/mention_autocomplete.dart @@ -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 { } _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}] ', ), diff --git a/play/1.4.0.txt b/play/1.4.0.txt new file mode 100644 index 0000000..96bc762 --- /dev/null +++ b/play/1.4.0.txt @@ -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. diff --git a/pubspec.yaml b/pubspec.yaml index 861aee2..4fb2043 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/release-notes/1.4.0.md b/release-notes/1.4.0.md new file mode 100644 index 0000000..01476bb --- /dev/null +++ b/release-notes/1.4.0.md @@ -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. diff --git a/test/connector/channel_cache_reset_test.dart b/test/connector/channel_cache_reset_test.dart new file mode 100644 index 0000000..3a341ac --- /dev/null +++ b/test/connector/channel_cache_reset_test.dart @@ -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', + ); + }, + ); +} diff --git a/test/connector/mention_contract_test.dart b/test/connector/mention_contract_test.dart new file mode 100644 index 0000000..8857298 --- /dev/null +++ b/test/connector/mention_contract_test.dart @@ -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')); + }); + }); +} diff --git a/test/connector/offband_caps2_test.dart b/test/connector/offband_caps2_test.dart new file mode 100644 index 0000000..5d56073 --- /dev/null +++ b/test/connector/offband_caps2_test.dart @@ -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)); + }); + }); +} diff --git a/test/connector/offband_device_ui_test.dart b/test/connector/offband_device_ui_test.dart new file mode 100644 index 0000000..4d2ccb2 --- /dev/null +++ b/test/connector/offband_device_ui_test.dart @@ -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 [ + [0xC5], + [0xC5, 0x03], + [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')), + ); + }); + }); +} diff --git a/test/models/path_selection_test.dart b/test/models/path_selection_test.dart new file mode 100644 index 0000000..b3ffbbc --- /dev/null +++ b/test/models/path_selection_test.dart @@ -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 path = const [], + int? pathOverride, + List? 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); + }); + }); +} diff --git a/test/services/block_service_test.dart b/test/services/block_service_test.dart index 5d9194e..7f83d93 100644 --- a/test/services/block_service_test.dart +++ b/test/services/block_service_test.dart @@ -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 keys = {}; - Map names = {}; + final Map> _keys = {}; + final Map> _names = {}; + bool legacyDropped = false; @override - Future> loadKeys() async => {...keys}; + String publicKeyHex = ''; @override - Future saveKeys(Set value) async => keys = {...value}; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; @override - Future> loadNames() async => {...names}; + Future dropLegacyGlobal() async => legacyDropped = true; + + Set keysFor(String scope) => {...?_keys[scope]}; + + @override + Future> loadKeys() async => + publicKeyHex.isEmpty ? {} : {...?_keys[publicKeyHex]}; + + @override + Future saveKeys(Set value) async { + if (publicKeyHex.isEmpty) return; + _keys[publicKeyHex] = {...value}; + } + + @override + Future> loadNames() async => + publicKeyHex.isEmpty ? {} : {...?_names[publicKeyHex]}; @override - Future saveNames(Map value) async => names = {...value}; + Future saveNames(Map 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 keys) => _keys[scope] = keys; +} diff --git a/test/services/translation_download_retry_test.dart b/test/services/translation_download_retry_test.dart new file mode 100644 index 0000000..3a3067a --- /dev/null +++ b/test/services/translation_download_retry_test.dart @@ -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 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()), + ); + }); + }); +}