diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a5bf67c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [Strycher] +ko_fi: offband diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c1ba57..0b36277 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,6 +72,8 @@ jobs: env: GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json + - name: Enable native assets (bundles sqlite3 + llamadart libs, #445) + run: flutter config --enable-native-assets - run: flutter build linux --release --no-pub --dart-define-from-file=dart_defines.json - name: Upload Linux bundle uses: actions/upload-artifact@v4 @@ -91,6 +93,8 @@ jobs: cache: true - run: flutter pub get - run: dart run build_runner build --delete-conflicting-outputs + - name: Enable native assets (bundles sqlite3 + llamadart libs, #445) + run: flutter config --enable-native-assets - run: flutter build macos --release --no-pub web: @@ -134,6 +138,8 @@ jobs: run: | @{ GIPHY_API_KEY = $env:GIPHY_API_KEY } | ConvertTo-Json -Compress | Set-Content -Path dart_defines.json -Encoding utf8 -NoNewline + - name: Enable native assets (bundles sqlite3 + llamadart libs, #445) + run: flutter config --enable-native-assets - run: flutter build windows --release --no-pub --dart-define-from-file=dart_defines.json - name: Upload Windows build uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release-signed.yml b/.github/workflows/release-signed.yml index fd8e32a..1529db2 100644 --- a/.github/workflows/release-signed.yml +++ b/.github/workflows/release-signed.yml @@ -206,6 +206,8 @@ jobs: run: | @{ GIPHY_API_KEY = $env:GIPHY_API_KEY } | ConvertTo-Json -Compress | Set-Content -Path dart_defines.json -Encoding utf8 -NoNewline + - name: Enable native assets (bundles sqlite3 + llamadart libs, #445) + run: flutter config --enable-native-assets - run: flutter build windows --release --no-pub --dart-define-from-file=dart_defines.json - name: Zip Windows build run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath offband-windows-x64.zip @@ -233,6 +235,8 @@ jobs: env: GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json + - name: Enable native assets (bundles sqlite3 + llamadart libs, #445) + run: flutter config --enable-native-assets - run: flutter build linux --release --no-pub --dart-define-from-file=dart_defines.json - name: Tar Linux bundle run: tar -czf offband-linux-x64.tar.gz -C build/linux/x64/release/bundle . diff --git a/.github/workflows/sync-labels-to-board.yml b/.github/workflows/sync-labels-to-board.yml index 0f4becf..a27e6bb 100644 --- a/.github/workflows/sync-labels-to-board.yml +++ b/.github/workflows/sync-labels-to-board.yml @@ -13,31 +13,82 @@ on: types: [labeled, unlabeled, closed] jobs: - # -- Auto-label closed issues board:done (mutual-exclusion cleanup) -- + # -- Closed issue -> board:done, strip any stale board:* label, set Status -- + # + # The `sync` job below only runs on labeled/unlabeled, and a label added with + # the default GITHUB_TOKEN does not cascade to trigger it. So the close path + # must be self-contained: enforce board:* mutual exclusivity AND set the + # project Status field itself, using PROJECT_PAT (which can write the org + # project). Fixes #447 (closed cards left in their old column). auto-label-closed: runs-on: ubuntu-latest - permissions: - issues: write if: github.event.action == 'closed' steps: - - name: Auto-label closed issues - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - run: | - labels=$(gh issue view ${{ github.event.issue.number }} --json labels -q '.labels[].name') - if ! echo "$labels" | grep -q "board:done"; then - gh issue edit ${{ github.event.issue.number }} --add-label "board:done" - else - stale=$(echo "$labels" | grep '^board:' | grep -v '^board:done$' || true) - if [ -n "$stale" ]; then - for lbl in $stale; do - gh issue edit ${{ github.event.issue.number }} --remove-label "$lbl" - done - gh issue edit ${{ github.event.issue.number }} --remove-label "board:done" - gh issue edit ${{ github.event.issue.number }} --add-label "board:done" - fi - fi + - name: Mark closed issue Done (strip stale board:*, set Status) + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_PAT }} + script: | + const PROJECT_ID = 'PVT_kwDOEXsS3c4BaleY'; + const STATUS_FIELD_ID = 'PVTSSF_lADOEXsS3c4BaleYzhVcBOA'; + const DONE_OPTION_ID = '72944195'; // board:done + + const issueNumber = context.payload.issue.number; + const issueNodeId = context.payload.issue.node_id; + const labels = context.payload.issue.labels.map(l => l.name); + + // 1. Strip any board:* label that is not board:done (mutual exclusion). + const stale = labels.filter(l => l.startsWith('board:') && l !== 'board:done'); + for (const lbl of stale) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: lbl, + }); + core.info(`Removed stale label: ${lbl}`); + } catch (e) { + core.warning(`Could not remove "${lbl}": ${e.message}`); + } + } + + // 2. Ensure board:done is present. + if (!labels.includes('board:done')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['board:done'], + }); + core.info('Added board:done'); + } + + // 3. Set the project Status field to Done directly (do not rely on a + // label-add cascade the GITHUB_TOKEN path cannot trigger). + const addResult = await github.graphql(` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + }`, { projectId: PROJECT_ID, contentId: issueNodeId }); + const itemId = addResult.addProjectV2ItemById.item.id; + + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + }`, { + projectId: PROJECT_ID, + itemId: itemId, + fieldId: STATUS_FIELD_ID, + optionId: DONE_OPTION_ID, + }); + core.info(`#${issueNumber} Status -> Done`); # -- Sync board/priority labels to project fields -- sync: diff --git a/CHANGELOG.md b/CHANGELOG.md index e3dc34d..37c481d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,79 @@ All notable changes to Offband Meshcore. Pre-releases are tagged `-beta.N` / `-rc.N`. +## [1.3.0] - 2026-07-30 + +The first Offband release published to the Google Play production track. A large +feature release on top of the 1.2.x line: cross-client reactions, Observer config +profiles, log and serial-capture export, a reorganized Settings screen, richer +path diagnostics, and a batch of reliability fixes. + +### Reactions + +- Incoming reactions now record **who** reacted, not just the tally, and a room's + own reaction is no longer misattributed to the room name (#383). +- Emoji reactions from PocketMesh / MeshCore One are parsed and rendered instead + of showing as raw token text; sending is unchanged (#380). + +### Observer config profiles + +- Import an Observer config profile from the federated catalog or a URL, preview a + field-by-field diff against the current config, and apply it, with the wiring + reachable from Observer settings (#404, #405, #406, #407). +- Profiles are defined by a YAML schema organized into capability sections; config + fetches are cache-busted so a re-import never serves a stale copy + (#402, #403, #456, #404). + +### Diagnostics and export + +- Share the App log and BLE log as a file: Save As on desktop, download on web, + system share sheet on mobile, using the platform-standard share icon + (#393, #427, #433, #396). +- New serial / boot-log capture screen under Debug: enable/disable/erase/status + control, a chunked download protocol with reassembly, live status polling, and a + "Reboot device" action for boot-log capture (#430, #428). +- The message Path screen now shows the path type, per-message SNR, and per-message + RSSI read from the reserved RX fields (#438, #439). +- Device Info shows the running build identity (branch @ sha, build time) and the + active radio data scope (#397, #295). + +### Settings + +- Settings categories reordered, with Observer moved above App Settings and the app + debug-logging toggle moved into a Debug category (#271, #272, #273, #274). +- Per-channel notification mode is reachable from the channel ellipsis menu (#275). +- New keep-screen-awake setting (#269). +- The advert notification setting was renamed to plain language and a stale + "Notifications" subtitle was dropped (#270, #276). + +### Reliability and fixes + +- Sending a max-length DM or channel message no longer wedges the send path: the + size cap is MTU-aware and a failed write is recovered instead of stalling all + further sends (#395). +- Live channel-change push (0x91) with debounce so a burst of changes coalesces + into one re-sync instead of many (#429). +- A storage-init failure now surfaces a persistent warning instead of failing + silently (#385). +- Desktop builds bundle their native-assets DLLs (sqlite3, llamadart) so they run + on a clean machine, including on Linux (#445). +- Back-button behavior refined: Back pops out of a channel and only top-level tabs + background the app; the dead top-level back arrow is hidden on a pinned desktop + layout (#389, #390). +- Android warns when the app is under battery optimization and guides the user to + exempt it, including from the scanner screen (#443). +- Send-side Smaz compression removed (phase 1), and a stable per-install client id + is sent in CMD_APP_START (#420, #297). + +### Storage + +- Desktop (Windows/Linux): message history that was split across several older + database files is now reunited. On launch, every discovered legacy store is + unioned into the pinned database (messages by id, contacts by public key); source + databases are never deleted, and a failed run leaves them intact and retries + (#367, #370). Builds on the earlier fix that pins the desktop database to one + fixed location (#363, #364). + ## [1.2.2] - 2026-07-21 A follow-up to 1.2.1 that recovers desktop history left behind in other databases. diff --git a/discord/1.3.0.md b/discord/1.3.0.md new file mode 100644 index 0000000..f53ca21 --- /dev/null +++ b/discord/1.3.0.md @@ -0,0 +1,28 @@ +🚀 **Offband Meshcore 1.3.0 is here, and it's the first build on the Google Play production track.** + +Our biggest feature drop since the 1.2.0 redesign. Highlights: + +🙂 **Reactions with names.** Incoming reactions now show **who** reacted, not just a count, and emoji reactions from **PocketMesh and MeshCore One** are read and displayed properly instead of arriving as raw text. + +🛰️ **Observer config profiles.** Import an Observer config from the shared catalog or a URL, see a **field-by-field diff** against what your radio has now, and apply it, straight from Observer settings. + +📤 **Export logs, capture serial.** The App and BLE logs now have a **Share** action (Save As on desktop, download on web, share sheet on mobile), plus a new **serial / boot-log capture** screen with a Reboot Device action. + +📶 **Richer path diagnostics.** The message Path screen now shows path type and **per-message SNR and RSSI**, so you can read link quality hop by hop. + +🗄️ **Desktop history reunited.** On Windows and Linux, message history that ended up split across several older database files is now brought back together automatically on launch, merged into one database with your original files left untouched. + +⚙️ Plus a **reorganized Settings** screen, a fix so sending a **max-length message no longer wedges** your outbox, **desktop builds that run on a clean machine** (Windows and Linux), and Android battery-optimization guidance. + +🟢 **Open to everyone.** This is the first official public release: no tester list, no sideloading, no waiting. Search **Offband MeshCore** on Google Play, or grab it from the links below. + +🧪 **The testing track stays open.** Want the latest before it reaches production? Join the test track and you'll get new features as I build them. Fair warning: testing builds are exactly that. Features land there first precisely because they are not confirmed stable yet. If something acts weird, that's the trade, and your bug reports are what make the production releases solid for everyone else. + +**📥 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.3.0 +🌐 **Or run it in your browser:** https://offband.app +🔗 Project home: https://offband.org + +💚 **A big thank-you** to the **OKIMesh community** (https://okimesh.org/) for the ideas, time, hardware, and testing that got this here. This release exists because of you. + +Offband is free and open source and always will be. If you'd like to help keep it going, there's a donation page at https://offband.org/donate. Thank you all. 📡 diff --git a/lib/connector/caplog_reassembler.dart b/lib/connector/caplog_reassembler.dart new file mode 100644 index 0000000..1bcb447 --- /dev/null +++ b/lib/connector/caplog_reassembler.dart @@ -0,0 +1,93 @@ +import 'dart:typed_data'; + +import 'meshcore_protocol.dart'; + +/// Outcome of feeding one `0xC4` frame to [CaplogReassembler.accept]. +enum CaplogStatus { started, chunk, completed, truncated, ignored } + +/// Result of [CaplogReassembler.accept]. [bytes] is set on [CaplogStatus.completed] +/// and [CaplogStatus.truncated]; [expected] is set on [CaplogStatus.truncated]. +class CaplogEvent { + const CaplogEvent(this.status, {this.bytes, this.expected}); + final CaplogStatus status; + final Uint8List? bytes; + final int? expected; +} + +/// Pure state machine that reassembles an Offband caplog (`0xC4`) streamed dump: +/// START `[0xC4, 0x01, total_len(uint32 LE)]` → CHUNK `[0xC4, 0x02, ]*` +/// → END `[0xC4, 0x03]`. +/// +/// A CHUNK or END arriving without a preceding START, and any non-`0xC4` frame, +/// is [CaplogStatus.ignored]. Kept free of connector/transport concerns so the +/// reassembly is unit-testable in isolation. (#430) +class CaplogReassembler { + final BytesBuilder _buffer = BytesBuilder(); + int? _expected; + bool _active = false; + + CaplogEvent accept(Uint8List frame) { + if (frame.length < 2 || frame[0] != respCodeOffbandCaplog) { + return const CaplogEvent(CaplogStatus.ignored); + } + switch (frame[1]) { + case caplogSubStart: + _buffer.clear(); + // total_len is optional/defensive: an older device might omit it. When + // absent we can't detect truncation, but still reassemble what arrives. + _expected = frame.length >= 6 ? readUint32LE(frame, 2) : null; + _active = true; + return const CaplogEvent(CaplogStatus.started); + case caplogSubChunk: + if (!_active) return const CaplogEvent(CaplogStatus.ignored); + if (frame.length > 2) _buffer.add(frame.sublist(2)); + return const CaplogEvent(CaplogStatus.chunk); + case caplogSubEnd: + if (!_active) return const CaplogEvent(CaplogStatus.ignored); + _active = false; + final bytes = _buffer.takeBytes(); + final expected = _expected; + _expected = null; + if (expected != null && bytes.length != expected) { + return CaplogEvent( + CaplogStatus.truncated, + bytes: bytes, + expected: expected, + ); + } + return CaplogEvent(CaplogStatus.completed, bytes: bytes); + default: + return const CaplogEvent(CaplogStatus.ignored); + } + } +} + +/// Thrown when a caplog download is rejected because another streamed response +/// is already in flight on the device (firmware answers `RESP_CODE_ERR`). (#430) +class CaplogBusyException implements Exception { + const CaplogBusyException(); + @override + String toString() => + 'CaplogBusyException: device busy, another stream is in flight'; +} + +/// Thrown when the reassembled caplog byte count doesn't match the length the +/// device announced in its START frame. (#430) +class CaplogTruncatedException implements Exception { + const CaplogTruncatedException({ + required this.received, + required this.expected, + this.chunks, + }); + final int received; + final int expected; + + /// Number of CHUNK frames the client accumulated before END, a diagnostic to + /// tell client/transport frame loss apart from the firmware streaming short. + final int? chunks; + + @override + String toString() => + 'CaplogTruncatedException: received $received of $expected bytes' + '${chunks != null ? ' in $chunks chunks' : ''}'; +} diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index c8e434b..472896d 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -17,6 +17,8 @@ import '../models/message.dart'; import '../models/offband_gps_status.dart'; import '../models/path_selection.dart'; import '../models/translation_support.dart'; +import '../helpers/pending_reactions.dart'; +import '../helpers/pocketmesh_reaction.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/time_anomaly.dart'; import '../helpers/cyr2lat.dart'; @@ -45,6 +47,7 @@ import '../storage/channel_message_store.dart'; import '../storage/channel_order_store.dart'; import '../storage/channel_settings_store.dart'; import '../storage/channel_store.dart'; +import '../storage/client_id_store.dart'; import '../storage/contact_discovery_store.dart'; import '../storage/contact_settings_store.dart'; import '../storage/contact_store.dart'; @@ -55,6 +58,7 @@ import '../utils/battery_utils.dart'; import '../utils/platform_info.dart'; import 'meshcore_uuids.dart'; import 'meshcore_protocol.dart'; +import 'caplog_reassembler.dart'; class DirectRepeater { static const int maxAgeMinutes = 30; // Max age for direct repeater info @@ -77,7 +81,7 @@ class DirectRepeater { pubkeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); /// True if [pathBytes] begins with this repeater's full configured-width - /// prefix — the width-aware replacement for a 1-byte first-hop match. (#156) + /// prefix, the width-aware replacement for a 1-byte first-hop match. (#156) bool matchesPathStart(List pathBytes) { if (pathBytes.length < pubkeyPrefix.length) return false; for (var i = 0; i < pubkeyPrefix.length; i++) { @@ -114,7 +118,7 @@ class DirectRepeater { const Duration(minutes: maxAgeMinutes); } - /// Records a directly-heard hop [prefix] at [snr] into [list] — the SNR-ranked + /// Records a directly-heard hop [prefix] at [snr] into [list], the SNR-ranked /// set of nearest direct repeaters, capped at [cap]. Updates an existing /// entry's SNR + timestamp, or adds a new one, evicting the weakest by SNR /// only when the newcomer is stronger. Returns true when the change is worth a @@ -236,9 +240,10 @@ class MeshCoreConnector extends ChangeNotifier { int _reactionSendQueueSequence = 0; final Set _loadedConversationKeys = {}; final Map> _processedChannelReactions = - {}; // channelIndex -> Set of "targetHash_emoji" + {}; // channelIndex -> Set of "targetHash_emoji_reactingSender" final Map> _processedContactReactions = {}; // contactPubKeyHex -> Set of "targetHash_emoji" + final PendingReactions _pendingReactions = PendingReactions(); StreamSubscription>? _scanSubscription; StreamSubscription? _connectionSubscription; @@ -279,6 +284,13 @@ class MeshCoreConnector extends ChangeNotifier { bool _blockOffloadStoreFull = false; bool _blockDumpInFlight = false; final Set _blockKeysTouchedDuringDump = {}; + // Caplog serial-capture download (0xC4) streamed reassembly (#430). + Completer? _caplogCompleter; + CaplogReassembler? _caplogReassembler; + bool _caplogAwaitingStart = false; + Completer? _caplogAckCompleter; + Completer? _caplogStatusCompleter; + int _caplogChunks = 0; // CHUNK frames in the active download (diagnostic) String? _firmwareVersion; String? _deviceModel; int? _offbandCaps; @@ -366,6 +378,7 @@ class MeshCoreConnector extends ChangeNotifier { bool _isSyncingChannels = false; bool _channelSyncInFlight = false; Timer? _channelSyncTimeout; + Timer? _channelsChangedDebounce; int _channelSyncRetries = 0; int _nextChannelIndexToRequest = 0; int _totalChannelsToRequest = 0; @@ -394,12 +407,10 @@ class MeshCoreConnector extends ChangeNotifier { final ChannelStore _channelStore = ChannelStore(); final UnreadStore _unreadStore = UnreadStore(); List _cachedChannels = []; - final Map _channelSmazEnabled = {}; final Map _channelCyr2LatEnabled = {}; final Map _channelCyr2LatProfileId = {}; bool _lastSentWasCliCommand = false; // Track if last sent message was a CLI command - final Map _contactSmazEnabled = {}; final Map _contactCyr2LatEnabled = {}; final Map _contactCyr2LatProfileId = {}; final Set _knownContactKeys = {}; @@ -419,6 +430,28 @@ class MeshCoreConnector extends ChangeNotifier { // Getters MeshCoreConnectionState get state => _state; BluetoothDevice? get device => _device; + + /// Largest frame (in bytes) the active transport can write in a single + /// operation. BLE caps one characteristic write at ATT_MTU - 3 (ATT opcode + + /// handle); a device that negotiates a smaller MTU than [maxFrameSize] assumes + /// would otherwise reject a max-length message and wedge the send path (#395). + /// USB/TCP have no such per-write cap, so they take the full [maxFrameSize]. + int get effectiveMaxFrameSize { + if (_activeTransport != MeshCoreTransportType.bluetooth) { + return maxFrameSize; + } + final mtu = _device?.mtuNow ?? 0; + // ATT_MTU 23 is the BLE minimum; a single write then carries 23 - 3 = 20 + // bytes. An unknown MTU must fall back to that floor, never [maxFrameSize], + // defaulting an unknown link to the largest size would authorize an + // oversized write (#395 review). + const minWritable = 20; + if (mtu <= 0) return minWritable; + final writable = mtu - 3; + if (writable >= maxFrameSize) return maxFrameSize; + return writable < minWritable ? minWritable : writable; + } + String? get deviceId => _deviceId; String get deviceIdLabel => _deviceId ?? 'Unknown'; @@ -577,7 +610,7 @@ class MeshCoreConnector extends ChangeNotifier { /// null on older firmware that sends a shorter frame. int? get offbandCaps => _offbandCaps; - /// Whether the connected firmware speaks the `0xC1` GPS extension — i.e. it + /// 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) bool get supportsOffbandGps => firmwareSupportsOffbandGps(_offbandCaps); @@ -593,13 +626,16 @@ class MeshCoreConnector extends ChangeNotifier { /// Whether this specific radio can control its external FEM LNA (#304). /// - /// Gated on the capability BIT alone — firmware derives it from a runtime FEM + /// Gated on the capability BIT alone, firmware derives it from a runtime FEM /// probe, so it is a per-unit answer: two Heltec V4s can legitimately disagree /// depending on the fitted chip, and `rak3401` reports false by design (its /// SKY66122 gates LNA and PA off one line, so a toggle would kill TX). /// Never shortcut this to a model or version check. bool get supportsOffbandFemLna => firmwareSupportsOffbandFemLna(_offbandCaps); + bool get supportsOffbandCaplog => + firmwareSupportsOffbandCaplog(_offbandCaps, _firmwareVerCode); + /// Current FEM LNA state as last reported by the radio, or null if unknown /// (pre-v16 firmware). Always reflects hardware truth, never a local guess. bool? get femLnaEnabled => _femLnaEnabled; @@ -618,7 +654,7 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildOffbandFemLnaGetFrame()); } - /// `[0xC3][sub][value]` — the value is the post-apply hardware state, so it is + /// `[0xC3][sub][value]`, the value is the post-apply hardware state, so it is /// adopted verbatim rather than assuming a SET took effect (#304). void _handleOffbandFemLnaReply(Uint8List frame) { final reply = parseOffbandFemLnaReply(frame); @@ -901,18 +937,6 @@ class MeshCoreConnector extends ChangeNotifier { ); } - bool isChannelSmazEnabled(int channelIndex) { - return _channelSmazEnabled[channelIndex] ?? false; - } - - bool isContactSmazEnabled(String contactKeyHex) { - return _contactSmazEnabled[contactKeyHex] ?? false; - } - - void ensureContactSmazSettingLoaded(String contactKeyHex) { - _ensureContactSmazSettingLoaded(contactKeyHex); - } - bool isChannelCyr2LatEnabled(int channelIndex) { _ensureChannelCyr2LatSettingLoaded(channelIndex); return _channelCyr2LatEnabled[channelIndex] ?? false; @@ -991,7 +1015,7 @@ class MeshCoreConnector extends ChangeNotifier { } void setChannelUnreadCount(int channelIndex, int count) { - // Don't persist channel state after disconnect — the store scope still + // Don't persist channel state after disconnect, the store scope still // points at the last radio, so a stale write can mis-scope (#23). if (!isConnected) return; final channel = _findChannelByIndex(channelIndex); @@ -1012,7 +1036,7 @@ class MeshCoreConnector extends ChangeNotifier { } void markChannelRead(int channelIndex) { - // Don't persist channel state after disconnect — the store scope still + // Don't persist channel state after disconnect, the store scope still // points at the last radio, so a stale write can mis-scope (#23). if (!isConnected) return; final channel = _findChannelByIndex(channelIndex); @@ -1043,28 +1067,8 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future setChannelSmazEnabled(int channelIndex, bool enabled) async { - _channelSmazEnabled[channelIndex] = enabled; - if (enabled) { - _channelCyr2LatEnabled[channelIndex] = false; - await _channelSettingsStore.saveCyr2LatEnabled(channelIndex, false); - } - await _channelSettingsStore.saveSmazEnabled(channelIndex, enabled); - notifyListeners(); - } - - Future setContactSmazEnabled(String contactKeyHex, bool enabled) async { - _contactSmazEnabled[contactKeyHex] = enabled; - await _contactSettingsStore.saveSmazEnabled(contactKeyHex, enabled); - notifyListeners(); - } - Future setChannelCyr2LatEnabled(int channelIndex, bool enabled) async { _channelCyr2LatEnabled[channelIndex] = enabled; - if (enabled) { - _channelSmazEnabled[channelIndex] = false; - await _channelSettingsStore.saveSmazEnabled(channelIndex, false); - } await _channelSettingsStore.saveCyr2LatEnabled(channelIndex, enabled); notifyListeners(); } @@ -1223,7 +1227,6 @@ class MeshCoreConnector extends ChangeNotifier { // contact list, which is what stalled the UI for ~44s on connect. await Future.wait([ for (final contact in cached) ...[ - _ensureContactSmazSettingLoaded(contact.publicKeyHex, notify: false), _ensureContactCyr2LatSettingLoaded(contact.publicKeyHex, notify: false), ], ]); @@ -1238,11 +1241,9 @@ class MeshCoreConnector extends ChangeNotifier { } Future loadChannelSettings({int? maxChannels}) async { - _channelSmazEnabled.clear(); _channelCyr2LatEnabled.clear(); final channelCount = maxChannels ?? _maxChannels; for (int i = 0; i < channelCount; i++) { - _channelSmazEnabled[i] = await _channelSettingsStore.loadSmazEnabled(i); _channelCyr2LatEnabled[i] = await _channelSettingsStore .loadCyr2LatEnabled(i); } @@ -1344,7 +1345,7 @@ class MeshCoreConnector extends ChangeNotifier { int timestampSeconds, ) async { if (!isConnected || text.isEmpty) return; - // Automatic retries route here without passing sendMessage — stop them + // Automatic retries route here without passing sendMessage, stop them // too once the contact is blocked (#252). if (_blockService?.isBlocked(contact.publicKeyHex) ?? false) { appLogger.info( @@ -1366,6 +1367,10 @@ class MeshCoreConnector extends ChangeNotifier { ); } catch (e) { appLogger.error('Failed to send message: $e', tag: 'Connector'); + // Propagate so MessageRetryService marks the message failed and drains the + // per-contact queue. Swallowing here left the message "active" forever and + // silently blocked every later DM to the contact until reconnect (#395). + rethrow; } } @@ -1668,8 +1673,8 @@ class MeshCoreConnector extends ChangeNotifier { await Future.delayed(const Duration(milliseconds: 300)); } - // Surface radios already connected to the OS — by us or by another app such - // as MeshCore — so we can attach to them. A connected BLE radio stops + // Surface radios already connected to the OS, by us or by another app such + // as MeshCore, so we can attach to them. A connected BLE radio stops // advertising, so a scan alone never finds it; systemDevices does. Supported // on every native platform (Android/iOS/macOS/Linux), not web. if (!PlatformInfo.isWeb) { @@ -2611,7 +2616,7 @@ class MeshCoreConnector extends ChangeNotifier { } /// 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 + /// 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). void _applySelfKeyToBlockService() { final service = _blockService; @@ -2892,7 +2897,7 @@ class MeshCoreConnector extends ChangeNotifier { /// Poll the radio's own GPS position every [_gpsLocationPollInterval] without /// resetting the session (the old `CMD_APP_START` poll re-inited the device - /// and broke contact/channel sync on gps=1 radios — #110/#140). Mechanism is + /// and broke contact/channel sync on gps=1 radios, #110/#140). Mechanism is /// firmware-aware: the Offband `0xC1` query where supported (#135, richer /// state), else standard MeshCore self-telemetry (`CMD_SEND_TELEMETRY_REQ` /// len==4 → `PUSH_CODE_TELEMETRY_RESPONSE`, decoded in [_handleSelfTelemetry]) @@ -2928,7 +2933,7 @@ class MeshCoreConnector extends ChangeNotifier { } /// Start or stop the GPS poll based on state: run whenever the radio reports - /// `gps=1` — the poll itself picks 0xC1 vs self-telemetry by firmware, so + /// `gps=1`, the poll itself picks 0xC1 vs self-telemetry by firmware, so /// stock-fw radios get the non-destructive self-telemetry refresh too (#123). /// Called from every input that can change `gps` or `offband_caps` (custom-var /// frames, the enable toggle, the device-info reply that delivers @@ -2943,7 +2948,7 @@ class MeshCoreConnector extends ChangeNotifier { /// Decode a self-telemetry reply /// (`[0x8B][reserved][self-pubkey 6][CayenneLPP]`) and update the radio's own - /// position from its GPS — the non-destructive replacement for the APP_START + /// position from its GPS, the non-destructive replacement for the APP_START /// GPS poll (#110). Contact telemetry (a different pubkey) is ignored here; /// the telemetry screen handles those. void _handleSelfTelemetry(List frame) { @@ -2951,7 +2956,7 @@ class MeshCoreConnector extends ChangeNotifier { if (selfKey == null || selfKey.length < 6) return; if (frame.length < 8) return; // code + reserved + 6-byte pubkey prefix if (!listEquals(frame.sublist(2, 8), selfKey.sublist(0, 6))) { - return; // not the self node — a contact telemetry reply + return; // not the self node, a contact telemetry reply } // Guard the parse: a malformed CayenneLPP payload from an external radio // must not throw out of the RX dispatch and stall the receive loop. (#123) @@ -3053,7 +3058,7 @@ class MeshCoreConnector extends ChangeNotifier { _webInitialHandshakeRequestSent = true; } await sendFrame(buildDeviceQueryFrame()); - await sendFrame(buildAppStartFrame()); + await sendFrame(buildAppStartFrame(clientId: ClientIdStore.load())); await requestBatteryStatus(force: true); await sendFrame(buildGetCustomVarsFrame()); await sendFrame(buildGetAutoAddFlagsFrame()); @@ -3076,7 +3081,7 @@ class MeshCoreConnector extends ChangeNotifier { _webInitialHandshakeRequestSent = true; } await sendFrame(buildDeviceQueryFrame()); - await sendFrame(buildAppStartFrame()); + await sendFrame(buildAppStartFrame(clientId: ClientIdStore.load())); await sendFrame(buildGetCustomVarsFrame()); await requestBatteryStatus(); await sendFrame(buildGetAutoAddFlagsFrame()); @@ -3095,7 +3100,7 @@ class MeshCoreConnector extends ChangeNotifier { /// and awaiting SELF_INFO, and never while an initial sync is in flight (the /// Web-path guard, so the retry doesn't contend with the contact/channel /// sync). The timer keeps rescheduling, so the skip is per-tick, not - /// permanent — once a sync settles, the next tick sends. (#88) + /// permanent, once a sync settles, the next tick sends. (#88) static bool shouldSendAppStartRetry({ required bool connected, required bool awaitingSelfInfo, @@ -3119,7 +3124,9 @@ class MeshCoreConnector extends ChangeNotifier { return; } attempts += 1; - unawaited(sendFrame(buildAppStartFrame())); + unawaited( + sendFrame(buildAppStartFrame(clientId: ClientIdStore.load())), + ); if (attempts >= maxAttempts) { timer.cancel(); } @@ -3128,7 +3135,7 @@ class MeshCoreConnector extends ChangeNotifier { } // BLE/USB: bounded backoff -> slow keep-alive (was an unbounded 3.5s // hammer). A slow handshake no longer churns the connect or contends with - // the initial sync, but the retry never hard-stops — a slow/recovering + // the initial sync, but the retry never hard-stops, a slow/recovering // device still gets prompted (#88). _appStartRetryAttempt = 0; _armAppStartRetry(); @@ -3152,7 +3159,7 @@ class MeshCoreConnector extends ChangeNotifier { awaitingSelfInfo: _awaitingSelfInfo, syncing: syncing, )) { - unawaited(sendFrame(buildAppStartFrame())); + unawaited(sendFrame(buildAppStartFrame(clientId: ClientIdStore.load()))); if (_appStartRetryAttempt < 5) _appStartRetryAttempt++; } _armAppStartRetry(); @@ -3209,7 +3216,7 @@ class MeshCoreConnector extends ChangeNotifier { }) async { if (!isConnected || text.isEmpty) return; // Outgoing DMs (incl. reactions and retries) to a blocked contact are - // dropped — mirrors the incoming DM-drop so a block is symmetric (#252). + // dropped, mirrors the incoming DM-drop so a block is symmetric (#252). if (_blockService?.isBlocked(contact.publicKeyHex) ?? false) { appLogger.info( 'Dropped outgoing DM to blocked contact', @@ -3235,7 +3242,7 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); // Route through retry service (same as normal messages) - // Don't use auto-rotation for reactions — just send directly + // Don't use auto-rotation for reactions, just send directly if (_retryService != null) { await _retryService!.sendMessageWithRetry(contact: contact, text: text); } else { @@ -3607,8 +3614,9 @@ class MeshCoreConnector extends ChangeNotifier { _channelMessages.putIfAbsent(channel.index, () => []); final messages = _channelMessages[channel.index]!; - // Process reaction locally to update the UI immediately - _processReaction(messages, reactionInfo); + // Process reaction locally to update the UI immediately. This is our own + // outgoing reaction, so the reactor is us. + _processReaction(messages, reactionInfo, _selfName ?? 'Me'); await _channelMessageStore.saveChannelMessages(channel.index, messages); // Mark this reaction as processed @@ -3620,11 +3628,22 @@ class MeshCoreConnector extends ChangeNotifier { final reactionQueueId = _nextReactionSendQueueId(); _pendingChannelSentQueue.add(reactionQueueId); await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); - await sendFrame( - buildSendChannelTextMsgFrame(channel.index, text), - channelSendQueueId: reactionQueueId, - expectsGenericAck: true, - ); + try { + await sendFrame( + buildSendChannelTextMsgFrame(channel.index, text), + channelSendQueueId: reactionQueueId, + expectsGenericAck: true, + ); + } catch (e) { + // A failed write must not leave a stale id at the head of the FIFO + // queue, or the next channel message's OK would be credited to it and + // the real message would never clear (#395). + _pendingChannelSentQueue.remove(reactionQueueId); + appLogger.error( + 'Failed to send channel reaction: $e', + tag: 'Connector', + ); + } return; } @@ -3642,11 +3661,19 @@ class MeshCoreConnector extends ChangeNotifier { final outboundText = prepareChannelOutboundText(channel.index, text); await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); - await sendFrame( - buildSendChannelTextMsgFrame(channel.index, outboundText), - channelSendQueueId: message.messageId, - expectsGenericAck: true, - ); + try { + await sendFrame( + buildSendChannelTextMsgFrame(channel.index, outboundText), + channelSendQueueId: message.messageId, + expectsGenericAck: true, + ); + } catch (e) { + // Clear the stuck queue id and surface the failure in the chat bubble + // instead of leaving the message pending and blocking the queue (#395). + _pendingChannelSentQueue.remove(message.messageId); + _markChannelMessageFailed(channel.index, message.messageId); + appLogger.error('Failed to send channel message: $e', tag: 'Connector'); + } } Future removeContact(Contact contact) async { @@ -3758,7 +3785,7 @@ class MeshCoreConnector extends ChangeNotifier { ); if (existingIndex >= 0) { final existing = _contacts[existingIndex]; - // Preserve pathOverride and pathOverrideBytes — only reset device path + // Preserve pathOverride and pathOverrideBytes, only reset device path _contacts[existingIndex] = existing.copyWith( pathLength: -1, path: Uint8List(0), @@ -4154,7 +4181,7 @@ class MeshCoreConnector extends ChangeNotifier { // than the one whose history is stored here, clear the stale history so a // previous occupant's messages (e.g. #echo) don't surface under the newly // added channel. Preserve only when the slot already holds the SAME channel - // (same PSK) — e.g. a rename. (#193) + // (same PSK), e.g. a rename. (#193) final existing = _findChannelByIndex(index); final sameChannel = existing != null && listEquals(existing.psk, psk); @@ -4200,7 +4227,7 @@ class MeshCoreConnector extends ChangeNotifier { if (!supportsOffbandGps) return null; final existing = _offbandGpsCompleter; if (existing != null && !existing.isCompleted) { - // A query is already in flight — share it rather than orphaning it. + // A query is already in flight, share it rather than orphaning it. return existing.future.timeout(timeout, onTimeout: () => null); } final completer = Completer(); @@ -4226,7 +4253,7 @@ class MeshCoreConnector extends ChangeNotifier { final completer = _offbandGpsCompleter; _offbandGpsCompleter = null; _latestOffbandGpsStatus = status; - // A live fix is the device's real position — keep self-lat/lon (and the + // A live fix is the device's real position, keep self-lat/lon (and the // map) fresh from it, replacing what the old APP_START GPS poll did. (#140) if (status.hasLiveFix) { _selfLatitude = status.latitude; @@ -4270,6 +4297,172 @@ class MeshCoreConnector extends ChangeNotifier { } } + /// Download the device's serial-capture buffer over the companion protocol + /// (0xC4). Sends the request and reassembles the START/CHUNK*/END stream into + /// the raw captured bytes. + /// + /// Throws [StateError] if a download is already in flight, + /// [CaplogBusyException] if the device rejects because another stream is + /// already in flight, [CaplogTruncatedException] if the byte count doesn't + /// match the announced length, or [TimeoutException] if it never finishes. + /// (#430) + Future downloadCaplog({ + Duration timeout = const Duration(seconds: 30), + }) async { + if (_caplogCompleter != null) { + throw StateError('A caplog download is already in progress'); + } + final completer = Completer(); + _caplogCompleter = completer; + _caplogReassembler = CaplogReassembler(); + _caplogAwaitingStart = true; + final timer = Timer(timeout, () { + if (!completer.isCompleted) { + completer.completeError( + TimeoutException('caplog download timed out', timeout), + ); + } + }); + try { + await sendFrame(buildOffbandCaplogRequestFrame()); + return await completer.future; + } finally { + timer.cancel(); + _caplogCompleter = null; + _caplogReassembler = null; + _caplogAwaitingStart = false; + } + } + + /// Route an incoming 0xC4 caplog frame into the in-flight download, if any. + /// START/CHUNK/END are reassembled by [CaplogReassembler]; frames arriving + /// with no download in flight are ignored. (#430) + void _handleOffbandCaplogFrame(Uint8List frame) { + // Control replies (ACK / STATUS) go to their own pending completers, + // independent of any download in flight. (#430) + if (frame.length >= 2) { + if (frame[1] == caplogRespAck) { + final ack = parseCaplogAck(frame); + final c = _caplogAckCompleter; + if (ack != null && c != null && !c.isCompleted) c.complete(ack); + return; + } + if (frame[1] == caplogRespStatus) { + final status = parseCaplogStatus(frame); + final c = _caplogStatusCompleter; + if (status != null && c != null && !c.isCompleted) c.complete(status); + return; + } + } + // Download stream (START/CHUNK/END). + final completer = _caplogCompleter; + final reassembler = _caplogReassembler; + if (completer == null || reassembler == null || completer.isCompleted) { + return; + } + final event = reassembler.accept(frame); + switch (event.status) { + case CaplogStatus.started: + _caplogAwaitingStart = false; + _caplogChunks = 0; + break; + case CaplogStatus.chunk: + _caplogChunks++; + break; + case CaplogStatus.completed: + _appDebugLogService?.info( + 'Caplog download complete: ${event.bytes!.length} bytes in ' + '$_caplogChunks chunks', + tag: 'Caplog', + ); + completer.complete(event.bytes); + break; + case CaplogStatus.truncated: + _appDebugLogService?.warn( + 'Caplog download truncated: ${event.bytes!.length} of ' + '${event.expected} bytes in $_caplogChunks chunks', + tag: 'Caplog', + ); + completer.completeError( + CaplogTruncatedException( + received: event.bytes!.length, + expected: event.expected!, + chunks: _caplogChunks, + ), + ); + break; + case CaplogStatus.ignored: + break; + } + } + + /// Enable or disable serial capture on the device (0xC4 control). Returns the + /// device's `ok` result. (#430) + Future setDeviceCaplogEnabled( + bool enabled, { + Duration timeout = const Duration(seconds: 5), + }) => _caplogControl( + enabled + ? buildOffbandCaplogEnableFrame() + : buildOffbandCaplogDisableFrame(), + timeout, + ); + + /// Erase the device's serial-capture buffer (0xC4 control). Returns `ok`. + /// (#430) + Future eraseDeviceCaplog({ + Duration timeout = const Duration(seconds: 5), + }) => _caplogControl(buildOffbandCaplogEraseFrame(), timeout); + + Future _caplogControl(Uint8List frame, Duration timeout) async { + if (_caplogAckCompleter != null) { + throw StateError('A caplog control op is already in progress'); + } + final completer = Completer(); + _caplogAckCompleter = completer; + final timer = Timer(timeout, () { + if (!completer.isCompleted) { + completer.completeError( + TimeoutException('caplog control timed out', timeout), + ); + } + }); + try { + await sendFrame(frame); + final ack = await completer.future; + return ack.ok; + } finally { + timer.cancel(); + _caplogAckCompleter = null; + } + } + + /// Query the device's capture status: enabled / level / used / capacity + /// (0xC4 control). (#430) + Future getDeviceCaplogStatus({ + Duration timeout = const Duration(seconds: 5), + }) async { + if (_caplogStatusCompleter != null) { + throw StateError('A caplog status query is already in progress'); + } + final completer = Completer(); + _caplogStatusCompleter = completer; + final timer = Timer(timeout, () { + if (!completer.isCompleted) { + completer.completeError( + TimeoutException('caplog status timed out', timeout), + ); + } + }); + try { + await sendFrame(buildOffbandCaplogStatusFrame()); + return await completer.future; + } finally { + timer.cancel(); + _caplogStatusCompleter = null; + } + } + /// Called when a BLOCK_LIST dump ends. A truncated dump (early-END) is /// re-requested once the link settles; never derive removals from a partial /// pull. The union reconcile against the local list is wired in B4. @@ -4290,10 +4483,10 @@ class MeshCoreConnector extends ChangeNotifier { }); return; } - // Complete dump — UNION. Exclude any key the user block/unblocked WHILE the + // Complete dump, UNION. Exclude any key the user block/unblocked WHILE the // dump was in flight: _pushBlockChange already sent the right ADD/REMOVE and // the local state is authoritative, so the stale dump must NOT override it - // (prevents re-importing an unblock — data loss — and redundant re-pushes). + // (prevents re-importing an unblock, data loss, and redundant re-pushes). final blockService = _blockService; if (blockService == null) { _blockDumpInFlight = false; @@ -4459,6 +4652,9 @@ class MeshCoreConnector extends ChangeNotifier { case cmdOffbandFemLna: _handleOffbandFemLnaReply(frame); break; + case respCodeOffbandCaplog: + _handleOffbandCaplogFrame(frame); + break; case respCodeSelfInfo: debugPrint('Got SELF_INFO'); _handleSelfInfo(frame); @@ -4562,6 +4758,17 @@ class MeshCoreConnector extends ChangeNotifier { case pushCodeTelemetryResponse: _handleSelfTelemetry(frame); break; + case pushCodeChannelsChanged: + // Device's channel table changed (add/update/delete). Re-poll instead + // of only refreshing on reconnect (#429 part A). Debounce so a burst + // (e.g. a bulk edit on the device) coalesces into one re-sync rather + // than repeatedly clearing and repopulating the channel list. + _channelsChangedDebounce?.cancel(); + _channelsChangedDebounce = Timer( + const Duration(milliseconds: 400), + () => unawaited(getChannels(force: true)), + ); + break; case respCodeChannelInfo: _handleChannelInfo(frame); break; @@ -4600,13 +4807,38 @@ class MeshCoreConnector extends ChangeNotifier { }) => isSyncingChannels && channelSyncInFlight && !hasPendingGenericAck; void _handleErrorFrame(Uint8List frame) { + // A caplog download awaiting its START frame: the firmware answers the + // generic RESP_CODE_ERR when another stream (block-list / contacts / + // observer config) is already in flight. Fail the download fast with a + // typed error instead of waiting out the timeout. Falling through to the + // normal handling below is harmless, it no-ops unless a channel sync is + // active. (#430) + if (_caplogAwaitingStart) { + _caplogAwaitingStart = false; + final download = _caplogCompleter; + if (download != null && !download.isCompleted) { + download.completeError(const CaplogBusyException()); + } + } + // Control ops (enable / disable / erase / status) are likewise rejected with + // RESP_CODE_ERR while a stream is in flight; fail their pending completers + // fast rather than hanging until the 5s timeout. A status poll swallows the + // error and retries, so this is safe even for an unrelated ERR. (#430) + final ack = _caplogAckCompleter; + if (ack != null && !ack.isCompleted) { + ack.completeError(const CaplogBusyException()); + } + final status = _caplogStatusCompleter; + if (status != null && !status.isCompleted) { + status.completeError(const CaplogBusyException()); + } final errCode = frame.length > 1 ? frame[1] : -1; _appDebugLogService?.warn( 'Firmware responded with error code: $errCode', tag: 'Protocol', ); - // An in-flight channel GET that draws an ERR means that slot is empty — + // An in-flight channel GET that draws an ERR means that slot is empty, // advance to the next index the instant the ERR lands instead of waiting // out the timeout + retry budget (mirrors the CHANNEL_INFO success path // minus adding a channel). This is the whole sync-slowness fix (#82). @@ -4711,7 +4943,7 @@ class MeshCoreConnector extends ChangeNotifier { } // GPS poll responses arrive as RESP_CODE_SELF_INFO but are not the real - // handshake — only update location and notify, skip store reloads and + // handshake, only update location and notify, skip store reloads and // contact sync which would clear and re-fetch contacts every minute. if (!wasAwaitingSelfInfo) { notifyListeners(); @@ -4769,7 +5001,7 @@ class MeshCoreConnector extends ChangeNotifier { /// Extract the additive `offband_caps` byte from a device-info reply. /// /// Offset verified against firmware MyMesh.cpp: the reply tail is three - /// consecutive `out_frame[i++]` writes — client_repeat (byte 80, v9+), + /// consecutive `out_frame[i++]` writes, client_repeat (byte 80, v9+), /// path_hash_mode (byte 81, v10+), offband_caps (byte 82, v14+). Bytes 80/81 /// are shipping/working (path-hash feature), so byte 82 is the adjacent next /// byte by construction. Pre-v14 firmware sends a shorter frame -> null. @@ -4778,8 +5010,8 @@ class MeshCoreConnector extends ChangeNotifier { frame.length >= 83 ? frame[82] : null; /// FEM LNA state byte, appended immediately after the caps byte in device-info - /// v16+ (firmware #298). Appended **unconditionally** — including on - /// non-capable boards, where it reads 0 — so the byte's presence indicates + /// v16+ (firmware #298). Appended **unconditionally**, including on + /// non-capable boards, where it reads 0, so the byte's presence indicates /// firmware version, not capability. The capability BIT is what decides /// whether to render the control. Null on v15 and older (shorter frame). static bool? parseFemLnaState(Uint8List frame) => @@ -4792,7 +5024,7 @@ class MeshCoreConnector extends ChangeNotifier { } _firmwareVerCode = frame[1]; - // Readable build date / model / firmware version — NUL-terminated strings + // Readable build date / model / firmware version, NUL-terminated strings // after the 8-byte header, before the binary config block. (#134) final infoStrings = parseDeviceInfoStrings(frame); final info = deviceInfoFields(infoStrings); @@ -4833,7 +5065,7 @@ class MeshCoreConnector extends ChangeNotifier { // testable helper; offset verified against firmware (see parseOffbandCaps). _offbandCaps = parseOffbandCaps(frame); // FEM LNA state rides one byte past the caps byte on v16+ (#304). Primary - // read on connect — a 0xC3 GET is only the fallback. + // read on connect, a 0xC3 GET is only the fallback. _femLnaEnabled = parseFemLnaState(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 @@ -4848,7 +5080,7 @@ class MeshCoreConnector extends ChangeNotifier { // 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(); - // Caps landed — if the radio speaks block, pull its list and union-reconcile. + // Caps landed, if the radio speaks block, pull its list and union-reconcile. _reconcileFirmwareBlock(); // Firmware reports MAX_CONTACTS / 2 for v3+ device info. @@ -5065,7 +5297,7 @@ class MeshCoreConnector extends ChangeNotifier { int _physicsMinTimeout(int pathLength, int airtime) { if (pathLength < 0) { - // Same as max for flood — firmware uses a single formula + // Same as max for flood, firmware uses a single formula return 500 + (16 * airtime); } else { return airtime * (pathLength + 1); @@ -5101,7 +5333,7 @@ class MeshCoreConnector extends ChangeNotifier { return mlTimeout.clamp(physicsMin, physicsMax); } - // No ML data — use firmware formula + // No ML data, use firmware formula return physicsMax; } @@ -5465,7 +5697,7 @@ class MeshCoreConnector extends ChangeNotifier { 'claimed=${claimed.toIso8601String()} ' 'delta=${formatRxTimeDelta(arrival, claimed)} path=$path'; // Direct service call (not appLogger): the file trail must not depend on - // the in-app debug-log toggle — the service always writes to disk and the + // the in-app debug-log toggle, the service always writes to disk and the // toggle only gates the in-app ring buffer. if (isRxTimeAnomaly(arrival, claimed)) { _appDebugLogService?.warn('$line ** TIME ANOMALY **', tag: 'Ingest'); @@ -5527,7 +5759,7 @@ class MeshCoreConnector extends ChangeNotifier { return; } - // Blocked sender: drop the DM entirely — no thread entry, no unread, no + // Blocked sender: drop the DM entirely, no thread entry, no unread, no // notification, no lastMessage bump. Adverts still flow for name self-heal. if (!message.isOutgoing && (_blockService?.isBlocked(message.senderKeyHex) ?? false)) { @@ -5651,17 +5883,31 @@ class MeshCoreConnector extends ChangeNotifier { } // Companion radio layout: - // [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...] - // double snr = 0; + // [code][snr][res1][res2][prefix x6][path_len][txt_type][timestamp x4][extra?][text...] + // snr: (int8)(snr_dB * 4), so dB = byte / 4.0 (MyMesh.cpp:512). + // res1 bit0 = device-composed outgoing flag (#429). + // res2 = RX RSSI as clamped int8 dBm, or 0 when unset (older firmware or + // no-RX text). Gate on != 0 (MyMesh.cpp:550-551, #465). RSSI is always + // negative for a real RX, so 0 unambiguously means "no data". + double? snr; + bool isOutgoing = false; + int? rssi; if (code == respCodeContactMsgRecvV3) { - // Older firmware layout with SNR as a signed byte after the code - // snr = reader.readInt8().toDouble() * 4; // SNR in dB, scaled by 4 - reader.skipBytes(1); // Skip SNR byte - reader.skipBytes(2); // Skip reserved bytes + snr = reader.readInt8() / 4.0; + // reserved1 bit0 = outgoing flag: set for a message composed on the + // device itself, so it renders as sent-by-me (#429 part B). + isOutgoing = (reader.readByte() & 0x01) != 0; + final rssiByte = reader.readInt8(); // reserved2 = RX RSSI (#439/#465) + rssi = rssiByte != 0 ? rssiByte : null; } final senderPrefix = reader.readBytes(6); - final pathLength = reader.readByte(); + // path_len is the flood hop count, or 0xFF when the frame arrived + // direct/point-to-point (MyMesh.cpp:545). Preserve that distinction for + // the Path screen before the 0xFF->0 normalization below. (#438) + final rawPathLen = reader.readByte(); + final isFloodRoute = rawPathLen != 0xFF; + final pathLength = rawPathLen; final txtType = reader.readByte(); final timestampRaw = reader.readUInt32LE(); final timestamp = DateTime.fromMillisecondsSinceEpoch( @@ -5717,13 +5963,18 @@ class MeshCoreConnector extends ChangeNotifier { senderKey: contact.publicKey, text: decodedText, timestamp: timestamp, - isOutgoing: false, + isOutgoing: isOutgoing, isCli: isCli, - status: MessageStatus.delivered, + status: isOutgoing ? MessageStatus.sent : MessageStatus.delivered, pathLength: pathLength == 0xFF ? 0 : pathLength, pathBytes: Uint8List(0), fourByteRoomContactKey: roomAuthorPrefix, - rxTime: DateTime.now(), + // A device-composed outgoing message has no real RX frame, so its SNR / + // path-type / rxTime are meaningless, null them like rxTime (#429/#438). + rxTime: isOutgoing ? null : DateTime.now(), + snr: isOutgoing ? null : snr, + rssi: isOutgoing ? null : rssi, + isFloodRoute: isOutgoing ? null : isFloodRoute, ); } catch (e) { appLogger.warn('Error parsing contact direct message: $e'); @@ -5758,17 +6009,6 @@ class MeshCoreConnector extends ChangeNotifier { /// Notifying per contact rebuilds the whole tree once per contact, and each /// rebuild itself walks the contact list, so a large address book turns this /// into O(contacts^2) work on the UI isolate. - Future _ensureContactSmazSettingLoaded( - String contactKeyHex, { - bool notify = true, - }) async { - if (_contactSmazEnabled.containsKey(contactKeyHex)) return; - final enabled = await _contactSettingsStore.loadSmazEnabled(contactKeyHex); - if (_contactSmazEnabled[contactKeyHex] == enabled) return; - _contactSmazEnabled[contactKeyHex] = enabled; - if (notify) notifyListeners(); - } - Future _ensureContactCyr2LatSettingLoaded( String contactKeyHex, { bool notify = true, @@ -5848,9 +6088,7 @@ class MeshCoreConnector extends ChangeNotifier { trimmed.startsWith('m:') || trimmed.startsWith('V1|'); if (!isStructuredPayload) { - if (isContactSmazEnabled(contact.publicKeyHex)) { - return Smaz.encodeIfSmaller(text); - } else if (isContactCyr2LatEnabled(contact.publicKeyHex)) { + if (isContactCyr2LatEnabled(contact.publicKeyHex)) { final profileId = getContactCyr2LatProfileId(contact.publicKeyHex); final profile = profileId != null && _appSettingsService != null ? _appSettingsService!.getCyr2LatProfileById(profileId) @@ -5876,9 +6114,7 @@ class MeshCoreConnector extends ChangeNotifier { final isStructuredPayload = trimmed.startsWith('g:') || trimmed.startsWith('m:'); if (!isStructuredPayload) { - if (isChannelSmazEnabled(channelIndex)) { - return Smaz.encodeIfSmaller(text); - } else if (isChannelCyr2LatEnabled(channelIndex)) { + if (isChannelCyr2LatEnabled(channelIndex)) { final profileId = getChannelCyr2LatProfileId(channelIndex); final profile = profileId != null && _appSettingsService != null ? _appSettingsService!.getCyr2LatProfileById(profileId) @@ -5989,7 +6225,10 @@ class MeshCoreConnector extends ChangeNotifier { } final parsed = ChannelMessage.fromFrame(frame); if (parsed != null && parsed.channelIndex != null) { - if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) { + // Device-composed (outgoing) messages legitimately carry our own name and + // no path; don't let the self-echo guard drop them (#429 part B). + if (!parsed.isOutgoing && + _shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) { return; } _lastChannelMsgRxTime = DateTime.now(); @@ -6236,6 +6475,29 @@ class MeshCoreConnector extends ChangeNotifier { return false; } + void _markChannelMessageFailed(int channelIndex, String messageId) { + final channelMessages = _channelMessages[channelIndex]; + if (channelMessages == null) return; + for (int i = channelMessages.length - 1; i >= 0; i--) { + final message = channelMessages[i]; + if (message.messageId != messageId) { + continue; + } + if (!message.isOutgoing || + message.status != ChannelMessageStatus.pending) { + return; + } + channelMessages[i] = message.copyWith( + status: ChannelMessageStatus.failed, + ); + unawaited( + _channelMessageStore.saveChannelMessages(channelIndex, channelMessages), + ); + notifyListeners(); + return; + } + } + void _handleOk() { if (_pendingGenericAckQueue.isEmpty) { return; @@ -6415,7 +6677,7 @@ class MeshCoreConnector extends ChangeNotifier { } Future setChannelOrder(List order) async { - // Don't persist channel state after disconnect — the store scope still + // Don't persist channel state after disconnect, the store scope still // points at the last radio, so a stale write can mis-scope (#23). if (!isConnected) return; _channelOrder = List.from(order); @@ -6534,13 +6796,45 @@ class MeshCoreConnector extends ChangeNotifier { _conversations.putIfAbsent(pubKeyHex, () => []); final messages = _conversations[pubKeyHex]!; - // Parse reaction info - final reactionInfo = Message.parseReaction(message.text); + // Parse reaction info. Our own format first, then the PocketMesh / + // MeshCore One format (#380). A room server behaves like a channel (many + // senders) while 1:1 does not, and the two forms are mutually exclusive by + // shape, so try both rather than guessing which one applies. + final reactionInfo = + Message.parseReaction(message.text) ?? + _parsePocketMeshReaction(message.text, isDm: true) ?? + _parsePocketMeshReaction(message.text, isDm: false); if (reactionInfo != null) { - // Check if we've already processed this exact reaction + // Check if we've already processed this exact reaction. A room server is + // many-party over the 1:1 transport, so the reacting author belongs in + // the key or two members sending the same emoji collapse into one; in a + // true 1:1 the author prefix is empty and the key is unchanged. _processedContactReactions.putIfAbsent(pubKeyHex, () => {}); + // The reactor's display name: in a room the frame's own author field + // identifies them; in a true 1:1 it is simply the contact. Falls back to + // the hex author prefix, then the pubkey, so it is never empty (#383). + final reactionContact = _contacts.cast().firstWhere( + (c) => c?.publicKeyHex == pubKeyHex, + orElse: () => null, + ); + final isRoomReaction = reactionContact?.type == advTypeRoom; + final reactingAuthorHex = message.fourByteRoomContactKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + // In a room, reactionContact is the room server, NOT the reactor, so its + // name must never be the fallback: it would attribute every unknown-author + // reaction to the room and then dedup distinct authors into one, dropping + // reactions (#383). Fall back to the per-author hex, which is unique per + // reactor. In a true 1:1 the author hex is empty and the contact IS the + // reactor, so its name is correct. + final fallbackName = (isRoomReaction && reactingAuthorHex.isNotEmpty) + ? reactingAuthorHex + : (reactionContact?.name ?? pubKeyHex); + final reactorName = + _resolveContactSenderName(message, reactionContact, isRoomReaction) ?? + fallbackName; final reactionIdentifier = - '${reactionInfo.targetHash}_${reactionInfo.emoji}'; + '${reactionInfo.targetHash}_${reactionInfo.emoji}_$reactingAuthorHex'; final isDuplicate = _processedContactReactions[pubKeyHex]!.contains( reactionIdentifier, @@ -6548,7 +6842,21 @@ class MeshCoreConnector extends ChangeNotifier { if (!isDuplicate) { // New reaction - process it - _processContactReaction(messages, reactionInfo, pubKeyHex); + final matched = _processContactReaction( + messages, + reactionInfo, + pubKeyHex, + reactorName, + ); + if (!matched) { + // Early arrival, not junk. Hold it for the target (#382). + _pendingReactions.add( + _contactScopeKey(pubKeyHex), + reactionInfo, + reactorName, + DateTime.now(), + ); + } _messageStore.saveMessages(pubKeyHex, messages); // Mark as processed @@ -6560,14 +6868,25 @@ class MeshCoreConnector extends ChangeNotifier { } messages.add(message); + // A message just landed, so a reaction that arrived before it may now have + // its target (#382). + _pendingReactions.retry( + _contactScopeKey(pubKeyHex), + (info, reactingSender) => + _processContactReaction(messages, info, pubKeyHex, reactingSender), + DateTime.now(), + ); _messageStore.saveMessages(pubKeyHex, messages); notifyListeners(); } - void _processContactReaction( + String _contactScopeKey(String pubKeyHex) => 'contact:$pubKeyHex'; + + bool _processContactReaction( List messages, ReactionInfo reactionInfo, String contactPubKeyHex, + String reactingSender, ) { final contact = _contacts.cast().firstWhere( (c) => c?.publicKeyHex == contactPubKeyHex, @@ -6575,9 +6894,10 @@ class MeshCoreConnector extends ChangeNotifier { ); final isRoomServer = contact?.type == advTypeRoom; - ReactionHelper.applyReaction( + return ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, + reactingSender: reactingSender, // Incoming reactions in 1:1: match against outgoing messages only shouldSkip: (msg) => isRoomServer != true && !msg.isOutgoing, getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, @@ -6585,8 +6905,12 @@ class MeshCoreConnector extends ChangeNotifier { _resolveContactSenderName(msg, contact, isRoomServer == true), getMessageText: (msg) => msg.text, getReactions: (msg) => msg.reactions, - updateMessage: (i, reactions) { - messages[i] = messages[i].copyWith(reactions: reactions); + getReactionSenders: (msg) => msg.reactionSenders, + updateMessage: (i, reactions, senders) { + messages[i] = messages[i].copyWith( + reactions: reactions, + reactionSenders: senders, + ); }, ); } @@ -6601,6 +6925,8 @@ class MeshCoreConnector extends ChangeNotifier { ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, + // Our own outgoing reaction, so the reactor is us. + reactingSender: _selfName ?? 'Me', // Outgoing reactions in 1:1: match against incoming messages shouldSkip: (msg) => !isRoomServer && msg.isOutgoing, getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, @@ -6608,8 +6934,12 @@ class MeshCoreConnector extends ChangeNotifier { _resolveContactSenderName(msg, contact, isRoomServer), getMessageText: (msg) => msg.text, getReactions: (msg) => msg.reactions, - updateMessage: (i, reactions) { - messages[i] = messages[i].copyWith(reactions: reactions); + getReactionSenders: (msg) => msg.reactionSenders, + updateMessage: (i, reactions, senders) { + messages[i] = messages[i].copyWith( + reactions: reactions, + reactionSenders: senders, + ); }, ); } @@ -6780,21 +7110,40 @@ class MeshCoreConnector extends ChangeNotifier { _channelMessages.putIfAbsent(channelIndex, () => []); final messages = _channelMessages[channelIndex]!; - // Parse reaction info - final reactionInfo = ChannelMessage.parseReaction(message.text); + // Parse reaction info. Our own format first, then the PocketMesh / + // MeshCore One format other clients send (#380). + final reactionInfo = + ChannelMessage.parseReaction(message.text) ?? + _parsePocketMeshReaction(message.text, isDm: false); if (reactionInfo != null) { // Check if we've already processed this exact reaction _processedChannelReactions.putIfAbsent(channelIndex, () => {}); + // The reacting sender belongs in the key: without it, two people sending + // the same emoji to the same message collapse into one and the count + // never leaves 1. final reactionIdentifier = - '${reactionInfo.targetHash}_${reactionInfo.emoji}'; + '${reactionInfo.targetHash}_${reactionInfo.emoji}_${message.senderName}'; final isDuplicate = _processedChannelReactions[channelIndex]!.contains( reactionIdentifier, ); if (!isDuplicate) { - // New reaction - process it - _processReaction(messages, reactionInfo); + // New reaction - process it. The reactor is the frame's sender. + final matched = _processReaction( + messages, + reactionInfo, + message.senderName, + ); + if (!matched) { + // Early arrival, not junk. Hold it for the target (#382). + _pendingReactions.add( + _channelScopeKey(channelIndex), + reactionInfo, + message.senderName, + DateTime.now(), + ); + } // Save updated messages _channelMessageStore.saveChannelMessages(channelIndex, messages); @@ -6849,25 +7198,49 @@ class MeshCoreConnector extends ChangeNotifier { messages.add(processedMessage); } + // A message just landed, so a reaction that arrived before it may now have + // its target (#382). + _pendingReactions.retry( + _channelScopeKey(channelIndex), + (info, reactingSender) => + _processReaction(messages, info, reactingSender), + DateTime.now(), + ); + // Save to persistent storage _channelMessageStore.saveChannelMessages(channelIndex, messages); return isNew; } - void _processReaction( + String _channelScopeKey(int channelIndex) => 'channel:$channelIndex'; + + /// The PocketMesh / MeshCore One reaction format, wrapped for matching. + /// Receive-only: we never emit it. + ReactionInfo? _parsePocketMeshReaction(String text, {required bool isDm}) { + final parsed = PocketMeshReaction.parse(text, isDm: isDm); + return parsed == null ? null : ReactionInfo.pocketMesh(parsed); + } + + bool _processReaction( List messages, ReactionInfo reactionInfo, + String reactingSender, ) { - ReactionHelper.applyReaction( + return ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, + reactingSender: reactingSender, shouldSkip: (_) => false, getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, getSenderName: (msg) => msg.senderName, getMessageText: (msg) => msg.text, getReactions: (msg) => msg.reactions, - updateMessage: (i, reactions) { - messages[i] = messages[i].copyWith(reactions: reactions); + getReactionSenders: (msg) => msg.reactionSenders, + updateMessage: (i, reactions, senders) { + messages[i] = messages[i].copyWith( + reactions: reactions, + reactionSenders: senders, + ); notifyListeners(); }, ); @@ -7070,7 +7443,7 @@ class MeshCoreConnector extends ChangeNotifier { /// reported clock. Frame layout (firmware companion_radio/MyMesh.cpp:678+): /// [0]=0x85, [1]=permissions, [2..7]=pubkey prefix (6 bytes), /// [8..11]=repeater RTC unix seconds (LE), [12]=ACL perms, [13]=fw level - /// The timestamp is only present in the v7+ "new login response" — older + /// The timestamp is only present in the v7+ "new login response", older /// firmware emits a shorter frame that we silently skip. void _handleLoginSuccess(Uint8List frame) { if (frame.length < 12) return; @@ -7149,6 +7522,7 @@ class MeshCoreConnector extends ChangeNotifier { _batteryPollTimer?.cancel(); _gpsLocationPollTimer?.cancel(); _radioStatsPollTimer?.cancel(); + _channelsChangedDebounce?.cancel(); radioStatsNotifier.dispose(); _receivedFramesController.close(); _usbManager.dispose(); @@ -7180,7 +7554,7 @@ class MeshCoreConnector extends ChangeNotifier { final pathBytes = packet.readBytes(pathByteLen); final payload = packet.readBytes(packet.remaining); - // #186: feed the passive topology map from every routed packet's path — + // #186: feed the passive topology map from every routed packet's path, // the same firehose that feeds nearest-repeater detection. Cheap: the // path is already parsed here. Guarded so a topology-side fault can never // stall the RX dispatch that also drives message handling. diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index f486e79..09d5901 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/widgets.dart'; @@ -255,15 +256,15 @@ const int respCodeCustomVars = 21; const int respCodeAutoAddConfig = 25; const int respCodeStats = 24; -/// Offband fork-only extension space (0xC0+) — never collides with upstream, +/// Offband fork-only extension space (0xC0+), never collides with upstream, /// never submitted upstream. Request and reply share the code. (#135) const int cmdOffbandGps = 0xC1; const int respCodeOffbandGps = 0xC1; -/// Request frame for [cmdOffbandGps] — a bare 1-byte command, no payload. (#135) +/// Request frame for [cmdOffbandGps], a bare 1-byte command, no payload. (#135) Uint8List buildOffbandGpsRequestFrame() => Uint8List.fromList([cmdOffbandGps]); -// --- Offband FEM LNA command (0xC3) — capability-gated. Heltec V4 external +// --- Offband FEM LNA command (0xC3), capability-gated. Heltec V4 external // FEM LNA control; firmware counterpart OffbandMesh/meshcore-firmware#298. // // Deliberately a fork-private command rather than an extra byte on the stock @@ -311,7 +312,108 @@ OffbandFemLnaReply? parseOffbandFemLnaReply(Uint8List frame) { return OffbandFemLnaReply(frame[1], frame[2]); } -// --- Offband block command (0xC2) — capability-gated; see +// --- Offband caplog serial-capture download (0xC4), companion-API only; NEVER +// on the mesh. Firmware counterpart OffbandMesh/meshcore-firmware#406. +// +// 0xC4, NOT 0xC3: the firmware first merged this on 0xC3, which collides with +// cmdOffbandFemLna (0xC3, #298), the caplog handler swallowed every 0xC3 frame +// before FEM/LNA dispatch. Reassigned to 0xC4, the next free code in the 0xC0+ +// space (0xC0 config, 0xC1 GPS, 0xC2 block, 0xC3 FEM LNA). +// +// Request: bare [0xC4], no payload (mirrors cmdOffbandGps). Reply is a streamed +// dump: START [0xC4, 0x01, total_len(uint32 LE)] → CHUNK [0xC4, 0x02, ]* +// → END [0xC4, 0x03]. The firmware auto-stops capture for the duration (so +// offsets stay stable) and rejects with the generic [respCodeErr] when another +// stream (block-list / contacts / observer config) is already in flight. (#430) +const int cmdOffbandCaplog = 0xC4; +const int respCodeOffbandCaplog = 0xC4; +const int caplogSubStart = 0x01; +const int caplogSubChunk = 0x02; +const int caplogSubEnd = 0x03; + +// Caplog request sub-codes in cmd_frame[1]. A bare [0xC4] (len 1) is DOWNLOAD +// for back-compat. Firmware #417/#408. (#430) +const int caplogReqDownload = 0x01; +const int caplogReqEnable = 0x02; // [0xC4,0x02,(level)], omit level for default +const int caplogReqDisable = 0x03; +const int caplogReqErase = 0x04; +const int caplogReqStatus = 0x05; + +// Caplog CONTROL response sub-codes in out_frame[1] (download stream sub-codes +// caplogSubStart/Chunk/End are above). +const int caplogRespAck = 0x10; // [0xC4,0x10, req_op, ok(0|1)] +const int caplogRespStatus = + 0x11; // [0xC4,0x11, enabled, level, used(4B LE), cap(4B LE)] + +/// Request frame to download the device's serial-capture buffer, a bare 1-byte +/// command, no payload (firmware treats bare [0xC4] as DOWNLOAD). (#430) +Uint8List buildOffbandCaplogRequestFrame() => + Uint8List.fromList([cmdOffbandCaplog]); + +/// Enable capture on the device (default verbosity level). (#430) +Uint8List buildOffbandCaplogEnableFrame() => + Uint8List.fromList([cmdOffbandCaplog, caplogReqEnable]); + +/// Disable (stop) capture on the device. (#430) +Uint8List buildOffbandCaplogDisableFrame() => + Uint8List.fromList([cmdOffbandCaplog, caplogReqDisable]); + +/// Erase the device's capture buffer. (#430) +Uint8List buildOffbandCaplogEraseFrame() => + Uint8List.fromList([cmdOffbandCaplog, caplogReqErase]); + +/// Query capture status (enabled / level / used / capacity). (#430) +Uint8List buildOffbandCaplogStatusFrame() => + Uint8List.fromList([cmdOffbandCaplog, caplogReqStatus]); + +/// Parsed `[0xC4,0x10, req_op, ok]` control acknowledgement. [reqOp] echoes the +/// request sub-code (enable/disable/erase); [ok] is the device's result. (#430) +class CaplogAck { + const CaplogAck(this.reqOp, {required this.ok}); + final int reqOp; + final bool ok; +} + +/// Parse a caplog control ACK; null if [frame] isn't one. (#430) +CaplogAck? parseCaplogAck(Uint8List frame) { + if (frame.length < 4 || + frame[0] != respCodeOffbandCaplog || + frame[1] != caplogRespAck) { + return null; + } + return CaplogAck(frame[2], ok: frame[3] == 1); +} + +/// Parsed `[0xC4,0x11, enabled, level, used(4B LE), cap(4B LE)]` status. (#430) +class CaplogDeviceStatus { + const CaplogDeviceStatus({ + required this.enabled, + required this.level, + required this.usedBytes, + required this.capacityBytes, + }); + final bool enabled; + final int level; + final int usedBytes; + final int capacityBytes; +} + +/// Parse a caplog STATUS reply; null if [frame] isn't one. (#430) +CaplogDeviceStatus? parseCaplogStatus(Uint8List frame) { + if (frame.length < 12 || + frame[0] != respCodeOffbandCaplog || + frame[1] != caplogRespStatus) { + return null; + } + return CaplogDeviceStatus( + enabled: frame[2] != 0, + level: frame[3], + usedBytes: readUint32LE(frame, 4), + capacityBytes: readUint32LE(frame, 8), + ); +} + +// --- Offband block command (0xC2), capability-gated; see // docs/architecture/block-contract-as-built.md §8. Firmware as-built PR #247. --- const int cmdOffbandBlock = 0xC2; const int offbandBlockAdd = 0x01; @@ -320,11 +422,11 @@ const int offbandBlockList = 0x03; const int offbandBlockClear = 0x04; /// Result of a malformed 0xC2 request: firmware replies with the GENERIC error -/// frame `[respCodeErr(1)][errCodeIllegalArg(6)]` — NOT 0xC2-prefixed, so the +/// frame `[respCodeErr(1)][errCodeIllegalArg(6)]`, NOT 0xC2-prefixed, so the /// app must recognise the 2-byte error frame and not wait for a 0xC2 echo. const int errCodeIllegalArg = 6; -/// `ERR_CODE_UNSUPPORTED_CMD` — returned for an Offband command the connected +/// `ERR_CODE_UNSUPPORTED_CMD`, returned for an Offband command the connected /// board can't service (e.g. a `0xC3` FEM LNA request to a board without FEM /// control). Unreachable when the capability bit is respected; firmware answers /// it defensively against a stale or mis-gated client. (#304) @@ -358,7 +460,7 @@ OffbandBlockReply? parseOffbandBlockReply(Uint8List frame) { /// True iff the connected firmware understands the `0xC1` GPS extension. Gated /// on the `offband_caps` byte being present: that byte is an Offband-fork /// addition (device-info v14+) which stock/upstream MeshCore never emits, so a -/// null caps value means non-Offband firmware that couldn't answer `0xC1` — and +/// null caps value means non-Offband firmware that couldn't answer `0xC1`, and /// must not be pinged with it. (#144) /// /// Interim presence-gate: a dedicated `OFFBAND_CAP_GPS` bit (firmware @@ -376,12 +478,12 @@ const int offbandCapBlock = 0x02; /// `OFFBAND_CAP_FEM_LNA` bit (bit 2) in the `offband_caps` byte: this radio can /// control its external FEM LNA (firmware #298). /// -/// PROVISIONAL — firmware owns the caps byte and has not yet confirmed 0x04 as +/// PROVISIONAL, firmware owns the caps byte and has not yet confirmed 0x04 as /// free. Do not ship against this without that confirmation (#304). /// /// Gate on the BIT ONLY, never on model or version: firmware derives it at /// runtime from the auto-detected FEM chip (KCT8103L vs GC1109), so it is a -/// per-unit answer — two Heltec V4s can legitimately disagree, and other +/// per-unit answer, two Heltec V4s can legitimately disagree, and other /// FEM-bearing boards report false today. const int offbandCapFemLna = 0x04; @@ -393,6 +495,18 @@ bool firmwareSupportsOffbandBlock(int? offbandCaps, int? firmwareVerCode) => (offbandCaps & offbandCapBlock) != 0 && (firmwareVerCode ?? 0) >= 15; +/// Caplog serial-capture capability (firmware #427). The bit is compile-time +/// static in device-info, so it's reliable across reboots, the client re-reads +/// it on reconnect and never has to race a STATUS probe. Bit 5 (0x08/0x10 are +/// reserved for WiFi-companion #365 / display-config); requires FIRMWARE_VER_CODE +/// >= 17, the version that introduced it. +const int offbandCapCaplog = 0x20; + +bool firmwareSupportsOffbandCaplog(int? offbandCaps, int? firmwareVerCode) => + offbandCaps != null && + (offbandCaps & offbandCapCaplog) != 0 && + (firmwareVerCode ?? 0) >= 17; + const int statsTypeCore = 0; const int statsTypeRadio = 1; const int statsTypePackets = 2; @@ -410,6 +524,8 @@ const int pushCodeTraceData = 0x89; const int pushCodeNewAdvert = 0x8A; const int pushCodeTelemetryResponse = 0x8B; const int pushCodeBinaryResponse = 0x8C; +const int pushCodeChannelsChanged = + 0x91; // #429 part A: device channel table changed; re-poll getChannels // Contact/advertisement types const int advTypeChat = 1; @@ -472,16 +588,26 @@ const int _sendTextMsgOverheadBytes = const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin -int maxContactMessageBytes() { - final byFrame = maxFrameSize - _sendTextMsgOverheadBytes; +// [maxFrameBytes] is the largest frame the active transport can write in one +// operation. On BLE that is ATT_MTU - 3, which can be smaller than [maxFrameSize]; +// pass it so a max-length message never overflows the characteristic write (#395). +// Defaults to [maxFrameSize] for callers without a live transport (USB/TCP, tests). +int maxContactMessageBytes({int? maxFrameBytes}) { + final frameBudget = maxFrameBytes ?? maxFrameSize; + final byFrame = frameBudget - _sendTextMsgOverheadBytes; return _minPositive(byFrame, maxTextPayloadBytes); } -int maxChannelMessageBytes(String? senderName) { +int maxChannelMessageBytes(String? senderName, {int? maxFrameBytes}) { + final frameBudget = maxFrameBytes ?? maxFrameSize; final nameLength = _senderNameBytes(senderName); final prefixBytes = nameLength + 2; // ": " final byPayload = maxTextPayloadBytes - prefixBytes; - final byFrame = maxFrameSize - _sendChannelTextMsgOverheadBytes; + // The wire text is ": ", so the prefix eats into the frame + // budget too. At maxFrameSize the payload limit always governed and this went + // unnoticed; with a smaller BLE budget the frame limit can govern, so the + // prefix must be subtracted here or a channel frame can still overflow (#395). + final byFrame = frameBudget - _sendChannelTextMsgOverheadBytes - prefixBytes; return _minPositive(byPayload, byFrame); } @@ -584,7 +710,7 @@ int readInt32LE(Uint8List data, int offset) { } // Path-length byte from the firmware. This is a PACKED field, and both halves -// are authoritative — the path is self-describing on the wire: +// are authoritative, the path is self-describing on the wire: // // high 2 bits = hash size - 1 (0..2 -> 1..3 bytes per hop hash) // low 6 bits = hash COUNT (the number of HOPS, 0-63) @@ -757,16 +883,32 @@ Uint8List buildRemoveContactFrame(Uint8List pubKey) { return writer.toBytes(); } +/// Byte length of the client id carried in [buildAppStartFrame] (#297). +/// +/// 6 is load-bearing, not arbitrary: stock firmware treats `cmd_frame[1..7]` as +/// reserved and reads the app name at a FIXED offset 8, while Wadamesh reads +/// byte 1 as the client-id length and the app name at `2 + cid_len`. Only +/// `cid_len == 6` puts the name at 8 for both, so one frame serves both. +const int clientIdLength = 6; + // Build CMD_APP_START frame -// Format: [cmd][app_ver][reserved x6][app_name...] +// Format: [cmd][cid_len=6][client_id x6][app_name...] +// Stock reads bytes 1..7 as reserved + name at 8; Wadamesh reads the client id +// and lands on the same name offset. See [clientIdLength]. Uint8List buildAppStartFrame({ String appName = 'MeshCoreOpen', - int appVersion = 1, + Uint8List? clientId, }) { + final id = Uint8List(clientIdLength); + if (clientId != null) { + // Truncate or zero-pad: the length byte must stay 6 or the app-name offset + // desyncs on one of the two firmwares. + id.setRange(0, min(clientId.length, clientIdLength), clientId); + } final writer = BufferWriter(); writer.writeByte(cmdAppStart); - writer.writeByte(appVersion); - writer.writeBytes(Uint8List(6)); // reserved bytes + writer.writeByte(clientIdLength); + writer.writeBytes(id); writer.writeString(appName); writer.writeByte(0); return writer.toBytes(); @@ -923,7 +1065,7 @@ Uint8List buildResetPathFrame(Uint8List pubKey) { // [hopCount] is a HOP count and [hashWidth] the bytes per hop hash; the two are // packed into the single wire path_len byte via encodePathLen(). // -// This previously wrote the count raw, leaving the mode bits 00 — which tells +// This previously wrote the count raw, leaving the mode bits 00, which tells // the radio "1-byte hashes". On a 2-byte net that handed the firmware 2-byte // hash data labelled as 1-byte hops, so it routed to nodes that were never on // the route. That is the send-side half of #240's misrouting. (#309) diff --git a/lib/connector/observer_config_client.dart b/lib/connector/observer_config_client.dart index 8934946..39a460d 100644 --- a/lib/connector/observer_config_client.dart +++ b/lib/connector/observer_config_client.dart @@ -3,7 +3,7 @@ // Mirrors the firmware contract in the firmware repo's // `examples/companion_radio/OffbandConfigProtocol.h` + `MyMesh::handleOffbandConfigCmd` // (Epic F #160-#163) byte-for-byte. This is a PURE codec: it builds request -// frames and parses response frames. It never touches BLE/USB/TCP — the +// frames and parses response frames. It never touches BLE/USB/TCP, the // connector sends the Uint8Lists and feeds received frames back to [parse]. // // Frame layout (both directions): byte[0] = code (0xC0), byte[1] = sub-type, @@ -97,13 +97,13 @@ class ObserverConfigClient { /// Bit 0 of the `offband_caps` byte appended to the device-info reply. static const int capWifiObserver = 0x01; - // Request sub-type — byte[1]. + // Request sub-type, byte[1]. static const int opSet = 0x00; static const int opGet = 0x01; static const int opView = 0x02; static const int opBrokers = 0x03; - // Response sub-type — byte[1]. + // Response sub-type, byte[1]. static const int rAck = 0x00; static const int rErr = 0x01; static const int rValue = 0x02; @@ -141,7 +141,7 @@ class ObserverConfigClient { return builder.toBytes(); } - /// SET ` ` — firmware splits on the FIRST space; [value] is the + /// SET ` `, firmware splits on the FIRST space; [value] is the /// verbatim remainder and may contain spaces. static Uint8List buildSet(String key, String value) => _frame(opSet, '$key $value'); @@ -166,7 +166,7 @@ class ObserverConfigClient { /// NOTE: `enabled` is intentionally NOT here. The firmware /// `handleSetBrokerField` has no `enabled` branch and the VIEW allowlist /// blocks `mqtt enable/disable/clear`, so activation + clear have no wire - /// path in this firmware (TODO: firmware F-task — see observer_config docs). + /// path in this firmware (TODO: firmware F-task, see observer_config docs). /// The activation guard still holds: any field SET on a live slot /// auto-disables it firmware-side. static List> brokerFieldSets( @@ -264,7 +264,7 @@ class ObserverConfigClient { /// [BrokerConfig]s. Feed each parsed [ConfigResponse]; [add] returns the /// decoded list once END arrives (or `[]` for an empty pool), else null. class BrokerListDecoder { - /// Hard cap on total key/value lines retained across the whole dump — a + /// Hard cap on total key/value lines retained across the whole dump, a /// hostile device cannot grow memory without bound (Gemini BLOCKER: DoS). /// Generous for the 10-slot contract (~16 fields each). static const int _maxFields = ObserverConfigClient.brokerSlotCount * 32; diff --git a/lib/helpers/config_profile_diff.dart b/lib/helpers/config_profile_diff.dart new file mode 100644 index 0000000..5119a80 --- /dev/null +++ b/lib/helpers/config_profile_diff.dart @@ -0,0 +1,107 @@ +import '../models/config_profile.dart'; +import 'config_profile_writes.dart'; + +/// Builds the human-facing diff for a profile apply (#406): current → new, per +/// field, categorized so the preview can render its two-tier confirm. +/// +/// Pure, the screen fetches current device values and passes them in, so this +/// is unit-testable without a device. + +enum DiffKind { + /// Device has no value; the profile sets one. + add, + + /// Device has a value; the profile sets a different one. + change, +} + +class DiffRow { + const DiffRow({ + required this.label, + required this.oldValue, + required this.newValue, + required this.kind, + required this.danger, + required this.secret, + }); + + /// Firmware key (flat) or `broker N.`, never a value, safe to show. + final String label; + + /// Current on-device value, or null if unknown/unreadable (e.g. write-only + /// `wifi.pwd`) or absent. + final String? oldValue; + final String newValue; + final DiffKind kind; + + /// In the credential/identity danger set, routed to the red gate. + final bool danger; + + /// A true secret (password / wifi.pwd), the UI must mask both values. + final bool secret; +} + +class ProfileDiff { + const ProfileDiff(this.rows); + final List rows; + + bool get isEmpty => rows.isEmpty; + List get dangerRows => rows.where((r) => r.danger).toList(); + List get safeRows => rows.where((r) => !r.danger).toList(); + bool get hasDanger => rows.any((r) => r.danger); +} + +/// Broker sub-keys + flat keys that hold true secrets (mask both values). +const Set _secretBrokerFields = {ConfigKeys.brokerPassword}; +const Set _secretFlatKeys = {ConfigKeys.wifiPassword}; + +/// Build the diff. [currentFlat] maps a flat key → current value (null = +/// unknown/absent). [currentBroker] maps slot → (subkey → current value). +/// Rows where the new value equals the current value are dropped (no-op). +ProfileDiff buildProfileDiff( + ProfileWrites writes, { + required Map currentFlat, + required Map> currentBroker, +}) { + final rows = []; + + for (final f in writes.flats) { + final current = currentFlat[f.key]; + if (current == f.value) continue; // unchanged + rows.add( + DiffRow( + label: f.key, + oldValue: current, + newValue: f.value, + kind: (current == null || current.isEmpty) + ? DiffKind.add + : DiffKind.change, + danger: f.danger, + secret: _secretFlatKeys.contains(f.key), + ), + ); + } + + for (final b in writes.brokers) { + final cur = currentBroker[b.slot] ?? const {}; + for (final entry in b.fields.entries) { + final current = cur[entry.key]; + if (current == entry.value) continue; // unchanged + rows.add( + DiffRow( + label: 'broker ${b.slot}.${entry.key}', + oldValue: current, + newValue: entry.value, + kind: (current == null || current.isEmpty) + ? DiffKind.add + : DiffKind.change, + danger: b.dangerFields.contains(entry.key), + secret: _secretBrokerFields.contains(entry.key), + ), + ); + } + // Broker enabled is not a profile field (#456), never diffed/applied. + } + + return ProfileDiff(rows); +} diff --git a/lib/helpers/config_profile_parser.dart b/lib/helpers/config_profile_parser.dart new file mode 100644 index 0000000..f5df390 --- /dev/null +++ b/lib/helpers/config_profile_parser.dart @@ -0,0 +1,264 @@ +import 'package:yaml/yaml.dart'; + +import '../models/config_profile.dart'; + +/// Thrown when a config-profile document is not valid (#403). +/// +/// Message is user-facing: the import flow shows it verbatim, so it names the +/// offending key/value (with its section) rather than a stack position. +class ConfigProfileFormatException implements Exception { + const ConfigProfileFormatException(this.message); + final String message; + @override + String toString() => 'ConfigProfileFormatException: $message'; +} + +/// Parse a YAML config profile into a [ConfigProfile] (#402 model). +/// +/// Strict by design, profiles are untrusted input (#139 trust note), so an +/// unknown key, a wrong type, or an out-of-range value is an error, not a silent +/// skip. Only keys present in the document appear in the model; everything else +/// stays null so the apply engines touch only what the profile sets. +/// +/// Expected shape (v2, capability sections, #456): +/// ```yaml +/// schema_version: 2 +/// name: "US wide-area" # optional label, not applied +/// wifi: { ssid: "...", password: "...", enabled: true } +/// mqtt: +/// region: "IAD" # -> mqtt.iata +/// status_interval: 60 +/// brokers: +/// - slot: 0 +/// enabled: true +/// url: "..." +/// port: 8883 +/// transport: tls # tcp | tls | wss +/// auth_type: basic # none | basic | jwt +/// username: "..." +/// ... +/// ``` +ConfigProfile parseConfigProfile(String source) { + final dynamic doc; + try { + doc = loadYaml(source); + } on YamlException catch (e) { + throw ConfigProfileFormatException('Not valid YAML: ${e.message}'); + } + + const root = 'document root'; + final map = _asMap(doc, root); + + final version = _requireInt(map, 'schema_version', root); + if (version > kConfigProfileSchemaVersion) { + throw ConfigProfileFormatException( + 'Profile schema_version $version is newer than this app supports ' + '($kConfigProfileSchemaVersion). Update the app.', + ); + } + if (version < 2) { + throw ConfigProfileFormatException( + 'Profile schema_version $version uses the old flat layout. Re-export it ' + 'in the sectioned v2 format (wifi:/mqtt:).', + ); + } + + _rejectUnknownKeys(map, const { + 'schema_version', + 'name', + 'wifi', + 'mqtt', + }, root); + + return ConfigProfile( + schemaVersion: version, + name: _optString(map, 'name', root), + wifi: _parseWifi(map['wifi']), + mqtt: _parseMqtt(map['mqtt']), + ); +} + +MqttSection? _parseMqtt(dynamic node) { + if (node == null) return null; + const ctx = 'mqtt'; + final map = _asMap(node, ctx); + _rejectUnknownKeys(map, const {'region', 'status_interval', 'brokers'}, ctx); + return MqttSection( + regionIata: _optString(map, 'region', ctx), + statusInterval: _optUint(map, 'status_interval', ctx), + brokers: _parseBrokers(map['brokers']), + ); +} + +WifiConfig? _parseWifi(dynamic node) { + if (node == null) return null; + const ctx = 'wifi'; + final map = _asMap(node, ctx); + _rejectUnknownKeys(map, const {'ssid', 'password', 'enabled'}, ctx); + return WifiConfig( + ssid: _optString(map, 'ssid', ctx), + password: _optString(map, 'password', ctx), + enabled: _optBool(map, 'enabled', ctx), + ); +} + +List _parseBrokers(dynamic node) { + if (node == null) return const []; + if (node is! YamlList) { + throw const ConfigProfileFormatException('"brokers" must be a list'); + } + final seenSlots = {}; + final brokers = []; + for (var i = 0; i < node.length; i++) { + final ctx = 'brokers[$i]'; + final map = _asMap(node[i], ctx); + _rejectUnknownKeys(map, const { + 'slot', + 'url', + 'port', + 'transport', + 'auth_type', + 'username', + 'password', + 'jwt_token', + 'jwt_aud', + 'jwt_refresh', + 'jwt_owner', + 'jwt_email', + 'ca_cert', + 'topic_prefix', + 'iata_override', + }, ctx); + + final slot = _requireInt(map, 'slot', ctx); + if (slot < 0 || slot >= kMaxBrokerSlots) { + throw ConfigProfileFormatException( + '$ctx.slot must be 0..${kMaxBrokerSlots - 1}, got $slot', + ); + } + if (!seenSlots.add(slot)) { + throw ConfigProfileFormatException('duplicate broker slot $slot'); + } + + final port = _optUint(map, 'port', ctx); + if (port != null && (port < 1 || port > 65535)) { + throw ConfigProfileFormatException( + '$ctx.port must be 1..65535, got $port', + ); + } + + brokers.add( + BrokerConfig( + slot: slot, + url: _optString(map, 'url', ctx), + port: port, + transport: _optEnum( + map, + 'transport', + ctx, + MqttTransport.fromWire, + 'tcp/tls/wss', + ), + authType: _optEnum( + map, + 'auth_type', + ctx, + MqttAuthType.fromWire, + 'none/basic/jwt', + ), + username: _optString(map, 'username', ctx), + password: _optString(map, 'password', ctx), + jwtToken: _optString(map, 'jwt_token', ctx), + jwtAudience: _optString(map, 'jwt_aud', ctx), + jwtRefresh: _optUint(map, 'jwt_refresh', ctx), + jwtOwner: _optString(map, 'jwt_owner', ctx), + jwtEmail: _optString(map, 'jwt_email', ctx), + caCert: _optString(map, 'ca_cert', ctx), + topicPrefix: _optString(map, 'topic_prefix', ctx), + iataOverride: _optString(map, 'iata_override', ctx), + ), + ); + } + return brokers; +} + +// --- typed accessors (all name their section for user-facing errors) -------- + +Map _asMap(dynamic node, String what) { + if (node is Map) return node; + throw ConfigProfileFormatException('$what must be a mapping'); +} + +void _rejectUnknownKeys(Map map, Set allowed, String what) { + for (final key in map.keys) { + if (!allowed.contains(key)) { + throw ConfigProfileFormatException('unknown key "$key" in $what'); + } + } +} + +int _requireInt(Map map, String key, String ctx) { + final v = map[key]; + if (v == null) { + throw ConfigProfileFormatException('$ctx is missing required "$key"'); + } + if (v is! int) { + throw ConfigProfileFormatException('$ctx."$key" must be an integer'); + } + return v; +} + +String? _optString(Map map, String key, String ctx) { + final v = map[key]; + if (v == null) return null; + if (v is! String) { + throw ConfigProfileFormatException('$ctx."$key" must be a string'); + } + return v; +} + +/// Optional non-negative integer (durations, ports, counts). Rejects negatives +/// since every integer field in a profile is a count/port/interval. +int? _optUint(Map map, String key, String ctx) { + final v = map[key]; + if (v == null) return null; + if (v is! int) { + throw ConfigProfileFormatException('$ctx."$key" must be an integer'); + } + if (v < 0) { + throw ConfigProfileFormatException('$ctx."$key" must not be negative'); + } + return v; +} + +bool? _optBool(Map map, String key, String ctx) { + final v = map[key]; + if (v == null) return null; + if (v is! bool) { + throw ConfigProfileFormatException('$ctx."$key" must be true or false'); + } + return v; +} + +T? _optEnum( + Map map, + String key, + String ctx, + T? Function(String?) fromWire, + String allowed, +) { + final v = map[key]; + if (v == null) return null; + if (v is! String) { + throw ConfigProfileFormatException( + '$ctx."$key" must be a string ($allowed)', + ); + } + final parsed = fromWire(v); + if (parsed == null) { + throw ConfigProfileFormatException( + '$ctx."$key" must be one of $allowed, got "$v"', + ); + } + return parsed; +} diff --git a/lib/helpers/config_profile_writes.dart b/lib/helpers/config_profile_writes.dart new file mode 100644 index 0000000..345fcb8 --- /dev/null +++ b/lib/helpers/config_profile_writes.dart @@ -0,0 +1,177 @@ +import '../models/config_profile.dart'; + +/// Enumerates the device writes a [ConfigProfile] implies (#405), device-agnostic +/// so observer / repeater / companion executors share the same rules: +/// +/// - **Skip null or empty**, a profile only touches keys it actually sets; a +/// blank never clobbers a configured value. Clearing is a separate explicit op. +/// - **Skip `jwt_token`**, it's live-minted by firmware at connect, never config. +/// - **Skip broker `enabled`** (#456), enabling a broker is the operator's +/// runtime decision, not profile config; apply preserves the current state. +/// - **Danger fields** (owner/identity/credentials) are flagged so the preview +/// (#406) can gate them: broker `username`/`password`/`jwt_owner`/`jwt_email`, +/// and global `wifi.pwd`. + +/// Broker sub-keys whose set/change/wipe requires the preview's red danger gate. +const Set kDangerBrokerFields = { + ConfigKeys.brokerUsername, + ConfigKeys.brokerPassword, + ConfigKeys.brokerJwtOwner, + ConfigKeys.brokerJwtEmail, +}; + +/// Global flat keys requiring the danger gate. +const Set kDangerFlatKeys = {ConfigKeys.wifiPassword}; + +/// A single flat (non-broker) write: `wifi.*`, `mqtt.iata`, `mqtt.status_interval`. +class FlatWrite { + const FlatWrite(this.key, this.value, {this.danger = false}); + final String key; + final String value; + final bool danger; +} + +/// The writes for one broker slot. [fields] excludes `jwt_token` (never written) +/// and the broker `enabled` flag (#456: not a profile field, apply preserves +/// the device's current enabled state). +class BrokerWrites { + const BrokerWrites({ + required this.slot, + required this.fields, + required this.dangerFields, + }); + final int slot; + final Map fields; + final Set dangerFields; +} + +/// The full set of writes a profile implies. +class ProfileWrites { + const ProfileWrites({required this.flats, required this.brokers}); + + /// Global flats, already ordered so `wifi.enabled` (if present) comes last. + final List flats; + final List brokers; + + bool get isEmpty => flats.isEmpty && brokers.isEmpty; + + /// True if any write (flat or broker) is a danger-gated field. + bool get hasDanger => + flats.any((f) => f.danger) || + brokers.any((b) => b.dangerFields.isNotEmpty); +} + +/// Partition writes into (safe, danger) so the preview's two buttons each apply +/// their own set: the normal Apply writes safe changes; the red gate writes the +/// credential/identity ones. A broker with both is split across both. +({ProfileWrites safe, ProfileWrites danger}) splitProfileWrites( + ProfileWrites w, +) { + final safeFlats = w.flats.where((f) => !f.danger).toList(); + final dangerFlats = w.flats.where((f) => f.danger).toList(); + + final safeBrokers = []; + final dangerBrokers = []; + for (final b in w.brokers) { + final safeFields = { + for (final e in b.fields.entries) + if (!b.dangerFields.contains(e.key)) e.key: e.value, + }; + final dangerFields = { + for (final e in b.fields.entries) + if (b.dangerFields.contains(e.key)) e.key: e.value, + }; + if (safeFields.isNotEmpty) { + safeBrokers.add( + BrokerWrites(slot: b.slot, fields: safeFields, dangerFields: const {}), + ); + } + if (dangerFields.isNotEmpty) { + dangerBrokers.add( + BrokerWrites( + slot: b.slot, + fields: dangerFields, + dangerFields: dangerFields.keys.toSet(), + ), + ); + } + } + + return ( + safe: ProfileWrites(flats: safeFlats, brokers: safeBrokers), + danger: ProfileWrites(flats: dangerFlats, brokers: dangerBrokers), + ); +} + +bool _blank(String? v) => v == null || v.isEmpty; + +ProfileWrites enumerateProfileWrites(ConfigProfile p) { + final flats = []; + + // WiFi, ssid/pwd first, enabled last (activation guard). + final wifi = p.wifi; + if (wifi != null) { + if (!_blank(wifi.ssid)) { + flats.add(FlatWrite(ConfigKeys.wifiSsid, wifi.ssid!)); + } + if (!_blank(wifi.password)) { + flats.add( + FlatWrite(ConfigKeys.wifiPassword, wifi.password!, danger: true), + ); + } + if (wifi.enabled != null) { + flats.add(FlatWrite(ConfigKeys.wifiEnabled, wifi.enabled! ? '1' : '0')); + } + } + + final mqtt = p.mqtt; + if (mqtt != null && !_blank(mqtt.regionIata)) { + flats.add(FlatWrite(ConfigKeys.mqttIata, mqtt.regionIata!)); + } + if (mqtt != null && mqtt.statusInterval != null) { + flats.add( + FlatWrite(ConfigKeys.mqttStatusInterval, '${mqtt.statusInterval}'), + ); + } + + final brokers = []; + for (final b in (mqtt?.brokers ?? const [])) { + final fields = {}; + void put(String key, String? value) { + if (!_blank(value)) fields[key] = value!; + } + + put(ConfigKeys.brokerUrl, b.url); + if (b.port != null) fields[ConfigKeys.brokerPort] = '${b.port}'; + if (b.transport != null) { + fields[ConfigKeys.brokerTransport] = b.transport!.wire; + } + if (b.authType != null) { + fields[ConfigKeys.brokerAuthType] = b.authType!.wire; + } + put(ConfigKeys.brokerUsername, b.username); + put(ConfigKeys.brokerPassword, b.password); + // jwt_token deliberately omitted, live-minted at connect, never config. + put(ConfigKeys.brokerJwtAudience, b.jwtAudience); + if (b.jwtRefresh != null) { + fields[ConfigKeys.brokerJwtRefresh] = '${b.jwtRefresh}'; + } + put(ConfigKeys.brokerJwtOwner, b.jwtOwner); + put(ConfigKeys.brokerJwtEmail, b.jwtEmail); + put(ConfigKeys.brokerCaCert, b.caCert); + put(ConfigKeys.brokerTopicPrefix, b.topicPrefix); + put(ConfigKeys.brokerIataOverride, b.iataOverride); + + if (fields.isEmpty) continue; // nothing to write + + brokers.add( + BrokerWrites( + slot: b.slot, + fields: fields, + dangerFields: fields.keys.where(kDangerBrokerFields.contains).toSet(), + ), + ); + } + + return ProfileWrites(flats: flats, brokers: brokers); +} diff --git a/lib/helpers/crockford_base32.dart b/lib/helpers/crockford_base32.dart new file mode 100644 index 0000000..208939f --- /dev/null +++ b/lib/helpers/crockford_base32.dart @@ -0,0 +1,56 @@ +/// Crockford Base32, as used by the PocketMesh / MeshCore One reaction format. +/// +/// Encode-only: a reaction hash is compared as a string and never decoded back +/// to bytes. The alphabet omits i, l, o and u to avoid visual ambiguity. +class CrockfordBase32 { + static const String alphabet = '0123456789abcdefghjkmnpqrstvwxyz'; + + /// Encode exactly 5 bytes (40 bits) as 8 characters, most significant first. + /// + /// Accumulates at most 12 bits at a time rather than packing all 40 into one + /// int: bitwise operators are 32-bit on the web target, so a 40-bit shift + /// would silently truncate there. + static String encode5(List bytes) { + if (bytes.length != 5) { + throw ArgumentError.value( + bytes.length, + 'bytes', + 'Crockford Base32 encode5 needs exactly 5 bytes', + ); + } + final out = StringBuffer(); + var buffer = 0; + var bits = 0; + for (final byte in bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + out.write(alphabet[(buffer >> bits) & 0x1F]); + } + } + return out.toString(); + } + + /// The lowercase canonical form of an 8-character hash, or null if [text] is + /// not valid Crockford Base32. + /// + /// Resolves the ambiguity aliases the format defines on input (O to 0, I and + /// L to 1, either case) so a sender that emits them still matches. `u` has no + /// alias and is rejected. + static String? normalize8(String text) { + if (text.length != 8) return null; + final out = StringBuffer(); + for (var i = 0; i < 8; i++) { + final lower = text[i].toLowerCase(); + final resolved = switch (lower) { + 'o' => '0', + 'i' || 'l' => '1', + _ => lower, + }; + if (!alphabet.contains(resolved)) return null; + out.write(resolved); + } + return out.toString(); + } +} diff --git a/lib/helpers/emoji_shortcodes.dart b/lib/helpers/emoji_shortcodes.dart index ec399b9..52c83e9 100644 --- a/lib/helpers/emoji_shortcodes.dart +++ b/lib/helpers/emoji_shortcodes.dart @@ -1925,7 +1925,7 @@ const Map _emojiAliases = { 'tick': '✅', }; -/// gemoji shortcodes plus [_emojiAliases] — what composers use. +/// gemoji shortcodes plus [_emojiAliases], what composers use. final Map emojiShortcodesWithAliases = { ...emojiShortcodes, ..._emojiAliases, diff --git a/lib/helpers/gif_helper.dart b/lib/helpers/gif_helper.dart index 5cbf244..8d2f65f 100644 --- a/lib/helpers/gif_helper.dart +++ b/lib/helpers/gif_helper.dart @@ -12,7 +12,7 @@ class GifHelper { static String? parseGif(String text) { // A reply prepends a leading `@[Name] ` mention; ignore it so a reply-gif // still resolves to the gif rather than plain text. Only a reply mention is - // stripped — arbitrary text before a gif is left as text. (#232) + // stripped, arbitrary text before a gif is left as text. (#232) final trimmed = text.trim().replaceFirst(RegExp(r'^@\[[^\]]+\]\s+'), ''); final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); if (match != null) { diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart index c4a2337..5455b06 100644 --- a/lib/helpers/path_helper.dart +++ b/lib/helpers/path_helper.dart @@ -58,7 +58,7 @@ class PathHelper { } /// Trace `path_sz` flag for a configured hash width: floor(log2(width)), so - /// width 1→0, 2→1, 3→1, 4→2. The wire hop size is `1 << path_sz` bytes — the + /// width 1→0, 2→1, 3→1, 4→2. The wire hop size is `1 << path_sz` bytes, the /// firmware's trace widths are powers of two, so this is the largest power of /// two ≤ the configured width (width 3 sends the leading 2 bytes per hop). (#150) static int tracePathSz(int hashWidth) { diff --git a/lib/helpers/pending_reactions.dart b/lib/helpers/pending_reactions.dart new file mode 100644 index 0000000..098a841 --- /dev/null +++ b/lib/helpers/pending_reactions.dart @@ -0,0 +1,89 @@ +import '../utils/app_logger.dart'; +import 'reaction_helper.dart'; + +class _Pending { + final String scopeKey; + final ReactionInfo info; + final String reactingSender; + final DateTime queuedAt; + + const _Pending(this.scopeKey, this.info, this.reactingSender, this.queuedAt); +} + +/// Reactions that arrived before the message they point at. +/// +/// Out-of-order delivery is normal on a mesh, so a reaction with no local +/// target is not junk, it is early. Before this existed the connector consumed +/// such a reaction and dropped it with no badge, no message and no log, which +/// is indistinguishable from nobody having reacted (GH #382). +/// +/// Entries are bounded and expire. A drop is logged at warn so it surfaces in +/// the in-app log and the file log rather than vanishing (SAFELANE 6). Whether +/// dropping is the right end state is still open; the ratio of late matches to +/// expiries in the log is the evidence for revisiting it. +class PendingReactions { + static const int maxEntries = 50; + static const Duration ttl = Duration(minutes: 15); + + final List<_Pending> _entries = []; + + int get length => _entries.length; + + void add( + String scopeKey, + ReactionInfo info, + String reactingSender, + DateTime now, + ) { + expire(now); + _entries.add(_Pending(scopeKey, info, reactingSender, now)); + if (_entries.length > maxEntries) { + _drop(_entries.removeAt(0), now, 'queue full'); + } + appLogger.info( + 'Queued reaction ${info.emoji} from $reactingSender for unseen target ' + '${info.targetHash} in $scopeKey (${_entries.length}/$maxEntries pending)', + tag: 'Reactions', + ); + } + + /// Re-attempt every live entry for [scopeKey]. [apply] receives the queued + /// reaction and the name of whoever sent it, and reports whether it found its + /// target; matched entries are removed, the rest stay. + void retry( + String scopeKey, + bool Function(ReactionInfo info, String reactingSender) apply, + DateTime now, + ) { + expire(now); + _entries.removeWhere((entry) { + if (entry.scopeKey != scopeKey) return false; + if (!apply(entry.info, entry.reactingSender)) return false; + appLogger.info( + 'Late-matched reaction ${entry.info.emoji} from ${entry.reactingSender} ' + 'to ${entry.info.targetHash} in $scopeKey after ' + '${now.difference(entry.queuedAt).inSeconds}s', + tag: 'Reactions', + ); + return true; + }); + } + + void expire(DateTime now) { + _entries.removeWhere((entry) { + if (now.difference(entry.queuedAt) < ttl) return false; + _drop(entry, now, 'expired after ${ttl.inMinutes}m'); + return true; + }); + } + + void _drop(_Pending entry, DateTime now, String why) { + appLogger.warn( + 'Dropping unmatched reaction ${entry.info.emoji} from ' + '${entry.reactingSender} for target ${entry.info.targetHash} in ' + '${entry.scopeKey} ($why, age ${now.difference(entry.queuedAt).inSeconds}s). ' + 'The message it points at was never seen locally.', + tag: 'Reactions', + ); + } +} diff --git a/lib/helpers/pocketmesh_reaction.dart b/lib/helpers/pocketmesh_reaction.dart new file mode 100644 index 0000000..d8b3da1 --- /dev/null +++ b/lib/helpers/pocketmesh_reaction.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +import 'crockford_base32.dart'; + +/// A reaction in the PocketMesh / MeshCore One wire format. +/// +/// Channel form: `{emoji}@[{targetSenderName}]\n{hash}` +/// Direct form: `{emoji}\n{hash}` +/// +/// The hash identifies the target message as sha256 over the target's body +/// text (UTF-8) followed by its sender-claimed timestamp as a little-endian +/// uint32 of epoch seconds, truncated to the first 5 bytes and encoded as 8 +/// Crockford Base32 characters. The channel `SenderName: ` prefix is NOT part +/// of the hashed text, which is why the sender travels in `@[...]` instead. +/// +/// Confirmed against a live capture from a MeshCore One peer, see GH #378. +/// Receive-only: Offband still sends its own `r:hhhh:ii` format (GH #379). +class PocketMeshReaction { + final String emoji; + + /// The target message's sender, present in the channel form only. In a direct + /// conversation the sender is implicit. + final String? targetSenderName; + + /// 8 characters, lowercase canonical form. + final String targetHash; + + const PocketMeshReaction({ + required this.emoji, + required this.targetHash, + this.targetSenderName, + }); + + static String computeHash(String bodyText, int timestampSeconds) { + final body = utf8.encode(bodyText); + final input = Uint8List(body.length + 4); + input.setRange(0, body.length, body); + // Division rather than shifts: `>>` is signed 32-bit on the web target and + // would misencode any timestamp past 2038. + final seconds = timestampSeconds % 4294967296; + input[body.length] = seconds % 256; + input[body.length + 1] = (seconds ~/ 256) % 256; + input[body.length + 2] = (seconds ~/ 65536) % 256; + input[body.length + 3] = (seconds ~/ 16777216) % 256; + return CrockfordBase32.encode5(sha256.convert(input).bytes.sublist(0, 5)); + } + + /// Parse [text] as a reaction, or null if it is an ordinary message. + /// + /// Mirrors the reference parser: the last line must be exactly 8 valid + /// Crockford Base32 characters, and the part before it must start with an + /// emoji. Both checks matter, since anything accepted here is swallowed + /// instead of being shown as a message. + static PocketMeshReaction? parse(String text, {required bool isDm}) { + final newline = text.lastIndexOf('\n'); + if (newline < 0) return null; + + final hash = CrockfordBase32.normalize8(text.substring(newline + 1)); + if (hash == null) return null; + + final head = text.substring(0, newline); + + if (isDm) { + if (head.contains('@[')) return null; + return _build(emoji: head, sender: null, hash: hash); + } + + final bracket = head.indexOf('@['); + if (bracket < 0) return null; + final afterBracket = head.substring(bracket + 2); + if (!afterBracket.endsWith(']')) return null; + final sender = afterBracket.substring(0, afterBracket.length - 1); + if (sender.isEmpty) return null; + + return _build( + emoji: head.substring(0, bracket), + sender: sender, + hash: hash, + ); + } + + static PocketMeshReaction? _build({ + required String emoji, + required String? sender, + required String hash, + }) { + if (!_isReactionEmoji(emoji)) return null; + return PocketMeshReaction( + emoji: emoji, + targetSenderName: sender, + targetHash: hash, + ); + } + + /// A reaction is one emoji, possibly with a variation selector, a skin-tone + /// modifier or ZWJ joins. Eight runes is well clear of the longest such + /// sequence and nowhere near a sentence. + /// + /// This cap is the guard that matters: without it, any multi-line message + /// starting with a symbol and ending in eight Crockford characters would be + /// swallowed whole and shown as the reaction "emoji". + static const int _maxEmojiRunes = 8; + + /// Deliberately conservative: a missed emoji only means the reaction renders + /// as text, which is the behaviour we have today, while a false positive + /// would swallow a real message. + /// + /// Arrows (U+2190-U+21FF, U+2934-U+2935) are excluded on purpose even though + /// they carry the Unicode Emoji property. They are ordinary punctuation in + /// prose, and a live capture from this mesh contained U+2192 mid-sentence in + /// a normal channel message. + static const List> _emojiRanges = [ + [0x1F000, 0x1FAFF], + [0x2600, 0x27BF], + [0x2B00, 0x2BFF], + [0x3030, 0x3030], + [0x303D, 0x303D], + [0x3297, 0x3299], + ]; + + static bool _isReactionEmoji(String text) { + final runes = text.runes; + if (runes.isEmpty || runes.length > _maxEmojiRunes) return false; + final first = runes.first; + for (final range in _emojiRanges) { + if (first >= range[0] && first <= range[1]) return true; + } + return false; + } +} diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index 36118ca..d236397 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -1,13 +1,57 @@ import '../widgets/emoji_picker.dart'; +import 'pocketmesh_reaction.dart'; + +/// Which client's reaction format a [ReactionInfo] came from. The two use +/// different target hashes, so matching has to know which one it holds. +enum ReactionDialect { + /// Offband's own `r:hhhh:ii`. + offband, + + /// PocketMesh / MeshCore One, `{emoji}@[{sender}]\n{hash}`. Receive-only. + pocketMesh, +} class ReactionInfo { final String targetHash; final String emoji; + final ReactionDialect dialect; - ReactionInfo({required this.targetHash, required this.emoji}); + /// The target message's sender, carried by the PocketMesh channel form only. + /// When set, a candidate must match it as well as the hash. + final String? targetSenderName; + + ReactionInfo({ + required this.targetHash, + required this.emoji, + this.dialect = ReactionDialect.offband, + this.targetSenderName, + }); + + ReactionInfo.pocketMesh(PocketMeshReaction reaction) + : targetHash = reaction.targetHash, + emoji = reaction.emoji, + dialect = ReactionDialect.pocketMesh, + targetSenderName = reaction.targetSenderName; } class ReactionHelper { + /// Deserialize the additive `reactionSenders` map from stored JSON (#383). + /// + /// Returns an empty map for records written before the field existed, so an + /// old store loads cleanly (its counts still come from the `reactions` key). + /// Malformed or wrongly-typed entries are skipped rather than thrown, because + /// a single bad entry must never fail the whole message-list load. + static Map> reactionSendersFromJson(Object? raw) { + if (raw is! Map) return {}; + final result = >{}; + raw.forEach((key, value) { + if (key is String && value is List) { + result[key] = value.whereType().toList(); + } + }); + return result; + } + /// Apply a reaction to a list of messages by matching the reaction hash. /// /// [messages] - the message list to search @@ -16,36 +60,75 @@ class ReactionHelper { /// [getSenderName] - extract sender name for hash (null for 1:1 implicit) /// [getMessageText] - extract message text /// [getReactions] - extract current reactions map + /// [getReactionSenders] - extract current emoji->reactor-names map (#383) + /// [reactingSender] - the name of whoever sent this reaction /// [shouldSkip] - filter function to skip messages (e.g., skip outgoing for incoming reactions) - /// [updateMessage] - callback to update the message at index with new reactions + /// [updateMessage] - callback to update the message at index with the new + /// count map and the new sender map /// /// Returns whether a match was found. + /// + /// [reactionSenders] is the persistent per-reactor record. A given reactor is + /// counted once per emoji: if their name is already in the list, the reaction + /// is a no-op on both maps (this survives restart, unlike the connector's + /// in-memory dedup set). Counts recorded before #383 have no sender list, so + /// the count is still incremented from its stored value rather than being + /// recomputed from the (partial) sender list, which would lose those. static bool applyReaction({ required List messages, required ReactionInfo reactionInfo, + required String reactingSender, required int Function(T) getTimestampSecs, required String? Function(T) getSenderName, required String Function(T) getMessageText, required Map Function(T) getReactions, + required Map> Function(T) getReactionSenders, required bool Function(T) shouldSkip, - required void Function(int index, Map newReactions) + required void Function( + int index, + Map newReactions, + Map> newSenders, + ) updateMessage, }) { final targetHash = reactionInfo.targetHash; + final targetSender = reactionInfo.targetSenderName; + final emoji = reactionInfo.emoji; for (int i = messages.length - 1; i >= 0; i--) { final msg = messages[i]; if (shouldSkip(msg)) continue; - final msgHash = computeReactionHash( - getTimestampSecs(msg), - getSenderName(msg), - getMessageText(msg), - ); + // Exact compare, no normalising: a node name can carry emoji and + // variation selectors (a live capture used "Strycher WM\u{1F6F0}\u{FE0F}") + // and any folding would break the match. + if (targetSender != null && getSenderName(msg) != targetSender) continue; + + final msgHash = switch (reactionInfo.dialect) { + ReactionDialect.offband => computeReactionHash( + getTimestampSecs(msg), + getSenderName(msg), + getMessageText(msg), + ), + ReactionDialect.pocketMesh => PocketMeshReaction.computeHash( + getMessageText(msg), + getTimestampSecs(msg), + ), + }; if (msgHash == targetHash) { + final senders = >{ + for (final e in getReactionSenders(msg).entries) + e.key: List.from(e.value), + }; + final list = senders.putIfAbsent(emoji, () => []); + if (list.contains(reactingSender)) { + // Already recorded this reactor for this emoji; matched, no change. + return true; + } + list.add(reactingSender); + final currentReactions = Map.from(getReactions(msg)); - currentReactions[reactionInfo.emoji] = - (currentReactions[reactionInfo.emoji] ?? 0) + 1; - updateMessage(i, currentReactions); + currentReactions[emoji] = (currentReactions[emoji] ?? 0) + 1; + updateMessage(i, currentReactions, senders); return true; } } diff --git a/lib/helpers/settings_persist.dart b/lib/helpers/settings_persist.dart index 7c0da55..05fc7fb 100644 --- a/lib/helpers/settings_persist.dart +++ b/lib/helpers/settings_persist.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; /// Runs a settings-persist [action] and surfaces a dismissible error SnackBar if /// it throws, instead of dropping the returned Future and swallowing the error -/// (no silent failures — #28). +/// (no silent failures, #28). /// /// Call sites intentionally don't await the returned Future: this function never /// rethrows (it catches internally), so dropping it is safe. The diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index d8042f5..3e5a27e 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -84,7 +84,7 @@ "settings_title": "Настройки", "settings_deviceInfo": "Информация за устройството", "settings_appSettings": "Настройки на приложението", - "settings_appSettingsSubtitle": "Уведомления, съобщения и предпочитания за карта", + "settings_appSettingsSubtitle": "Външен вид, превод и настройки на картата", "settings_nodeSettings": "Настройки на възела", "settings_nodeName": "Име на възела", "settings_nodeNameNotSet": "Не е зададено", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Покажи известие при получаване на нови съобщения", "appSettings_channelMessageNotifications": "Уведомления за съобщения от канал", "appSettings_channelMessageNotificationsSubtitle": "Покажи известие при получаване на съобщения от канали", - "appSettings_advertisementNotifications": "Уведомления за реклами", - "appSettings_advertisementNotificationsSubtitle": "Покажи известие, когато бъдат открити нови възли.", + "appSettings_advertisementNotifications": "Открит нов възел", + "appSettings_advertisementNotificationsSubtitle": "Известяване при откриване на нови репитери или контакти", "appSettings_messaging": "Съобщения", "appSettings_clearPathOnMaxRetry": "Изчисти Път на Макс Опит", "appSettings_clearPathOnMaxRetrySubtitle": "Възстанови контактния път след 5 неуспешни опита за изпращане", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3.0-4.2V)", "appSettings_batteryLifepo4": "Литиево желязо фосфат (2.6-3.65V)", "appSettings_batteryLipo": "Литиев полимер (3.0-4.2V)", + "appSettings_keepScreenAwake": "Поддържане на екрана включен", + "appSettings_keepScreenAwakeSubtitle": "Екранът няма да изгасва, докато приложението е отворено. Изразходва повече батерия.", "appSettings_mapDisplay": "Карта за показване", "appSettings_showRepeaters": "Показване на повторители", "appSettings_showRepeatersSubtitle": "Показване на възпроизвеждащи се възли на картата", @@ -388,7 +390,6 @@ } } }, - "channels_smazCompression": "Компресия SMAZ", "channels_cyr2latCompression": "Компресия Cyr2Lat", "channels_cyr2latCompressionDscr": "Заменя някои кирилични символи с латиница при изпращане.", "channels_cyr2latSettingsHeading": "Настройки на Cyr2Lat", @@ -605,7 +606,6 @@ "chat_type": "Въведете", "chat_path": "Пътекино", "chat_publicKey": "Публичен ключ", - "chat_compressOutgoingMessages": "Компресиране на изходящи съобщения", "chat_floodForced": "Потоп (принуден)", "chat_directForced": "Директно (принудително)", "chat_hopsForced": "{count} скока (принудително)", @@ -1316,7 +1316,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Брой заявки", "telemetry_error": "Неуспешно получаване на данни", "telemetry_noData": "Няма налични данни за телеметрията.", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index aa65bef..126068f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -84,7 +84,7 @@ "settings_title": "Einstellungen", "settings_deviceInfo": "Geräteinformationen", "settings_appSettings": "App-Einstellungen", - "settings_appSettingsSubtitle": "Benachrichtigungen, Messaging und Kartenwahrnehmung", + "settings_appSettingsSubtitle": "Erscheinungsbild, Übersetzung und Karteneinstellungen", "settings_nodeSettings": "Knoten-Einstellungen", "settings_nodeName": "Knotenname", "settings_nodeNameNotSet": "Nicht festgelegt", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Zeige Benachrichtigung beim Empfang neuer Direktnachrichten", "appSettings_channelMessageNotifications": "Kanalnachrichten Benachrichtigungen", "appSettings_channelMessageNotificationsSubtitle": "Zeige Benachrichtigung beim Empfangen von Kanalnachrichten", - "appSettings_advertisementNotifications": "Ankündigungsbenachrichtigungen", - "appSettings_advertisementNotificationsSubtitle": "Zeige Benachrichtigung, wenn neue Knoten entdeckt werden.", + "appSettings_advertisementNotifications": "Neuer Knoten entdeckt", + "appSettings_advertisementNotificationsSubtitle": "Benachrichtigen, wenn neue Repeater oder Kontakte empfangen werden", "appSettings_messaging": "Nachrichten", "appSettings_clearPathOnMaxRetry": "Lösche Pfade bei Max Wiederholungsversuchen", "appSettings_clearPathOnMaxRetrySubtitle": "Zurücksetzen der Kontaktpfade nach 5 fehlgeschlagenen Sendeabbrüchen", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0–4,2 V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6–3,65 V)", "appSettings_batteryLipo": "LiPo (3,0–4,2V)", + "appSettings_keepScreenAwake": "Bildschirm anlassen", + "appSettings_keepScreenAwakeSubtitle": "Verhindert, dass sich das Display ausschaltet, während die App geöffnet ist. Verbraucht mehr Akku.", "appSettings_mapDisplay": "Kartendarstellung", "appSettings_showRepeaters": "Zeige Repeater", "appSettings_showRepeatersSubtitle": "Zeige Repeater-Knoten auf der Karte an", @@ -388,7 +390,6 @@ } } }, - "channels_smazCompression": "SMAZ-Komprimierung", "channels_cyr2latCompression": "Cyr2Lat-Komprimierung", "channels_cyr2latCompressionDscr": "Ersetzt einige kyrillische Zeichen durch lateinische Zeichen, wenn sie gesendet werden.", "channels_cyr2latSettingsHeading": "Cyr2Lat-Einstellungen", @@ -605,7 +606,6 @@ "chat_type": "Gebe ein", "chat_path": "Pfad", "chat_publicKey": "Öffentlicher Schlüssel", - "chat_compressOutgoingMessages": "Komprimieren ausgehender Nachrichten", "chat_floodForced": "Geflutet (erzwungen)", "chat_directForced": "Direkt (erzwungen)", "chat_hopsForced": "{count} Sprünge (erzwungen)", @@ -1316,7 +1316,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Anzahl der Anfragen", "telemetry_error": "Daten konnten nicht abgerufen werden", "telemetry_noData": "Keine Telemetriedaten verfügbar.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a7b3413..97c4383 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -142,7 +142,7 @@ "settings_title": "Settings", "settings_deviceInfo": "Device Info", "settings_appSettings": "App Settings", - "settings_appSettingsSubtitle": "Notifications, messaging, and map preferences", + "settings_appSettingsSubtitle": "Appearance, translation, and map preferences", "settings_messageSettings": "Message Settings", "@settings_messageSettings": { "description": "Settings category title for notification and message-handling preferences" @@ -170,13 +170,13 @@ "settings_gpsStatusTitle": "GPS status", "settings_gpsStatusRefresh": "Query GPS", "settings_gpsStatusLiveFix": "Live GPS fix", - "settings_gpsStatusNoFix": "No fix — using stored/last position", + "settings_gpsStatusNoFix": "No fix: using stored/last position", "settings_gpsStatusCoords": "Coordinates", "settings_gpsStatusSats": "Satellites", "settings_gpsStatusAltitude": "Altitude", "settings_gpsStatusTime": "Fix time", "settings_gpsStatusTapRefresh": "Tap Query GPS to read the current GPS state", - "settings_gpsStatusNoResponse": "No response — device may need the GPS-query firmware build", + "settings_gpsStatusNoResponse": "No response: device may need the GPS-query firmware build", "settings_locationIntervalInvalid": "Interval must be at least 60 seconds, and less than 86400 seconds.", "settings_latitude": "Latitude", "settings_longitude": "Longitude", @@ -239,6 +239,9 @@ "settings_infoOffbandCaps": "Offband capabilities", "settings_infoBattery": "Battery", "settings_infoPublicKey": "Public Key", + "settings_infoBuild": "Build", + "settings_infoDataScope": "Data scope", + "settings_infoDataScopeSubtitle": "Messages, contacts, and channels are stored per radio under this key", "settings_publicKeyCopied": "Public key copied", "settings_infoContactsCount": "Contacts Count", "settings_infoChannelCount": "Channel Count", @@ -304,8 +307,8 @@ "appSettings_messageNotificationsSubtitle": "Show notification when receiving new messages", "appSettings_channelMessageNotifications": "Channel Message Notifications", "appSettings_channelMessageNotificationsSubtitle": "Show notification when receiving channel messages", - "appSettings_advertisementNotifications": "Advertisement Notifications", - "appSettings_advertisementNotificationsSubtitle": "Show notification when new nodes are discovered", + "appSettings_advertisementNotifications": "New node discovered", + "appSettings_advertisementNotificationsSubtitle": "Notify when new repeaters or contacts are heard", "appSettings_messaging": "Messaging", "appSettings_clearPathOnMaxRetry": "Clear Path on Max Retry", "appSettings_clearPathOnMaxRetrySubtitle": "Reset contact path after 5 failed send attempts", @@ -350,6 +353,8 @@ "appSettings_batteryNmc": "18650 NMC (3.0-4.2V)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65V)", "appSettings_batteryLipo": "LiPo (3.0-4.2V)", + "appSettings_keepScreenAwake": "Keep screen awake", + "appSettings_keepScreenAwakeSubtitle": "Prevent the display from sleeping while the app is open. Uses more battery.", "appSettings_mapDisplay": "Map Display", "appSettings_showRepeaters": "Show Repeaters", "appSettings_showRepeatersSubtitle": "Display repeater nodes on the map", @@ -624,7 +629,6 @@ } } }, - "channels_smazCompression": "SMAZ compression", "channels_cyr2latCompression": "Cyr2Lat compression", "channels_cyr2latCompressionDscr": "Replaces some Cyrillic characters with Latin characters when sending.", "channels_cyr2latSettingsHeading": "Cyr2Lat Setup", @@ -751,6 +755,12 @@ "debugLog_bleTitle": "BLE Debug Log", "debugLog_copyLog": "Copy log", "debugLog_clearLog": "Clear log", + "debugLog_shareLog": "Share logs", + "debugLog_saveLog": "Save logs", + "debugLog_downloadLog": "Download logs", + "debugLog_shareSubject": "Offband Meshcore logs", + "debugLog_logSaved": "Log saved", + "debugLog_logUnavailable": "No log available to export", "debugLog_copied": "Debug log copied", "debugLog_bleCopied": "BLE log copied", "debugLog_noEntries": "No debug logs yet", @@ -877,7 +887,6 @@ "chat_type": "Type", "chat_path": "Path", "chat_publicKey": "Public Key", - "chat_compressOutgoingMessages": "Compress outgoing messages", "chat_floodForced": "Flood (forced)", "chat_directForced": "Direct (forced)", "chat_hopsForced": "{count} hops (forced)", @@ -1418,13 +1427,13 @@ "repeater_advancedSettings": "Advanced", "repeater_advancedSettingsSubtitle": "Tuning knobs for experienced operators", "repeater_pathHashMode": "Path hash mode", - "repeater_pathHashModeHelper": "Bytes used to encode this repeater's ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). v1.13 and older firmware drops multi-byte paths — only raise once your network is on v1.14+.", + "repeater_pathHashModeHelper": "Bytes used to encode this repeater's ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). v1.13 and older firmware drops multi-byte paths. Only raise once your network is on v1.14+.", "repeater_txDelay": "Flood TX delay", "repeater_txDelayHelper": "Retransmit spacing for flood traffic, as a multiplier of the packet's airtime (0-2, default 0.5). Higher = fewer collisions but slower delivery.", "repeater_directTxDelay": "Direct TX delay", "repeater_directTxDelayHelper": "Retransmit spacing for direct (non-flood) traffic, as a multiplier of the packet's airtime (0-2, default 0.3).", "repeater_intThresh": "Interference threshold", - "repeater_intThreshHelper": "Threshold passed to the radio's noise-floor calibration so it rejects interference above this level. 0 disables — only raise if you see RX errors in a noisy band.", + "repeater_intThreshHelper": "Threshold passed to the radio's noise-floor calibration so it rejects interference above this level. 0 disables. Only raise if you see RX errors in a noisy band.", "repeater_agcResetInterval": "AGC reset interval", "repeater_agcResetIntervalHelper": "How often to reset the radio's automatic gain control to recover from a stuck gain state. Seconds, snapped down to a multiple of 4. 0 disables periodic resets.", "repeater_actionsTitle": "Actions", @@ -1453,7 +1462,7 @@ } } }, - "repeater_settingsSavedRebootNeeded": "Settings saved — reboot the repeater to apply", + "repeater_settingsSavedRebootNeeded": "Settings saved. Reboot the repeater to apply", "repeater_settingsPartialFailure": "Some settings failed: {failures}", "@repeater_settingsPartialFailure": { "placeholders": { @@ -1945,6 +1954,33 @@ } }, "channelPath_pathLabelTitle": "Path", + "channelPath_routeDirect": "direct (routed)", + "channelPath_routeFlood": "flood, {hops} hops", + "@channelPath_routeFlood": { + "placeholders": { + "hops": { + "type": "int" + } + } + }, + "channelPath_snrLabel": "SNR", + "channelPath_snrValue": "{db} dB", + "@channelPath_snrValue": { + "placeholders": { + "db": { + "type": "String" + } + } + }, + "channelPath_rssiLabel": "RSSI", + "channelPath_rssiValue": "{dbm} dBm", + "@channelPath_rssiValue": { + "placeholders": { + "dbm": { + "type": "int" + } + } + }, "channelPath_observedPathHeader": "Observed Path", "channelPath_selectedPathLabel": "{label} • {prefixes}", "@channelPath_selectedPathLabel": { @@ -2414,7 +2450,7 @@ "settings_gpxExportShareText": "Map data exported from meshcore-open", "settings_gpxExportShareSubject": "meshcore-open GPX map data export", "snrIndicator_nearByRepeaters": "Nearby Repeaters", - "snrIndicator_noNeighbors": "No neighbors heard currently — messages still broadcast and route.", + "snrIndicator_noNeighbors": "No neighbors heard currently. Messages still broadcast and route.", "snrIndicator_lastSeen": "Last seen", "contactsSettings_title": "Contacts settings", "contactsSettings_autoAddTitle": "Automatic Discovery", @@ -2607,7 +2643,7 @@ } } }, - "block_blockedNameOnly": "Blocked \"{name}\" across all channels — name only until we learn their key", + "block_blockedNameOnly": "Blocked \"{name}\" across all channels. Name only until we learn their key", "@block_blockedNameOnly": { "placeholders": { "name": { @@ -2620,8 +2656,36 @@ "block_none": "No blocked users", "block_keysSection": "Blocked contacts", "block_namesSection": "Blocked names", - "block_namesHint": "Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.", - "block_offloadActive": "Firmware offload active — blocks sync to this radio", - "block_offloadStoreFull": "Radio block list full (32) — extra blocks stay app-only", - "block_composerNotice": "You've blocked this contact" + "block_namesHint": "Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.", + "block_offloadActive": "Firmware offload active: blocks sync to this radio", + "block_offloadStoreFull": "Radio block list full (32): extra blocks stay app-only", + "block_composerNotice": "You've blocked this contact", + "storageUnavailableTitle": "Message storage unavailable", + "storageUnavailableBody": "Your history is not lost, but the app can't open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.", + "@storageUnavailableTitle": { + "description": "Persistent banner title shown when the storage/database layer fails to open (#385)." + }, + "@storageUnavailableBody": { + "description": "Persistent banner body explaining that storage is unavailable and messages are not being saved (#385)." + }, + "batteryOptimizationTitle": "Battery optimization is on", + "@batteryOptimizationTitle": { + "description": "Title of the persistent banner warning that Android battery optimization may drop the background radio connection (#443)." + }, + "batteryOptimizationBody": "Android may sleep the app when the screen is off and drop the radio connection. Set this app's battery usage to Unrestricted for a reliable background connection.", + "@batteryOptimizationBody": { + "description": "Body of the battery-optimization warning banner (#443)." + }, + "batteryOptimizationOpenSettings": "Open battery settings", + "@batteryOptimizationOpenSettings": { + "description": "Button that opens the Android battery-optimization settings screen (#443)." + }, + "batteryOptimizationDismiss": "Dismiss", + "@batteryOptimizationDismiss": { + "description": "Tooltip for the button that hides the battery-optimization banner for this session (#443)." + }, + "batteryOptimizationOpenFailed": "Couldn't open battery settings", + "@batteryOptimizationOpenFailed": { + "description": "Snackbar shown when the battery-optimization settings screen could not be opened (#443)." + } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index bf16f93..d182945 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -84,7 +84,7 @@ "settings_title": "Configuración", "settings_deviceInfo": "Información del dispositivo", "settings_appSettings": "Configuración de la App", - "settings_appSettingsSubtitle": "Notificaciones, mensajes y preferencias de mapa", + "settings_appSettingsSubtitle": "Apariencia, traducción y preferencias de mapa", "settings_nodeSettings": "Configuración del Nodo", "settings_nodeName": "Nombre del nodo", "settings_nodeNameNotSet": "No está configurado", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Mostrar notificación al recibir nuevos mensajes", "appSettings_channelMessageNotifications": "Notificaciones de Mensajes del Canal", "appSettings_channelMessageNotificationsSubtitle": "Mostrar notificación al recibir mensajes del canal", - "appSettings_advertisementNotifications": "Notificaciones de Anuncios", - "appSettings_advertisementNotificationsSubtitle": "Mostrar notificación cuando se descubren nuevos nodos", + "appSettings_advertisementNotifications": "Nuevo nodo descubierto", + "appSettings_advertisementNotificationsSubtitle": "Notificar cuando se detecten nuevos repetidores o contactos", "appSettings_messaging": "Mensajería", "appSettings_clearPathOnMaxRetry": "Borrar Camino en Max Reintentos", "appSettings_clearPathOnMaxRetrySubtitle": "Restablecer la ruta de contacto después de 5 intentos de envío fallidos", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2 V)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65 V)", "appSettings_batteryLipo": "LiPo (3.0-4.2V)", + "appSettings_keepScreenAwake": "Mantener la pantalla encendida", + "appSettings_keepScreenAwakeSubtitle": "Evita que la pantalla se apague mientras la aplicación está abierta. Consume más batería.", "appSettings_mapDisplay": "Visualización del Mapa", "appSettings_showRepeaters": "Mostrar Repetidores", "appSettings_showRepeatersSubtitle": "Mostrar nodos de repetidor en el mapa", @@ -388,7 +390,6 @@ } } }, - "channels_smazCompression": "Compresión SMAZ", "channels_cyr2latCompression": "Compresión Cyr2Lat", "channels_cyr2latCompressionDscr": "Reemplaza algunos caracteres cirílicos con caracteres latinos al enviar.", "channels_cyr2latSettingsHeading": "Configuración de Cyr2Lat", @@ -605,7 +606,6 @@ "chat_type": "Escribe", "chat_path": "Ruta", "chat_publicKey": "Clave Pública", - "chat_compressOutgoingMessages": "Comprimir mensajes salientes", "chat_floodForced": "Inundación (forzada)", "chat_directForced": "Directo (forzado)", "chat_hopsForced": "{count} saltos (forzados)", @@ -1316,7 +1316,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Número de solicitudes", "telemetry_error": "No se pudieron obtener los datos", "telemetry_noData": "No hay datos de telemetría disponibles.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5fc1ef9..dc4c258 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -84,7 +84,7 @@ "settings_title": "Paramètres", "settings_deviceInfo": "Informations du périphérique", "settings_appSettings": "Paramètres de l'application", - "settings_appSettingsSubtitle": "Notifications, messagerie et préférences de carte", + "settings_appSettingsSubtitle": "Apparence, traduction et préférences de carte", "settings_nodeSettings": "Paramètres du nœud", "settings_nodeName": "Nom du nœud", "settings_nodeNameNotSet": "Non défini", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Afficher une notification lors de la réception de nouveaux messages", "appSettings_channelMessageNotifications": "Notifications des Messages de Canal", "appSettings_channelMessageNotificationsSubtitle": "Afficher une notification lors de la réception des messages de canal", - "appSettings_advertisementNotifications": "Notifications d'annonces", - "appSettings_advertisementNotificationsSubtitle": "Afficher une notification lors de la découverte de nouveaux nœuds", + "appSettings_advertisementNotifications": "Nouveau nœud découvert", + "appSettings_advertisementNotificationsSubtitle": "Notifier lorsque de nouveaux répéteurs ou contacts sont détectés", "appSettings_messaging": "Messagerie", "appSettings_clearPathOnMaxRetry": "Effacer le chemin sur Max Retry", "appSettings_clearPathOnMaxRetrySubtitle": "Réinitialiser le chemin de contact après 5 tentatives d'envoi infructueuses", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Garder l'écran allumé", + "appSettings_keepScreenAwakeSubtitle": "Empêche l'écran de s'éteindre lorsque l'application est ouverte. Consomme plus de batterie.", "appSettings_mapDisplay": "Affichage de la carte", "appSettings_showRepeaters": "Afficher les répéteurs", "appSettings_showRepeatersSubtitle": "Afficher les nœuds répéteurs sur la carte", @@ -388,7 +390,6 @@ } } }, - "channels_smazCompression": "Compression SMAZ", "channels_cyr2latCompression": "Compression Cyr2Lat", "channels_cyr2latCompressionDscr": "Remplace certains caractères cyrilliques par des caractères latins lors de l'envoi.", "channels_cyr2latSettingsHeading": "Paramètres Cyr2Lat", @@ -605,7 +606,6 @@ "chat_type": "Saisir", "chat_path": "Chemin", "chat_publicKey": "Clé Publique", - "chat_compressOutgoingMessages": "Compresser les messages sortants", "chat_floodForced": "Tout le réseau (forcée)", "chat_directForced": "Direct (forcé)", "chat_hopsForced": "{count} sauts (forcés)", @@ -1316,7 +1316,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Nombre de requêtes", "telemetry_error": "Impossible de récupérer les données", "telemetry_noData": "Aucune donnée de télémétrie disponible.", diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index 4ba092c..ab33d7a 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -141,7 +141,7 @@ "settings_title": "Beállítások", "settings_deviceInfo": "A készülék információi", "settings_appSettings": "Alkalmazási beállítások", - "settings_appSettingsSubtitle": "Értesítések, üzenetküldés és térképi beállítások", + "settings_appSettingsSubtitle": "Megjelenés, fordítás és térképbeállítások", "settings_nodeSettings": "Műközép beállítások", "settings_nodeName": "Vonal neve", "settings_nodeNameNotSet": "Nem megállapított", @@ -262,8 +262,8 @@ "appSettings_messageNotificationsSubtitle": "A figyelmeztetést megjelenítve, amikor új üzenet érkezik", "appSettings_channelMessageNotifications": "Csatorna-üzenetek értesítése", "appSettings_channelMessageNotificationsSubtitle": "A figyelmeztetést megjelenítve, amikor új üzenet érkezik a csatornáról", - "appSettings_advertisementNotifications": "Reklám értesítések", - "appSettings_advertisementNotificationsSubtitle": "A figyelmeztetést megjelenítve, amikor új csomópontok kerülnek felfedezésre.", + "appSettings_advertisementNotifications": "Új csomópont észlelve", + "appSettings_advertisementNotificationsSubtitle": "Értesítés, ha új átjátszók vagy névjegyek érkeznek", "appSettings_messaging": "Üzenetek küldése", "appSettings_clearPathOnMaxRetry": "Egyértelmű út a Max Retry funkció használatával", "appSettings_clearPathOnMaxRetrySubtitle": "A kapcsolat visszaállítás 5 sikertelen továbbítás után", @@ -308,6 +308,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2 V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6–3,65 V)", "appSettings_batteryLipo": "LiPo (3,0-4,2 V)", + "appSettings_keepScreenAwake": "Képernyő ébren tartása", + "appSettings_keepScreenAwakeSubtitle": "Megakadályozza a képernyő elalvását, amíg az alkalmazás nyitva van. Több akkumulátort fogyaszt.", "appSettings_mapDisplay": "Térkép megjelenítése", "appSettings_showRepeaters": "Megismétlés", "appSettings_showRepeatersSubtitle": "A térképen megjelenítsük a repeater-eket.", @@ -548,7 +550,6 @@ } } }, - "channels_smazCompression": "SMAZ kompresszió", "channels_cyr2latCompression": "Cyr2Lat kompresszió", "channels_cyr2latCompressionDscr": "Néhány Cirill betűt Latin betűkkel helyettesít küldéskor.", "channels_cyr2latSettingsHeading": "Cyr2Lat beállítások", @@ -778,7 +779,6 @@ "chat_type": "Típus", "chat_path": "Út", "chat_publicKey": "Nyelvkönyv", - "chat_compressOutgoingMessages": "A küldött üzenetek tömörítése", "chat_floodForced": "Áradás (kényszerített)", "chat_directForced": "Közvetlen (erélyes)", "chat_hopsForced": "{count} ánusz (erővel)", @@ -1502,7 +1502,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Kérések száma", "telemetry_error": "Nem sikerült lekérni az adatokat", "telemetry_noData": "Nincsenek elérhető telemetriadatok.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index c0f5d6b..cdde5fe 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -84,7 +84,7 @@ "settings_title": "Impostazioni", "settings_deviceInfo": "Informazioni Dispositivo", "settings_appSettings": "Impostazioni App", - "settings_appSettingsSubtitle": "Notifiche, messaggi e preferenze della mappa", + "settings_appSettingsSubtitle": "Aspetto, traduzione e preferenze mappa", "settings_nodeSettings": "Impostazioni Nodo", "settings_nodeName": "Nome Nodo", "settings_nodeNameNotSet": "Non impostato", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Mostra notifica all'arrivo di nuovi messaggi", "appSettings_channelMessageNotifications": "Notifiche Messaggi Canale", "appSettings_channelMessageNotificationsSubtitle": "Mostra notifica all'arrivo di messaggi nel canale", - "appSettings_advertisementNotifications": "Notifiche Pubblicitarie", - "appSettings_advertisementNotificationsSubtitle": "Mostra notifica quando vengono scoperti nuovi nodi", + "appSettings_advertisementNotifications": "Nuovo nodo rilevato", + "appSettings_advertisementNotificationsSubtitle": "Notifica quando vengono rilevati nuovi ripetitori o contatti", "appSettings_messaging": "Messaggi", "appSettings_clearPathOnMaxRetry": "Cancella Percorso su Massimo Riprovo", "appSettings_clearPathOnMaxRetrySubtitle": "Reimposta il percorso di contatto dopo 5 tentativi di invio falliti", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Mantieni lo schermo acceso", + "appSettings_keepScreenAwakeSubtitle": "Impedisce allo schermo di spegnersi mentre l'app è aperta. Consuma più batteria.", "appSettings_mapDisplay": "Visualizzazione Mappa", "appSettings_showRepeaters": "Mostra Ripetitori", "appSettings_showRepeatersSubtitle": "Mostra i nodi ripetitori sulla mappa", @@ -388,7 +390,6 @@ } } }, - "channels_smazCompression": "Compressione SMAZ", "channels_cyr2latCompression": "Compressione Cyr2Lat", "channels_cyr2latCompressionDscr": "Sostituisce alcuni caratteri cirillici con caratteri latini durante l'invio.", "channels_cyr2latSettingsHeading": "Impostazioni Cyr2Lat", @@ -605,7 +606,6 @@ "chat_type": "Digita", "chat_path": "Percorso", "chat_publicKey": "Chiave Pubblica", - "chat_compressOutgoingMessages": "Comprimi messaggi in uscita", "chat_floodForced": "Inondazione (forzata)", "chat_directForced": "Riavvia (forzato)", "chat_hopsForced": "{count} salti (forzati)", @@ -1316,7 +1316,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Numero di richieste", "telemetry_error": "Impossibile recuperare i dati", "telemetry_noData": "Nessun dato di telemetria disponibile.", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 70d54f1..477c683 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -141,7 +141,7 @@ "settings_title": "設定", "settings_deviceInfo": "デバイス情報", "settings_appSettings": "アプリ設定", - "settings_appSettingsSubtitle": "通知、メッセージング、および地図の表示設定", + "settings_appSettingsSubtitle": "外観、翻訳、地図の設定", "settings_nodeSettings": "ノード設定", "settings_nodeName": "ノード名", "settings_nodeNameNotSet": "設定されていない", @@ -262,8 +262,8 @@ "appSettings_messageNotificationsSubtitle": "新しいメッセージを受信した際に、通知を表示する", "appSettings_channelMessageNotifications": "チャネルメッセージの通知", "appSettings_channelMessageNotificationsSubtitle": "チャンネルからのメッセージを受信した際に、通知を表示する", - "appSettings_advertisementNotifications": "広告通知", - "appSettings_advertisementNotificationsSubtitle": "新しいノードが発見された場合に通知を表示する", + "appSettings_advertisementNotifications": "新しいノードを検出", + "appSettings_advertisementNotificationsSubtitle": "新しいリピーターや連絡先を受信したときに通知します", "appSettings_messaging": "メッセージング", "appSettings_clearPathOnMaxRetry": "マックスリトライでの明確な手順", "appSettings_clearPathOnMaxRetrySubtitle": "5回送信に失敗した場合、連絡経路をリセットする", @@ -308,6 +308,8 @@ "appSettings_batteryNmc": "18650型 NMC (3.0-4.2V)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65V)", "appSettings_batteryLipo": "LiPo (3.0-4.2V)", + "appSettings_keepScreenAwake": "画面をスリープさせない", + "appSettings_keepScreenAwakeSubtitle": "アプリを開いている間、画面が消灯しないようにします。バッテリーの消費が増えます。", "appSettings_mapDisplay": "地図の表示", "appSettings_showRepeaters": "繰り返し再生機能", "appSettings_showRepeatersSubtitle": "地図上にリピーターノードを表示する", @@ -548,7 +550,6 @@ } } }, - "channels_smazCompression": "SMAZ 圧縮", "channels_cyr2latCompression": "Cyr2Lat 圧縮", "channels_cyr2latCompressionDscr": "送信時に一部のキリル文字をラテン文字に置き換えます。", "channels_cyr2latSettingsHeading": "cyr2latの設定", @@ -778,7 +779,6 @@ "chat_type": "種類", "chat_path": "道", "chat_publicKey": "公開鍵", - "chat_compressOutgoingMessages": "送信されるメッセージを圧縮する", "chat_floodForced": "洪水(強制的な)", "chat_directForced": "直接的な(強制的な)", "chat_hopsForced": "{count} 本のホップ(強制的に採取)", @@ -1502,7 +1502,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "リクエスト数", "telemetry_error": "データを取得できません", "telemetry_noData": "テレメトリデータは利用できません。", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 1eb840e..d47f527 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -141,7 +141,7 @@ "settings_title": "설정", "settings_deviceInfo": "장치 정보", "settings_appSettings": "앱 설정", - "settings_appSettingsSubtitle": "알림, 메시징, 지도 설정", + "settings_appSettingsSubtitle": "모양, 번역 및 지도 환경설정", "settings_nodeSettings": "노드 설정", "settings_nodeName": "노드 이름", "settings_nodeNameNotSet": "설정되지 않음", @@ -262,8 +262,8 @@ "appSettings_messageNotificationsSubtitle": "새로운 메시지를 받을 때 알림 표시", "appSettings_channelMessageNotifications": "채널 메시지 알림", "appSettings_channelMessageNotificationsSubtitle": "채널 메시지를 수신할 때 알림 표시", - "appSettings_advertisementNotifications": "광고 알림", - "appSettings_advertisementNotificationsSubtitle": "새 노드가 발견되었을 때 알림 표시", + "appSettings_advertisementNotifications": "새 노드 발견", + "appSettings_advertisementNotificationsSubtitle": "새 리피터나 연락처가 감지되면 알림", "appSettings_messaging": "메시징", "appSettings_clearPathOnMaxRetry": "Max 재시도 시 경로 명확하게 설정", "appSettings_clearPathOnMaxRetrySubtitle": "5번의 전송 시도가 실패하면 연락 경로를 재설정", @@ -308,6 +308,8 @@ "appSettings_batteryNmc": "18650 NMC (3.0-4.2V)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65V)", "appSettings_batteryLipo": "리튬 폴리머 (3.0-4.2V)", + "appSettings_keepScreenAwake": "화면 켜짐 유지", + "appSettings_keepScreenAwakeSubtitle": "앱이 열려 있는 동안 화면이 꺼지지 않도록 합니다. 배터리를 더 소모합니다.", "appSettings_mapDisplay": "지도 표시", "appSettings_showRepeaters": "반복 기능 표시", "appSettings_showRepeatersSubtitle": "지도에 반복자 노드를 표시", @@ -548,7 +550,6 @@ } } }, - "channels_smazCompression": "SMAZ 압축", "channels_cyr2latCompression": "Cyr2Lat 압축", "channels_cyr2latCompressionDscr": "보낼 때 일부 키릴 문자를 라틴 문자로 바꿉니다.", "channels_cyr2latSettingsHeading": "Cyr2Lat 설정", @@ -778,7 +779,6 @@ "chat_type": "종류", "chat_path": "경로", "chat_publicKey": "공개 키", - "chat_compressOutgoingMessages": "전송되는 메시지 압축", "chat_floodForced": "홍수 (강제)", "chat_directForced": "직접적인 (강제적인)", "chat_hopsForced": "{count}번 띄우기 (강제)", @@ -1502,7 +1502,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "요청 수", "telemetry_error": "데이터를 가져올 수 없습니다", "telemetry_noData": "텔레메트리 데이터는 제공되지 않습니다.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0ad7394..eda069c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -691,7 +691,7 @@ abstract class AppLocalizations { /// No description provided for @settings_appSettingsSubtitle. /// /// In en, this message translates to: - /// **'Notifications, messaging, and map preferences'** + /// **'Appearance, translation, and map preferences'** String get settings_appSettingsSubtitle; /// Settings category title for notification and message-handling preferences @@ -823,7 +823,7 @@ abstract class AppLocalizations { /// No description provided for @settings_gpsStatusNoFix. /// /// In en, this message translates to: - /// **'No fix — using stored/last position'** + /// **'No fix: using stored/last position'** String get settings_gpsStatusNoFix; /// No description provided for @settings_gpsStatusCoords. @@ -859,7 +859,7 @@ abstract class AppLocalizations { /// No description provided for @settings_gpsStatusNoResponse. /// /// In en, this message translates to: - /// **'No response — device may need the GPS-query firmware build'** + /// **'No response: device may need the GPS-query firmware build'** String get settings_gpsStatusNoResponse; /// No description provided for @settings_locationIntervalInvalid. @@ -1192,6 +1192,24 @@ abstract class AppLocalizations { /// **'Public Key'** String get settings_infoPublicKey; + /// No description provided for @settings_infoBuild. + /// + /// In en, this message translates to: + /// **'Build'** + String get settings_infoBuild; + + /// No description provided for @settings_infoDataScope. + /// + /// In en, this message translates to: + /// **'Data scope'** + String get settings_infoDataScope; + + /// No description provided for @settings_infoDataScopeSubtitle. + /// + /// In en, this message translates to: + /// **'Messages, contacts, and channels are stored per radio under this key'** + String get settings_infoDataScopeSubtitle; + /// No description provided for @settings_publicKeyCopied. /// /// In en, this message translates to: @@ -1543,13 +1561,13 @@ abstract class AppLocalizations { /// No description provided for @appSettings_advertisementNotifications. /// /// In en, this message translates to: - /// **'Advertisement Notifications'** + /// **'New node discovered'** String get appSettings_advertisementNotifications; /// No description provided for @appSettings_advertisementNotificationsSubtitle. /// /// In en, this message translates to: - /// **'Show notification when new nodes are discovered'** + /// **'Notify when new repeaters or contacts are heard'** String get appSettings_advertisementNotificationsSubtitle; /// No description provided for @appSettings_messaging. @@ -1714,6 +1732,18 @@ abstract class AppLocalizations { /// **'LiPo (3.0-4.2V)'** String get appSettings_batteryLipo; + /// No description provided for @appSettings_keepScreenAwake. + /// + /// In en, this message translates to: + /// **'Keep screen awake'** + String get appSettings_keepScreenAwake; + + /// No description provided for @appSettings_keepScreenAwakeSubtitle. + /// + /// In en, this message translates to: + /// **'Prevent the display from sleeping while the app is open. Uses more battery.'** + String get appSettings_keepScreenAwakeSubtitle; + /// No description provided for @appSettings_mapDisplay. /// /// In en, this message translates to: @@ -2350,12 +2380,6 @@ abstract class AppLocalizations { /// **'Edit Channel {index}'** String channels_editChannelTitle(int index); - /// No description provided for @channels_smazCompression. - /// - /// In en, this message translates to: - /// **'SMAZ compression'** - String get channels_smazCompression; - /// No description provided for @channels_cyr2latCompression. /// /// In en, this message translates to: @@ -2770,6 +2794,42 @@ abstract class AppLocalizations { /// **'Clear log'** String get debugLog_clearLog; + /// No description provided for @debugLog_shareLog. + /// + /// In en, this message translates to: + /// **'Share logs'** + String get debugLog_shareLog; + + /// No description provided for @debugLog_saveLog. + /// + /// In en, this message translates to: + /// **'Save logs'** + String get debugLog_saveLog; + + /// No description provided for @debugLog_downloadLog. + /// + /// In en, this message translates to: + /// **'Download logs'** + String get debugLog_downloadLog; + + /// No description provided for @debugLog_shareSubject. + /// + /// In en, this message translates to: + /// **'Offband Meshcore logs'** + String get debugLog_shareSubject; + + /// No description provided for @debugLog_logSaved. + /// + /// In en, this message translates to: + /// **'Log saved'** + String get debugLog_logSaved; + + /// No description provided for @debugLog_logUnavailable. + /// + /// In en, this message translates to: + /// **'No log available to export'** + String get debugLog_logUnavailable; + /// No description provided for @debugLog_copied. /// /// In en, this message translates to: @@ -3070,12 +3130,6 @@ abstract class AppLocalizations { /// **'Public Key'** String get chat_publicKey; - /// No description provided for @chat_compressOutgoingMessages. - /// - /// In en, this message translates to: - /// **'Compress outgoing messages'** - String get chat_compressOutgoingMessages; - /// No description provided for @chat_floodForced. /// /// In en, this message translates to: @@ -4697,7 +4751,7 @@ abstract class AppLocalizations { /// No description provided for @repeater_pathHashModeHelper. /// /// In en, this message translates to: - /// **'Bytes used to encode this repeater\'s ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). v1.13 and older firmware drops multi-byte paths — only raise once your network is on v1.14+.'** + /// **'Bytes used to encode this repeater\'s ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). v1.13 and older firmware drops multi-byte paths. Only raise once your network is on v1.14+.'** String get repeater_pathHashModeHelper; /// No description provided for @repeater_txDelay. @@ -4733,7 +4787,7 @@ abstract class AppLocalizations { /// No description provided for @repeater_intThreshHelper. /// /// In en, this message translates to: - /// **'Threshold passed to the radio\'s noise-floor calibration so it rejects interference above this level. 0 disables — only raise if you see RX errors in a noisy band.'** + /// **'Threshold passed to the radio\'s noise-floor calibration so it rejects interference above this level. 0 disables. Only raise if you see RX errors in a noisy band.'** String get repeater_intThreshHelper; /// No description provided for @repeater_agcResetInterval. @@ -4805,7 +4859,7 @@ abstract class AppLocalizations { /// No description provided for @repeater_settingsSavedRebootNeeded. /// /// In en, this message translates to: - /// **'Settings saved — reboot the repeater to apply'** + /// **'Settings saved. Reboot the repeater to apply'** String get repeater_settingsSavedRebootNeeded; /// No description provided for @repeater_settingsPartialFailure. @@ -6302,6 +6356,42 @@ abstract class AppLocalizations { /// **'Path'** String get channelPath_pathLabelTitle; + /// No description provided for @channelPath_routeDirect. + /// + /// In en, this message translates to: + /// **'direct (routed)'** + String get channelPath_routeDirect; + + /// No description provided for @channelPath_routeFlood. + /// + /// In en, this message translates to: + /// **'flood, {hops} hops'** + String channelPath_routeFlood(int hops); + + /// No description provided for @channelPath_snrLabel. + /// + /// In en, this message translates to: + /// **'SNR'** + String get channelPath_snrLabel; + + /// No description provided for @channelPath_snrValue. + /// + /// In en, this message translates to: + /// **'{db} dB'** + String channelPath_snrValue(String db); + + /// No description provided for @channelPath_rssiLabel. + /// + /// In en, this message translates to: + /// **'RSSI'** + String get channelPath_rssiLabel; + + /// No description provided for @channelPath_rssiValue. + /// + /// In en, this message translates to: + /// **'{dbm} dBm'** + String channelPath_rssiValue(int dbm); + /// No description provided for @channelPath_observedPathHeader. /// /// In en, this message translates to: @@ -7327,7 +7417,7 @@ abstract class AppLocalizations { /// No description provided for @snrIndicator_noNeighbors. /// /// In en, this message translates to: - /// **'No neighbors heard currently — messages still broadcast and route.'** + /// **'No neighbors heard currently. Messages still broadcast and route.'** String get snrIndicator_noNeighbors; /// No description provided for @snrIndicator_lastSeen. @@ -7897,7 +7987,7 @@ abstract class AppLocalizations { /// No description provided for @block_blockedNameOnly. /// /// In en, this message translates to: - /// **'Blocked \"{name}\" across all channels — name only until we learn their key'** + /// **'Blocked \"{name}\" across all channels. Name only until we learn their key'** String block_blockedNameOnly(String name); /// No description provided for @block_settingsTitle. @@ -7933,19 +8023,19 @@ abstract class AppLocalizations { /// No description provided for @block_namesHint. /// /// In en, this message translates to: - /// **'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'** + /// **'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'** String get block_namesHint; /// No description provided for @block_offloadActive. /// /// In en, this message translates to: - /// **'Firmware offload active — blocks sync to this radio'** + /// **'Firmware offload active: blocks sync to this radio'** String get block_offloadActive; /// No description provided for @block_offloadStoreFull. /// /// In en, this message translates to: - /// **'Radio block list full (32) — extra blocks stay app-only'** + /// **'Radio block list full (32): extra blocks stay app-only'** String get block_offloadStoreFull; /// No description provided for @block_composerNotice. @@ -7953,6 +8043,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'You\'ve blocked this contact'** String get block_composerNotice; + + /// Persistent banner title shown when the storage/database layer fails to open (#385). + /// + /// In en, this message translates to: + /// **'Message storage unavailable'** + String get storageUnavailableTitle; + + /// Persistent banner body explaining that storage is unavailable and messages are not being saved (#385). + /// + /// In en, this message translates to: + /// **'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'** + String get storageUnavailableBody; + + /// Title of the persistent banner warning that Android battery optimization may drop the background radio connection (#443). + /// + /// In en, this message translates to: + /// **'Battery optimization is on'** + String get batteryOptimizationTitle; + + /// Body of the battery-optimization warning banner (#443). + /// + /// In en, this message translates to: + /// **'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'** + String get batteryOptimizationBody; + + /// Button that opens the Android battery-optimization settings screen (#443). + /// + /// In en, this message translates to: + /// **'Open battery settings'** + String get batteryOptimizationOpenSettings; + + /// Tooltip for the button that hides the battery-optimization banner for this session (#443). + /// + /// In en, this message translates to: + /// **'Dismiss'** + String get batteryOptimizationDismiss; + + /// Snackbar shown when the battery-optimization settings screen could not be opened (#443). + /// + /// In en, this message translates to: + /// **'Couldn\'t open battery settings'** + String get batteryOptimizationOpenFailed; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index a379cc6..a73400e 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -317,7 +317,7 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Уведомления, съобщения и предпочитания за карта'; + 'Външен вид, превод и настройки на картата'; @override String get settings_messageSettings => 'Message Settings'; @@ -388,7 +388,7 @@ class AppLocalizationsBg extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -408,7 +408,7 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -599,6 +599,16 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_infoPublicKey => 'Общ публичен ключ'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -786,11 +796,11 @@ class AppLocalizationsBg extends AppLocalizations { 'Покажи известие при получаване на съобщения от канали'; @override - String get appSettings_advertisementNotifications => 'Уведомления за реклами'; + String get appSettings_advertisementNotifications => 'Открит нов възел'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Покажи известие, когато бъдат открити нови възли.'; + 'Известяване при откриване на нови репитери или контакти'; @override String get appSettings_messaging => 'Съобщения'; @@ -895,6 +905,13 @@ class AppLocalizationsBg extends AppLocalizations { @override String get appSettings_batteryLipo => 'Литиев полимер (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => 'Поддържане на екрана включен'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Екранът няма да изгасва, докато приложението е отворено. Изразходва повече батерия.'; + @override String get appSettings_mapDisplay => 'Карта за показване'; @@ -1269,9 +1286,6 @@ class AppLocalizationsBg extends AppLocalizations { return 'Редактирай Канал $index'; } - @override - String get channels_smazCompression => 'Компресия SMAZ'; - @override String get channels_cyr2latCompression => 'Компресия Cyr2Lat'; @@ -1503,6 +1517,24 @@ class AppLocalizationsBg extends AppLocalizations { @override String get debugLog_clearLog => 'Изчисти логовете'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Копирано лого за отстраняване на грешки'; @@ -1702,10 +1734,6 @@ class AppLocalizationsBg extends AppLocalizations { @override String get chat_publicKey => 'Публичен ключ'; - @override - String get chat_compressOutgoingMessages => - 'Компресиране на изходящи съобщения'; - @override String get chat_floodForced => 'Потоп (принуден)'; @@ -3640,6 +3668,30 @@ class AppLocalizationsBg extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Пътекино'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Наблюдаван път'; @@ -4280,7 +4332,7 @@ class AppLocalizationsBg extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Последно видян'; @@ -4622,7 +4674,7 @@ class AppLocalizationsBg extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4642,16 +4694,39 @@ class AppLocalizationsBg extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b60bea3..1b209c5 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -320,7 +320,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Benachrichtigungen, Messaging und Kartenwahrnehmung'; + 'Erscheinungsbild, Übersetzung und Karteneinstellungen'; @override String get settings_messageSettings => 'Message Settings'; @@ -390,7 +390,7 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -410,7 +410,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -594,6 +594,16 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_infoPublicKey => 'Öffentlicher Schlüssel'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -783,12 +793,11 @@ class AppLocalizationsDe extends AppLocalizations { 'Zeige Benachrichtigung beim Empfangen von Kanalnachrichten'; @override - String get appSettings_advertisementNotifications => - 'Ankündigungsbenachrichtigungen'; + String get appSettings_advertisementNotifications => 'Neuer Knoten entdeckt'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Zeige Benachrichtigung, wenn neue Knoten entdeckt werden.'; + 'Benachrichtigen, wenn neue Repeater oder Kontakte empfangen werden'; @override String get appSettings_messaging => 'Nachrichten'; @@ -891,6 +900,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0–4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Bildschirm anlassen'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Verhindert, dass sich das Display ausschaltet, während die App geöffnet ist. Verbraucht mehr Akku.'; + @override String get appSettings_mapDisplay => 'Kartendarstellung'; @@ -1265,9 +1281,6 @@ class AppLocalizationsDe extends AppLocalizations { return 'Bearbeiteter Kanal $index'; } - @override - String get channels_smazCompression => 'SMAZ-Komprimierung'; - @override String get channels_cyr2latCompression => 'Cyr2Lat-Komprimierung'; @@ -1501,6 +1514,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get debugLog_clearLog => 'Protokoll löschen'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Debug-Protokoll kopiert'; @@ -1699,10 +1730,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get chat_publicKey => 'Öffentlicher Schlüssel'; - @override - String get chat_compressOutgoingMessages => - 'Komprimieren ausgehender Nachrichten'; - @override String get chat_floodForced => 'Geflutet (erzwungen)'; @@ -3647,6 +3674,30 @@ class AppLocalizationsDe extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Pfad'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Beobachteter Pfad'; @@ -4294,7 +4345,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Zuletzt gesehen'; @@ -4640,7 +4691,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4660,16 +4711,39 @@ class AppLocalizationsDe extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index db7573f..8ad3bf9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -314,7 +314,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notifications, messaging, and map preferences'; + 'Appearance, translation, and map preferences'; @override String get settings_messageSettings => 'Message Settings'; @@ -384,7 +384,7 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -404,7 +404,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -584,6 +584,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_infoPublicKey => 'Public Key'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -770,12 +780,11 @@ class AppLocalizationsEn extends AppLocalizations { 'Show notification when receiving channel messages'; @override - String get appSettings_advertisementNotifications => - 'Advertisement Notifications'; + String get appSettings_advertisementNotifications => 'New node discovered'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Show notification when new nodes are discovered'; + 'Notify when new repeaters or contacts are heard'; @override String get appSettings_messaging => 'Messaging'; @@ -876,6 +885,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => 'Keep screen awake'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Prevent the display from sleeping while the app is open. Uses more battery.'; + @override String get appSettings_mapDisplay => 'Map Display'; @@ -1244,9 +1260,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Edit Channel $index'; } - @override - String get channels_smazCompression => 'SMAZ compression'; - @override String get channels_cyr2latCompression => 'Cyr2Lat compression'; @@ -1473,6 +1486,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get debugLog_clearLog => 'Clear log'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Debug log copied'; @@ -1667,9 +1698,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chat_publicKey => 'Public Key'; - @override - String get chat_compressOutgoingMessages => 'Compress outgoing messages'; - @override String get chat_floodForced => 'Flood (forced)'; @@ -2585,7 +2613,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Bytes used to encode this repeater\'s ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). v1.13 and older firmware drops multi-byte paths — only raise once your network is on v1.14+.'; + 'Bytes used to encode this repeater\'s ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). v1.13 and older firmware drops multi-byte paths. Only raise once your network is on v1.14+.'; @override String get repeater_txDelay => 'Flood TX delay'; @@ -2606,7 +2634,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get repeater_intThreshHelper => - 'Threshold passed to the radio\'s noise-floor calibration so it rejects interference above this level. 0 disables — only raise if you see RX errors in a noisy band.'; + 'Threshold passed to the radio\'s noise-floor calibration so it rejects interference above this level. 0 disables. Only raise if you see RX errors in a noisy band.'; @override String get repeater_agcResetInterval => 'AGC reset interval'; @@ -2651,7 +2679,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get repeater_settingsSavedRebootNeeded => - 'Settings saved — reboot the repeater to apply'; + 'Settings saved. Reboot the repeater to apply'; @override String repeater_settingsPartialFailure(String failures) { @@ -3576,6 +3604,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Path'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Observed Path'; @@ -4210,7 +4262,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Last seen'; @@ -4544,7 +4596,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4564,16 +4616,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 9253a00..9ba54d3 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -318,7 +318,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notificaciones, mensajes y preferencias de mapa'; + 'Apariencia, traducción y preferencias de mapa'; @override String get settings_messageSettings => 'Message Settings'; @@ -388,7 +388,7 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -408,7 +408,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -595,6 +595,16 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_infoPublicKey => 'Clave Pública'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -782,12 +792,11 @@ class AppLocalizationsEs extends AppLocalizations { 'Mostrar notificación al recibir mensajes del canal'; @override - String get appSettings_advertisementNotifications => - 'Notificaciones de Anuncios'; + String get appSettings_advertisementNotifications => 'Nuevo nodo descubierto'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Mostrar notificación cuando se descubren nuevos nodos'; + 'Notificar cuando se detecten nuevos repetidores o contactos'; @override String get appSettings_messaging => 'Mensajería'; @@ -890,6 +899,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => 'Mantener la pantalla encendida'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Evita que la pantalla se apague mientras la aplicación está abierta. Consume más batería.'; + @override String get appSettings_mapDisplay => 'Visualización del Mapa'; @@ -1266,9 +1282,6 @@ class AppLocalizationsEs extends AppLocalizations { return 'Editar Canal $index'; } - @override - String get channels_smazCompression => 'Compresión SMAZ'; - @override String get channels_cyr2latCompression => 'Compresión Cyr2Lat'; @@ -1500,6 +1513,24 @@ class AppLocalizationsEs extends AppLocalizations { @override String get debugLog_clearLog => 'Borrar registro'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Registro de depuración copiado'; @@ -1698,9 +1729,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get chat_publicKey => 'Clave Pública'; - @override - String get chat_compressOutgoingMessages => 'Comprimir mensajes salientes'; - @override String get chat_floodForced => 'Inundación (forzada)'; @@ -3635,6 +3663,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Ruta'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Ruta Observada'; @@ -4280,7 +4332,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Visto por última vez'; @@ -4627,7 +4679,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4647,16 +4699,39 @@ class AppLocalizationsEs extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 19bd894..8ba78ec 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -320,7 +320,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notifications, messagerie et préférences de carte'; + 'Apparence, traduction et préférences de carte'; @override String get settings_messageSettings => 'Message Settings'; @@ -391,7 +391,7 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -411,7 +411,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -599,6 +599,16 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_infoPublicKey => 'Clé Publique'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -786,12 +796,11 @@ class AppLocalizationsFr extends AppLocalizations { 'Afficher une notification lors de la réception des messages de canal'; @override - String get appSettings_advertisementNotifications => - 'Notifications d\'annonces'; + String get appSettings_advertisementNotifications => 'Nouveau nœud découvert'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Afficher une notification lors de la découverte de nouveaux nœuds'; + 'Notifier lorsque de nouveaux répéteurs ou contacts sont détectés'; @override String get appSettings_messaging => 'Messagerie'; @@ -896,6 +905,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Garder l\'écran allumé'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Empêche l\'écran de s\'éteindre lorsque l\'application est ouverte. Consomme plus de batterie.'; + @override String get appSettings_mapDisplay => 'Affichage de la carte'; @@ -1271,9 +1287,6 @@ class AppLocalizationsFr extends AppLocalizations { return 'Modifier le Canal $index'; } - @override - String get channels_smazCompression => 'Compression SMAZ'; - @override String get channels_cyr2latCompression => 'Compression Cyr2Lat'; @@ -1505,6 +1518,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get debugLog_clearLog => 'Effacer le journal'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Journal de débogage copié'; @@ -1706,10 +1737,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get chat_publicKey => 'Clé Publique'; - @override - String get chat_compressOutgoingMessages => - 'Compresser les messages sortants'; - @override String get chat_floodForced => 'Tout le réseau (forcée)'; @@ -3658,6 +3685,30 @@ class AppLocalizationsFr extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Chemin'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Chemin observé'; @@ -4309,7 +4360,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Dernière fois vu'; @@ -4656,7 +4707,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4676,16 +4727,39 @@ class AppLocalizationsFr extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 98933ef..8bab96a 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -317,7 +317,7 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Értesítések, üzenetküldés és térképi beállítások'; + 'Megjelenés, fordítás és térképbeállítások'; @override String get settings_messageSettings => 'Message Settings'; @@ -390,7 +390,7 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -410,7 +410,7 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -597,6 +597,16 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_infoPublicKey => 'Nyelvkönyv'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -788,11 +798,11 @@ class AppLocalizationsHu extends AppLocalizations { 'A figyelmeztetést megjelenítve, amikor új üzenet érkezik a csatornáról'; @override - String get appSettings_advertisementNotifications => 'Reklám értesítések'; + String get appSettings_advertisementNotifications => 'Új csomópont észlelve'; @override String get appSettings_advertisementNotificationsSubtitle => - 'A figyelmeztetést megjelenítve, amikor új csomópontok kerülnek felfedezésre.'; + 'Értesítés, ha új átjátszók vagy névjegyek érkeznek'; @override String get appSettings_messaging => 'Üzenetek küldése'; @@ -894,6 +904,13 @@ class AppLocalizationsHu extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2 V)'; + @override + String get appSettings_keepScreenAwake => 'Képernyő ébren tartása'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Megakadályozza a képernyő elalvását, amíg az alkalmazás nyitva van. Több akkumulátort fogyaszt.'; + @override String get appSettings_mapDisplay => 'Térkép megjelenítése'; @@ -1271,9 +1288,6 @@ class AppLocalizationsHu extends AppLocalizations { return 'Módosítsd a csatornát $index'; } - @override - String get channels_smazCompression => 'SMAZ kompresszió'; - @override String get channels_cyr2latCompression => 'Cyr2Lat kompresszió'; @@ -1507,6 +1521,24 @@ class AppLocalizationsHu extends AppLocalizations { @override String get debugLog_clearLog => 'Jelzett napló'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Hibajelentő napló másolva'; @@ -1708,9 +1740,6 @@ class AppLocalizationsHu extends AppLocalizations { @override String get chat_publicKey => 'Nyelvkönyv'; - @override - String get chat_compressOutgoingMessages => 'A küldött üzenetek tömörítése'; - @override String get chat_floodForced => 'Áradás (kényszerített)'; @@ -3651,6 +3680,30 @@ class AppLocalizationsHu extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Út'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Megfigyelt útvonal'; @@ -4299,7 +4352,7 @@ class AppLocalizationsHu extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Utoljára, amikor látták'; @@ -4643,7 +4696,7 @@ class AppLocalizationsHu extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4663,16 +4716,39 @@ class AppLocalizationsHu extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index b0197a8..e2b0d0d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -320,7 +320,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notifiche, messaggi e preferenze della mappa'; + 'Aspetto, traduzione e preferenze mappa'; @override String get settings_messageSettings => 'Message Settings'; @@ -390,7 +390,7 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -410,7 +410,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -597,6 +597,16 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_infoPublicKey => 'Chiave Pubblica'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -784,12 +794,11 @@ class AppLocalizationsIt extends AppLocalizations { 'Mostra notifica all\'arrivo di messaggi nel canale'; @override - String get appSettings_advertisementNotifications => - 'Notifiche Pubblicitarie'; + String get appSettings_advertisementNotifications => 'Nuovo nodo rilevato'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Mostra notifica quando vengono scoperti nuovi nodi'; + 'Notifica quando vengono rilevati nuovi ripetitori o contatti'; @override String get appSettings_messaging => 'Messaggi'; @@ -893,6 +902,13 @@ class AppLocalizationsIt extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Mantieni lo schermo acceso'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Impedisce allo schermo di spegnersi mentre l\'app è aperta. Consuma più batteria.'; + @override String get appSettings_mapDisplay => 'Visualizzazione Mappa'; @@ -1267,9 +1283,6 @@ class AppLocalizationsIt extends AppLocalizations { return 'Modifica Canale $index'; } - @override - String get channels_smazCompression => 'Compressione SMAZ'; - @override String get channels_cyr2latCompression => 'Compressione Cyr2Lat'; @@ -1502,6 +1515,24 @@ class AppLocalizationsIt extends AppLocalizations { @override String get debugLog_clearLog => 'Cancella log'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Log di debug copiato'; @@ -1701,9 +1732,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get chat_publicKey => 'Chiave Pubblica'; - @override - String get chat_compressOutgoingMessages => 'Comprimi messaggi in uscita'; - @override String get chat_floodForced => 'Inondazione (forzata)'; @@ -3640,6 +3668,30 @@ class AppLocalizationsIt extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Percorso'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Percorso Osservato'; @@ -4288,7 +4340,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Ultimo accesso'; @@ -4632,7 +4684,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4652,16 +4704,39 @@ class AppLocalizationsIt extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index b244d39..4065395 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -306,7 +306,7 @@ class AppLocalizationsJa extends AppLocalizations { String get settings_appSettings => 'アプリ設定'; @override - String get settings_appSettingsSubtitle => '通知、メッセージング、および地図の表示設定'; + String get settings_appSettingsSubtitle => '外観、翻訳、地図の設定'; @override String get settings_messageSettings => 'Message Settings'; @@ -373,7 +373,7 @@ class AppLocalizationsJa extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -393,7 +393,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -570,6 +570,16 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settings_infoPublicKey => '公開鍵'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -753,11 +763,11 @@ class AppLocalizationsJa extends AppLocalizations { 'チャンネルからのメッセージを受信した際に、通知を表示する'; @override - String get appSettings_advertisementNotifications => '広告通知'; + String get appSettings_advertisementNotifications => '新しいノードを検出'; @override String get appSettings_advertisementNotificationsSubtitle => - '新しいノードが発見された場合に通知を表示する'; + '新しいリピーターや連絡先を受信したときに通知します'; @override String get appSettings_messaging => 'メッセージング'; @@ -849,6 +859,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => '画面をスリープさせない'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'アプリを開いている間、画面が消灯しないようにします。バッテリーの消費が増えます。'; + @override String get appSettings_mapDisplay => '地図の表示'; @@ -1212,9 +1229,6 @@ class AppLocalizationsJa extends AppLocalizations { return 'チャンネル $index の編集'; } - @override - String get channels_smazCompression => 'SMAZ 圧縮'; - @override String get channels_cyr2latCompression => 'Cyr2Lat 圧縮'; @@ -1438,6 +1452,24 @@ class AppLocalizationsJa extends AppLocalizations { @override String get debugLog_clearLog => '詳細なログ'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'デバッグログをコピー'; @@ -1628,9 +1660,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get chat_publicKey => '公開鍵'; - @override - String get chat_compressOutgoingMessages => '送信されるメッセージを圧縮する'; - @override String get chat_floodForced => '洪水(強制的な)'; @@ -3456,6 +3485,30 @@ class AppLocalizationsJa extends AppLocalizations { @override String get channelPath_pathLabelTitle => '道'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => '観察された経路'; @@ -4074,7 +4127,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => '最後に確認された場所'; @@ -4397,7 +4450,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4417,16 +4470,39 @@ class AppLocalizationsJa extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 90228a2..2469343 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -305,7 +305,7 @@ class AppLocalizationsKo extends AppLocalizations { String get settings_appSettings => '앱 설정'; @override - String get settings_appSettingsSubtitle => '알림, 메시징, 지도 설정'; + String get settings_appSettingsSubtitle => '모양, 번역 및 지도 환경설정'; @override String get settings_messageSettings => 'Message Settings'; @@ -373,7 +373,7 @@ class AppLocalizationsKo extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -393,7 +393,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -570,6 +570,16 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settings_infoPublicKey => '공개 키'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -752,11 +762,11 @@ class AppLocalizationsKo extends AppLocalizations { '채널 메시지를 수신할 때 알림 표시'; @override - String get appSettings_advertisementNotifications => '광고 알림'; + String get appSettings_advertisementNotifications => '새 노드 발견'; @override String get appSettings_advertisementNotificationsSubtitle => - '새 노드가 발견되었을 때 알림 표시'; + '새 리피터나 연락처가 감지되면 알림'; @override String get appSettings_messaging => '메시징'; @@ -847,6 +857,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appSettings_batteryLipo => '리튬 폴리머 (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => '화면 켜짐 유지'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + '앱이 열려 있는 동안 화면이 꺼지지 않도록 합니다. 배터리를 더 소모합니다.'; + @override String get appSettings_mapDisplay => '지도 표시'; @@ -1207,9 +1224,6 @@ class AppLocalizationsKo extends AppLocalizations { return '채널 $index 편집'; } - @override - String get channels_smazCompression => 'SMAZ 압축'; - @override String get channels_cyr2latCompression => 'Cyr2Lat 압축'; @@ -1433,6 +1447,24 @@ class AppLocalizationsKo extends AppLocalizations { @override String get debugLog_clearLog => '명확한 로그'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => '디버깅 로그 복사'; @@ -1624,9 +1656,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get chat_publicKey => '공개 키'; - @override - String get chat_compressOutgoingMessages => '전송되는 메시지 압축'; - @override String get chat_floodForced => '홍수 (강제)'; @@ -3458,6 +3487,30 @@ class AppLocalizationsKo extends AppLocalizations { @override String get channelPath_pathLabelTitle => '경로'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => '관찰된 경로'; @@ -4075,7 +4128,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => '마지막으로 목격'; @@ -4398,7 +4451,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4418,16 +4471,39 @@ class AppLocalizationsKo extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 440530b..ebbe9ae 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -316,7 +316,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notificaties, berichten en kaartinstellingen'; + 'Weergave, vertaling en kaartvoorkeuren'; @override String get settings_messageSettings => 'Message Settings'; @@ -387,7 +387,7 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -407,7 +407,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -591,6 +591,16 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_infoPublicKey => 'Openbare Sleutel'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -779,11 +789,11 @@ class AppLocalizationsNl extends AppLocalizations { 'Toon notificatie bij het ontvangen van kanaalberichten'; @override - String get appSettings_advertisementNotifications => 'Advertentie-meldingen'; + String get appSettings_advertisementNotifications => 'Nieuwe node ontdekt'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Toon notificatie wanneer nieuwe nodes worden ontdekt'; + 'Melden wanneer nieuwe repeaters of contacten worden gehoord'; @override String get appSettings_messaging => 'Berichten'; @@ -885,6 +895,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Scherm aan houden'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Voorkomt dat het scherm in slaapstand gaat terwijl de app open is. Verbruikt meer batterij.'; + @override String get appSettings_mapDisplay => 'Kaartweergave'; @@ -1256,9 +1273,6 @@ class AppLocalizationsNl extends AppLocalizations { return 'Bewerk Kanaal $index'; } - @override - String get channels_smazCompression => 'SMAZ compressie'; - @override String get channels_cyr2latCompression => 'Cyr2Lat compressie'; @@ -1489,6 +1503,24 @@ class AppLocalizationsNl extends AppLocalizations { @override String get debugLog_clearLog => 'Log wissen'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Debuglog gekopieerd'; @@ -1686,10 +1718,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get chat_publicKey => 'Openbare Sleutel'; - @override - String get chat_compressOutgoingMessages => - 'Verzenden van uitgaande berichten comprimeren'; - @override String get chat_floodForced => 'Flood (afgedwongen)'; @@ -3621,6 +3649,30 @@ class AppLocalizationsNl extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Pad'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Waargenomen Pad'; @@ -4266,7 +4318,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Laatst gezien'; @@ -4607,7 +4659,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4627,16 +4679,39 @@ class AppLocalizationsNl extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index be65e06..75b3bef 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -321,7 +321,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Powiadomienia, wiadomości i preferencje mapy'; + 'Wygląd, tłumaczenie i ustawienia mapy'; @override String get settings_messageSettings => 'Message Settings'; @@ -393,7 +393,7 @@ class AppLocalizationsPl extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -413,7 +413,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -599,6 +599,16 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_infoPublicKey => 'Klucz Publiczny'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -787,12 +797,11 @@ class AppLocalizationsPl extends AppLocalizations { 'Pokaż powiadomienie przy odbieraniu wiadomości z kanału'; @override - String get appSettings_advertisementNotifications => - 'Powiadomienia o rozgłoszeniach'; + String get appSettings_advertisementNotifications => 'Wykryto nowy węzeł'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Wyświetl powiadomienie, gdy zostaną wykryte nowe węzły.'; + 'Powiadamiaj o wykryciu nowych przemienników lub kontaktów'; @override String get appSettings_messaging => 'Wiadomości'; @@ -895,6 +904,13 @@ class AppLocalizationsPl extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Nie wygaszaj ekranu'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Zapobiega wygaszaniu ekranu, gdy aplikacja jest otwarta. Zużywa więcej baterii.'; + @override String get appSettings_mapDisplay => 'Wyświetlanie mapy'; @@ -1276,9 +1292,6 @@ class AppLocalizationsPl extends AppLocalizations { return 'Edytuj Kanał $index'; } - @override - String get channels_smazCompression => 'Kompresja SMAZ'; - @override String get channels_cyr2latCompression => 'Kompresja Cyr2Lat'; @@ -1512,6 +1525,24 @@ class AppLocalizationsPl extends AppLocalizations { @override String get debugLog_clearLog => 'Wyczyść dziennik'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Skopiowano dziennik debugowania'; @@ -1713,9 +1744,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get chat_publicKey => 'Klucz Publiczny'; - @override - String get chat_compressOutgoingMessages => 'Kompresuj wychodzące wiadomości'; - @override String get chat_floodForced => 'Zalew (wymuszony)'; @@ -3653,6 +3681,30 @@ class AppLocalizationsPl extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Ścieżka'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Obserwowana ścieżka'; @@ -4304,7 +4356,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Ostatnio widziany'; @@ -4644,7 +4696,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4664,16 +4716,39 @@ class AppLocalizationsPl extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index efcfad0..52cd3d3 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -319,7 +319,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notificações, mensagens e preferências de mapa'; + 'Aparência, tradução e preferências de mapa'; @override String get settings_messageSettings => 'Message Settings'; @@ -390,7 +390,7 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -410,7 +410,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -597,6 +597,16 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_infoPublicKey => 'Chave Pública'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -784,12 +794,11 @@ class AppLocalizationsPt extends AppLocalizations { 'Mostrar notificação ao receber mensagens do canal'; @override - String get appSettings_advertisementNotifications => - 'Notificações de Anúncios'; + String get appSettings_advertisementNotifications => 'Novo nó descoberto'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Mostrar notificação quando novos nós forem descobertos'; + 'Notificar quando novos repetidores ou contatos forem detectados'; @override String get appSettings_messaging => 'Mensagens'; @@ -892,6 +901,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Manter a tela ligada'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Impede que a tela desligue enquanto o app está aberto. Consome mais bateria.'; + @override String get appSettings_mapDisplay => 'Exibição do Mapa'; @@ -1266,9 +1282,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'Editar Canal $index'; } - @override - String get channels_smazCompression => 'Compressão SMAZ'; - @override String get channels_cyr2latCompression => 'Compressão Cyr2Lat'; @@ -1499,6 +1512,24 @@ class AppLocalizationsPt extends AppLocalizations { @override String get debugLog_clearLog => 'Limpar log'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Log de depuração copiado'; @@ -1697,9 +1728,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get chat_publicKey => 'Chave Pública'; - @override - String get chat_compressOutgoingMessages => 'Comprimir mensagens enviadas'; - @override String get chat_floodForced => 'Inundação (forçada)'; @@ -3634,6 +3662,30 @@ class AppLocalizationsPt extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Caminho'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Rastreamento Observado'; @@ -4277,7 +4329,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Visto pela última vez'; @@ -4620,7 +4672,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4640,16 +4692,39 @@ class AppLocalizationsPt extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 63b5679..ff61747 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -319,7 +319,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Уведомления, сообщения и настройки карты'; + 'Внешний вид, перевод и настройки карты'; @override String get settings_messageSettings => 'Message Settings'; @@ -389,7 +389,7 @@ class AppLocalizationsRu extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -409,7 +409,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -596,6 +596,16 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_infoPublicKey => 'Публичный ключ'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -784,12 +794,11 @@ class AppLocalizationsRu extends AppLocalizations { 'Показывать уведомление при получении сообщений в каналах'; @override - String get appSettings_advertisementNotifications => - 'Уведомления об анонсированиях'; + String get appSettings_advertisementNotifications => 'Обнаружен новый узел'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Показывать уведомление при обнаружении новых нод'; + 'Уведомлять при обнаружении новых репитеров или контактов'; @override String get appSettings_messaging => 'Обмен сообщениями'; @@ -894,6 +903,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3.0–4.2 В)'; + @override + String get appSettings_keepScreenAwake => 'Не выключать экран'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Экран не будет гаснуть, пока приложение открыто. Расходует больше заряда.'; + @override String get appSettings_mapDisplay => 'Отображение карты'; @@ -1267,9 +1283,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Изменить канал $index'; } - @override - String get channels_smazCompression => 'Сжатие SMAZ'; - @override String get channels_cyr2latCompression => 'Сжатие Cyr2Lat'; @@ -1500,6 +1513,24 @@ class AppLocalizationsRu extends AppLocalizations { @override String get debugLog_clearLog => 'Очистить журнал'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Журнал отладки скопирован'; @@ -1702,9 +1733,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chat_publicKey => 'Публичный ключ'; - @override - String get chat_compressOutgoingMessages => 'Сжимать исходящие сообщения'; - @override String get chat_floodForced => 'Рассылка (принудительно)'; @@ -3642,6 +3670,30 @@ class AppLocalizationsRu extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Путь'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Наблюдаемый путь'; @@ -4295,7 +4347,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Последний раз видели'; @@ -4638,7 +4690,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4658,16 +4710,39 @@ class AppLocalizationsRu extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index be75989..479c060 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -318,7 +318,7 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Upozornenia, správy a nastavenia mapy'; + 'Vzhľad, preklad a nastavenia mapy'; @override String get settings_messageSettings => 'Message Settings'; @@ -388,7 +388,7 @@ class AppLocalizationsSk extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -408,7 +408,7 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -591,6 +591,16 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_infoPublicKey => 'Verejný kľúč'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -776,11 +786,11 @@ class AppLocalizationsSk extends AppLocalizations { 'Zobraziť upozornenie pri prijímaní správ z kanálu'; @override - String get appSettings_advertisementNotifications => 'Upozornenia na reklamy'; + String get appSettings_advertisementNotifications => 'Zistený nový uzol'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Zobraziť upozornenie, keď sa objavia nové uzly.'; + 'Upozorniť pri zachytení nových prevádzačov alebo kontaktov'; @override String get appSettings_messaging => 'Správy'; @@ -881,6 +891,13 @@ class AppLocalizationsSk extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Nechať obrazovku zapnutú'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Zabráni vypnutiu obrazovky, kým je aplikácia otvorená. Spotrebuje viac batérie.'; + @override String get appSettings_mapDisplay => 'Zobrazenie mapy'; @@ -1256,9 +1273,6 @@ class AppLocalizationsSk extends AppLocalizations { return 'Upraviť kanál $index'; } - @override - String get channels_smazCompression => 'Odstránenie kompresie SMAZ'; - @override String get channels_cyr2latCompression => 'Odstránenie kompresie Cyr2Lat'; @@ -1489,6 +1503,24 @@ class AppLocalizationsSk extends AppLocalizations { @override String get debugLog_clearLog => 'Vymažať záznam'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Záznam ladenia skopírovaný'; @@ -1689,9 +1721,6 @@ class AppLocalizationsSk extends AppLocalizations { @override String get chat_publicKey => 'Verejný kľúč'; - @override - String get chat_compressOutgoingMessages => 'Komprimovať odoslané správy'; - @override String get chat_floodForced => 'Povodňová (nutená)'; @@ -3620,6 +3649,30 @@ class AppLocalizationsSk extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Cesta'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Sledovaná cesta'; @@ -4263,7 +4316,7 @@ class AppLocalizationsSk extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Naposledy videný'; @@ -4603,7 +4656,7 @@ class AppLocalizationsSk extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4623,16 +4676,39 @@ class AppLocalizationsSk extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 4b9518c..0942f71 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -316,7 +316,7 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Obveščanja, sporoščanje in zemljevidi.'; + 'Videz, prevajanje in nastavitve zemljevida'; @override String get settings_messageSettings => 'Message Settings'; @@ -386,7 +386,7 @@ class AppLocalizationsSl extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -406,7 +406,7 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -588,6 +588,16 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_infoPublicKey => 'Javni ključ'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -774,11 +784,11 @@ class AppLocalizationsSl extends AppLocalizations { 'Pokaži obvestilo ob prejemanju sporočil kanala'; @override - String get appSettings_advertisementNotifications => 'Opozorila o oglasih'; + String get appSettings_advertisementNotifications => 'Zaznano novo vozlišče'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Pokaži obvestilo, ko so najdene nove naprave.'; + 'Obvesti ob zaznavi novih repetitorjev ali stikov'; @override String get appSettings_messaging => 'Komuniciranje'; @@ -882,6 +892,13 @@ class AppLocalizationsSl extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Ohrani zaslon prižgan'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Prepreči, da bi zaslon ugasnil, medtem ko je aplikacija odprta. Porabi več baterije.'; + @override String get appSettings_mapDisplay => 'Prikaz zemljevida'; @@ -1253,9 +1270,6 @@ class AppLocalizationsSl extends AppLocalizations { return 'Uredi Kanal $index'; } - @override - String get channels_smazCompression => 'Kompresija SMAZ'; - @override String get channels_cyr2latCompression => 'Kompresija Cyr2Lat'; @@ -1487,6 +1501,24 @@ class AppLocalizationsSl extends AppLocalizations { @override String get debugLog_clearLog => 'Briši log'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Beležka kopirana.'; @@ -1683,9 +1715,6 @@ class AppLocalizationsSl extends AppLocalizations { @override String get chat_publicKey => 'Ključ javnega tipa'; - @override - String get chat_compressOutgoingMessages => 'Stisnite izhodne sporočila'; - @override String get chat_floodForced => 'Porolni (nasilje).'; @@ -3615,6 +3644,30 @@ class AppLocalizationsSl extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Pot'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Opazovana pot'; @@ -4260,7 +4313,7 @@ class AppLocalizationsSl extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Zadnjič videno'; @@ -4601,7 +4654,7 @@ class AppLocalizationsSl extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4621,16 +4674,39 @@ class AppLocalizationsSl extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index ab1047d..7a79520 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -315,7 +315,7 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Meddelanden, notiser och kartinställningar'; + 'Utseende, översättning och kartinställningar'; @override String get settings_messageSettings => 'Message Settings'; @@ -385,7 +385,7 @@ class AppLocalizationsSv extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -405,7 +405,7 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -586,6 +586,16 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_infoPublicKey => 'Allmänt nyckel'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -771,11 +781,11 @@ class AppLocalizationsSv extends AppLocalizations { 'Visa notis när meddelanden i kanal mottas'; @override - String get appSettings_advertisementNotifications => 'Annonsmeddelanden'; + String get appSettings_advertisementNotifications => 'Ny nod upptäckt'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Visa notis när nya noder upptäcks'; + 'Avisera när nya repeatrar eller kontakter hörs'; @override String get appSettings_messaging => 'Meddelanden'; @@ -876,6 +886,13 @@ class AppLocalizationsSv extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Håll skärmen tänd'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Hindrar skärmen från att slockna medan appen är öppen. Drar mer batteri.'; + @override String get appSettings_mapDisplay => 'Kartvisning'; @@ -1246,9 +1263,6 @@ class AppLocalizationsSv extends AppLocalizations { return 'Redigera Kanal $index'; } - @override - String get channels_smazCompression => 'SMAZ-komprimering'; - @override String get channels_cyr2latCompression => 'Cyr2Lat-komprimering'; @@ -1481,6 +1495,24 @@ class AppLocalizationsSv extends AppLocalizations { @override String get debugLog_clearLog => 'Rensa logg'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Felsökningslogg kopierad'; @@ -1677,9 +1709,6 @@ class AppLocalizationsSv extends AppLocalizations { @override String get chat_publicKey => 'Allmänt nyckel'; - @override - String get chat_compressOutgoingMessages => 'Kryptera utgående meddelanden'; - @override String get chat_floodForced => 'Översvämning (tvingad)'; @@ -3595,6 +3624,30 @@ class AppLocalizationsSv extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Sökväg'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Observerad Sökväg'; @@ -4233,7 +4286,7 @@ class AppLocalizationsSv extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Senast sedd'; @@ -4576,7 +4629,7 @@ class AppLocalizationsSv extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4596,16 +4649,39 @@ class AppLocalizationsSv extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index ee32259..388858a 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -318,7 +318,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Сповіщення, повідомлення та налаштування карти'; + 'Зовнішній вигляд, переклад і налаштування карти'; @override String get settings_messageSettings => 'Message Settings'; @@ -387,7 +387,7 @@ class AppLocalizationsUk extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -407,7 +407,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => @@ -594,6 +594,16 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_infoPublicKey => 'Відкритий ключ'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -780,12 +790,11 @@ class AppLocalizationsUk extends AppLocalizations { 'Показувати сповіщення при отриманні повідомлень каналу'; @override - String get appSettings_advertisementNotifications => - 'Сповіщення про оголошення'; + String get appSettings_advertisementNotifications => 'Виявлено новий вузол'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Показувати сповіщення при виявленні нових вузлів'; + 'Сповіщати про виявлення нових ретрансляторів або контактів'; @override String get appSettings_messaging => 'Обмін повідомленнями'; @@ -888,6 +897,13 @@ class AppLocalizationsUk extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3.0-4.2В)'; + @override + String get appSettings_keepScreenAwake => 'Не вимикати екран'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Екран не гаснутиме, доки застосунок відкрито. Витрачає більше заряду.'; + @override String get appSettings_mapDisplay => 'Відображення карти'; @@ -1262,9 +1278,6 @@ class AppLocalizationsUk extends AppLocalizations { return 'Редагувати канал $index'; } - @override - String get channels_smazCompression => 'Стиснення SMAZ'; - @override String get channels_cyr2latCompression => 'Стиснення Cyr2Lat'; @@ -1494,6 +1507,24 @@ class AppLocalizationsUk extends AppLocalizations { @override String get debugLog_clearLog => 'Очистити журнал'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => 'Журнал налагодження скопійовано'; @@ -1697,9 +1728,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get chat_publicKey => 'Відкритий ключ'; - @override - String get chat_compressOutgoingMessages => 'Стискати вихідні повідомлення'; - @override String get chat_floodForced => 'Через всю мережу (примусово)'; @@ -3638,6 +3666,30 @@ class AppLocalizationsUk extends AppLocalizations { @override String get channelPath_pathLabelTitle => 'Шлях'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => 'Спостережуваний шлях'; @@ -4294,7 +4346,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => 'Останній раз бачили'; @@ -4639,7 +4691,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4659,16 +4711,39 @@ class AppLocalizationsUk extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 7ae7266..a1fea72 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -303,7 +303,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_appSettings => '应用设置'; @override - String get settings_appSettingsSubtitle => '通知、消息和地图偏好'; + String get settings_appSettingsSubtitle => '外观、翻译和地图设置'; @override String get settings_messageSettings => 'Message Settings'; @@ -370,7 +370,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_gpsStatusLiveFix => 'Live GPS fix'; @override - String get settings_gpsStatusNoFix => 'No fix — using stored/last position'; + String get settings_gpsStatusNoFix => 'No fix: using stored/last position'; @override String get settings_gpsStatusCoords => 'Coordinates'; @@ -390,7 +390,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_gpsStatusNoResponse => - 'No response — device may need the GPS-query firmware build'; + 'No response: device may need the GPS-query firmware build'; @override String get settings_locationIntervalInvalid => '间隔时间必须至少为 60 秒,但不超过 86400 秒。'; @@ -562,6 +562,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_infoPublicKey => '公钥'; + @override + String get settings_infoBuild => 'Build'; + + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; @@ -741,10 +751,11 @@ class AppLocalizationsZh extends AppLocalizations { String get appSettings_channelMessageNotificationsSubtitle => '收到频道消息时显示通知'; @override - String get appSettings_advertisementNotifications => '广播通知'; + String get appSettings_advertisementNotifications => '发现新节点'; @override - String get appSettings_advertisementNotificationsSubtitle => '发现新节点时显示通知'; + String get appSettings_advertisementNotificationsSubtitle => + '当检测到新的中继器或联系人时通知'; @override String get appSettings_messaging => '消息'; @@ -833,6 +844,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appSettings_batteryLipo => '锂聚合物电池 (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => '保持屏幕常亮'; + + @override + String get appSettings_keepScreenAwakeSubtitle => '应用打开时防止屏幕休眠。会更耗电。'; + @override String get appSettings_mapDisplay => '地图显示'; @@ -1193,9 +1210,6 @@ class AppLocalizationsZh extends AppLocalizations { return '编辑频道 $index'; } - @override - String get channels_smazCompression => 'SMAZ 压缩'; - @override String get channels_cyr2latCompression => 'Cyr2Lat 压缩'; @@ -1419,6 +1433,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get debugLog_clearLog => '清除日志'; + @override + String get debugLog_shareLog => 'Share logs'; + + @override + String get debugLog_saveLog => 'Save logs'; + + @override + String get debugLog_downloadLog => 'Download logs'; + + @override + String get debugLog_shareSubject => 'Offband Meshcore logs'; + + @override + String get debugLog_logSaved => 'Log saved'; + + @override + String get debugLog_logUnavailable => 'No log available to export'; + @override String get debugLog_copied => '调试日志已复制'; @@ -1595,9 +1627,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get chat_publicKey => '公钥'; - @override - String get chat_compressOutgoingMessages => '压缩发送的消息'; - @override String get chat_floodForced => '泛洪(强制)'; @@ -3361,6 +3390,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get channelPath_pathLabelTitle => '路径'; + @override + String get channelPath_routeDirect => 'direct (routed)'; + + @override + String channelPath_routeFlood(int hops) { + return 'flood, $hops hops'; + } + + @override + String get channelPath_snrLabel => 'SNR'; + + @override + String channelPath_snrValue(String db) { + return '$db dB'; + } + + @override + String get channelPath_rssiLabel => 'RSSI'; + + @override + String channelPath_rssiValue(int dbm) { + return '$dbm dBm'; + } + @override String get channelPath_observedPathHeader => '观察到的路径'; @@ -3955,7 +4008,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get snrIndicator_noNeighbors => - 'No neighbors heard currently — messages still broadcast and route.'; + 'No neighbors heard currently. Messages still broadcast and route.'; @override String get snrIndicator_lastSeen => '最近访问'; @@ -4271,7 +4324,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String block_blockedNameOnly(String name) { - return 'Blocked \"$name\" across all channels — name only until we learn their key'; + return 'Blocked \"$name\" across all channels. Name only until we learn their key'; } @override @@ -4291,16 +4344,39 @@ class AppLocalizationsZh extends AppLocalizations { @override String get block_namesHint => - 'Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.'; + 'Name-only blocks (channel senders). Each upgrades to a full block once we learn their key.'; @override String get block_offloadActive => - 'Firmware offload active — blocks sync to this radio'; + 'Firmware offload active: blocks sync to this radio'; @override String get block_offloadStoreFull => - 'Radio block list full (32) — extra blocks stay app-only'; + 'Radio block list full (32): extra blocks stay app-only'; @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; + + @override + String get batteryOptimizationOpenFailed => 'Couldn\'t open battery settings'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 7749e09..74c1619 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -84,7 +84,7 @@ "settings_title": "Instellingen", "settings_deviceInfo": "Apparateninformatie", "settings_appSettings": "App Instellingen", - "settings_appSettingsSubtitle": "Notificaties, berichten en kaartinstellingen", + "settings_appSettingsSubtitle": "Weergave, vertaling en kaartvoorkeuren", "settings_nodeSettings": "Node Instellingen", "settings_nodeName": "Nodenaam", "settings_nodeNameNotSet": "Niet ingesteld", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Toon notificatie bij het ontvangen van nieuwe berichten", "appSettings_channelMessageNotifications": "Kanaal Bericht Meldingen", "appSettings_channelMessageNotificationsSubtitle": "Toon notificatie bij het ontvangen van kanaalberichten", - "appSettings_advertisementNotifications": "Advertentie-meldingen", - "appSettings_advertisementNotificationsSubtitle": "Toon notificatie wanneer nieuwe nodes worden ontdekt", + "appSettings_advertisementNotifications": "Nieuwe node ontdekt", + "appSettings_advertisementNotificationsSubtitle": "Melden wanneer nieuwe repeaters of contacten worden gehoord", "appSettings_messaging": "Berichten", "appSettings_clearPathOnMaxRetry": "Wis Pad op Max Retry", "appSettings_clearPathOnMaxRetrySubtitle": "Reset contactpad na 5 mislukte verzendpogingen", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Scherm aan houden", + "appSettings_keepScreenAwakeSubtitle": "Voorkomt dat het scherm in slaapstand gaat terwijl de app open is. Verbruikt meer batterij.", "appSettings_mapDisplay": "Kaartweergave", "appSettings_showRepeaters": "Toon Repeaters", "appSettings_showRepeatersSubtitle": "Toon repeaternodes op de kaart", @@ -388,7 +390,6 @@ } } }, - "channels_smazCompression": "SMAZ compressie", "channels_cyr2latCompression": "Cyr2Lat compressie", "channels_cyr2latCompressionDscr": "Vervangt sommige Cyrillische tekens door Latijnse tekens bij het verzenden.", "channels_cyr2latSettingsHeading": "Instellingen Cyr2Lat", @@ -605,7 +606,6 @@ "chat_type": "Typen", "chat_path": "Pad", "chat_publicKey": "Openbare Sleutel", - "chat_compressOutgoingMessages": "Verzenden van uitgaande berichten comprimeren", "chat_floodForced": "Flood (afgedwongen)", "chat_directForced": "Direct (afgedwongen)", "chat_hopsForced": "{count} hops (afgedwongen)", @@ -1316,7 +1316,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Aantal aanvragen", "telemetry_error": "Kan gegevens niet ophalen", "telemetry_noData": "Geen telemetriedata beschikbaar.", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 69e9bff..65eb6dd 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -84,7 +84,7 @@ "settings_title": "Ustawienia", "settings_deviceInfo": "Informacje o urządzeniu", "settings_appSettings": "Ustawienia aplikacji", - "settings_appSettingsSubtitle": "Powiadomienia, wiadomości i preferencje mapy", + "settings_appSettingsSubtitle": "Wygląd, tłumaczenie i ustawienia mapy", "settings_nodeSettings": "Ustawienia węzła", "settings_nodeName": "Nazwa węzła", "settings_nodeNameNotSet": "Nie ustawione", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Pokaż powiadomienie przy otrzymywaniu nowych wiadomości", "appSettings_channelMessageNotifications": "Powiadomienia o Wiadomościach na Kanałach", "appSettings_channelMessageNotificationsSubtitle": "Pokaż powiadomienie przy odbieraniu wiadomości z kanału", - "appSettings_advertisementNotifications": "Powiadomienia o rozgłoszeniach", - "appSettings_advertisementNotificationsSubtitle": "Wyświetl powiadomienie, gdy zostaną wykryte nowe węzły.", + "appSettings_advertisementNotifications": "Wykryto nowy węzeł", + "appSettings_advertisementNotificationsSubtitle": "Powiadamiaj o wykryciu nowych przemienników lub kontaktów", "appSettings_messaging": "Wiadomości", "appSettings_clearPathOnMaxRetry": "Wyczyść ścieżkę po maks. liczbie prób", "appSettings_clearPathOnMaxRetrySubtitle": "Resetuj ścieżkę kontaktu po 5 nieudanych próbach wysłania", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65 V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Nie wygaszaj ekranu", + "appSettings_keepScreenAwakeSubtitle": "Zapobiega wygaszaniu ekranu, gdy aplikacja jest otwarta. Zużywa więcej baterii.", "appSettings_mapDisplay": "Wyświetlanie mapy", "appSettings_showRepeaters": "Pokaż przekaźniki", "appSettings_showRepeatersSubtitle": "Wyświetl węzły przekaźników na mapie", @@ -398,7 +400,6 @@ } } }, - "channels_smazCompression": "Kompresja SMAZ", "channels_cyr2latCompression": "Kompresja Cyr2Lat", "channels_cyr2latCompressionDscr": "Zastępuje niektóre znaki cyrylicy alfabetem łacińskim podczas wysyłania.", "channels_cyr2latSettingsHeading": "Ustawienia Cyr2Lat", @@ -615,7 +616,6 @@ "chat_type": "Typ", "chat_path": "Ścieżka", "chat_publicKey": "Klucz Publiczny", - "chat_compressOutgoingMessages": "Kompresuj wychodzące wiadomości", "chat_floodForced": "Zalew (wymuszony)", "chat_directForced": "Bezpośrednio (wymuszono)", "chat_hopsForced": "{count} skoków (wymuszonych)", @@ -1326,7 +1326,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Liczba żądań", "telemetry_error": "Nie udało się pobrać danych", "telemetry_noData": "Brak dostępnych danych telemetrycznych.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index c598a54..ff41163 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -84,7 +84,7 @@ "settings_title": "Configurações", "settings_deviceInfo": "Informações do Dispositivo", "settings_appSettings": "Configurações do App", - "settings_appSettingsSubtitle": "Notificações, mensagens e preferências de mapa", + "settings_appSettingsSubtitle": "Aparência, tradução e preferências de mapa", "settings_nodeSettings": "Configurações do Nó", "settings_nodeName": "Nome do Nó", "settings_nodeNameNotSet": "Não definido", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Mostrar notificação ao receber novas mensagens", "appSettings_channelMessageNotifications": "Notificações de Mensagens do Canal", "appSettings_channelMessageNotificationsSubtitle": "Mostrar notificação ao receber mensagens do canal", - "appSettings_advertisementNotifications": "Notificações de Anúncios", - "appSettings_advertisementNotificationsSubtitle": "Mostrar notificação quando novos nós forem descobertos", + "appSettings_advertisementNotifications": "Novo nó descoberto", + "appSettings_advertisementNotificationsSubtitle": "Notificar quando novos repetidores ou contatos forem detectados", "appSettings_messaging": "Mensagens", "appSettings_clearPathOnMaxRetry": "Limpar Caminho em Tentativas Máximas", "appSettings_clearPathOnMaxRetrySubtitle": "Redefinir o caminho de contato após 5 tentativas de envio falhas", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Manter a tela ligada", + "appSettings_keepScreenAwakeSubtitle": "Impede que a tela desligue enquanto o app está aberto. Consome mais bateria.", "appSettings_mapDisplay": "Exibição do Mapa", "appSettings_showRepeaters": "Mostrar Repetidores", "appSettings_showRepeatersSubtitle": "Exibir nós de repetidor no mapa", @@ -388,7 +390,6 @@ } } }, - "channels_smazCompression": "Compressão SMAZ", "channels_cyr2latCompression": "Compressão Cyr2Lat", "channels_cyr2latCompressionDscr": "Substitui alguns caracteres cirílicos por caracteres latinos ao enviar.", "channels_cyr2latSettingsHeading": "Configuração do Cyr2Lat", @@ -605,7 +606,6 @@ "chat_type": "Digite", "chat_path": "Caminho", "chat_publicKey": "Chave Pública", - "chat_compressOutgoingMessages": "Comprimir mensagens enviadas", "chat_floodForced": "Inundação (forçada)", "chat_directForced": "Direto (forçado)", "chat_hopsForced": "{count} saltos (forçado)", @@ -1316,7 +1316,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Número de solicitações", "telemetry_error": "Não foi possível obter os dados", "telemetry_noData": "Não estão disponíveis dados de telemetria.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 82d6565..f1b9089 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -57,7 +57,7 @@ "settings_title": "Настройки", "settings_deviceInfo": "Информация об устройстве", "settings_appSettings": "Настройки приложения", - "settings_appSettingsSubtitle": "Уведомления, сообщения и настройки карты", + "settings_appSettingsSubtitle": "Внешний вид, перевод и настройки карты", "settings_nodeSettings": "Настройки ноды", "settings_nodeName": "Имя ноды", "settings_nodeNameNotSet": "Не установлено", @@ -155,8 +155,8 @@ "appSettings_messageNotificationsSubtitle": "Показывать уведомление при получении новых сообщений", "appSettings_channelMessageNotifications": "Уведомления о сообщениях в каналах", "appSettings_channelMessageNotificationsSubtitle": "Показывать уведомление при получении сообщений в каналах", - "appSettings_advertisementNotifications": "Уведомления об анонсированиях", - "appSettings_advertisementNotificationsSubtitle": "Показывать уведомление при обнаружении новых нод", + "appSettings_advertisementNotifications": "Обнаружен новый узел", + "appSettings_advertisementNotificationsSubtitle": "Уведомлять при обнаружении новых репитеров или контактов", "appSettings_messaging": "Обмен сообщениями", "appSettings_clearPathOnMaxRetry": "Сбросить маршрут после максимального числа попыток", "appSettings_clearPathOnMaxRetrySubtitle": "Сбросить маршрут контакта после 5 неудачных попыток отправки", @@ -173,6 +173,8 @@ "appSettings_batteryNmc": "18650 NMC (3.0–4.2 В)", "appSettings_batteryLifepo4": "LiFePO4 (2.6–3.65 В)", "appSettings_batteryLipo": "LiPo (3.0–4.2 В)", + "appSettings_keepScreenAwake": "Не выключать экран", + "appSettings_keepScreenAwakeSubtitle": "Экран не будет гаснуть, пока приложение открыто. Расходует больше заряда.", "appSettings_mapDisplay": "Отображение карты", "appSettings_showRepeaters": "Показывать репитеры", "appSettings_showRepeatersSubtitle": "Отображать репитеры на карте", @@ -252,7 +254,6 @@ "channels_pskMustBe32Hex": "PSK должен содержать 32 шестнадцатеричных символа", "channels_channelAdded": "Канал \"{name}\" добавлен", "channels_editChannelTitle": "Изменить канал {index}", - "channels_smazCompression": "Сжатие SMAZ", "channels_cyr2latCompression": "Сжатие Cyr2Lat", "channels_cyr2latCompressionDscr": "Заменяет некоторые кириллические символы на латиницу при отправке.", "channels_cyr2latSettingsHeading": "Настройка Cyr2Lat", @@ -367,7 +368,6 @@ "chat_type": "Тип", "chat_path": "Маршрут", "chat_publicKey": "Публичный ключ", - "chat_compressOutgoingMessages": "Сжимать исходящие сообщения", "chat_floodForced": "Рассылка (принудительно)", "chat_directForced": "Прямой (принудительно)", "chat_hopsForced": "{count} хоп(ов) (принудительно)", @@ -722,7 +722,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Количество запросов", "telemetry_error": "Не удалось получить данные", "neighbors_receivedData": "Полученные данные о соседях", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index f12b4c4..c1c03df 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -84,7 +84,7 @@ "settings_title": "Nastavenia", "settings_deviceInfo": "Informácie o zariadení", "settings_appSettings": "Nastavenia aplikácie", - "settings_appSettingsSubtitle": "Upozornenia, správy a nastavenia mapy", + "settings_appSettingsSubtitle": "Vzhľad, preklad a nastavenia mapy", "settings_nodeSettings": "Nastavenia uzla", "settings_nodeName": "Názov uzla", "settings_nodeNameNotSet": "Nezriadené", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Zobraziť upozornenie pri prijímaní nových správ", "appSettings_channelMessageNotifications": "Notifikácie z kanálov", "appSettings_channelMessageNotificationsSubtitle": "Zobraziť upozornenie pri prijímaní správ z kanálu", - "appSettings_advertisementNotifications": "Upozornenia na reklamy", - "appSettings_advertisementNotificationsSubtitle": "Zobraziť upozornenie, keď sa objavia nové uzly.", + "appSettings_advertisementNotifications": "Zistený nový uzol", + "appSettings_advertisementNotificationsSubtitle": "Upozorniť pri zachytení nových prevádzačov alebo kontaktov", "appSettings_messaging": "Správy", "appSettings_clearPathOnMaxRetry": "Vyčisti cestu na Max Retry", "appSettings_clearPathOnMaxRetrySubtitle": "Resetovať kontaktný priebeh po 5 neúspešných pokusoch o doručenie", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6–3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Nechať obrazovku zapnutú", + "appSettings_keepScreenAwakeSubtitle": "Zabráni vypnutiu obrazovky, kým je aplikácia otvorená. Spotrebuje viac batérie.", "appSettings_mapDisplay": "Zobrazenie mapy", "appSettings_showRepeaters": "Zobraziť opakovače", "appSettings_showRepeatersSubtitle": "Zobraziť opakujúce sa uzly na mape", @@ -388,7 +390,6 @@ } } }, - "channels_smazCompression": "Odstránenie kompresie SMAZ", "channels_cyr2latCompression": "Odstránenie kompresie Cyr2Lat", "channels_cyr2latCompressionDscr": "Pri odosielaní nahradí niektoré znaky cyriliky latinskými znakmi.", "channels_cyr2latSettingsHeading": "Nastavenia Cyr2Lat", @@ -605,7 +606,6 @@ "chat_type": "Napište", "chat_path": "Cesta", "chat_publicKey": "Verejný kľúč", - "chat_compressOutgoingMessages": "Komprimovať odoslané správy", "chat_floodForced": "Povodňová (nutená)", "chat_directForced": "Priame (donútené)", "chat_hopsForced": "{count} skokov (nutené)", @@ -1316,7 +1316,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Počet požiadaviek", "telemetry_error": "Nepodarilo sa získať údaje", "telemetry_noData": "Nejsú dostupné žiadne údaje z telemetrie.", diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 4c47018..31621a8 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -84,7 +84,7 @@ "settings_title": "Nastavitve", "settings_deviceInfo": "Informacije o napravei", "settings_appSettings": "Nastavitve aplikacije", - "settings_appSettingsSubtitle": "Obveščanja, sporoščanje in zemljevidi.", + "settings_appSettingsSubtitle": "Videz, prevajanje in nastavitve zemljevida", "settings_nodeSettings": "Nastavitev časa", "settings_nodeName": "Ime node-a", "settings_nodeNameNotSet": "Ni nastavljeno", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Pokaži obvestilo ob prejemu novih sporočil.", "appSettings_channelMessageNotifications": "Obvestila o sporočilih kanala", "appSettings_channelMessageNotificationsSubtitle": "Pokaži obvestilo ob prejemanju sporočil kanala", - "appSettings_advertisementNotifications": "Opozorila o oglasih", - "appSettings_advertisementNotificationsSubtitle": "Pokaži obvestilo, ko so najdene nove naprave.", + "appSettings_advertisementNotifications": "Zaznano novo vozlišče", + "appSettings_advertisementNotificationsSubtitle": "Obvesti ob zaznavi novih repetitorjev ali stikov", "appSettings_messaging": "Komuniciranje", "appSettings_clearPathOnMaxRetry": "Ponovite pot do cilja na največjem štetju", "appSettings_clearPathOnMaxRetrySubtitle": "Ponovi pot zimske obveščevalne poti po 5 neuspešnih poskusih pošiljanja", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6–3,65 V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Ohrani zaslon prižgan", + "appSettings_keepScreenAwakeSubtitle": "Prepreči, da bi zaslon ugasnil, medtem ko je aplikacija odprta. Porabi več baterije.", "appSettings_mapDisplay": "Prikaz zemljevida", "appSettings_showRepeaters": "Prikaži repetitorje", "appSettings_showRepeatersSubtitle": "Prikaži repetitorje na mapi", @@ -388,7 +390,6 @@ } } }, - "channels_smazCompression": "Kompresija SMAZ", "channels_cyr2latCompression": "Kompresija Cyr2Lat", "channels_cyr2latCompressionDscr": "Pri pošiljanju nekatere cirilice nadomesti z latiničnimi.", "channels_cyr2latSettingsHeading": "Nastavitve Cyr2Lat", @@ -605,7 +606,6 @@ "chat_type": "Vnesite", "chat_path": "Pot", "chat_publicKey": "Ključ javnega tipa", - "chat_compressOutgoingMessages": "Stisnite izhodne sporočila", "chat_floodForced": "Porolni (nasilje).", "chat_directForced": "Nezglašen (nasilje)", "chat_hopsForced": "{count} skoki (nasilje)", @@ -1316,7 +1316,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Število zahtev", "telemetry_error": "Podatkov ni bilo mogoče pridobiti", "telemetry_noData": "Niso na voljo podatki o telemetriji.", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index a3a5c21..8eac1fd 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -84,7 +84,7 @@ "settings_title": "Inställningar", "settings_deviceInfo": "Enhetens information", "settings_appSettings": "Appinställningar", - "settings_appSettingsSubtitle": "Meddelanden, notiser och kartinställningar", + "settings_appSettingsSubtitle": "Utseende, översättning och kartinställningar", "settings_nodeSettings": "Nodinställningar", "settings_nodeName": "Nodnamn", "settings_nodeNameNotSet": "Inte angivet", @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Visa notis när nya meddelanden tas emot", "appSettings_channelMessageNotifications": "Kanalmeddelandena", "appSettings_channelMessageNotificationsSubtitle": "Visa notis när meddelanden i kanal mottas", - "appSettings_advertisementNotifications": "Annonsmeddelanden", - "appSettings_advertisementNotificationsSubtitle": "Visa notis när nya noder upptäcks", + "appSettings_advertisementNotifications": "Ny nod upptäckt", + "appSettings_advertisementNotificationsSubtitle": "Avisera när nya repeatrar eller kontakter hörs", "appSettings_messaging": "Meddelanden", "appSettings_clearPathOnMaxRetry": "Rensa Vägen på Max Försök", "appSettings_clearPathOnMaxRetrySubtitle": "Återställ kontaktväg efter 5 misslyckade försök att skicka", @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6–3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Håll skärmen tänd", + "appSettings_keepScreenAwakeSubtitle": "Hindrar skärmen från att slockna medan appen är öppen. Drar mer batteri.", "appSettings_mapDisplay": "Kartvisning", "appSettings_showRepeaters": "Visa återuppslag", "appSettings_showRepeatersSubtitle": "Visa återspelsnoder på kartan", @@ -388,7 +390,6 @@ } } }, - "channels_smazCompression": "SMAZ-komprimering", "channels_cyr2latCompression": "Cyr2Lat-komprimering", "channels_cyr2latCompressionDscr": "Ersätter vissa kyrilliska tecken med latinska tecken när du skickar.", "channels_cyr2latSettingsHeading": "Inställningar för Cyr2Lat", @@ -605,7 +606,6 @@ "chat_type": "Skriv", "chat_path": "Sökväg", "chat_publicKey": "Allmänt nyckel", - "chat_compressOutgoingMessages": "Kryptera utgående meddelanden", "chat_floodForced": "Översvämning (tvingad)", "chat_directForced": "Direkt (tvingad)", "chat_hopsForced": "{count} hopp (tvingat)", @@ -1316,7 +1316,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Antal förfrågningar", "telemetry_error": "Det gick inte att hämta data", "telemetry_noData": "Inga telemetridata tillgängliga.", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 5572b79..61081eb 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -85,7 +85,7 @@ "settings_title": "Налаштування", "settings_deviceInfo": "Інформація про пристрій", "settings_appSettings": "Налаштування застосунку", - "settings_appSettingsSubtitle": "Сповіщення, повідомлення та налаштування карти", + "settings_appSettingsSubtitle": "Зовнішній вигляд, переклад і налаштування карти", "settings_nodeSettings": "Налаштування вузла", "settings_nodeName": "Ім'я вузла", "settings_nodeNameNotSet": "Не встановлено", @@ -193,8 +193,8 @@ "appSettings_messageNotificationsSubtitle": "Показувати сповіщення при отриманні нових повідомлень", "appSettings_channelMessageNotifications": "Сповіщення каналів", "appSettings_channelMessageNotificationsSubtitle": "Показувати сповіщення при отриманні повідомлень каналу", - "appSettings_advertisementNotifications": "Сповіщення про оголошення", - "appSettings_advertisementNotificationsSubtitle": "Показувати сповіщення при виявленні нових вузлів", + "appSettings_advertisementNotifications": "Виявлено новий вузол", + "appSettings_advertisementNotificationsSubtitle": "Сповіщати про виявлення нових ретрансляторів або контактів", "appSettings_messaging": "Обмін повідомленнями", "appSettings_clearPathOnMaxRetry": "Очищати шлях після макс. спроб", "appSettings_clearPathOnMaxRetrySubtitle": "Скидати шлях до контакту після 5 невдалих спроб надсилання", @@ -218,6 +218,8 @@ "appSettings_batteryNmc": "18650 NMC (3.0-4.2В)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65В)", "appSettings_batteryLipo": "LiPo (3.0-4.2В)", + "appSettings_keepScreenAwake": "Не вимикати екран", + "appSettings_keepScreenAwakeSubtitle": "Екран не гаснутиме, доки застосунок відкрито. Витрачає більше заряду.", "appSettings_mapDisplay": "Відображення карти", "appSettings_showRepeaters": "Показувати ретранслятори", "appSettings_showRepeatersSubtitle": "Відображати вузли-ретранслятори на карті", @@ -391,7 +393,6 @@ } } }, - "channels_smazCompression": "Стиснення SMAZ", "channels_cyr2latCompression": "Стиснення Cyr2Lat", "channels_cyr2latCompressionDscr": "Замінює деякі кириличні символи на латиницю при відправці.", "channels_cyr2latSettingsHeading": "Налаштування Cyr2Lat", @@ -609,7 +610,6 @@ "chat_type": "Ввід", "chat_path": "Шлях", "chat_publicKey": "Відкритий ключ", - "chat_compressOutgoingMessages": "Стискати вихідні повідомлення", "chat_floodForced": "Через всю мережу (примусово)", "chat_directForced": "Напряму (примусово)", "chat_hopsForced": "{count} переходів (примусово)", @@ -1327,7 +1327,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "Кількість запитів", "telemetry_error": "Не вдалося отримати дані", "telemetry_noData": "Дані телеметрії недоступні.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 70b3649..17f96e0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -85,7 +85,7 @@ "settings_title": "设置", "settings_deviceInfo": "设备信息", "settings_appSettings": "应用设置", - "settings_appSettingsSubtitle": "通知、消息和地图偏好", + "settings_appSettingsSubtitle": "外观、翻译和地图设置", "settings_nodeSettings": "节点设置", "settings_nodeName": "节点名称", "settings_nodeNameNotSet": "未设置", @@ -205,8 +205,8 @@ "appSettings_messageNotificationsSubtitle": "收到新消息时显示通知", "appSettings_channelMessageNotifications": "频道消息通知", "appSettings_channelMessageNotificationsSubtitle": "收到频道消息时显示通知", - "appSettings_advertisementNotifications": "广播通知", - "appSettings_advertisementNotificationsSubtitle": "发现新节点时显示通知", + "appSettings_advertisementNotifications": "发现新节点", + "appSettings_advertisementNotificationsSubtitle": "当检测到新的中继器或联系人时通知", "appSettings_messaging": "消息", "appSettings_clearPathOnMaxRetry": "达到最大重试次数时清除路径", "appSettings_clearPathOnMaxRetrySubtitle": "在5次发送失败后重置联系路径。", @@ -230,6 +230,8 @@ "appSettings_batteryNmc": "18650 NMC 电池 (3.0-4.2V)", "appSettings_batteryLifepo4": "磷酸铁锂 (2.6-3.65V)", "appSettings_batteryLipo": "锂聚合物电池 (3.0-4.2V)", + "appSettings_keepScreenAwake": "保持屏幕常亮", + "appSettings_keepScreenAwakeSubtitle": "应用打开时防止屏幕休眠。会更耗电。", "appSettings_mapDisplay": "地图显示", "appSettings_showRepeaters": "显示转发节点", "appSettings_showRepeatersSubtitle": "在地图上显示转发节点", @@ -403,7 +405,6 @@ } } }, - "channels_smazCompression": "SMAZ 压缩", "channels_cyr2latCompression": "Cyr2Lat 压缩", "channels_cyr2latCompressionDscr": "发送时将一些西里尔字符替换为拉丁字符。", "channels_cyr2latSettingsHeading": "Cyr2Lat 設定", @@ -632,7 +633,6 @@ "chat_type": "类型", "chat_path": "路径", "chat_publicKey": "公钥", - "chat_compressOutgoingMessages": "压缩发送的消息", "chat_floodForced": "泛洪(强制)", "chat_directForced": "直连(强制)", "chat_hopsForced": "{count} 跳(强制)", @@ -1346,7 +1346,7 @@ "telemetry_directionValue": "{degrees}°", "telemetry_concentrationValue": "{ppm} ppm", "telemetry_percentageValue": "{percent}%", - "telemetry_analogValue": "{value}", + "telemetry_analogValue": "{value}", "telemetry_autoFetchQuantity": "请求次数", "telemetry_error": "无法获取数据", "telemetry_noData": "暂无遥测数据", diff --git a/lib/main.dart b/lib/main.dart index 5e7ae1a..c9e76ac 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,9 +29,12 @@ import 'services/observer_config_service.dart'; import 'services/block_service.dart'; import 'services/window_geometry_service.dart'; import 'services/store_consolidation_service.dart'; +import 'services/storage_health_service.dart'; import 'storage/drift/blob_store.dart'; +import 'widgets/storage_unavailable_banner.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; +import 'widgets/keep_screen_awake.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -39,6 +42,22 @@ void main() async { // Initialize SharedPreferences cache await PrefsManager.initialize(); + // Probe the storage layer up front (#385). If the database can't open, e.g. + // the native sqlite library fails to load, every read/write silently fails + // and the app looks wiped. Capture that here so the UI can warn loudly + // instead of showing an empty, normal-looking screen (SAFELANE §6). + final storageHealth = StorageHealthService(); + try { + if (!await BlobStore.instance.verifyReadWrite()) { + storageHealth.markUnavailable('database read-back mismatch'); + } + } catch (e) { + storageHealth.markUnavailable(e); + // appLogger is not initialized this early; debugPrint is safe here and the + // per-store operations still log via appLogger once it is up. + debugPrint('[Storage] Health probe failed; storage unavailable: $e'); + } + // Move bulk data (message history, contacts) out of SharedPreferences into // drift (#335). Must run after prefs are up and BEFORE any store reads, so // no code sees a half-migrated state. Idempotent: a no-op once done. @@ -123,6 +142,7 @@ void main() async { runApp( MeshCoreApp( + storageHealth: storageHealth, connector: connector, retryService: retryService, pathHistoryService: pathHistoryService, @@ -163,6 +183,7 @@ https://creativecommons.org/licenses/by/4.0/ } class MeshCoreApp extends StatelessWidget { + final StorageHealthService storageHealth; final MeshCoreConnector connector; final MessageRetryService retryService; final PathHistoryService pathHistoryService; @@ -180,6 +201,7 @@ class MeshCoreApp extends StatelessWidget { const MeshCoreApp({ super.key, + required this.storageHealth, required this.connector, required this.retryService, required this.pathHistoryService, @@ -200,6 +222,7 @@ class MeshCoreApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ + ChangeNotifierProvider.value(value: storageHealth), ChangeNotifierProvider.value(value: connector), ChangeNotifierProvider.value(value: retryService), ChangeNotifierProvider.value(value: pathHistoryService), @@ -257,7 +280,14 @@ class MeshCoreApp extends StatelessWidget { NotificationService().setLocale(locale); return AnnotatedRegion( value: _systemUiOverlayStyle(context), - child: child ?? const SizedBox.shrink(), + child: KeepScreenAwake( + child: Consumer( + builder: (context, health, _) => StorageUnavailableBanner( + show: !health.available, + child: child ?? const SizedBox.shrink(), + ), + ), + ), ); }, home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 782e78a..4997403 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -121,6 +121,7 @@ class AppSettings { final bool mapShowMarkers; final bool mapShowGuessedLocations; final bool enableMessageTracing; + final bool keepScreenAwake; final ClockFormat clockFormat; final Map? mapCacheBounds; final int mapCacheMinZoom; @@ -147,7 +148,7 @@ class AppSettings { /// written, so an older build can be rolled back to without losing mutes. final Set mutedChannels; - /// Per-channel notification level, keyed by [channelNotifyKey] — the PSK + /// Per-channel notification level, keyed by [channelNotifyKey], the PSK /// identity when known, else the slot index. Keying by PSK (not by display /// name, and not by slot) means the setting survives a rename and does not /// bleed across a slot reassignment (#193/#194). @@ -198,6 +199,7 @@ class AppSettings { this.mapShowMarkers = true, this.mapShowGuessedLocations = true, this.enableMessageTracing = false, + this.keepScreenAwake = false, this.clockFormat = ClockFormat.system, this.mapCacheBounds, this.mapCacheMinZoom = 10, @@ -263,6 +265,7 @@ class AppSettings { 'map_show_markers': mapShowMarkers, 'map_show_guessed_locations': mapShowGuessedLocations, 'enable_message_tracing': enableMessageTracing, + 'keep_screen_awake': keepScreenAwake, 'clock_format': clockFormat.value, 'map_cache_bounds': mapCacheBounds, 'map_cache_min_zoom': mapCacheMinZoom, @@ -352,6 +355,7 @@ class AppSettings { mapShowGuessedLocations: json['map_show_guessed_locations'] as bool? ?? true, enableMessageTracing: json['enable_message_tracing'] as bool? ?? false, + keepScreenAwake: json['keep_screen_awake'] as bool? ?? false, clockFormat: parseClockFormat(json['clock_format']), mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map( (key, value) => MapEntry(key.toString(), (value as num).toDouble()), @@ -475,6 +479,7 @@ class AppSettings { bool? mapShowMarkers, bool? mapShowGuessedLocations, bool? enableMessageTracing, + bool? keepScreenAwake, ClockFormat? clockFormat, Object? mapCacheBounds = _unset, int? mapCacheMinZoom, @@ -525,6 +530,7 @@ class AppSettings { mapShowGuessedLocations: mapShowGuessedLocations ?? this.mapShowGuessedLocations, enableMessageTracing: enableMessageTracing ?? this.enableMessageTracing, + keepScreenAwake: keepScreenAwake ?? this.keepScreenAwake, clockFormat: clockFormat ?? this.clockFormat, mapCacheBounds: mapCacheBounds == _unset ? this.mapCacheBounds diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 886bab3..092f00a 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -51,8 +51,16 @@ class ChannelMessage { final String? replyToText; final Map reactions; + /// Emoji to the list of sender names who reacted with it (#383). + /// + /// Additive over [reactions]: the count map is still written and read so an + /// older build degrades to "counts work, names missing" rather than failing + /// to load. Reactions recorded before this field existed appear in + /// [reactions] with no matching entry here. + final Map> reactionSenders; + /// Local wall-clock time this message's frame arrived, set at ingest. - /// Null for outgoing messages and records stored before #285 — never + /// Null for outgoing messages and records stored before #285, never /// fabricated. [timestamp] is the SENDER's claimed time; this is ours. final DateTime? rxTime; @@ -80,11 +88,13 @@ class ChannelMessage { this.replyToSenderName, this.replyToText, Map? reactions, + Map>? reactionSenders, this.rxTime, }) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}', reactions = reactions ?? {}, + reactionSenders = reactionSenders ?? {}, pathBytes = pathBytes ?? Uint8List(0), pathVariants = _mergePathVariants( pathBytes ?? Uint8List(0), @@ -96,7 +106,7 @@ class ChannelMessage { /// Hop count decoded from the firmware path-length byte (low 6 bits). /// The raw byte also packs hash width in its high 2 bits, so the stored - /// pathLength is NOT the hop count — use this getter for display. + /// pathLength is NOT the hop count, use this getter for display. /// Returns null when unknown; a negative (flood) sentinel is preserved. int? get hopCount { final pl = pathLength; @@ -129,6 +139,7 @@ class ChannelMessage { MessageTranslationStatus? translationStatus, Object? translationModelId = _unset, Map? reactions, + Map>? reactionSenders, DateTime? rxTime, }) { return ChannelMessage( @@ -163,6 +174,7 @@ class ChannelMessage { replyToSenderName: replyToSenderName ?? this.replyToSenderName, replyToText: replyToText ?? this.replyToText, reactions: reactions ?? this.reactions, + reactionSenders: reactionSenders ?? this.reactionSenders, rxTime: rxTime ?? this.rxTime, ); } @@ -183,18 +195,17 @@ class ChannelMessage { int txtType; Uint8List pathBytes = Uint8List(0); int channelIdx; + bool isOutgoing = false; if (code == respCodeChannelMsgRecvV3) { reader.skipBytes(1); // Skip SNR - final flags = reader.readByte(); - final hasPath = (flags & 0x01) != 0; - reader.skipBytes(1); // Skip reserved byte + // reserved1 bit0 = outgoing flag: set for a message composed on the + // device itself, so it renders as sent-by-me (#429 part B). No firmware + // appends path bytes to this frame, so path_len is metadata only. + isOutgoing = (reader.readByte() & 0x01) != 0; + reader.skipBytes(1); // Skip reserved2 channelIdx = reader.readByte(); pathLen = reader.readInt8(); txtType = reader.readByte(); - if (hasPath && pathLen > 0) { - reader.rewind(); // Rewind to read path length again for pathBytes - pathBytes = reader.readBytes(pathLen); - } } else { channelIdx = reader.readByte(); pathLen = reader.readInt8(); @@ -232,12 +243,12 @@ class ChannelMessage { senderName: senderName, text: decodedText, timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000), - isOutgoing: false, + isOutgoing: isOutgoing, status: ChannelMessageStatus.sent, pathLength: pathLen, pathBytes: pathBytes, channelIndex: channelIdx, - rxTime: DateTime.now(), + rxTime: isOutgoing ? null : DateTime.now(), ); } catch (e) { appLogger.error('Error parsing channel message frame: $e'); diff --git a/lib/models/config_catalog.dart b/lib/models/config_catalog.dart new file mode 100644 index 0000000..b7de366 --- /dev/null +++ b/lib/models/config_catalog.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; + +// Catalog manifest model (#404): the `profiles.json` a source publishes. +// +// Schema is documented in the `OffbandMesh/config-profiles` repo (SCHEMA.md). +// Parsing is resilient, a single malformed entry is skipped rather than +// failing the whole catalog, but a manifest that isn't JSON, or declares a +// newer `manifest_version` than we support, is rejected outright. + +/// Highest manifest_version this build understands. +const int kCatalogManifestVersion = 1; + +/// Thrown when a manifest cannot be used at all (bad JSON / unsupported version). +class ConfigCatalogFormatException implements Exception { + const ConfigCatalogFormatException(this.message); + final String message; + @override + String toString() => 'ConfigCatalogFormatException: $message'; +} + +enum CatalogStatus { + published, + retired, + + /// A status string this build doesn't recognize. Treated as not-published so + /// an unknown future status never accidentally surfaces in the picker. + unknown; + + static CatalogStatus fromWire(String? v) { + switch (v) { + case 'published': + return CatalogStatus.published; + case 'retired': + return CatalogStatus.retired; + default: + return CatalogStatus.unknown; + } + } +} + +/// One catalog entry. [name] and [url] are required; the rest are hints. +class CatalogEntry { + const CatalogEntry({ + required this.name, + required this.url, + this.description, + this.region, + this.schemaVersion, + this.status = CatalogStatus.published, + }); + + final String name; + final String url; + final String? description; + final String? region; + final int? schemaVersion; + final CatalogStatus status; +} + +class ConfigCatalog { + const ConfigCatalog({ + required this.manifestVersion, + required this.entries, + this.skippedEntries = 0, + }); + + final int manifestVersion; + final List entries; + + /// Count of malformed entries skipped during parse, surfaced so a partly-bad + /// catalog doesn't look complete (no silent truncation). + final int skippedEntries; + + /// Entries the app should offer in the picker. + List get published => + entries.where((e) => e.status == CatalogStatus.published).toList(); +} + +/// Parse a `profiles.json` manifest. Throws [ConfigCatalogFormatException] for a +/// manifest that can't be used; skips individual malformed entries. +ConfigCatalog parseCatalog(String source) { + final dynamic doc; + try { + doc = jsonDecode(source); + } on FormatException catch (e) { + throw ConfigCatalogFormatException('Not valid JSON: ${e.message}'); + } + if (doc is! Map) { + throw const ConfigCatalogFormatException('manifest must be a JSON object'); + } + + final version = doc['manifest_version']; + if (version is! int) { + throw const ConfigCatalogFormatException( + 'manifest_version is required and must be an integer', + ); + } + if (version > kCatalogManifestVersion) { + throw ConfigCatalogFormatException( + 'manifest_version $version is newer than this app supports ' + '($kCatalogManifestVersion). Update the app.', + ); + } + + final rawProfiles = doc['profiles']; + if (rawProfiles != null && rawProfiles is! List) { + throw const ConfigCatalogFormatException('"profiles" must be a list'); + } + + final entries = []; + var skipped = 0; + for (final raw in (rawProfiles as List? ?? const [])) { + if (raw is! Map) { + skipped++; + continue; + } + final name = raw['name']; + final url = raw['url']; + if (name is! String || name.isEmpty || url is! String || url.isEmpty) { + skipped++; // name + url are the minimum an entry must carry + continue; + } + entries.add( + CatalogEntry( + name: name, + url: url, + description: raw['description'] is String + ? raw['description'] as String + : null, + region: raw['region'] is String ? raw['region'] as String : null, + schemaVersion: raw['schema_version'] is int + ? raw['schema_version'] as int + : null, + // Absent status = published (author just omitted it). A present-but- + // unrecognized status resolves to unknown and is hidden (forward-safe). + status: raw.containsKey('status') + ? CatalogStatus.fromWire( + raw['status'] is String ? raw['status'] as String : null, + ) + : CatalogStatus.published, + ), + ); + } + + return ConfigCatalog( + manifestVersion: version, + entries: entries, + skippedEntries: skipped, + ); +} diff --git a/lib/models/config_profile.dart b/lib/models/config_profile.dart new file mode 100644 index 0000000..ee2c34c --- /dev/null +++ b/lib/models/config_profile.dart @@ -0,0 +1,196 @@ +/// Importable device config-profile model (#402, feature #136). +/// +/// A profile is a portable set of config values a user applies to a device +/// (observer #139, repeater #137, companion #138) instead of baked defaults. +/// This file defines only the schema and typed model, parsing (#403) and the +/// per-device apply engines live elsewhere. +/// +/// Every field is nullable: a profile carries only the keys it wants to set, so +/// the apply engines can write field-at-a-time and leave everything else alone. +/// +/// Key names and the wire encoding mirror the firmware config schema +/// (`meshcore-firmware/.../wifi_observer/ConfigSchema.h`). transport and +/// auth_type travel as their string names, not the NVS ordinals. +library; + +/// Bumped when the on-disk/YAML shape changes incompatibly. The parser (#403) +/// rejects a profile whose declared version it does not understand. +/// v2 (#456): capability sections (`wifi`, `mqtt`, …). v1 was the flat layout. +const int kConfigProfileSchemaVersion = 2; + +/// Number of broker slots the firmware exposes (`mqtt_b0`..`mqtt_b5`). +const int kMaxBrokerSlots = 6; + +/// MQTT transport, wire value is the name (`tcp`/`tls`/`wss`), not the ordinal. +enum MqttTransport { + tcp, + tls, + wss; + + String get wire => name; + + static MqttTransport? fromWire(String? v) { + if (v == null) return null; + for (final t in values) { + if (t.name == v.toLowerCase()) return t; + } + return null; + } +} + +/// MQTT auth type, wire value is the name (`none`/`basic`/`jwt`). +enum MqttAuthType { + none, + basic, + jwt; + + String get wire => name; + + static MqttAuthType? fromWire(String? v) { + if (v == null) return null; + for (final a in values) { + if (a.name == v.toLowerCase()) return a; + } + return null; + } +} + +/// WiFi credentials (`wifi.*`). [password] maps to the firmware's `wifi.pwd` +/// and is write-only on the device (GET returns an error), so a profile can set +/// it but never round-trips it back. +class WifiConfig { + const WifiConfig({this.ssid, this.password, this.enabled}); + + final String? ssid; + final String? password; + final bool? enabled; + + bool get isEmpty => ssid == null && password == null && enabled == null; +} + +/// One broker slot (`mqtt.broker..*`). Sensitive fields ([password], +/// [jwtToken]) are included so a profile *can* carry them, but sharing a profile +/// with secrets is a trust concern the import flow must surface (#139 trust note). +class BrokerConfig { + const BrokerConfig({ + required this.slot, + this.url, + this.port, + this.transport, + this.authType, + this.username, + this.password, + this.jwtToken, + this.jwtAudience, + this.jwtRefresh, + this.jwtOwner, + this.jwtEmail, + this.caCert, + this.topicPrefix, + this.iataOverride, + }); + + /// 0-based slot index, `0 <= slot < kMaxBrokerSlots`. + /// + /// Broker `enabled` is deliberately NOT a profile field (#456): enabling a + /// broker is the operator's runtime decision (firmware ships slots disabled, + /// opt-in per #262). A profile configures the connection; apply preserves the + /// device's current enabled state. + final int slot; + final String? url; + final int? port; + final MqttTransport? transport; + final MqttAuthType? authType; + final String? username; + final String? password; + final String? jwtToken; + final String? jwtAudience; + final int? jwtRefresh; + final String? jwtOwner; + final String? jwtEmail; + final String? caCert; + final String? topicPrefix; + final String? iataOverride; +} + +/// The `mqtt` section, observer/MQTT capability. Shared by any device running +/// the observer role (observer, observer-repeater, observer-companion); never a +/// plain companion or plain repeater (#456). +class MqttSection { + const MqttSection({ + this.regionIata, + this.statusInterval, + this.brokers = const [], + }); + + /// `mqtt.iata`, the region/IATA code. + final String? regionIata; + + /// `mqtt.status_interval`, seconds between status publishes. + final int? statusInterval; + + /// Populated broker slots only (sparse; each carries its [BrokerConfig.slot]). + final List brokers; + + bool get isEmpty => + regionIata == null && statusInterval == null && brokers.isEmpty; +} + +/// A complete importable config profile, a set of capability-scoped sections +/// (#456). A device applies the sections it supports; the apply *mechanism* is +/// per-device, the section *schema* is per-capability and shared. Future +/// sections (`radio`, `repeater`, `companion`, `display`) slot in alongside. +class ConfigProfile { + const ConfigProfile({ + required this.schemaVersion, + this.name, + this.wifi, + this.mqtt, + }); + + final int schemaVersion; + + /// Optional human label (not applied to the device). + final String? name; + + /// `wifi` section, any wifi-capable device. + final WifiConfig? wifi; + + /// `mqtt` section, observer/MQTT capability. + final MqttSection? mqtt; +} + +/// Firmware config-key names. Callers (parser #403, apply engines) build keys +/// from these rather than hard-coding strings, so a firmware rename lands in one +/// place. Broker keys are `mqtt.broker..`. +abstract final class ConfigKeys { + static const String wifiPrefix = 'wifi.'; + static const String wifiSsid = 'wifi.ssid'; + static const String wifiPassword = 'wifi.pwd'; + static const String wifiEnabled = 'wifi.enabled'; + + static const String mqttIata = 'mqtt.iata'; + static const String mqttStatusInterval = 'mqtt.status_interval'; + + static const String brokerPrefix = 'mqtt.broker.'; + + // Broker sub-keys, appended after `mqtt.broker..`. + static const String brokerEnabled = 'enabled'; + static const String brokerUrl = 'url'; + static const String brokerPort = 'port'; + static const String brokerTransport = 'transport'; + static const String brokerAuthType = 'auth_type'; + static const String brokerUsername = 'username'; + static const String brokerPassword = 'password'; + static const String brokerJwtToken = 'jwt_token'; + static const String brokerJwtAudience = 'jwt_aud'; + static const String brokerJwtRefresh = 'jwt_refresh'; + static const String brokerJwtOwner = 'jwt_owner'; + static const String brokerJwtEmail = 'jwt_email'; + static const String brokerCaCert = 'ca_cert'; + static const String brokerTopicPrefix = 'topic_prefix'; + static const String brokerIataOverride = 'iata_override'; + + /// Full key for a broker sub-key, e.g. `mqtt.broker.2.url`. + static String broker(int slot, String subKey) => '$brokerPrefix$slot.$subKey'; +} diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 07e2d1c..972c04f 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -188,7 +188,7 @@ class Contact { // (`src/Packet.h:79-84`). 0xFF stays the flood sentinel. // // This previously read `count` BYTES, so at 2-byte width it kept half of - // every path and discarded the rest — the truncation behind #240's failed + // every path and discarded the rest, the truncation behind #240's failed // repeater logins. The width is taken from the path itself rather than the // connector's global width, so a path is always sliced at the width it was // captured at. (#309) diff --git a/lib/models/message.dart b/lib/models/message.dart index e91fb56..71281c7 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -31,14 +31,37 @@ class Message { final int? pathLength; final Uint8List pathBytes; final Map reactions; + + /// Emoji to the list of sender names who reacted with it (#383). + /// + /// Additive over [reactions]: the count map is still written and read so an + /// older build degrades to "counts work, names missing" rather than failing + /// to load. Reactions recorded before this field existed appear in + /// [reactions] with no matching entry here. + final Map> reactionSenders; final Map reactionStatuses; final Uint8List fourByteRoomContactKey; /// Local wall-clock time this message's frame arrived, set at ingest. - /// Null for outgoing messages and records stored before #285 — never + /// Null for outgoing messages and records stored before #285, never /// fabricated. [timestamp] is the SENDER's claimed time; this is ours. final DateTime? rxTime; + /// RX signal-to-noise in dB for the received frame, captured from the v3 + /// contact-msg-recv frame. Null for outgoing messages, pre-v3 firmware, and + /// records stored before #438, never fabricated. (#438) + final double? snr; + + /// RX signal strength in dBm. Null until firmware populates it, the wire + /// currently sends a reserved 0 byte here, so we do NOT read it yet (#439). + final int? rssi; + + /// True if this frame arrived flood-routed (path_len = hop count); false if + /// it came direct/point-to-point (firmware sends path_len 0xFF for that, + /// MyMesh.cpp:545). Null for outgoing/pre-#438 records. Drives the Path + /// screen's "direct (routed)" vs "flood, N hops" label. (#438) + final bool? isFloodRoute; + Message({ required this.senderKey, required this.text, @@ -62,14 +85,19 @@ class Message { Uint8List? pathBytes, Uint8List? fourByteRoomContactKey, Map? reactions, + Map>? reactionSenders, Map? reactionStatuses, this.rxTime, + this.snr, + this.rssi, + this.isFloodRoute, }) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${pubKeyToHex(senderKey)}_${text.hashCode}', pathBytes = pathBytes ?? Uint8List(0), fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0), reactions = reactions ?? {}, + reactionSenders = reactionSenders ?? {}, reactionStatuses = reactionStatuses ?? {}; String get senderKeyHex => pubKeyToHex(senderKey); @@ -91,9 +119,13 @@ class Message { MessageTranslationStatus? translationStatus, Object? translationModelId = _unset, Map? reactions, + Map>? reactionSenders, Map? reactionStatuses, Uint8List? fourByteRoomContactKey, DateTime? rxTime, + double? snr, + int? rssi, + bool? isFloodRoute, }) { return Message( senderKey: senderKey, @@ -125,10 +157,14 @@ class Message { pathLength: pathLength ?? this.pathLength, pathBytes: pathBytes ?? this.pathBytes, reactions: reactions ?? this.reactions, + reactionSenders: reactionSenders ?? this.reactionSenders, reactionStatuses: reactionStatuses ?? this.reactionStatuses, fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey, rxTime: rxTime ?? this.rxTime, + snr: snr ?? this.snr, + rssi: rssi ?? this.rssi, + isFloodRoute: isFloodRoute ?? this.isFloodRoute, ); } diff --git a/lib/models/observer_config.dart b/lib/models/observer_config.dart index e22d2c9..db1452b 100644 --- a/lib/models/observer_config.dart +++ b/lib/models/observer_config.dart @@ -3,7 +3,7 @@ // These mirror the firmware wire contract in the firmware repo's // `examples/companion_radio/OffbandConfigProtocol.h` (CMD_OFFBAND_CONFIG 0xC0, // Epic F #160-#163). The device NVS is the single source of truth: the client -// holds no local copy — it reads via ObserverConfigClient and writes +// holds no local copy, it reads via ObserverConfigClient and writes // field-at-a-time. Secrets (password / wifi.pwd) are write-only on the wire. /// MQTT broker transport. On the wire this is the string name @@ -77,13 +77,13 @@ enum WifiStatus { sealed class SecretField { const SecretField(); - /// Leave the stored secret untouched — the codec sends nothing. + /// Leave the stored secret untouched, the codec sends nothing. const factory SecretField.unchanged() = SecretUnchanged; - /// Clear the stored secret — the codec sends an empty value. + /// Clear the stored secret, the codec sends an empty value. const factory SecretField.clear() = SecretClear; - /// Replace the stored secret — the codec sends [value]. + /// Replace the stored secret, the codec sends [value]. const factory SecretField.set(String value) = SecretSet; } @@ -103,16 +103,16 @@ class SecretSet extends SecretField { /// Outcome of verifying that a broker enable/disable actually took effect, by /// re-reading the slot after the SET (#89). A firmware build can ACK a toggle as /// success without applying it (meshcore-firmware#179), so the UI never trusts -/// the ACK alone — it re-reads and reports what the device actually says. +/// the ACK alone, it re-reads and reports what the device actually says. enum BrokerApplyOutcome { /// Re-read confirms the device is in the intended enabled state. applied, - /// The device ACKed success but the re-read shows the unchanged state — it did + /// The device ACKed success but the re-read shows the unchanged state, it did /// not apply the toggle. Surfaced as a warning, never a false success. notApplied, - /// The re-read itself failed (timeout/disconnect — e.g. an enable that rebooted + /// The re-read itself failed (timeout/disconnect, e.g. an enable that rebooted /// the device), so the result can't be confirmed either way. unverified, } @@ -174,7 +174,7 @@ enum BrokerLastError { } } -/// Display category for a broker's status line — drives the label colour/icon. +/// Display category for a broker's status line, drives the label colour/icon. enum BrokerStatusKind { connected, connecting, failed, held, idle, disabled } /// A broker's human-facing status: a [label] plus a [kind] the UI colours. @@ -185,7 +185,7 @@ class BrokerStatus { } /// One MQTT broker slot (0-9), as read back from the `OCFG_BROKERS` dump. -/// Non-secret fields only — `password` is reported as presence ([passwordSet]), +/// Non-secret fields only, `password` is reported as presence ([passwordSet]), /// `jwt_token` is never a config key. class BrokerConfig { const BrokerConfig({ @@ -218,7 +218,7 @@ class BrokerConfig { final BrokerAuthType authType; final String username; - /// Whether a password is stored — from the wire `(set)` / `(unset)` token. + /// Whether a password is stored, from the wire `(set)` / `(unset)` token. /// The value itself is never returned. final bool passwordSet; final String topicPrefix; @@ -237,7 +237,7 @@ class BrokerConfig { final BrokerLastError lastError; /// Resolved defaults shown as greyed hints when the raw key is blank (#173). - /// Never written back — the blank raw key stays the source of truth. + /// Never written back, the blank raw key stays the source of truth. final String jwtOwnerResolved; final String iataResolved; @@ -251,7 +251,7 @@ class BrokerConfig { if (url.isEmpty) return 'URL is required'; if (port < 1 || port > 65535) return 'Port must be between 1 and 65535'; // JWT owner (device pubkey), IATA override, etc. are firmware-defaulted, so - // the client does NOT gate them — the firmware enforces with its defaults. + // the client does NOT gate them, the firmware enforces with its defaults. return null; } @@ -272,7 +272,7 @@ class BrokerConfig { /// Human-facing status, derived from [enabled] + [runtimeState] + [lastError] /// (#172). When the device doesn't report runtime state ([runtimeState] is /// [BrokerRuntimeState.unknown], e.g. older firmware) this is the plain - /// enabled/disabled the UI showed before — so absent fields read as today. + /// enabled/disabled the UI showed before, so absent fields read as today. BrokerStatus get status { if (!enabled) { return const BrokerStatus('Disabled', BrokerStatusKind.disabled); @@ -288,9 +288,9 @@ class BrokerConfig { : ' (${lastError.name})'; return BrokerStatus('Failed$reason', BrokerStatusKind.failed); case BrokerRuntimeState.heldNoClock: - return const BrokerStatus('Held — no clock', BrokerStatusKind.held); + return const BrokerStatus('Held, no clock', BrokerStatusKind.held); case BrokerRuntimeState.heldNoHeap: - return const BrokerStatus('Held — low heap', BrokerStatusKind.held); + return const BrokerStatus('Held, low heap', BrokerStatusKind.held); case BrokerRuntimeState.down: case BrokerRuntimeState.unknown: return const BrokerStatus('Enabled', BrokerStatusKind.idle); diff --git a/lib/models/offband_gps_status.dart b/lib/models/offband_gps_status.dart index b21886d..904102e 100644 --- a/lib/models/offband_gps_status.dart +++ b/lib/models/offband_gps_status.dart @@ -1,4 +1,4 @@ -/// Parsed payload of a `RESP_CODE_OFFBAND_GPS` (0xC1) reply — the on-demand GPS +/// Parsed payload of a `RESP_CODE_OFFBAND_GPS` (0xC1) reply, the on-demand GPS /// state from an Offband-fork companion radio. (#135) /// /// Wire format is one ASCII string of space-separated `key=value` tokens, e.g. @@ -27,7 +27,7 @@ class OffbandGpsStatus { this.timestampUtc, }); - /// True when the radio reports a live satellite fix with usable coordinates — + /// True when the radio reports a live satellite fix with usable coordinates, /// i.e. position is GPS-driven, not a stored/manual location. (#135) bool get hasLiveFix => fix && latitude != null && longitude != null; diff --git a/lib/screens/app_debug_log_screen.dart b/lib/screens/app_debug_log_screen.dart index ca6a6bf..5a10920 100644 --- a/lib/screens/app_debug_log_screen.dart +++ b/lib/screens/app_debug_log_screen.dart @@ -6,6 +6,7 @@ import '../l10n/l10n.dart'; import '../services/app_debug_log_service.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; +import '../utils/log_export.dart'; class AppDebugLogScreen extends StatelessWidget { const AppDebugLogScreen({super.key}); @@ -22,6 +23,19 @@ class AppDebugLogScreen extends StatelessWidget { title: AdaptiveAppBarTitle(context.l10n.debugLog_appTitle), centerTitle: true, actions: [ + IconButton( + tooltip: LogExport.tooltip(context), + icon: Icon(LogExport.icon), + onPressed: () => LogExport.shareLogs( + context, + webContent: () => entries + .map( + (entry) => + '[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}', + ) + .join('\n'), + ), + ), IconButton( tooltip: context.l10n.debugLog_copyLog, icon: const Icon(Icons.copy), diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index 81c4602..99c89d0 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import 'package:flutter/services.dart'; import '../l10n/l10n.dart'; @@ -7,9 +6,7 @@ import '../services/ble_debug_log_service.dart'; import '../connector/meshcore_protocol.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:url_launcher/url_launcher.dart'; -import '../services/file_log_service.dart'; +import '../utils/log_export.dart'; enum _BleLogView { frames, rawLogRx } @@ -33,17 +30,22 @@ class _BleDebugLogScreenState extends State { final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty; - final isMobile = - defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; return Scaffold( appBar: AppBar( title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle), actions: [ IconButton( - tooltip: isMobile ? 'Share logs' : 'Open logs folder', - icon: Icon(isMobile ? Icons.ios_share : Icons.folder_open), - onPressed: () => _exportLogs(context), + tooltip: LogExport.tooltip(context), + icon: Icon(LogExport.icon), + onPressed: () => LogExport.shareLogs( + context, + webContent: () => entries + .map( + (entry) => + '${entry.description}\n${entry.hexPreview}\n', + ) + .join('\n'), + ), ), IconButton( tooltip: context.l10n.debugLog_copyLog, @@ -172,36 +174,6 @@ class _BleDebugLogScreenState extends State { ); } - /// Export the on-disk log (#97): the system share sheet on mobile, or open the - /// logs folder in the file manager on desktop. The file holds both the app log - /// and BLE frames, flushed first so it's current. - Future _exportLogs(BuildContext context) async { - final messenger = ScaffoldMessenger.of(context); - final file = await FileLogService.instance.flushAndGetActiveFile(); - if (file == null) { - messenger.showSnackBar( - const SnackBar( - content: Text('File logging is unavailable on this platform'), - ), - ); - return; - } - final isMobile = - defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; - if (isMobile) { - await SharePlus.instance.share( - ShareParams( - subject: 'Offband Meshcore logs', - files: [XFile(file.path)], - ), - ); - } else { - final dir = FileLogService.instance.logDir; - if (dir != null) await launchUrl(Uri.file(dir.path)); - } - } - void _showRawDialog(BuildContext context, _RawPacketInfo info) { showDialog( context: context, diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 6917817..651bbc4 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -24,6 +24,7 @@ import '../models/channel_message.dart'; import '../models/translation_support.dart'; import '../models/app_settings.dart'; import '../services/app_settings_service.dart'; +import '../widgets/channel_notify_mode.dart'; import '../services/block_service.dart'; import '../services/chat_text_scale_service.dart'; import '../services/translation_service.dart'; @@ -308,9 +309,7 @@ class _ChannelChatScreenState extends State { navigator.pop(); navigator.pushReplacement( buildQuickSwitchRoute( - index == 0 - ? const ContactsScreen(hideBackButton: true) - : const MapScreen(hideBackButton: true), + index == 0 ? const ContactsScreen() : const MapScreen(), ), ); } @@ -347,6 +346,9 @@ class _ChannelChatScreenState extends State { // a channel on desktop, where there is no system back button and the // hamburger has taken the back arrow's place. selectedIndex: 1, + // Pushed detail: Back pops to the channel list, not to the background + // (#389). The bar above is only for tab highlighting/switching. + isTopLevel: false, onDestinationSelected: _handleQuickSwitch, contactsUnreadCount: context .watch() @@ -421,13 +423,33 @@ class _ChannelChatScreenState extends State { PopupMenuButton( icon: const Icon(Icons.more_vert), onSelected: (value) { - if (value == 'clearChat') { + if (value == 'notifications') { + showChannelNotifyModeDialog(context, widget.channel); + } else if (value == 'clearChat') { context.read().clearMessagesForChannel( _currentChannel.index, ); } }, itemBuilder: (context) => [ + PopupMenuItem( + value: 'notifications', + child: Row( + children: [ + Icon( + channelNotifyModeIcon( + context.read().channelNotifyMode( + identityKey: channelNotifyKeyFor(widget.channel), + channelName: widget.channel.name, + ), + ), + size: 20, + ), + const SizedBox(width: 12), + Text(context.l10n.channels_notifications), + ], + ), + ), PopupMenuItem( value: 'clearChat', child: Row( @@ -613,7 +635,7 @@ class _ChannelChatScreenState extends State { /// Block the sender of a channel post. Resolves the claimed name to known /// pubkey(s) and blocks each (full block: DM + adverts + all channels); if it /// resolves to nothing, blocks the name globally across all channels (promotes - /// to a pubkey block later, once the identity is learned — #174). + /// to a pubkey block later, once the identity is learned, #174). Future _blockChannelSender(ChannelMessage message) async { final connector = context.read(); final blockService = context.read(); @@ -1131,7 +1153,10 @@ class _ChannelChatScreenState extends State { Widget _buildMessageComposer() { final connector = context.watch(); - final maxBytes = maxChannelMessageBytes(connector.selfName); + final maxBytes = maxChannelMessageBytes( + connector.selfName, + maxFrameBytes: connector.effectiveMaxFrameSize, + ); final settings = context.watch().settings; return Column( mainAxisSize: MainAxisSize.min, @@ -1227,12 +1252,9 @@ class _ChannelChatScreenState extends State { hintText: context.l10n.chat_typeMessage, onSubmitted: (_) => _sendMessage(), encoder: - (connector.isChannelSmazEnabled( - _currentChannel.index, - ) || - connector.isChannelCyr2LatEnabled( - _currentChannel.index, - )) + connector.isChannelCyr2LatEnabled( + _currentChannel.index, + ) ? (text) => connector.prepareChannelOutboundText( _currentChannel.index, text, @@ -1364,7 +1386,10 @@ class _ChannelChatScreenState extends State { messageText = '@[${_replyingToMessage!.senderName}] $messageText'; } - final maxBytes = maxChannelMessageBytes(connector.selfName); + final maxBytes = maxChannelMessageBytes( + connector.selfName, + maxFrameBytes: connector.effectiveMaxFrameSize, + ); final outboundText = connector.prepareChannelOutboundText( _currentChannel.index, messageText, @@ -1398,7 +1423,7 @@ class _ChannelChatScreenState extends State { ); } - /// One-click reply quoting the route a message took — hop count + path, + /// One-click reply quoting the route a message took, hop count + path, /// truncated with … to fit the channel byte budget, fired directly. (#106) void _sendRouteReply(ChannelMessage message) { final connector = context.read(); @@ -1406,7 +1431,10 @@ class _ChannelChatScreenState extends State { final hops = message.pathBytes.length ~/ (w < 1 ? 1 : w); final prefix = '@[${message.senderName}] ↩ ${context.l10n.chat_hopsCount(hops)} · via '; - final maxBytes = maxChannelMessageBytes(connector.selfName); + final maxBytes = maxChannelMessageBytes( + connector.selfName, + maxFrameBytes: connector.effectiveMaxFrameSize, + ); var path = _formatPathPrefixes(message.pathBytes, w); var truncated = false; diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 274e004..7014c34 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -21,10 +21,21 @@ import '../widgets/adaptive_app_bar_title.dart'; class ChannelMessagePathScreen extends StatelessWidget { final ChannelMessage message; final bool channelMessage; + + /// RF details for a direct message, sourced from the [Message] (#438). Null + /// for channel messages and outgoing/legacy records; each row is hidden when + /// its value is null. [rssi] stays null until firmware ships it (#439). + final double? snr; + final int? rssi; + final bool? isFloodRoute; + const ChannelMessagePathScreen({ super.key, required this.message, this.channelMessage = false, + this.snr, + this.rssi, + this.isFloodRoute, }); @override @@ -146,10 +157,24 @@ class ChannelMessagePathScreen extends StatelessWidget { ), _buildDetailRow( l10n.channelPath_pathLabelTitle, - _formatPathLabel(message.hopCount, l10n), + isFloodRoute == null + ? _formatPathLabel(message.hopCount, l10n) + : (isFloodRoute! + ? l10n.channelPath_routeFlood(message.hopCount ?? 0) + : l10n.channelPath_routeDirect), ), if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel), + if (snr != null) + _buildDetailRow( + l10n.channelPath_snrLabel, + l10n.channelPath_snrValue(snr!.toStringAsFixed(2)), + ), + if (rssi != null) + _buildDetailRow( + l10n.channelPath_rssiLabel, + l10n.channelPath_rssiValue(rssi!), + ), ], ), ), diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index eedfd71..3bdf221 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -13,7 +13,6 @@ import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../services/app_settings_service.dart'; import '../services/ui_view_state_service.dart'; -import '../models/app_settings.dart'; import '../models/channel.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; @@ -27,6 +26,8 @@ import '../widgets/app_shell.dart'; import '../widgets/channel_drawer_list.dart'; import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; +import '../widgets/channel_notify_mode.dart'; +import '../widgets/battery_optimization_banner.dart'; import '../helpers/snack_bar_builder.dart'; import 'channel_chat_screen.dart'; import 'community_qr_scanner_screen.dart'; @@ -35,9 +36,7 @@ import 'map_screen.dart'; import 'settings_screen.dart'; class ChannelsScreen extends StatefulWidget { - final bool hideBackButton; - - const ChannelsScreen({super.key, this.hideBackButton = false}); + const ChannelsScreen({super.key}); @override State createState() => _ChannelsScreenState(); @@ -110,7 +109,11 @@ class _ChannelsScreenState extends State context, MaterialPageRoute(builder: (context) => const SettingsScreen()), ), - appBar: AppBar( + appBarBuilder: (context, pinned) => AppBar( + // Top-level tab: no auto back-arrow. Hamburger when the panel is not + // pinned; nothing when it is (panel is docked), so no dead arrow (#390). + automaticallyImplyLeading: false, + leading: pinned ? null : AppShell.drawerMenuButton(), title: AppBarTitle(context.l10n.channels_title), centerTitle: true, bottom: const SyncProgressAppBarBottom(), @@ -136,185 +139,195 @@ class _ChannelsScreenState extends State ), ], ), - body: RefreshIndicator( - onRefresh: () async { - await context.read().getChannels(force: true); - }, - child: () { - final channels = connector.channels; - final waitingForFirstChannel = - connector.isLoadingChannels && channels.isEmpty; - - // Only block the list while the first channel is actively loading. - // If the initial sync aborts, show cached/partial channels instead - // of trapping the user behind an idle spinner. - if (waitingForFirstChannel) { - return const Center(child: CircularProgressIndicator()); - } + body: BatteryOptimizationBanner( + child: RefreshIndicator( + onRefresh: () async { + await context.read().getChannels(force: true); + }, + child: () { + final channels = connector.channels; + final waitingForFirstChannel = + connector.isLoadingChannels && channels.isEmpty; + + // Only block the list while the first channel is actively loading. + // If the initial sync aborts, show cached/partial channels instead + // of trapping the user behind an idle spinner. + if (waitingForFirstChannel) { + return const Center(child: CircularProgressIndicator()); + } - if (channels.isEmpty) { - return ListView( - children: [ - SizedBox( - height: MediaQuery.of(context).size.height - 200, - child: EmptyState( - icon: Icons.tag, - title: context.l10n.channels_noChannelsConfigured, - action: FilledButton.icon( - onPressed: () => _addPublicChannel(context, connector), - icon: const Icon(Icons.public), - label: Text(context.l10n.channels_addPublicChannel), + if (channels.isEmpty) { + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: EmptyState( + icon: Icons.tag, + title: context.l10n.channels_noChannelsConfigured, + action: FilledButton.icon( + onPressed: () => _addPublicChannel(context, connector), + icon: const Icon(Icons.public), + label: Text(context.l10n.channels_addPublicChannel), + ), ), ), ), - ], - ); - } + ); + } - final filteredChannels = _filterAndSortChannels( - channels, - connector, - viewState, - ); + final filteredChannels = _filterAndSortChannels( + channels, + connector, + viewState, + ); - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.channels_searchChannels, - prefixIcon: const Icon(Icons.search), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (viewState.channelsSearchText.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchDebounce?.cancel(); - _searchDebounce = null; - _searchController.clear(); - context - .read() - .setChannelsSearchText(''); - }, - ), - _buildFilterButton(viewState), - ], - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: context.l10n.channels_searchChannels, + prefixIcon: const Icon(Icons.search), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (viewState.channelsSearchText.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchDebounce?.cancel(); + _searchDebounce = null; + _searchController.clear(); + context + .read() + .setChannelsSearchText(''); + }, + ), + _buildFilterButton(viewState), + ], + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), + onChanged: (value) { + _searchDebounce?.cancel(); + _searchDebounce = Timer( + const Duration(milliseconds: 300), + () { + if (!mounted) return; + context + .read() + .setChannelsSearchText(value); + }, + ); + }, ), - onChanged: (value) { - _searchDebounce?.cancel(); - _searchDebounce = Timer( - const Duration(milliseconds: 300), - () { - if (!mounted) return; - context - .read() - .setChannelsSearchText(value); - }, - ); - }, ), - ), - Expanded( - child: filteredChannels.isEmpty - ? ListView( - children: [ - SizedBox( - height: MediaQuery.of(context).size.height - 300, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: 64, - color: Colors.grey[400], + Expanded( + child: filteredChannels.isEmpty + ? LayoutBuilder( + builder: (context, constraints) => + SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, ), - const SizedBox(height: 16), - Text( - context.l10n.channels_noChannelsFound, - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], + child: Center( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + context.l10n.channels_noChannelsFound, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + ], ), ), - ], + ), ), - ), + ) + : (viewState.channelsSortOption == + ChannelSortOption.manual && + viewState.channelsSearchText.isEmpty) + ? ReorderableListView.builder( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: 88, ), - ], - ) - : (viewState.channelsSortOption == - ChannelSortOption.manual && - viewState.channelsSearchText.isEmpty) - ? ReorderableListView.builder( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 8, - bottom: 88, - ), - buildDefaultDragHandles: false, - itemCount: filteredChannels.length, - onReorderItem: (oldIndex, newIndex) { - // onReorderItem already adjusts newIndex after the - // removed item, unlike the deprecated onReorder. - final reordered = List.from( - filteredChannels, - ); - final item = reordered.removeAt(oldIndex); - reordered.insert(newIndex, item); - unawaited( - connector.setChannelOrder( - reordered.map((c) => c.index).toList(), - ), - ); - }, - itemBuilder: (context, index) { - final channel = filteredChannels[index]; - return _buildChannelTile( - context, - connector, - channelMessageStore, - channel, - showDragHandle: true, - dragIndex: index, - ); - }, - ) - : ListView.builder( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 8, - bottom: 88, + buildDefaultDragHandles: false, + itemCount: filteredChannels.length, + onReorderItem: (oldIndex, newIndex) { + // onReorderItem already adjusts newIndex after the + // removed item, unlike the deprecated onReorder. + final reordered = List.from( + filteredChannels, + ); + final item = reordered.removeAt(oldIndex); + reordered.insert(newIndex, item); + unawaited( + connector.setChannelOrder( + reordered.map((c) => c.index).toList(), + ), + ); + }, + itemBuilder: (context, index) { + final channel = filteredChannels[index]; + return _buildChannelTile( + context, + connector, + channelMessageStore, + channel, + showDragHandle: true, + dragIndex: index, + ); + }, + ) + : ListView.builder( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: 88, + ), + itemCount: filteredChannels.length, + itemBuilder: (context, index) { + final channel = filteredChannels[index]; + return _buildChannelTile( + context, + connector, + channelMessageStore, + channel, + ); + }, ), - itemCount: filteredChannels.length, - itemBuilder: (context, index) { - final channel = filteredChannels[index]; - return _buildChannelTile( - context, - connector, - channelMessageStore, - channel, - ); - }, - ), - ), - ], - ); - }(), + ), + ], + ); + }(), + ), ), floatingActionButton: FloatingActionButton( onPressed: () => _showAddChannelDialog(context), @@ -468,71 +481,6 @@ class _ChannelsScreenState extends State ); } - /// Notify-mode storage key for [channel]: its PSK identity, so the setting - /// survives a rename and does not follow a reused slot (#259). - String _notifyKeyFor(Channel channel) => AppSettings.channelNotifyKey( - channelIndex: channel.index, - pskHex: channel.pskHex, - ); - - IconData _notifyModeIcon(ChannelNotifyMode mode) { - switch (mode) { - case ChannelNotifyMode.all: - return Icons.notifications_outlined; - case ChannelNotifyMode.mentionsOnly: - return Icons.alternate_email; - case ChannelNotifyMode.off: - return Icons.notifications_off_outlined; - } - } - - String _notifyModeLabel(BuildContext context, ChannelNotifyMode mode) { - switch (mode) { - case ChannelNotifyMode.all: - return context.l10n.channels_notifyAll; - case ChannelNotifyMode.mentionsOnly: - return context.l10n.channels_notifyMentionsOnly; - case ChannelNotifyMode.off: - return context.l10n.channels_notifyOff; - } - } - - void _showNotifyModeDialog(BuildContext context, Channel channel) { - final settingsService = context.read(); - final identityKey = _notifyKeyFor(channel); - final current = settingsService.channelNotifyMode( - identityKey: identityKey, - channelName: channel.name, - ); - - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(dialogContext.l10n.channels_notifications), - contentPadding: const EdgeInsets.symmetric(vertical: 8), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final mode in ChannelNotifyMode.values) - ListTile( - leading: Icon(_notifyModeIcon(mode)), - title: Text(_notifyModeLabel(dialogContext, mode)), - trailing: mode == current ? const Icon(Icons.check) : null, - onTap: () async { - Navigator.pop(dialogContext); - await settingsService.setChannelNotifyMode( - identityKey: identityKey, - channelName: channel.name, - mode: mode, - ); - }, - ), - ], - ), - ), - ); - } - void _showChannelActions( BuildContext context, MeshCoreConnector connector, @@ -542,7 +490,7 @@ class _ChannelsScreenState extends State final parentContext = context; final settingsService = context.read(); final notifyMode = settingsService.channelNotifyMode( - identityKey: _notifyKeyFor(channel), + identityKey: channelNotifyKeyFor(channel), channelName: channel.name, ); @@ -575,14 +523,14 @@ class _ChannelsScreenState extends State }, ), ListTile( - leading: Icon(_notifyModeIcon(notifyMode)), + leading: Icon(channelNotifyModeIcon(notifyMode)), title: Text(context.l10n.channels_notifications), - subtitle: Text(_notifyModeLabel(context, notifyMode)), + subtitle: Text(channelNotifyModeLabel(context, notifyMode)), onTap: () async { Navigator.pop(sheetContext); await Future.delayed(const Duration(milliseconds: 100)); if (parentContext.mounted) { - _showNotifyModeDialog(parentContext, channel); + showChannelNotifyModeDialog(parentContext, channel); } }, ), @@ -635,13 +583,13 @@ class _ChannelsScreenState extends State case 0: Navigator.pushReplacement( context, - buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)), + buildQuickSwitchRoute(const ContactsScreen()), ); break; case 2: Navigator.pushReplacement( context, - buildQuickSwitchRoute(const MapScreen(hideBackButton: true)), + buildQuickSwitchRoute(const MapScreen()), ); break; } @@ -1450,7 +1398,6 @@ class _ChannelsScreenState extends State ); final nameController = TextEditingController(text: channel.name); final pskController = TextEditingController(text: channel.pskHex); - bool smazEnabled = connector.isChannelSmazEnabled(channel.index); bool cyr2latEnabled = connector.isChannelCyr2LatEnabled(channel.index); String? selectedCyr2LatProfileId = connector.getChannelCyr2LatProfileId( channel.index, @@ -1496,17 +1443,6 @@ class _ChannelsScreenState extends State ), ), const SizedBox(height: 16), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(dialogContext.l10n.channels_smazCompression), - value: smazEnabled, - onChanged: (value) => setState(() { - smazEnabled = value; - if (smazEnabled) { - cyr2latEnabled = false; - } - }), - ), SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(dialogContext.l10n.channels_cyr2latCompression), @@ -1516,9 +1452,6 @@ class _ChannelsScreenState extends State value: cyr2latEnabled, onChanged: (value) => setState(() { cyr2latEnabled = value; - if (cyr2latEnabled) { - smazEnabled = false; - } }), ), if (cyr2latEnabled) ...[ @@ -1573,10 +1506,6 @@ class _ChannelsScreenState extends State Navigator.pop(dialogContext); try { await connector.setChannel(channel.index, name, psk); - await connector.setChannelSmazEnabled( - channel.index, - smazEnabled, - ); await connector.setChannelCyr2LatEnabled( channel.index, cyr2latEnabled, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 0e73350..791a24c 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -595,7 +595,9 @@ class _ChatScreenState extends State { if (context.watch().isBlocked(widget.contact.publicKeyHex)) { return _buildBlockedBar(); } - final maxBytes = maxContactMessageBytes(); + final maxBytes = maxContactMessageBytes( + maxFrameBytes: connector.effectiveMaxFrameSize, + ); final colorScheme = Theme.of(context).colorScheme; final settings = context.watch().settings; return Container( @@ -672,12 +674,9 @@ class _ChatScreenState extends State { hintText: context.l10n.chat_typeMessage, onSubmitted: (_) => _sendMessage(connector), encoder: - (connector.isContactSmazEnabled( - widget.contact.publicKeyHex, - ) || - connector.isContactCyr2LatEnabled( - widget.contact.publicKeyHex, - )) + connector.isContactCyr2LatEnabled( + widget.contact.publicKeyHex, + ) ? (text) => connector.prepareContactOutboundText( widget.contact, text, @@ -815,7 +814,9 @@ class _ChatScreenState extends State { } } } - final maxBytes = maxContactMessageBytes(); + final maxBytes = maxContactMessageBytes( + maxFrameBytes: connector.effectiveMaxFrameSize, + ); final outboundText = connector.prepareContactOutboundText( _resolveContact(connector), outgoingText, @@ -1438,7 +1439,7 @@ class _ChatScreenState extends State { ); if (result == null) { - return; // Cancelled — keep existing path + return; // Cancelled, keep existing path } if (!mounted) { @@ -1497,7 +1498,12 @@ class _ChatScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => ChannelMessagePathScreen(message: pathMessage), + builder: (context) => ChannelMessagePathScreen( + message: pathMessage, + snr: message.snr, + rssi: message.rssi, + isFloodRoute: message.isFloodRoute, + ), ), ); } diff --git a/lib/screens/companion_radio_stats_screen.dart b/lib/screens/companion_radio_stats_screen.dart index b17bd8b..5bc00da 100644 --- a/lib/screens/companion_radio_stats_screen.dart +++ b/lib/screens/companion_radio_stats_screen.dart @@ -20,7 +20,7 @@ class CompanionRadioStatsScreen extends StatelessWidget { } } -/// Embeddable Radio Stats content (no Scaffold) — used by both +/// Embeddable Radio Stats content (no Scaffold), used by both /// [CompanionRadioStatsScreen] and the settings shell's Radio Stats pane. /// Acquires 1 s radio-stats polling while mounted and releases it on dispose. class CompanionRadioStatsBody extends StatefulWidget { diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 9ffbae6..3934973 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -48,9 +48,7 @@ enum RoomLoginDestination { chat, management } enum ContactOperationType { import, export, zeroHopShare } class ContactsScreen extends StatefulWidget { - final bool hideBackButton; - - const ContactsScreen({super.key, this.hideBackButton = false}); + const ContactsScreen({super.key}); @override State createState() => _ContactsScreenState(); @@ -329,7 +327,11 @@ class _ContactsScreenState extends State context, MaterialPageRoute(builder: (context) => const SettingsScreen()), ), - appBar: AppBar( + appBarBuilder: (context, pinned) => AppBar( + // Top-level tab: no auto back-arrow. Hamburger when the panel is not + // pinned; nothing when it is (panel is docked), so no dead arrow (#390). + automaticallyImplyLeading: false, + leading: pinned ? null : AppShell.drawerMenuButton(), title: AppBarTitle(context.l10n.contacts_title), bottom: const SyncProgressAppBarBottom(), actions: [ @@ -949,13 +951,13 @@ class _ContactsScreenState extends State case 1: Navigator.pushReplacement( context, - buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)), + buildQuickSwitchRoute(const ChannelsScreen()), ); break; case 2: Navigator.pushReplacement( context, - buildQuickSwitchRoute(const MapScreen(hideBackButton: true)), + buildQuickSwitchRoute(const MapScreen()), ); break; } @@ -1333,7 +1335,7 @@ class _ContactsScreenState extends State } else { // Confirm-first: pre-fill the route builder with the // inferred route when we have one, else an empty builder. - // The user always confirms — we never fire a silent guess. + // The user always confirms, we never fire a silent guess. // (#186; replaces the old strongest-repeater guess.) final suggested = (inferred != null && inferred.isNotEmpty) diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index 4a68c22..49e35f5 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -411,6 +411,9 @@ class _LineOfSightMapScreenState extends State { return AppShell( selectedIndex: 2, + // Pushed detail (LOS analysis over the map): Back pops to the map, not to + // the background (#389). + isTopLevel: false, onDestinationSelected: (index) => _handleQuickSwitch(index, context), contactsUnreadCount: context .watch() @@ -1359,13 +1362,13 @@ class _LineOfSightMapScreenState extends State { case 0: Navigator.pushReplacement( context, - buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)), + buildQuickSwitchRoute(const ContactsScreen()), ); break; case 1: Navigator.pushReplacement( context, - buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)), + buildQuickSwitchRoute(const ChannelsScreen()), ); break; } diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 2b9ade1..200efa2 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -42,7 +42,6 @@ class MapScreen extends StatefulWidget { final String? highlightLabel; final String? highlightMarkerKey; final double highlightZoom; - final bool hideBackButton; const MapScreen({ super.key, @@ -50,7 +49,6 @@ class MapScreen extends StatefulWidget { this.highlightLabel, this.highlightMarkerKey, this.highlightZoom = 15.0, - this.hideBackButton = false, }); @override @@ -138,7 +136,7 @@ class _MapScreenState extends State { sumSquaredDiff += diff * diff; } - // Sample standard deviation (n-1) — most appropriate here + // Sample standard deviation (n-1), most appropriate here final variance = sumSquaredDiff / (values.length - 1); return sqrt(variance); @@ -253,7 +251,7 @@ class _MapScreenState extends State { return c.hasLocation; }).toList(); - // All contacts with a known location — used as anchors regardless of + // All contacts with a known location, used as anchors regardless of // time/key-prefix filters so that repeaters are always available. final allContactsWithLocation = allContacts .where((c) => c.hasLocation) @@ -421,7 +419,12 @@ class _MapScreenState extends State { context, MaterialPageRoute(builder: (context) => const SettingsScreen()), ), - appBar: AppBar( + appBarBuilder: (context, pinned) => AppBar( + // Top-level tab: no auto back-arrow. Hamburger when the panel is + // not pinned; nothing when pinned (panel docked), so no dead + // arrow (#390). + automaticallyImplyLeading: false, + leading: pinned ? null : AppShell.drawerMenuButton(), title: AppBarTitle(context.l10n.map_title), centerTitle: true, bottom: const SyncProgressAppBarBottom(), @@ -707,7 +710,7 @@ class _MapScreenState extends State { // Collect the contact-side (last-hop) repeater from every known path. // path = [device-side hop, ..., contact-side hop] - // Only path.last is actually within radio range of the contact — using + // Only path.last is actually within radio range of the contact, using // earlier bytes would anchor against our own side of the network. final pathSets = >[ contact.path.toList(), @@ -719,7 +722,7 @@ class _MapScreenState extends State { if (pathBytes.length < w) continue; // Anchor on the contact-side hop. Paths are stored device-side-first // (Contact.path is the reversed wire path), so the contact-side repeater - // is the trailing w bytes — the same hop the 1-byte code read via + // is the trailing w bytes, the same hop the 1-byte code read via // pathBytes.last, just widened to the configured width. final r = repeaterByHash[packPrefix(pathBytes, pathBytes.length - w)]; if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!)); @@ -1007,7 +1010,7 @@ class _MapScreenState extends State { return filtered; } - /// True if two contacts share their first [width] public-key bytes — the + /// True if two contacts share their first [width] public-key bytes, the /// width-aware replacement for a 1-byte first-byte comparison. (#156) bool _samePubkeyPrefix(Contact a, Contact b, int width) { if (a.publicKey.length < width || b.publicKey.length < width) return false; @@ -1636,13 +1639,13 @@ class _MapScreenState extends State { case 0: Navigator.pushReplacement( context, - buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)), + buildQuickSwitchRoute(const ContactsScreen()), ); break; case 1: Navigator.pushReplacement( context, - buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)), + buildQuickSwitchRoute(const ChannelsScreen()), ); break; } diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index faaf0a8..a4dc3fb 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -195,7 +195,7 @@ class _PathTraceMapScreenState extends State { // Per the firmware trace contract, the route must EXCLUDE our own origin // prefix (it is implicit at both ends). Drop a leading self hop if the - // stored path includes it — otherwise route[0] matches no forwarding node + // stored path includes it, otherwise route[0] matches no forwarding node // (we are the sender) and the packet never advances → silent timeout. (#150) var route = pathBytes; final self = context.read().selfPublicKey; @@ -210,7 +210,7 @@ class _PathTraceMapScreenState extends State { if (isSelf) route = Uint8List.fromList(route.sublist(hopBytes)); } - // Empty path, or an all-zero "flood" marker, means no real route — trace + // Empty path, or an all-zero "flood" marker, means no real route, trace // the target's own width prefix directly. Never return an empty payload: // the firmware rejects an empty trace with error 1. (#150) if (route.isEmpty || route.every((b) => b == 0)) { @@ -256,7 +256,7 @@ class _PathTraceMapScreenState extends State { final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp; if (path.isEmpty) { - // No route to trace — don't send an empty payload (firmware error 1); + // No route to trace, don't send an empty payload (firmware error 1); // surface "not available" instead. (#150) appLogger.info( 'Path trace skipped: no route to trace', diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index 7072395..8e5c892 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -397,7 +397,7 @@ class _RepeaterSettingsScreenState extends State { /// /// Response/command pairing is guaranteed by the prefix-matching layer in /// RepeaterCommandService (firmware echoes the `XX|` token from MyMesh.cpp), - /// so no shape-based validation is needed here — `tryParse` handles any + /// so no shape-based validation is needed here, `tryParse` handles any /// malformed value by leaving state untouched. bool _handleGetResponse(String command, String response) { final normalized = command.trim().toLowerCase(); @@ -924,7 +924,7 @@ class _RepeaterSettingsScreenState extends State { await Future.delayed(const Duration(milliseconds: 200)); } - // Only clear password fields if every password command succeeded — + // Only clear password fields if every password command succeeded, // otherwise the user keeps their typed value to retry. if (!passwordsFailed) { _passwordController.clear(); diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index e2f635a..1ad1dc0 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -9,6 +9,7 @@ import '../l10n/l10n.dart'; import '../services/linux_ble_error_classifier.dart'; import '../utils/app_logger.dart'; import '../widgets/adaptive_app_bar_title.dart'; +import '../widgets/battery_optimization_banner.dart'; import '../widgets/device_tile.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; @@ -121,24 +122,26 @@ class _ScannerScreenState extends State { centerTitle: true, automaticallyImplyLeading: false, ), - body: SafeArea( - top: false, - child: Consumer( - builder: (context, connector, child) { - return Column( - children: [ - // Bluetooth off warning - if (_bluetoothState == BluetoothAdapterState.off) - _bluetoothOffWarning(context), + body: BatteryOptimizationBanner( + child: SafeArea( + top: false, + child: Consumer( + builder: (context, connector, child) { + return Column( + children: [ + // Bluetooth off warning + if (_bluetoothState == BluetoothAdapterState.off) + _bluetoothOffWarning(context), - // Status bar - _buildStatusBar(context, connector), + // Status bar + _buildStatusBar(context, connector), - // Device list - Expanded(child: _buildDeviceList(context, connector)), - ], - ); - }, + // Device list + Expanded(child: _buildDeviceList(context, connector)), + ], + ); + }, + ), ), ), bottomNavigationBar: Consumer( diff --git a/lib/screens/serial_capture_screen.dart b/lib/screens/serial_capture_screen.dart new file mode 100644 index 0000000..f5a749d --- /dev/null +++ b/lib/screens/serial_capture_screen.dart @@ -0,0 +1,477 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; + +import '../connector/caplog_reassembler.dart'; +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../utils/log_export.dart'; + +/// Serial-capture diagnostics screen (#430). +/// +/// Controls the connected radio's serial-capture buffer over the 0xC4 companion +/// command (enable / disable / erase / status) and downloads it as a shareable +/// file. Support is gated on the device-info **capability bit** (`0x20` + +/// `FIRMWARE_VER_CODE >= 17`), which is static and refreshes on reconnect, so +/// the feature never latches "unsupported" after a reboot. Capture state is +/// derived from the device STATUS, so an auto-resumed capture (post-reboot) +/// shows STOP, not START. +/// +/// Strings are English-only for now; localization is a follow-up (#427). +class SerialCaptureScreen extends StatefulWidget { + const SerialCaptureScreen({super.key}); + + @override + State createState() => _SerialCaptureScreenState(); +} + +class _SerialCaptureScreenState extends State { + /// Capture-window options in minutes for the timed flow; 0 = until stopped. + static const List _durations = [1, 5, 15, 30, 0]; + + MeshCoreConnector? _connector; + CaplogDeviceStatus? _status; + int _durationMinutes = 5; + bool _busy = false; + String? _error; + bool _wasConnected = false; + + DateTime? _startedAt; + int? _timedWindowMinutes; // set while a timed capture is running (for the UI) + Timer? _tick; + Timer? _autoStop; + Timer? _statusPoll; + + @override + void initState() { + super.initState(); + final c = context.read(); + _connector = c; + c.addListener(_onConnectorChanged); + _wasConnected = c.isConnected; + if (c.isConnected && c.supportsOffbandCaplog) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _ensureStatusPolling(); + _refreshStatus(); + }); + } + } + + @override + void dispose() { + _connector?.removeListener(_onConnectorChanged); + _stopTimers(); + super.dispose(); + } + + /// React to connection transitions: on reconnect re-derive device state (no + /// latch); on disconnect stop polling a dead link. + void _onConnectorChanged() { + final c = _connector; + if (c == null || !mounted) return; + final connected = c.isConnected; + if (connected && !_wasConnected) { + _wasConnected = true; + if (c.supportsOffbandCaplog) { + _ensureStatusPolling(); + _refreshStatus(); + } + } else if (!connected && _wasConnected) { + _wasConnected = false; + _stopTimers(); + setState(() {}); + } + } + + Future _refreshStatus() async { + final c = _connector; + if (c == null || !c.isConnected) return; + try { + final status = await c.getDeviceCaplogStatus(); + if (!mounted) return; + setState(() => _status = status); + // Keep local timers/elapsed in sync with the device's actual state, so an + // auto-resumed capture after a reboot is reflected as running. + if (status.enabled) { + _startedAt ??= DateTime.now(); + _startTick(); + } else { + _startedAt = null; + _timedWindowMinutes = null; + _stopTick(); + } + } catch (_) { + // Transient (e.g. mid-reconnect); keep last-known state and retry on the + // next poll / reconnect rather than latching. + } + } + + /// STATUS poll runs whenever the screen is on a connected, caplog-capable + /// device, so the buffer readout stays live even while idle (not just during + /// capture). Stopped only on disconnect / dispose. + void _ensureStatusPolling() { + _statusPoll ??= Timer.periodic( + const Duration(seconds: 3), + (_) => _refreshStatus(), + ); + } + + /// 1s elapsed-time tick; runs only while capturing. + void _startTick() { + _tick ??= Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + void _stopTick() { + _tick?.cancel(); + _tick = null; + } + + void _stopTimers() { + _tick?.cancel(); + _autoStop?.cancel(); + _statusPoll?.cancel(); + _tick = _autoStop = _statusPoll = null; + } + + Future _setEnabled(bool enabled) async { + final c = _connector; + if (c == null) return false; + final ok = await c.setDeviceCaplogEnabled(enabled); + if (!ok) { + throw Exception('device rejected ${enabled ? 'enable' : 'disable'}'); + } + return ok; + } + + Future _startTimed() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await _connector?.eraseDeviceCaplog(); // clean session: start from 0 + await _setEnabled(true); + if (!mounted) return; + _startedAt = DateTime.now(); + _timedWindowMinutes = _durationMinutes > 0 ? _durationMinutes : null; + _startTick(); + if (_durationMinutes > 0) { + _autoStop?.cancel(); + _autoStop = Timer(Duration(minutes: _durationMinutes), _stop); + } + await _refreshStatus(); + } catch (e) { + if (mounted) setState(() => _error = 'Could not start capture: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _stop() async { + if (!mounted) return; + setState(() => _busy = true); + try { + await _setEnabled(false); + if (mounted) { + _startedAt = null; + _timedWindowMinutes = null; + } + _autoStop?.cancel(); + _autoStop = null; + _stopTick(); + await _refreshStatus(); + } catch (e) { + if (mounted) setState(() => _error = 'Could not stop capture: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// Boot-log flow (#428): enable capture with no timer, then reboot so the + /// radio records the boot sequence from power-on. The connection drops during + /// reboot; on reconnect `_onConnectorChanged` re-derives STATUS and (once + /// firmware #428 persists the flag) the capture shows as running → Stop here. + Future _startAndReboot() async { + final c = _connector; + if (c == null) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Start capture & reboot?'), + content: const Text( + 'Enables serial capture, then reboots the radio so the boot log is ' + 'captured from power-on. There is no timer. Capture runs until you ' + 'Stop it. The connection drops during the reboot; when it reconnects, ' + 'capture is still running and you can Stop and download here.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.red[700]), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Start & Reboot'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await c.eraseDeviceCaplog(); // fresh boot log from power-on + await _setEnabled(true); // no timer: runs until Stop + _startedAt = DateTime.now(); + _timedWindowMinutes = null; + await c.rebootDevice(); + // Connection drops now; reconnect handling re-derives STATUS. + } catch (e) { + if (mounted) setState(() => _error = 'Start & reboot failed: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _download() async { + final c = _connector; + if (c == null) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final bytes = await c.downloadCaplog(); + if (!mounted) return; + if (bytes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Buffer is empty, nothing to download.'), + ), + ); + return; + } + final dir = await getTemporaryDirectory(); + final ts = DateTime.now(); + final name = + 'serial-capture-' + '${ts.year}${_pad2(ts.month)}${_pad2(ts.day)}-' + '${_pad2(ts.hour)}${_pad2(ts.minute)}${_pad2(ts.second)}.txt'; + final file = File('${dir.path}${Platform.pathSeparator}$name'); + await file.writeAsBytes(bytes); + if (!mounted) return; + await LogExport.shareFile( + context, + file, + subject: 'Offband serial capture', + ); + } on CaplogBusyException { + if (mounted) { + setState( + () => _error = 'Device busy (another transfer in progress). Retry.', + ); + } + } on CaplogTruncatedException catch (e) { + if (mounted) setState(() => _error = 'Capture truncated: $e'); + } on TimeoutException { + if (mounted) setState(() => _error = 'No response from device.'); + } catch (e) { + if (mounted) setState(() => _error = 'Download failed: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _erase() async { + final c = _connector; + if (c == null) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await c.eraseDeviceCaplog(); + await _refreshStatus(); + } catch (e) { + if (mounted) setState(() => _error = 'Erase failed: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + static String _pad2(int n) => n.toString().padLeft(2, '0'); + + String _elapsed() { + final s = _startedAt == null + ? 0 + : DateTime.now().difference(_startedAt!).inSeconds; + return '${_pad2(s ~/ 60)}:${_pad2(s % 60)}'; + } + + String _remaining() { + final window = _timedWindowMinutes; + if (_startedAt == null || window == null) return ''; + final total = window * 60; + final left = (total - DateTime.now().difference(_startedAt!).inSeconds) + .clamp(0, total); + return '${_pad2(left ~/ 60)}:${_pad2(left % 60)}'; + } + + @override + Widget build(BuildContext context) { + final connector = context.watch(); + return Scaffold( + appBar: AppBar(title: const Text('Serial capture'), centerTitle: true), + body: _body(connector), + ); + } + + Widget _body(MeshCoreConnector connector) { + if (!connector.isConnected) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center( + child: Text( + 'Connect to a device to use serial capture.', + textAlign: TextAlign.center, + ), + ), + ); + } + if (!connector.supportsOffbandCaplog) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center( + child: Text( + "This device's firmware doesn't support serial capture.", + textAlign: TextAlign.center, + ), + ), + ); + } + + final capturing = _status?.enabled ?? (_startedAt != null); + final timed = _timedWindowMinutes != null; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (_error != null) + Card( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + const Icon(Icons.error_outline), + const SizedBox(width: 8), + Expanded(child: Text(_error!)), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => setState(() => _error = null), + ), + ], + ), + ), + ), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + capturing ? 'Capturing…' : 'Idle', + style: Theme.of(context).textTheme.titleMedium, + ), + if (capturing) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Elapsed ${_elapsed()}' + '${timed ? ' · auto-stops in ${_remaining()}' : ''}', + ), + ), + if (_status != null) ...[ + const SizedBox(height: 12), + LinearProgressIndicator( + value: _status!.capacityBytes > 0 + ? (_status!.usedBytes / _status!.capacityBytes).clamp( + 0.0, + 1.0, + ) + : null, + ), + const SizedBox(height: 4), + Text( + 'Buffer ${_status!.usedBytes} / ${_status!.capacityBytes} bytes', + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Text('Capture duration'), + const Spacer(), + DropdownButton( + value: _durationMinutes, + onChanged: capturing + ? null + : (v) { + if (v != null) setState(() => _durationMinutes = v); + }, + items: _durations + .map( + (m) => DropdownMenuItem( + value: m, + child: Text(m == 0 ? 'Until I stop' : '$m min'), + ), + ) + .toList(), + ), + ], + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _busy ? null : (capturing ? _stop : _startTimed), + icon: Icon(capturing ? Icons.stop : Icons.fiber_manual_record), + label: Text(capturing ? 'Stop capture' : 'Start capture'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _busy || capturing ? null : _download, + icon: Icon(LogExport.icon), + label: const Text('Download & share'), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: _busy || capturing ? null : _erase, + icon: const Icon(Icons.delete_outline), + label: const Text('Erase buffer'), + ), + const Divider(height: 24), + // Boot-log flow (#428): enable capture (no timer) + reboot, styled as a + // dangerous action (it reboots the radio and drops the connection). + FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.red[700], + foregroundColor: Colors.white, + ), + onPressed: _busy || capturing ? null : _startAndReboot, + icon: const Icon(Icons.restart_alt), + label: const Text('Start & Reboot (capture boot log)'), + ), + ], + ); + } +} diff --git a/lib/screens/settings/app_settings_view.dart b/lib/screens/settings/app_settings_view.dart index a2a57e4..5486422 100644 --- a/lib/screens/settings/app_settings_view.dart +++ b/lib/screens/settings/app_settings_view.dart @@ -15,8 +15,9 @@ import '../map_cache_screen.dart'; /// Embeddable view (no Scaffold) for the App Settings shell pane. /// -/// Renders appearance, translation, battery, map, Cyr2Lat, and debug cards. -/// Notifications and message-handling settings live in [MessageSettingsView]. +/// Renders appearance, translation, battery, map, and Cyr2Lat cards. +/// Notifications and message-handling settings live in [MessageSettingsView]; +/// app debug logging lives in the Debug category on the settings screen. class AppSettingsView extends StatelessWidget { const AppSettingsView({super.key}); @@ -43,8 +44,6 @@ class AppSettingsView extends StatelessWidget { _buildMapSettingsCard(context, settingsService), const SizedBox(height: 16), _buildCyr2LatCard(context, settingsService), - const SizedBox(height: 16), - _buildDebugCard(context, settingsService), ], ); }, @@ -558,6 +557,20 @@ class AppSettingsView extends StatelessWidget { ], ), ), + + const Divider(height: 1), + SwitchListTile( + secondary: const Icon(Icons.screen_lock_portrait_outlined), + title: Text(context.l10n.appSettings_keepScreenAwake), + subtitle: Text(context.l10n.appSettings_keepScreenAwakeSubtitle), + value: settingsService.settings.keepScreenAwake, + onChanged: (value) { + persistSetting( + context, + () => settingsService.setKeepScreenAwake(value), + ); + }, + ), ], ), ); @@ -1355,57 +1368,6 @@ class AppSettingsView extends StatelessWidget { ), ); } - - Widget _buildDebugCard( - BuildContext context, - AppSettingsService settingsService, - ) { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - context.l10n.appSettings_debugCard, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - SwitchListTile( - secondary: const Icon(Icons.bug_report_outlined), - title: Text(context.l10n.appSettings_appDebugLogging), - subtitle: Text(context.l10n.appSettings_appDebugLoggingSubtitle), - value: settingsService.settings.appDebugLogEnabled, - onChanged: (value) async { - try { - await settingsService.setAppDebugLogEnabled(value); - } catch (_) { - if (context.mounted) { - showDismissibleSnackBar( - context, - content: const Text( - 'Could not change debug logging. Please try again.', - ), - ); - } - return; - } - if (!context.mounted) return; - showDismissibleSnackBar( - context, - content: Text( - value - ? context.l10n.appSettings_appDebugLoggingEnabled - : context.l10n.appSettings_appDebugLoggingDisabled, - ), - duration: const Duration(seconds: 2), - ); - }, - ), - ], - ), - ); - } } /// Owns the [TextEditingController] for the manual model URL field so it diff --git a/lib/screens/settings/broker_editor_screen.dart b/lib/screens/settings/broker_editor_screen.dart index e152e29..2dc78ab 100644 --- a/lib/screens/settings/broker_editor_screen.dart +++ b/lib/screens/settings/broker_editor_screen.dart @@ -114,7 +114,7 @@ class _BrokerEditorScreenState extends State { /// Pre-enable validation, shared with the list's quick Enable via /// [BrokerConfig.enableError]. Only structural fields (URL, port) gate a save - /// — the firmware enforces the rest via its own defaults. + ///, the firmware enforces the rest via its own defaults. String? _validate() => BrokerConfig( slot: _baseline.slot, url: _url.text.trim(), @@ -122,7 +122,7 @@ class _BrokerEditorScreenState extends State { ).enableError; /// Greyed placeholder showing the firmware-resolved default for a field whose - /// raw value is blank (#173). The blank raw key stays the source of truth — + /// raw value is blank (#173). The blank raw key stays the source of truth, /// this is hint text only, never written back. Null when nothing to resolve. String? _resolvedHint(String resolved, {bool shortenKey = false}) { if (resolved.isEmpty) return null; @@ -142,7 +142,7 @@ class _BrokerEditorScreenState extends State { } Future _save() async { - // Validation only gates ENABLING — disabling a slot with blank/partial + // Validation only gates ENABLING, disabling a slot with blank/partial // fields is fine; it won't be active. if (_enabled) { final err = _validate(); @@ -169,7 +169,7 @@ class _BrokerEditorScreenState extends State { messenger.showSnackBar( SnackBar( content: Text( - 'Save failed at "${result.failedField ?? 'a field'}" — the slot ' + 'Save failed at "${result.failedField ?? 'a field'}", the slot ' 'was left disabled. Re-read and retry.', ), backgroundColor: errorColor, @@ -177,7 +177,7 @@ class _BrokerEditorScreenState extends State { ); return; } - // The writes were ACKed — but a firmware build can ACK without applying the + // The writes were ACKed, but a firmware build can ACK without applying the // enabled state (meshcore-firmware#179). Settle, re-read, and only claim // success if the device actually matches what we asked for. await Future.delayed(ObserverConfigService.applySettleDelay); @@ -195,14 +195,14 @@ class _BrokerEditorScreenState extends State { ); navigator.pop(true); case BrokerApplyOutcome.notApplied: - // Stay on the editor, re-seed to the device's true state, and warn — the + // Stay on the editor, re-seed to the device's true state, and warn, the // fields were written but the device didn't honor the enable/disable. if (fresh != null) _seedFrom(fresh); messenger.showSnackBar( SnackBar( content: Text( 'Saved, but the device did not ${intended ? 'enable' : 'disable'} ' - 'broker ${_baseline.slot} — possible firmware issue', + 'broker ${_baseline.slot}, possible firmware issue', ), backgroundColor: errorColor, ), @@ -211,7 +211,7 @@ class _BrokerEditorScreenState extends State { messenger.showSnackBar( SnackBar( content: Text( - 'Broker ${_baseline.slot} saved — could not confirm enabled ' + 'Broker ${_baseline.slot} saved. Could not confirm enabled ' 'state (device may be rebooting)', ), ), @@ -429,7 +429,7 @@ class _BrokerEditorScreenState extends State { obscureText: true, decoration: InputDecoration( labelText: _baseline.passwordSet - ? 'Password (set — leave blank to keep)' + ? 'Password (set, leave blank to keep)' : 'Password', border: const OutlineInputBorder(), ), diff --git a/lib/screens/settings/config_profile_import_screen.dart b/lib/screens/settings/config_profile_import_screen.dart new file mode 100644 index 0000000..9ede147 --- /dev/null +++ b/lib/screens/settings/config_profile_import_screen.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; + +import '../../models/config_catalog.dart'; +import '../../services/config_source_service.dart'; +import '../../services/observer_config_service.dart'; +import 'config_profile_preview_screen.dart'; + +/// Entry screen for importing a config profile (#407): browse the curated +/// catalog or point at any source URL, then hand a fetched profile to the +/// preview/apply screen (#406). +class ConfigProfileImportScreen extends StatefulWidget { + const ConfigProfileImportScreen({super.key, required this.service}); + + /// The connected observer's config service, threaded to the preview screen. + final ObserverConfigService service; + + @override + State createState() => + _ConfigProfileImportScreenState(); +} + +class _ConfigProfileImportScreenState extends State { + final _source = ConfigSourceService(); + final _urlController = TextEditingController(); + + List _entries = const []; + int _skipped = 0; + String? _catalogLabel; // which catalog is shown (default vs custom) + String? _error; + bool _loading = true; + bool _busy = false; // fetching a single profile before navigating + + @override + void initState() { + super.initState(); + _loadCatalog(kDefaultCatalogUrl, label: 'Offband catalog'); + } + + @override + void dispose() { + _urlController.dispose(); + _source.dispose(); + super.dispose(); + } + + Future _loadCatalog(String url, {required String label}) async { + setState(() { + _loading = true; + _error = null; + }); + try { + final catalog = await _source.fetchCatalog(url); + if (!mounted) return; + setState(() { + _entries = catalog.published; + _skipped = catalog.skippedEntries; + _catalogLabel = label; + }); + } catch (e) { + if (mounted) setState(() => _error = _msg(e)); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// Handle the URL box: a catalog URL replaces the list; a single-profile URL + /// goes straight to preview. + Future _openUrl() async { + final raw = _urlController.text.trim(); + if (raw.isEmpty) return; + setState(() => _error = null); + final ResolvedSource resolved; + try { + resolved = resolveSourceUrl(raw); + } catch (e) { + setState(() => _error = _msg(e)); + return; + } + if (resolved.kind == SourceKind.catalog) { + await _loadCatalog(resolved.url, label: 'Custom catalog'); + } else { + await _openProfile(resolved.url); + } + } + + Future _openProfile(String url) async { + setState(() { + _busy = true; + _error = null; + }); + try { + final profile = await _source.fetchProfile(url); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ConfigProfilePreviewScreen( + profile: profile, + service: widget.service, + ), + ), + ); + } catch (e) { + if (mounted) setState(() => _error = _msg(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + String _msg(Object e) { + if (e is ConfigSourceException) return e.message; + if (e is ConfigCatalogFormatException) return e.message; + return e.toString(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text('Import config profile'), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _urlController, + decoration: const InputDecoration( + labelText: 'Source URL (catalog or .yaml)', + helperText: 'A region\'s catalog, or a direct profile', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _openUrl(), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _busy ? null : _openUrl, + child: const Text('Load'), + ), + ], + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.error_outline, color: theme.colorScheme.error), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), + ), + const Divider(height: 8), + Expanded(child: _catalogBody(theme)), + if (_busy) const LinearProgressIndicator(), + ], + ), + ); + } + + Widget _catalogBody(ThemeData theme) { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_entries.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + _error == null + ? 'No profiles published in ${_catalogLabel ?? 'this catalog'} yet.' + : 'Could not load the catalog.', + textAlign: TextAlign.center, + ), + ), + ); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(_catalogLabel ?? 'Catalog', style: theme.textTheme.titleSmall), + if (_skipped > 0) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '$_skipped malformed ${_skipped == 1 ? 'entry' : 'entries'} skipped', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 8), + for (final e in _entries) + Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: const Icon(Icons.description_outlined), + title: Text(e.name), + subtitle: Text( + [ + if (e.region != null) 'Region: ${e.region}', + if (e.description != null) e.description!, + ].join('\n'), + ), + isThreeLine: e.description != null && e.region != null, + trailing: const Icon(Icons.chevron_right), + onTap: _busy ? null : () => _openProfile(e.url), + ), + ), + ], + ); + } +} diff --git a/lib/screens/settings/config_profile_preview_screen.dart b/lib/screens/settings/config_profile_preview_screen.dart new file mode 100644 index 0000000..a790fc7 --- /dev/null +++ b/lib/screens/settings/config_profile_preview_screen.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; + +import '../../helpers/config_profile_diff.dart'; +import '../../helpers/config_profile_writes.dart'; +import '../../models/config_profile.dart'; +import '../../services/observer_apply_service.dart'; +import '../../services/observer_config_service.dart'; + +/// Full sub-screen preview of a config-profile apply (#406): shows the current +/// -> new diff, then a two-tier confirm, a normal Apply for plain config, and a +/// separate red gate for credential/identity changes. +class ConfigProfilePreviewScreen extends StatefulWidget { + const ConfigProfilePreviewScreen({ + super.key, + required this.profile, + required this.service, + }); + + final ConfigProfile profile; + final ObserverConfigService service; + + @override + State createState() => + _ConfigProfilePreviewScreenState(); +} + +class _ConfigProfilePreviewScreenState + extends State { + late ProfileWrites _writes; + ProfileDiff? _diff; + String? _error; + bool _loading = true; + bool _applying = false; + + @override + void initState() { + super.initState(); + _writes = enumerateProfileWrites(widget.profile); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final currentFlat = {}; + for (final f in _writes.flats) { + currentFlat[f.key] = await widget.service.getFlat(f.key); + } + final currentBroker = >{}; + for (final b in _writes.brokers) { + final m = {}; + for (final key in [...b.fields.keys, ConfigKeys.brokerEnabled]) { + m[key] = await widget.service.getFlat(ConfigKeys.broker(b.slot, key)); + } + currentBroker[b.slot] = m; + } + final diff = buildProfileDiff( + _writes, + currentFlat: currentFlat, + currentBroker: currentBroker, + ); + if (mounted) setState(() => _diff = diff); + } catch (e) { + if (mounted) setState(() => _error = 'Could not read current config: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _apply(ProfileWrites writes, String what) async { + setState(() => _applying = true); + try { + final result = await ObserverApplyService(widget.service).apply(writes); + if (!mounted) return; + final msg = result.allOk + ? '$what applied.' + : '$what: ${result.failures.length} of ${result.items.length} failed ' + '(${result.failures.first.error ?? 'error'}).'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + backgroundColor: result.allOk + ? null + : Theme.of(context).colorScheme.error, + ), + ); + await _load(); // re-diff against true state (partial-save recovery) + } finally { + if (mounted) setState(() => _applying = false); + } + } + + @override + Widget build(BuildContext context) { + final diff = _diff; + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text('Review config profile'), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? _ErrorView(message: _error!, onRetry: _load) + : diff == null + ? const SizedBox.shrink() + : _buildBody(context, diff), + ); + } + + Widget _buildBody(BuildContext context, ProfileDiff diff) { + if (diff.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'No changes: the device already matches this profile.', + textAlign: TextAlign.center, + ), + ), + ); + } + + final theme = Theme.of(context); + final split = splitProfileWrites(_writes); + + return Column( + children: [ + if (widget.profile.name != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + widget.profile.name!, + style: theme.textTheme.titleMedium, + ), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final r in diff.safeRows) _DiffTile(row: r), + if (diff.hasDanger) ...[ + const SizedBox(height: 16), + _DangerHeader(count: diff.dangerRows.length), + for (final r in diff.dangerRows) + _DiffTile(row: r, danger: true), + ], + ], + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + FilledButton( + onPressed: _applying || diff.safeRows.isEmpty + ? null + : () => _apply(split.safe, 'Config changes'), + child: Text( + diff.safeRows.isEmpty + ? 'No plain-config changes' + : 'Apply ${diff.safeRows.length} config change' + '${diff.safeRows.length == 1 ? '' : 's'}', + ), + ), + if (diff.hasDanger) ...[ + const SizedBox(height: 8), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ), + onPressed: _applying + ? null + : () => _confirmDanger(context, split.danger, diff), + child: Text( + 'Danger: change ${diff.dangerRows.length} ' + 'credential/identity value' + '${diff.dangerRows.length == 1 ? '' : 's'}', + ), + ), + ], + ], + ), + ), + ), + ], + ); + } + + Future _confirmDanger( + BuildContext context, + ProfileWrites dangerWrites, + ProfileDiff diff, + ) async { + final theme = Theme.of(context); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Change credential / identity values?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'These bind this device to an identity or credentials. Only proceed ' + 'if you trust this profile:', + ), + const SizedBox(height: 12), + for (final r in diff.dangerRows) + Text('• ${r.label}', style: theme.textTheme.bodySmall), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Change them'), + ), + ], + ), + ); + // Guard the async gap: the screen may have been disposed while the dialog + // was open; _apply's setState would then throw (Gemini review). + if (ok == true && mounted) await _apply(dangerWrites, 'Credential changes'); + } +} + +class _DiffTile extends StatelessWidget { + const _DiffTile({required this.row, this.danger = false}); + final DiffRow row; + final bool danger; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isChange = row.kind == DiffKind.change; + final newText = row.secret ? '••••••' : row.newValue; + final oldText = row.secret + ? '—' + : (row.oldValue == null || row.oldValue!.isEmpty ? '—' : row.oldValue!); + final accent = danger + ? theme.colorScheme.error + : isChange + ? Colors.amber.shade800 + : theme.colorScheme.primary; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: Icon( + isChange ? Icons.edit_outlined : Icons.add_circle_outline, + color: accent, + ), + title: Text(row.label, style: theme.textTheme.bodyMedium), + subtitle: Text( + isChange ? '$oldText → $newText' : 'set to $newText', + style: theme.textTheme.bodySmall, + ), + ), + ); + } +} + +class _DangerHeader extends StatelessWidget { + const _DangerHeader({required this.count}); + final int count; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 8, top: 4), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: theme.colorScheme.error), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Credential / identity changes ($count), require the red button', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ], + ), + ); + } +} + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + final String message; + final VoidCallback onRetry; + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton(onPressed: onRetry, child: const Text('Retry')), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/mqtt_brokers_screen.dart b/lib/screens/settings/mqtt_brokers_screen.dart index 367b227..f948dfa 100644 --- a/lib/screens/settings/mqtt_brokers_screen.dart +++ b/lib/screens/settings/mqtt_brokers_screen.dart @@ -8,7 +8,7 @@ import 'broker_editor_screen.dart'; /// The MQTT broker pool as its own screen (#80), reached from the Observer /// settings pane. Tap a broker to edit it; long-press for Enable / Disable / -/// Edit / Clear; the `+` FAB adds the next empty slot — matching the FAB-add +/// Edit / Clear; the `+` FAB adds the next empty slot, matching the FAB-add /// pattern of channels_screen. Single-slot re-GET keeps it off the 84-frame /// pool dump after each action. class MqttBrokersScreen extends StatefulWidget { @@ -90,7 +90,7 @@ class _MqttBrokersScreenState extends State { ); return; } - // The SET was ACKed — but a firmware build can ACK without applying it + // The SET was ACKed, but a firmware build can ACK without applying it // (meshcore-firmware#179). Settle, re-read the slot, and report what the // device ACTUALLY did rather than trusting the ACK. await Future.delayed(ObserverConfigService.applySettleDelay); @@ -123,7 +123,7 @@ class _MqttBrokersScreenState extends State { SnackBar( content: Text( 'Device reported success but broker ${b.slot} is still ' - '${fresh!.enabled ? 'enabled' : 'disabled'} — possible firmware ' + '${fresh!.enabled ? 'enabled' : 'disabled'}, possible firmware ' 'issue', ), backgroundColor: errorColor, @@ -133,7 +133,7 @@ class _MqttBrokersScreenState extends State { messenger.showSnackBar( SnackBar( content: Text( - 'Broker ${b.slot} ${enable ? 'enable' : 'disable'} sent — could ' + 'Broker ${b.slot} ${enable ? 'enable' : 'disable'} sent. Could ' 'not confirm (device may be rebooting). Tap Refresh to re-check.', ), ), @@ -266,9 +266,7 @@ class _MqttBrokersScreenState extends State { child: const Icon(Icons.add), ), body: _brokers.isEmpty - ? const Center( - child: Text('No brokers configured — tap + to add one'), - ) + ? const Center(child: Text('No brokers configured. Tap + to add one')) : ListView( children: [ for (final b in _brokers) diff --git a/lib/screens/settings/observer_settings_view.dart b/lib/screens/settings/observer_settings_view.dart index aa12baa..64297a6 100644 --- a/lib/screens/settings/observer_settings_view.dart +++ b/lib/screens/settings/observer_settings_view.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../../connector/meshcore_connector.dart'; import '../../models/observer_config.dart'; import '../../services/observer_config_service.dart'; +import 'config_profile_import_screen.dart'; import 'mqtt_brokers_screen.dart'; /// Observer settings pane: WiFi / MQTT (region) / display flat settings with @@ -13,10 +14,10 @@ import 'mqtt_brokers_screen.dart'; /// Staged-save model: every control edits LOCAL state; nothing reaches the /// device until "Save", which sends only the changed keys (the firmware has no /// transaction, so each is an individual SET awaiting its ACK). Device NVS is -/// the source of truth — Refresh re-reads it. +/// the source of truth, Refresh re-reads it. /// /// TODO(l10n): strings are English-only pending ARB keys. -/// The broker pool opens as its own screen (MqttBrokersScreen, #80) — tap to +/// The broker pool opens as its own screen (MqttBrokersScreen, #80), tap to /// edit, long-press for Enable/Disable/Edit/Clear, + FAB to add. class ObserverSettingsView extends StatefulWidget { const ObserverSettingsView({super.key}); @@ -46,7 +47,7 @@ class _ObserverSettingsViewState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _refreshWhenIdle()); } - /// Defer the initial read until the device's channel/contact sync settles — + /// Defer the initial read until the device's channel/contact sync settles, /// observer config traffic must not compete with (and slow) that sync (#81). void _refreshWhenIdle() { final conn = context.read(); @@ -170,7 +171,7 @@ class _ObserverSettingsViewState extends State { : ok ? 'Observer settings saved' : (svc.lastError ?? - 'Some changes failed — re-read from the device'), + 'Some changes failed. Re-read from the device'), ), backgroundColor: ok ? null : Theme.of(context).colorScheme.error, ), @@ -200,7 +201,7 @@ class _ObserverSettingsViewState extends State { if (svc.stale) _banner( Icons.warning_amber, - 'Showing the last read — the device did not answer the latest refresh.', + 'Showing the last read, the device did not answer the latest refresh.', ), if (svc.lastError != null) _banner(Icons.error_outline, svc.lastError!), _actionRow(), @@ -282,7 +283,7 @@ class _ObserverSettingsViewState extends State { if (svc.brokersUnavailable) _banner( Icons.cloud_off, - 'Broker pool unavailable — the device did not finish sending it.', + 'Broker pool unavailable. The device did not finish sending it.', ) else ListTile( @@ -295,6 +296,21 @@ class _ObserverSettingsViewState extends State { MaterialPageRoute(builder: (_) => const MqttBrokersScreen()), ), ), + const Divider(height: 32), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.download_outlined), + title: const Text('Import config profile'), + subtitle: const Text( + 'Apply region / WiFi / brokers from a catalog or URL', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ConfigProfileImportScreen(service: svc), + ), + ), + ), ], ); } @@ -353,7 +369,7 @@ class _ObserverSettingsViewState extends State { String _brokerSummary(List brokers) { final populated = brokers.where((b) => b.isPopulated).toList(); - if (populated.isEmpty) return 'None configured — tap to add'; + if (populated.isEmpty) return 'None configured. Tap to add'; final enabled = populated.where((b) => b.enabled).length; return '${populated.length} configured · $enabled enabled'; } diff --git a/lib/screens/settings/settings_shell.dart b/lib/screens/settings/settings_shell.dart index 02ceb57..75779fe 100644 --- a/lib/screens/settings/settings_shell.dart +++ b/lib/screens/settings/settings_shell.dart @@ -21,7 +21,7 @@ class SettingsCategory { /// Wide layouts (>= [_wideBreakpoint] logical pixels) show a pinned category /// rail beside the selected detail pane. Narrow layouts show the category list /// and push the detail onto the navigator. Same categories, same code, every -/// platform — the layout is chosen from the available width, not the OS. +/// platform, the layout is chosen from the available width, not the OS. class SettingsShell extends StatefulWidget { final String title; final List categories; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e7a030f..81177da 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -12,8 +12,10 @@ import '../l10n/l10n.dart'; import '../models/offband_gps_status.dart'; import '../models/radio_settings.dart'; import '../services/app_debug_log_service.dart'; +import '../services/app_settings_service.dart'; import '../connector/observer_config_client.dart'; import '../helpers/snack_bar_builder.dart'; +import '../utils/build_info.dart'; import 'settings/settings_shell.dart'; import 'settings/app_settings_view.dart'; import 'settings/message_settings_view.dart'; @@ -21,6 +23,7 @@ import 'settings/observer_settings_view.dart'; import 'settings/blocked_view.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; +import 'serial_capture_screen.dart'; import 'topology_debug_screen.dart'; import 'companion_radio_stats_screen.dart'; import '../widgets/sync_progress_overlay.dart'; @@ -77,7 +80,7 @@ class _SettingsScreenState extends State { List _categories(BuildContext context) { final l10n = context.l10n; - // Rebuild ONLY when the observer gate flips — NOT on every connector update. + // Rebuild ONLY when the observer gate flips, NOT on every connector update. // A blanket context.watch here rebuilt the whole settings screen on every // sync frame, thrashing the UI thread and slowing the channel sync (#81). final showObserver = context.select( @@ -87,6 +90,11 @@ class _SettingsScreenState extends State { ), ); return [ + SettingsCategory( + icon: Icons.badge_outlined, + title: l10n.settings_nodeSettings, + builder: _identityPane, + ), SettingsCategory( icon: Icons.settings_input_antenna, title: l10n.settings_radioSettings, @@ -99,40 +107,30 @@ class _SettingsScreenState extends State { subtitle: l10n.radioStats_settingsSubtitle, builder: _radioStatsPane, ), - SettingsCategory( - icon: Icons.badge_outlined, - title: l10n.settings_nodeSettings, - builder: _identityPane, - ), SettingsCategory( icon: Icons.shield_outlined, title: l10n.settings_privacy, subtitle: l10n.settings_privacySubtitle, builder: _privacyPane, ), - SettingsCategory( - icon: Icons.block, - title: l10n.block_settingsTitle, - subtitle: l10n.block_settingsSubtitle, - builder: _blockedPane, - ), SettingsCategory( icon: Icons.contacts_outlined, title: l10n.contacts_title, subtitle: l10n.settings_contactSettingsSubtitle, builder: _contactsPane, ), + SettingsCategory( + icon: Icons.block, + title: l10n.block_settingsTitle, + subtitle: l10n.block_settingsSubtitle, + builder: _blockedPane, + ), SettingsCategory( icon: Icons.sms_outlined, title: l10n.settings_messageSettings, subtitle: l10n.settings_messageSettingsSubtitle, builder: _messageSettingsPane, ), - SettingsCategory( - icon: Icons.info_outline, - title: l10n.settings_deviceInfo, - builder: _devicePane, - ), if (showObserver) SettingsCategory( icon: Icons.cloud_outlined, @@ -146,6 +144,11 @@ class _SettingsScreenState extends State { subtitle: l10n.settings_appSettingsSubtitle, builder: _appPane, ), + SettingsCategory( + icon: Icons.info_outline, + title: l10n.settings_deviceInfo, + builder: _devicePane, + ), SettingsCategory( icon: Icons.bolt_outlined, title: l10n.settings_actions, @@ -417,12 +420,37 @@ class _SettingsScreenState extends State { _buildBatteryInfoRow(context, connector), if (connector.selfName != null) _buildInfoRow(l10n.settings_nodeName, connector.selfName!), - if (connector.selfPublicKey != null) + if (connector.selfPublicKey != null) ...[ _buildInfoRow( l10n.settings_infoPublicKey, pubKeyToHex(connector.selfPublicKey!), copyValue: pubKeyToHex(connector.selfPublicKey!), ), + // #295: stores are keyed by the first 10 hex of the connected + // radio's public key, so switching radios silently swaps which + // history you are looking at. Surface the key that is in effect. + ...() { + final hex = pubKeyToHex(connector.selfPublicKey!); + final scope = hex.length > 10 ? hex.substring(0, 10) : hex; + if (scope.isEmpty) return []; + return [ + _buildInfoRow( + l10n.settings_infoDataScope, + scope, + copyValue: scope, + ), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + l10n.settings_infoDataScopeSubtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ]; + }(), + ], _buildInfoRow( l10n.settings_infoContactsCount, '${connector.contacts.length}', @@ -431,6 +459,14 @@ class _SettingsScreenState extends State { l10n.settings_infoChannelCount, '${connector.channels.length}', ), + // #397: identity of the running binary, injected at build time and + // independent of the marketing version. Its own line so "which build + // am I on" is answerable at a glance; copyable for bug reports. + _buildInfoRow( + l10n.settings_infoBuild, + BuildInfo.stamp, + copyValue: BuildInfo.stamp, + ), ], ), ), @@ -571,6 +607,7 @@ class _SettingsScreenState extends State { Widget _buildDebugCard(BuildContext context) { final l10n = context.l10n; + final settingsService = context.watch(); return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -597,6 +634,38 @@ class _SettingsScreenState extends State { }, ), const Divider(height: 1), + SwitchListTile( + secondary: const Icon(Icons.bug_report_outlined), + title: Text(l10n.appSettings_appDebugLogging), + subtitle: Text(l10n.appSettings_appDebugLoggingSubtitle), + value: settingsService.settings.appDebugLogEnabled, + onChanged: (value) async { + try { + await settingsService.setAppDebugLogEnabled(value); + } catch (_) { + if (context.mounted) { + showDismissibleSnackBar( + context, + content: const Text( + 'Could not change debug logging. Please try again.', + ), + ); + } + return; + } + if (!context.mounted) return; + showDismissibleSnackBar( + context, + content: Text( + value + ? l10n.appSettings_appDebugLoggingEnabled + : l10n.appSettings_appDebugLoggingDisabled, + ), + duration: const Duration(seconds: 2), + ); + }, + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.code_outlined), title: Text(l10n.settings_appDebugLog), @@ -612,6 +681,24 @@ class _SettingsScreenState extends State { }, ), const Divider(height: 1), + // Serial capture (#430). English-only for now; localization follow-up. + ListTile( + leading: const Icon(Icons.download_outlined), + title: const Text('Serial capture'), + subtitle: const Text( + "Capture the radio's serial log and share it as a file", + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SerialCaptureScreen(), + ), + ); + }, + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.hub_outlined), title: const Text('Mesh topology'), @@ -633,7 +720,7 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.sync_problem_outlined), title: const Text('Sync queued messages now'), subtitle: const Text( - 'Force-pull messages the radio is holding — diagnostic for #51 ' + 'Force-pull messages the radio is holding, diagnostic for #51 ' '(radio reports a count but the app shows none). Logs the flow ' 'for diagnostics.', ), @@ -2203,7 +2290,7 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { contentPadding: EdgeInsets.zero, ), ], - // Only this radio's own FEM probe decides whether this appears — never + // Only this radio's own FEM probe decides whether this appears, never // model or version (#304). Deliberately not mirrored into local state: // firmware returns post-apply hardware truth, so the switch renders // what the radio reports rather than what we asked for. @@ -2249,7 +2336,7 @@ class _RadioSettingsSnapshot { required this.txPowerDbm, }); - /// Frequency in integer Hz — avoids floating-point comparison issues. + /// Frequency in integer Hz, avoids floating-point comparison issues. int get frequencyHz => (frequencyMHz * 1000).round(); /// Convert from the connector's raw-int snapshot to UI-enum snapshot. diff --git a/lib/screens/topology_debug_screen.dart b/lib/screens/topology_debug_screen.dart index cc3a1dd..3b2f569 100644 --- a/lib/screens/topology_debug_screen.dart +++ b/lib/screens/topology_debug_screen.dart @@ -13,7 +13,7 @@ import '../widgets/adaptive_app_bar_title.dart'; /// epic #186). It answers one question on real hardware: is /// [MeshTopologyService] actually ingesting? Node/edge counts climb as routed /// packets arrive; stuck at zero while traffic flows means the RX ingestion -/// wiring is dead. Strings are hardcoded EN on purpose — this is a dev-facing +/// wiring is dead. Strings are hardcoded EN on purpose, this is a dev-facing /// diagnostic, deliberately not part of the localized UX surface. class TopologyDebugScreen extends StatelessWidget { const TopologyDebugScreen({super.key}); @@ -85,7 +85,7 @@ class TopologyDebugScreen extends StatelessWidget { List contacts, ) { final selfLabel = self == null - ? 'not set yet — waiting for first packet' + ? 'not set yet, waiting for first packet' : (_nameOf(self, contacts) == null ? _hex(self) : '${_nameOf(self, contacts)} (${_hex(self)})'); @@ -229,7 +229,7 @@ class TopologyDebugScreen extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'Listening — this fills as routed packets arrive from the mesh. ' + 'Listening. This fills as routed packets arrive from the mesh. ' 'Leave it connected a few minutes.', textAlign: TextAlign.center, style: TextStyle(fontSize: 12, color: Colors.grey[500]), @@ -282,7 +282,7 @@ class TopologyDebugScreen extends StatelessWidget { final self = topo.self; final buf = StringBuffer() ..writeln( - 'Mesh topology — ${topo.nodeCount} nodes, ${topo.edgeCount} edges', + 'Mesh topology: ${topo.nodeCount} nodes, ${topo.edgeCount} edges', ) ..writeln('self: ${self == null ? "(unset)" : _hex(self)}') ..writeln('freshness: ${topo.freshnessSeconds ~/ 86400}d') diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index 25992de..d426546 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -329,7 +329,7 @@ class _UsbScreenState extends State { ..addAll(ports); }); } catch (_) { - // Silent — hot-plug failures are non-critical. + // Silent, hot-plug failures are non-critical. } } diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index d0ea235..4352f41 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -104,6 +104,10 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(enableMessageTracing: value)); } + Future setKeepScreenAwake(bool value) async { + await updateSettings(_settings.copyWith(keepScreenAwake: value)); + } + Future setClockFormat(ClockFormat value) async { await updateSettings(_settings.copyWith(clockFormat: value)); } diff --git a/lib/services/block_service.dart b/lib/services/block_service.dart index 0b27747..9c9b081 100644 --- a/lib/services/block_service.dart +++ b/lib/services/block_service.dart @@ -35,14 +35,14 @@ class BlockService extends ChangeNotifier { } /// 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. + /// 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(); final normalized = (key == null || key.isEmpty) ? null : key; final changed = normalized != _selfKeyHex; _selfKeyHex = normalized; - // Heal even when the key is unchanged — a self-block can appear after the + // 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); @@ -93,7 +93,7 @@ class BlockService extends ChangeNotifier { /// 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. + /// Never removes, the union only adds. Future importKeys(Iterable keysHex) async { var changed = false; for (final k in keysHex) { @@ -134,7 +134,7 @@ class BlockService extends ChangeNotifier { 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). + // self-block, drop the name block and stop (#250). if (isSelf(key)) { await _store.saveNames(_blockedNames); notifyListeners(); diff --git a/lib/services/config_source_service.dart b/lib/services/config_source_service.dart new file mode 100644 index 0000000..0ea976d --- /dev/null +++ b/lib/services/config_source_service.dart @@ -0,0 +1,113 @@ +import 'package:http/http.dart' as http; + +import '../helpers/config_profile_parser.dart'; +import '../models/config_catalog.dart'; +import '../models/config_profile.dart'; + +/// Fetches config profiles from a remote source (#404): the curated Offband +/// catalog, or any region's self-hosted catalog / single profile. +/// +/// Device-agnostic, observer (#139), repeater (#137), and companion (#138) +/// share this. Only the apply step differs per device. + +/// Default curated catalog (repo: OffbandMesh/config-profiles). +const String kDefaultCatalogUrl = + 'https://raw.githubusercontent.com/OffbandMesh/config-profiles/main/profiles.json'; + +/// What a user-entered URL points at, decided by its tail. +enum SourceKind { catalog, profile } + +class ResolvedSource { + const ResolvedSource(this.kind, this.url); + final SourceKind kind; + + /// The URL to actually fetch (may differ from the input, a directory URL + /// resolves to `/profiles.json`). + final String url; +} + +/// Thrown on any fetch/decode failure; message is user-facing. +class ConfigSourceException implements Exception { + const ConfigSourceException(this.message); + final String message; + @override + String toString() => 'ConfigSourceException: $message'; +} + +/// Resolve a user-entered URL by its tail (no reliance on HTTP directory +/// listing): +/// - ends `.json` -> a catalog manifest +/// - ends `.yaml` / `.yml` -> a single profile +/// - otherwise (trailing `/` or no filename) -> `profiles.json` +ResolvedSource resolveSourceUrl(String input) { + final trimmed = input.trim(); + final uri = Uri.tryParse(trimmed); + if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) { + throw const ConfigSourceException( + 'Enter a full http(s) URL to a catalog or a .yaml profile', + ); + } + + final lower = trimmed.toLowerCase(); + if (lower.endsWith('.json')) { + return ResolvedSource(SourceKind.catalog, trimmed); + } + if (lower.endsWith('.yaml') || lower.endsWith('.yml')) { + return ResolvedSource(SourceKind.profile, trimmed); + } + // Directory / bare host: append the conventional manifest filename. + final base = trimmed.endsWith('/') ? trimmed : '$trimmed/'; + return ResolvedSource(SourceKind.catalog, '${base}profiles.json'); +} + +class ConfigSourceService { + ConfigSourceService({http.Client? client}) + : _client = client ?? http.Client(); + + final http.Client _client; + + /// Fetch + parse a catalog manifest. + Future fetchCatalog(String url) async { + return parseCatalog(await _get(url)); + } + + /// Fetch + parse a single profile YAML. + Future fetchProfile(String url) async { + return parseConfigProfile(await _get(url)); + } + + Future _get(String url) async { + final http.Response resp; + try { + // Cache-bust for federated catalogs on normal servers/CDNs: a unique + // query param + no-cache headers get them to serve fresh content. + // KNOWN LIMITATION (#452): raw.githubusercontent, the DEFAULT catalog + // host, ignores BOTH (verified: X-Cache HIT on a unique-query request) + // and serves its cached copy for up to max-age=300 (~5 min). So the + // default catalog can lag up to 5 min after an edit; this does not defeat + // that. Kept because region-hosted catalogs elsewhere do honor it. + final base = Uri.parse(url); + final busted = base.replace( + queryParameters: { + ...base.queryParameters, + '_': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + resp = await _client.get( + busted, + headers: const {'Cache-Control': 'no-cache', 'Pragma': 'no-cache'}, + ); + } catch (e) { + throw ConfigSourceException('Could not reach $url: $e'); + } + if (resp.statusCode != 200) { + throw ConfigSourceException('$url returned HTTP ${resp.statusCode}'); + } + if (resp.body.isEmpty) { + throw ConfigSourceException('$url returned an empty response'); + } + return resp.body; + } + + void dispose() => _client.close(); +} diff --git a/lib/services/linux_ble_pairing_service.dart b/lib/services/linux_ble_pairing_service.dart index 8ed52f6..e95d67f 100644 --- a/lib/services/linux_ble_pairing_service.dart +++ b/lib/services/linux_ble_pairing_service.dart @@ -154,7 +154,7 @@ class LinuxBlePairingService { return false; } - // Timeout path — pairing neither succeeded nor failed. + // Timeout path, pairing neither succeeded nor failed. onLog?.call('Pairing did not complete before timeout'); if (!result.pinSent && !proactivePinRetryUsed && diff --git a/lib/services/mesh_topology_service.dart b/lib/services/mesh_topology_service.dart index 1a42e23..00d672f 100644 --- a/lib/services/mesh_topology_service.dart +++ b/lib/services/mesh_topology_service.dart @@ -3,7 +3,7 @@ import 'dart:collection'; import 'package:flutter/foundation.dart'; /// A node (repeater/relay) in the passively-observed mesh topology, keyed by its -/// width-byte path-hash prefix. (#186 Phase B — see docs/trace-topology-spec.md) +/// width-byte path-hash prefix. (#186 Phase B, see docs/trace-topology-spec.md) class TopoNode { final Uint8List prefix; int lastSeen; // unix seconds @@ -29,7 +29,7 @@ class TopoEdge { int lastSeen; int count; - /// Set when a trace routed over this link timed out — inference deprioritises + /// Set when a trace routed over this link timed out, inference deprioritises /// it so the graph self-heals as topology drifts. int? lastFailed; @@ -43,13 +43,13 @@ class TopoEdge { } /// Accumulates the routing paths of received packets into a topology graph and -/// infers trace routes from it — the thing the firmware can't do (it only walks +/// infers trace routes from it, the thing the firmware can't do (it only walks /// a caller-supplied route) and the reference apps push onto the user (manual /// build). Deliberately SEPARATE from PathHistoryService (Ben, 2026-07-05): /// PathHistory weights message routing; this is the trace oracle. /// /// Storage is bounded by design: the graph is deduplicated (a node/edge stored -/// once, refreshed), pruned by freshness, and hard-capped by node count — so it +/// once, refreshed), pruned by freshness, and hard-capped by node count, so it /// scales with mesh size, not hop depth or packet volume. (#186) class MeshTopologyService extends ChangeNotifier { static const int defaultFreshnessSeconds = 7 * 24 * 3600; // 7 days @@ -98,7 +98,7 @@ class MeshTopologyService extends ChangeNotifier { double? neighbourSnrOf(List prefix) => _nodes[_hex(prefix)]?.neighbourSnr; - /// Our own node prefix — the BFS origin for inference and the anchor for the + /// Our own node prefix, the BFS origin for inference and the anchor for the /// `us ~ direct-neighbour` edges. void setSelf(List prefix, int width) { final w = width < 1 ? 1 : width; @@ -117,7 +117,7 @@ class MeshTopologyService extends ChangeNotifier { /// `origin → h1 → … → hk → us`, so `hk` = `path`'s last hop = our direct /// neighbour). [lastHopSnr] is the SNR of our reception (attributed to `hk`). /// Returns true on a notify-worthy STRUCTURAL change (new node/edge). Cheap: - /// O(path length ≤ 64) map writes — the path was already parsed upstream. + /// O(path length ≤ 64) map writes, the path was already parsed upstream. bool observePath( List path, { required int width, @@ -193,7 +193,7 @@ class MeshTopologyService extends ChangeNotifier { } /// The ordered intermediate hops from us to [target], EXCLUDING both us and - /// the target — feed straight into + /// the target, feed straight into /// `PathHelper.buildTraceRoundTrip(routingPath: result, targetPrefix: target)`. /// /// - `[]` → target is a direct neighbour (a one-hop ping is correct). @@ -287,7 +287,7 @@ class MeshTopologyService extends ChangeNotifier { } } - /// Drops nodes/edges not heard within the freshness window — for persistence + /// Drops nodes/edges not heard within the freshness window, for persistence /// bounds (Phase D). Inference already ignores stale links at query time. void pruneStale([int? nowTs]) { final now = nowTs ?? _now(); diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 733dfc5..8d048ed 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -21,7 +21,7 @@ class _AckHistoryEntry { }); } -/// (messageId, timestamp, attemptIndex, pathSelection) — stored per ACK hash +/// (messageId, timestamp, attemptIndex, pathSelection), stored per ACK hash /// for O(1) lookup. [pathSelection] snapshots the route used for this /// specific attempt so that a late PUSH_CODE_SEND_CONFIRMED credits the /// correct path even when the message has since been retried on a different @@ -34,7 +34,7 @@ typedef AckHashMapping = ({ }); class RetryServiceConfig { - final void Function(Contact, String, int, int) sendMessage; + final Future Function(Contact, String, int, int) sendMessage; final void Function(String, Message) addMessage; final void Function(Message) updateMessage; final Function(Contact)? clearContactPath; @@ -74,10 +74,14 @@ class RetryServiceConfig { class MessageRetryService extends ChangeNotifier { static const int maxAckHistorySize = 100; + // How long to wait for the radio's RESP_CODE_SENT before treating an + // apparently-sent message as failed so it can't wedge the queue (#395). + static const int _sentConfirmationTimeoutMs = 8000; int _maxRetries = 5; int get maxRetries => _maxRetries; final Map _timeoutTimers = {}; + final Map _sentConfirmationTimers = {}; final Map _pendingMessages = {}; final Map _pendingContacts = {}; final Map> _attemptPathHistory = {}; @@ -170,7 +174,7 @@ class MessageRetryService extends ChangeNotifier { _config?.addMessage(contact.publicKeyHex, message); - // Queue per contact — only one message in-flight at a time to avoid + // Queue per contact, only one message in-flight at a time to avoid // overflowing the firmware's 8-entry expected_ack_table. final contactKey = contact.publicKeyHex; _sendQueue[contactKey] ??= []; @@ -263,7 +267,7 @@ class MessageRetryService extends ChangeNotifier { ); _pendingMessages[messageId] = updatedMessage; } else if (message.retryCount > 0) { - // No schedule entry for this retry — re-resolve path from current contact + // No schedule entry for this retry, re-resolve path from current contact // state so user's path override changes are picked up between retries. final resolved = resolvePathSelection(contact); final updatedMessage = message.copyWith( @@ -300,7 +304,7 @@ class MessageRetryService extends ChangeNotifier { } } - // Re-validate after async gap — a timer or ACK could have resolved/retried + // Re-validate after async gap, a timer or ACK could have resolved/retried // this message while we were awaiting the path callback. final currentMessage = _pendingMessages[messageId]; if (currentMessage == null || _resolvedMessages.contains(messageId)) { @@ -348,7 +352,43 @@ class MessageRetryService extends ChangeNotifier { ); } - config.sendMessage(contact, message.text, attempt, timestampSeconds); + // Await so a send failure (e.g. an oversized BLE write) propagates to the + // catchError in _sendNextForContact, which marks the message failed and + // drains the per-contact queue instead of wedging it (#395). + await config.sendMessage(contact, message.text, attempt, timestampSeconds); + + // The write reached the transport; now guard the window until the radio + // returns RESP_CODE_SENT. If it never does (frame dropped on the wire), this + // message would otherwise stay "active" forever and block the queue (#395). + _startSentConfirmationTimer(messageId); + } + + void _startSentConfirmationTimer(String messageId) { + _sentConfirmationTimers[messageId]?.cancel(); + _sentConfirmationTimers[messageId] = Timer( + const Duration(milliseconds: _sentConfirmationTimeoutMs), + () => _handleSentConfirmationTimeout(messageId), + ); + } + + void _handleSentConfirmationTimeout(String messageId) { + _sentConfirmationTimers.remove(messageId); + final message = _pendingMessages[messageId]; + final contact = _pendingContacts[messageId]; + if (message == null || contact == null) return; + // RESP_CODE_SENT would have moved the status to sent (and cancelled this + // timer); still-pending here means the radio never confirmed the send. + if (message.status != MessageStatus.pending) return; + + final failed = message.copyWith(status: MessageStatus.failed); + _pendingMessages[messageId] = failed; + _config?.updateMessage(failed); + _config?.debugLogService?.warn( + 'No RESP_CODE_SENT within ${_sentConfirmationTimeoutMs}ms, marking send failed', + tag: 'AckHash', + ); + notifyListeners(); + _onMessageResolved(messageId, contact.publicKeyHex); } bool updateMessageFromSent(int ackHash, int timeoutMs) { @@ -424,6 +464,8 @@ class MessageRetryService extends ChangeNotifier { _pendingMessages[messageId] = updatedMessage; config.updateMessage(updatedMessage); + // Radio confirmed the send, the delivery-ack timer takes over from here. + _sentConfirmationTimers.remove(messageId)?.cancel(); _startTimeoutTimer(messageId, actualTimeout); return true; } @@ -459,6 +501,7 @@ class MessageRetryService extends ChangeNotifier { _pendingContacts.remove(messageId); _attemptPathHistory.remove(messageId); _timeoutTimers.remove(messageId); + _sentConfirmationTimers.remove(messageId)?.cancel(); _resolvedMessages.remove(messageId); } @@ -747,6 +790,10 @@ class MessageRetryService extends ChangeNotifier { timer.cancel(); } _timeoutTimers.clear(); + for (var timer in _sentConfirmationTimers.values) { + timer.cancel(); + } + _sentConfirmationTimers.clear(); _pendingMessages.clear(); _pendingContacts.clear(); _attemptPathHistory.clear(); diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index f08d88a..67d5a2c 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -5,6 +5,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter/foundation.dart'; import '../helpers/gif_helper.dart'; +import '../helpers/pocketmesh_reaction.dart'; import '../helpers/reaction_helper.dart'; import '../l10n/app_localizations.dart'; import '../utils/platform_info.dart'; @@ -155,6 +156,15 @@ class NotificationService { if (reaction != null) { return 'Reacted ${reaction.emoji}'; } + // The PocketMesh / MeshCore One format, or the tray still shows the raw + // emoji-plus-hash payload (#380). The two forms are mutually exclusive by + // shape, so trying both is unambiguous. + final foreign = + PocketMeshReaction.parse(trimmed, isDm: true) ?? + PocketMeshReaction.parse(trimmed, isDm: false); + if (foreign != null) { + return 'Reacted ${foreign.emoji}'; + } // resolveGifUrl, not parseGif: the tray must summarise exactly the set the // chat renders inline, or an allowlisted Tenor GIF shows as a raw URL. (#283) if (GifHelper.resolveGifUrl(trimmed) != null) { diff --git a/lib/services/observer_apply_service.dart b/lib/services/observer_apply_service.dart new file mode 100644 index 0000000..fbf3783 --- /dev/null +++ b/lib/services/observer_apply_service.dart @@ -0,0 +1,65 @@ +import '../helpers/config_profile_writes.dart'; +import 'observer_config_service.dart'; + +/// Result of applying one write (or one broker slot). [label] is safe to show, +/// it names the key/slot, never a value, so secrets never leak into UI or logs. +class ApplyItemResult { + const ApplyItemResult(this.label, this.ok, [this.error]); + final String label; + final bool ok; + final String? error; +} + +class ObserverApplyResult { + const ObserverApplyResult(this.items); + final List items; + + bool get allOk => items.every((i) => i.ok); + List get failures => items.where((i) => !i.ok).toList(); +} + +/// Applies a [ProfileWrites] plan to the connected observer (#405). +/// +/// Globals go through [ObserverConfigService.setFlat]; each broker through +/// [ObserverConfigService.saveBroker], which already disables-first, writes +/// field-at-a-time, writes `enabled` LAST, and stops at the first failure so a +/// partial save never leaves a slot live-but-corrupt (#80). We read the slot's +/// current `enabled` first so a profile that omits it preserves device state. +class ObserverApplyService { + ObserverApplyService(this._svc); + final ObserverConfigService _svc; + + Future apply(ProfileWrites writes) async { + final items = []; + + for (final f in writes.flats) { + final ok = await _svc.setFlat(f.key, f.value); + items.add(ApplyItemResult(f.key, ok, ok ? null : _svc.lastError)); + } + + for (final b in writes.brokers) { + // Profiles never set broker enabled (#456), preserve the device's current + // state: read wasLive for the safe-save dance and re-enable to the same. + final current = await _svc.getBroker(b.slot); + final wasLive = current?.enabled ?? false; + + final res = await _svc.saveBroker( + b.slot, + fields: b.fields, + enable: wasLive, + wasLive: wasLive, + ); + items.add( + res.ok + ? ApplyItemResult('broker ${b.slot}', true) + : ApplyItemResult( + 'broker ${b.slot}', + false, + 'field "${res.failedField}" failed, slot left disabled', + ), + ); + } + + return ObserverApplyResult(items); + } +} diff --git a/lib/services/observer_config_service.dart b/lib/services/observer_config_service.dart index 35ed00f..5b7af48 100644 --- a/lib/services/observer_config_service.dart +++ b/lib/services/observer_config_service.dart @@ -2,7 +2,7 @@ // [ObserverConfigClient], sends them over the active transport, awaits and // parses the response, and holds the parsed [ObserverConfig]. // -// Device NVS is the source of truth — this service caches the last read but +// Device NVS is the source of truth, this service caches the last read but // never persists locally. The firmware protocol has no request id, so // responses are correlated by ORDER: keep ONE request in flight at a time // (the settings UI sends staged changes sequentially, awaiting each). @@ -32,22 +32,22 @@ class ObserverConfigService extends ChangeNotifier { ObserverConfig? _config; ObserverConfig? get config => _config; - /// True after a refresh failed to complete — the UI shows stale data warning + /// True after a refresh failed to complete, the UI shows stale data warning /// rather than silently presenting an out-of-date snapshot. bool _stale = false; bool get stale => _stale; - /// Last error surfaced (SAFELANE §6 — every failure is visible). The UI shows + /// Last error surfaced (SAFELANE §6, every failure is visible). The UI shows /// it; cleared on the next success. String? _lastError; String? get lastError => _lastError; - /// True when the broker-pool dump failed but the flat settings read OK — the + /// True when the broker-pool dump failed but the flat settings read OK, the /// UI shows the broker section as unavailable instead of blanking the pane. bool _brokersUnavailable = false; bool get brokersUnavailable => _brokersUnavailable; - /// Whether the connected device supports the config command — the version + /// Whether the connected device supports the config command, the version /// gate AND the capability bit, read live from the connector's device-info /// parse. The settings UI watches the connector, so the Observer category /// appears/hides as this flips on connect/disconnect. @@ -140,7 +140,7 @@ class ObserverConfigService extends ChangeNotifier { /// Save a broker [slot] field-at-a-time per the firmware contract: a live slot /// is disabled first, each changed [fields] entry is written, then `enabled` /// is written LAST (when [enable]). Stops at the first ERR/timeout and reports - /// the failed field — a partial save therefore leaves the slot disabled, never + /// the failed field, a partial save therefore leaves the slot disabled, never /// live-corrupt (#80). The caller re-GETs the slot to show true state + offer /// retry/clear. Future saveBroker( @@ -167,10 +167,10 @@ class ObserverConfigService extends ChangeNotifier { return const BrokerSaveResult.ok(); } - /// Definitively wipe a broker slot — `mqtt.broker..clear` (#80). + /// Definitively wipe a broker slot, `mqtt.broker..clear` (#80). Future clearBroker(int slot) => setBrokerField(slot, 'clear', '1'); - /// Re-read a single broker [slot] field-by-field — the post-save / recovery + /// Re-read a single broker [slot] field-by-field, the post-save / recovery /// re-GET. Cheaper than the whole pool: 14 scalar GETs for one slot, not the /// 84-frame OCFG_BROKERS dump (#80). Returns null if any field GET fails. Future getBroker(int slot) async { @@ -194,7 +194,7 @@ class ObserverConfigService extends ChangeNotifier { for (final f in fields) { final v = await getFlat('mqtt.broker.$slot.$f'); if (v == null) { - // password is write-only — a missing read is its presence, not a slot + // password is write-only, a missing read is its presence, not a slot // failure; any other missing field means the re-GET is incomplete. if (f == 'password') continue; return null; @@ -210,7 +210,7 @@ class ObserverConfigService extends ChangeNotifier { /// Read the broker pool (paginated START → KV → END). Null on a stall or /// error. The completion deadline is an INACTIVITY watchdog re-armed on every - /// frame — a large pool streams field-by-field and outlasts any single fixed + /// frame, a large pool streams field-by-field and outlasts any single fixed /// deadline, so only a genuine stall (no frame for [timeout]) fails (#103). /// The fetch lifecycle is logged to the shared file log (#108) so a tester's /// Share-logs capture shows in plain text where a pool load succeeds or fails. @@ -239,7 +239,7 @@ class ObserverConfigService extends ChangeNotifier { final r = ObserverConfigClient.parse(f); if (r is ConfigBrokersStart) { expected = r.count; - _logBroker('pool GET: header — ${r.count} brokers'); + _logBroker('pool GET: header, ${r.count} brokers'); } frames++; final list = decoder.add(r); @@ -260,7 +260,7 @@ class ObserverConfigService extends ChangeNotifier { _logBroker( 'pool GET: FAILED after ' '${DateTime.now().difference(started).inMilliseconds}ms ' - '($frames frames, header said ${expected ?? "?"}) — $e', + '($frames frames, header said ${expected ?? "?"}), $e', ); _setError('broker list failed: $e'); return null; @@ -285,7 +285,7 @@ class ObserverConfigService extends ChangeNotifier { final alwaysOn = await getFlat('display.always_on'); final rotation = await getFlat('display.rotation'); // Skip the heavy broker dump (84 frames) when only flat settings changed - // (e.g. after a flat Save) — it must not re-flood BLE for a toggle (#81). + // (e.g. after a flat Save), it must not re-flood BLE for a toggle (#81). final brokers = includeBrokers ? await getBrokers() : null; final keptBrokers = _config?.brokers ?? const []; diff --git a/lib/services/queue_sync_file_log_io.dart b/lib/services/queue_sync_file_log_io.dart index 4dd2d8d..205e753 100644 --- a/lib/services/queue_sync_file_log_io.dart +++ b/lib/services/queue_sync_file_log_io.dart @@ -11,7 +11,7 @@ import 'package:path_provider/path_provider.dart'; // The IOSink is intentionally held open for the app's lifetime (append-only, // low-frequency) rather than open/close per line; the OS closes the handle on // process exit. `_sinkFuture ??= _openSink()` assigns synchronously before the -// await, so concurrent callers share one sink — no double-open on the event +// await, so concurrent callers share one sink, no double-open on the event // loop. Future? _sinkFuture; diff --git a/lib/services/storage_health_service.dart b/lib/services/storage_health_service.dart new file mode 100644 index 0000000..1a36ffe --- /dev/null +++ b/lib/services/storage_health_service.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; + +/// Tracks whether the app's storage layer (drift/SQLite) opened successfully at +/// startup (#385). +/// +/// When the database can't open, e.g. the native `sqlite3` library fails to +/// load, every read and write silently fails and the app looks wiped. This +/// holds that state so the UI can show a loud, persistent warning instead of an +/// empty, normal-looking screen (SAFELANE §6: no silent failures). +class StorageHealthService extends ChangeNotifier { + bool _available = true; + String? _error; + + /// True until a startup probe proves storage cannot be read/written. + bool get available => _available; + + /// The underlying error, for the log and diagnostics. Null when healthy. + String? get error => _error; + + /// Marks storage as unavailable and records [error]. A one-way latch: once + /// unavailable it stays that way for the session (recovery is a restart), so + /// a later call is a no-op and does not re-notify. + void markUnavailable(Object error) { + if (!_available) return; + _available = false; + _error = error.toString(); + notifyListeners(); + } +} diff --git a/lib/services/timeout_prediction_service.dart b/lib/services/timeout_prediction_service.dart index d92ca64..679c777 100644 --- a/lib/services/timeout_prediction_service.dart +++ b/lib/services/timeout_prediction_service.dart @@ -24,7 +24,7 @@ class TimeoutPredictionService extends ChangeNotifier { static const int maxObservations = 100; static const int _retrainInterval = 5; // 1.5x multiplier on raw prediction to account for variance in delivery - // times — tight enough to improve on worst-case physics, loose enough + // times, tight enough to improve on worst-case physics, loose enough // to avoid premature timeouts from model noise. static const double _safetyMargin = 1.5; static const int _minContactObservations = 10; diff --git a/lib/services/usb_serial_frame_codec.dart b/lib/services/usb_serial_frame_codec.dart index 59e4d4b..0b7b39a 100644 --- a/lib/services/usb_serial_frame_codec.dart +++ b/lib/services/usb_serial_frame_codec.dart @@ -3,7 +3,11 @@ import 'dart:typed_data'; const int usbSerialTxFrameStart = 0x3c; const int usbSerialRxFrameStart = 0x3e; const int usbSerialHeaderLength = 3; -const int usbSerialMaxPayloadLength = 172; +// Max companion frame the decoder will accept, matching the firmware's +// MAX_FRAME_SIZE (BaseSerialInterface.h = 176, "+4 for transport codes"). This +// was 172, which silently rejected full-size frames, exposed by caplog chunks +// (176 B), the first feature to use the full frame. Normal traffic stays <=172. +const int usbSerialMaxPayloadLength = 176; Uint8List wrapUsbSerialTxFrame(Uint8List payload) { if (payload.length > usbSerialMaxPayloadLength) { diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart index 05655bb..4405919 100644 --- a/lib/services/usb_serial_service_native.dart +++ b/lib/services/usb_serial_service_native.dart @@ -104,7 +104,7 @@ class UsbSerialService { } /// Best-effort USB vendor ID for [portName] from the flserial port - /// enumeration, or null if it can't be determined. Enumeration only — it does + /// enumeration, or null if it can't be determined. Enumeration only, it does /// NOT open the port, so it can't trigger the reset the gate exists to avoid. /// Used to gate the DTR open pulse. (#244/#245) int? _lookupPortVid(String portName) { @@ -194,7 +194,7 @@ class UsbSerialService { bool opened = false; for (final candidate in candidates) { - // Always create a fresh FlSerial instance — a cached instance retains + // Always create a fresh FlSerial instance, a cached instance retains // a stale flh handle from prior failed opens, which causes the native // fl_open() to mis-route the request and report port-not-exist even // when the device node is physically present. @@ -206,7 +206,7 @@ class UsbSerialService { final msg = 'Failed to open USB port $candidate (status: $openStatus)'; _debugLogService?.error(msg, tag: 'USB Serial'); - // Not a FlSerialException — treat as terminal failure + // Not a FlSerialException, treat as terminal failure _status = UsbSerialStatus.disconnected; throw StateError(msg); } @@ -224,8 +224,8 @@ class UsbSerialService { } else { // ESP32 USB-Serial/JTAG (VID 303A) maps DTR/RTS onto EN/BOOT, so a // DTR-low window resets the chip into ROM download mode mid-open and - // wedges it. Any non-nRF52 device — including an unknown/unparseable - // VID — opens with DTR asserted and no pulse (a stale DTR is safer + // wedges it. Any non-nRF52 device, including an unknown/unparseable + // VID, opens with DTR asserted and no pulse (a stale DTR is safer // than wedging the device). (#244/#245) serial.setDTR(true); } @@ -370,7 +370,7 @@ class UsbSerialService { // state for all ports. The global reset is done in connect() instead, // before the next open, which is the safer place to do it. - // Now it is safe to cancel the Dart subscription — the native thread has + // Now it is safe to cancel the Dart subscription, the native thread has // already seen the port close and will not fire any more callbacks. await _dataSubscription?.cancel(); _dataSubscription = null; @@ -404,7 +404,7 @@ class UsbSerialService { void dispose() { // Synchronously close the native port so the SerialThread exits before // the Dart isolate is torn down (e.g. on hot restart). The async - // disconnect() path via unawaited() offers no ordering guarantee — the + // disconnect() path via unawaited() offers no ordering guarantee, the // isolate may die before the Future resolves, leaving the thread alive // with a dangling NativeCallable pointer. if (_useDesktopFlSerial) { @@ -412,7 +412,7 @@ class UsbSerialService { try { if (serial?.isOpen() == FlOpenStatus.open) { serial?.setDTR(false); - serial?.closePort(); // synchronous C call — kills the SerialThread + serial?.closePort(); // synchronous C call, kills the SerialThread } } catch (_) {} } diff --git a/lib/services/windows_taskbar_service.dart b/lib/services/windows_taskbar_service.dart index 648fd15..4b26845 100644 --- a/lib/services/windows_taskbar_service.dart +++ b/lib/services/windows_taskbar_service.dart @@ -3,7 +3,7 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter/services.dart'; -/// Flashes the Windows taskbar button to draw attention — e.g. a new message +/// Flashes the Windows taskbar button to draw attention, e.g. a new message /// arriving while the app window is not in the foreground. /// /// The native side (`windows/runner/flutter_window.cpp`) only flashes when the diff --git a/lib/storage/block_store.dart b/lib/storage/block_store.dart index 22c892b..0ca7e9d 100644 --- a/lib/storage/block_store.dart +++ b/lib/storage/block_store.dart @@ -5,7 +5,7 @@ import 'prefs_manager.dart'; /// Persistence for the app-global block list. /// -/// Deliberately **global** — unlike the other stores it is NOT scoped by the +/// 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`. diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index b80016f..831a579 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -4,6 +4,7 @@ import 'package:meshcore_open/utils/app_logger.dart'; import '../models/channel_message.dart'; import '../models/translation_support.dart'; +import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; import 'drift/blob_store.dart'; @@ -18,7 +19,7 @@ class ChannelMessageStore { set setPublicKeyHex(String value) => publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; - /// Resolves a channel slot index to its channel's PSK hex — a stable identity + /// Resolves a channel slot index to its channel's PSK hex, a stable identity /// that does not change when the channel moves slots. Set by the connector. /// When it yields a non-empty hex, history is keyed by PSK so reusing a slot /// can never surface a previous occupant's messages. Falls back to the slot @@ -165,8 +166,8 @@ class ChannelMessageStore { String? jsonString = await blobs.readWithPrefsFallback(key); // One-time migration into the PSK-identity key. Only runs when the PSK is - // known (key != index key). Adopts pre-#194 history keyed by slot index — - // device-scoped first, then the oldest unscoped key — and drops the source. + // known (key != index key). Adopts pre-#194 history keyed by slot index, + // device-scoped first, then the oldest unscoped key, and drops the source. // Build-B (#193) clears a slot's index history on mismatched reuse, so a // live slot's index data is the channel's own by the time we read here. if ((jsonString == null || jsonString.isEmpty) && @@ -267,6 +268,7 @@ class ChannelMessageStore { 'replyToSenderName': msg.replyToSenderName, 'replyToText': msg.replyToText, 'reactions': msg.reactions, + 'reactionSenders': msg.reactionSenders, }; } @@ -317,6 +319,9 @@ class ChannelMessageStore { (key, value) => MapEntry(key, value as int), ) ?? {}, + reactionSenders: ReactionHelper.reactionSendersFromJson( + json['reactionSenders'], + ), ); } diff --git a/lib/storage/channel_settings_store.dart b/lib/storage/channel_settings_store.dart index e6af56b..6c3c39c 100644 --- a/lib/storage/channel_settings_store.dart +++ b/lib/storage/channel_settings_store.dart @@ -2,58 +2,14 @@ import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ChannelSettingsStore { - static const String _keyPrefix = 'channel_smaz_'; static const String _cyr2latKeyPrefix = 'channel_cyr2lat_'; String publicKeyHex = ''; set setPublicKeyHex(String value) => publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; - String get keyFor => '$_keyPrefix$publicKeyHex'; String get keyForCyr2Lat => '$_cyr2latKeyPrefix$publicKeyHex'; - Future loadSmazEnabled(int channelIndex) async { - if (publicKeyHex.isEmpty) { - appLogger.warn( - 'Public key hex is not set. Cannot load channel settings.', - ); - return false; - } - final prefs = PrefsManager.instance; - final key = '$keyFor$channelIndex'; - final oldKey = '$_keyPrefix$channelIndex'; - bool? enabled = prefs.getBool(key); - if (enabled == null) { - // Attempt migration from legacy unscoped key on first load - // Only touch storage when a legacy key actually exists. This ran - // unconditionally, and on Windows shared_preferences re-serialises and - // rewrites the WHOLE prefs file on every mutation, so removing a key - // that was never there still cost a full multi-MB write. Repeated per - // channel/contact on connect, that blocked the UI isolate for ~38s. - enabled = prefs.getBool(oldKey); - if (enabled != null) { - appLogger.info( - 'Migrating channel settings from legacy key $oldKey to scoped key $key', - ); - await prefs.setBool(key, enabled); - await prefs.remove(oldKey); - } - } - return enabled ?? false; - } - - Future saveSmazEnabled(int channelIndex, bool enabled) async { - if (publicKeyHex.isEmpty) { - appLogger.warn( - 'Public key hex is not set. Cannot save channel settings.', - ); - return; - } - final prefs = PrefsManager.instance; - final key = '$keyFor$channelIndex'; - await prefs.setBool(key, enabled); - } - Future loadCyr2LatEnabled(int channelIndex) async { if (publicKeyHex.isEmpty) { appLogger.warn( diff --git a/lib/storage/client_id_store.dart b/lib/storage/client_id_store.dart new file mode 100644 index 0000000..2aecfd3 --- /dev/null +++ b/lib/storage/client_id_store.dart @@ -0,0 +1,59 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'prefs_manager.dart'; + +/// Per-install client identity sent in `CMD_APP_START` (#297). +/// +/// Wadamesh keeps a per-client history watermark (`last_delivered_seq`) keyed by +/// this id. Without one we land in the shared empty-string slot alongside every +/// other MeshCore client on the machine, so whichever app connects first drains +/// the device's history ring and the next app is told "no more messages" for +/// frames it never received. +/// +/// The id is exactly [length] bytes so a single frame satisfies both firmwares: +/// stock treats `cmd_frame[1..7]` as reserved and reads the app name at a fixed +/// offset 8, while Wadamesh reads it at `2 + cid_len`. With `cid_len == 6` both +/// land on 8. +class ClientIdStore { + static const String _key = 'client_install_id'; + + /// Byte length of the id. Do not change without re-checking both firmwares, + /// 6 is what keeps the app-name offset agreeing at 8. + static const int length = 6; + + /// The install's client id, generated and persisted on first use so it is + /// stable across builds (all builds share one SharedPreferences store). + static Uint8List load() { + final prefs = PrefsManager.instance; + final stored = prefs.getString(_key); + if (stored != null && stored.length == length * 2) { + final bytes = Uint8List(length); + for (var i = 0; i < length; i++) { + final byte = int.tryParse( + stored.substring(i * 2, i * 2 + 2), + radix: 16, + ); + if (byte == null) { + return _generate(prefs); + } + bytes[i] = byte; + } + return bytes; + } + return _generate(prefs); + } + + static Uint8List _generate(SharedPreferences prefs) { + final random = Random.secure(); + final bytes = Uint8List(length); + for (var i = 0; i < length; i++) { + bytes[i] = random.nextInt(256); + } + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + prefs.setString(_key, hex); + return bytes; + } +} diff --git a/lib/storage/contact_settings_store.dart b/lib/storage/contact_settings_store.dart index 109f5eb..1783258 100644 --- a/lib/storage/contact_settings_store.dart +++ b/lib/storage/contact_settings_store.dart @@ -2,58 +2,14 @@ import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ContactSettingsStore { - static const String _keyPrefix = 'contact_smaz_'; static const String _cyr2latKeyPrefix = 'contact_cyr2lat_'; String publicKeyHex = ''; set setPublicKeyHex(String value) => publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; - String get keyFor => '$_keyPrefix$publicKeyHex'; String get keyForCyr2Lat => '$_cyr2latKeyPrefix$publicKeyHex'; - Future loadSmazEnabled(String contactKeyHex) async { - if (publicKeyHex.isEmpty) { - appLogger.warn( - 'Public key hex is not set. Cannot load contact settings.', - ); - return false; - } - final prefs = PrefsManager.instance; - final key = '$keyFor$contactKeyHex'; - final oldKey = '$_keyPrefix$contactKeyHex'; - bool? enabled = prefs.getBool(key); - if (enabled == null) { - // Attempt migration from legacy unscoped key on first load - // Only touch storage when a legacy key actually exists. This ran - // unconditionally, and on Windows shared_preferences re-serialises and - // rewrites the WHOLE prefs file on every mutation, so removing a key - // that was never there still cost a full multi-MB write. Repeated per - // channel/contact on connect, that blocked the UI isolate for ~38s. - enabled = prefs.getBool(oldKey); - if (enabled != null) { - appLogger.info( - 'Migrating contact settings from legacy key $oldKey to scoped key $key', - ); - await prefs.setBool(key, enabled); - await prefs.remove(oldKey); - } - } - return prefs.getBool(key) ?? false; - } - - Future saveSmazEnabled(String contactKeyHex, bool enabled) async { - if (publicKeyHex.isEmpty) { - appLogger.warn( - 'Public key hex is not set. Cannot save contact settings.', - ); - return; - } - final prefs = PrefsManager.instance; - final key = '$keyFor$contactKeyHex'; - await prefs.setBool(key, enabled); - } - Future loadCyr2LatEnabled(String contactKeyHex) async { if (publicKeyHex.isEmpty) { appLogger.warn( diff --git a/lib/storage/drift/blob_store.dart b/lib/storage/drift/blob_store.dart index 96aba56..ea75b9f 100644 --- a/lib/storage/drift/blob_store.dart +++ b/lib/storage/drift/blob_store.dart @@ -97,6 +97,11 @@ class BlobStore { return row?.value; } + /// Proves the database opens and round-trips a write. Throws (or returns + /// false) if storage is unusable, e.g. the native sqlite library can't load + /// (#385). Used by the startup health probe. + Future verifyReadWrite() => _db.verifyReadWrite(); + Future write(String key, String value) async { await _db .into(_db.storedBlobs) diff --git a/lib/storage/drift/offband_database.dart b/lib/storage/drift/offband_database.dart index 1b2e028..0c91e57 100644 --- a/lib/storage/drift/offband_database.dart +++ b/lib/storage/drift/offband_database.dart @@ -52,7 +52,7 @@ class OffbandDatabase extends _$OffbandDatabase { web: DriftWebOptions( sqlite3Wasm: Uri.parse('sqlite3.wasm'), // Filename matches the asset published by the drift release, which is - // `drift_worker.js` — not the `drift_worker.dart.js` the older docs + // `drift_worker.js`, not the `drift_worker.dart.js` the older docs // name. Both assets are taken from the SAME drift release so they are // built against each other. driftWorker: Uri.parse('drift_worker.js'), diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 37b0575..d795153 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import '../models/message.dart'; import '../models/translation_support.dart'; +import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; import '../utils/app_logger.dart'; import 'drift/blob_store.dart'; @@ -168,11 +169,15 @@ class MessageStore { ? base64Encode(msg.pathBytes) : null, 'reactions': msg.reactions, + 'reactionSenders': msg.reactionSenders, 'reactionStatuses': msg.reactionStatuses.map( (key, value) => MapEntry(key, value.index), ), 'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey), 'rxTime': msg.rxTime?.millisecondsSinceEpoch, + 'snr': msg.snr, + 'rssi': msg.rssi, + 'isFloodRoute': msg.isFloodRoute, }; } @@ -216,6 +221,9 @@ class MessageStore { (key, value) => MapEntry(key, value as int), ) ?? {}, + reactionSenders: ReactionHelper.reactionSendersFromJson( + json['reactionSenders'], + ), reactionStatuses: (json['reactionStatuses'] as Map?)?.map( (key, value) => MapEntry(key, MessageStatus.values[value as int]), @@ -229,6 +237,9 @@ class MessageStore { rxTime: json['rxTime'] != null ? DateTime.fromMillisecondsSinceEpoch(json['rxTime'] as int) : null, + snr: (json['snr'] as num?)?.toDouble(), + rssi: json['rssi'] as int?, + isFloodRoute: json['isFloodRoute'] as bool?, ); } } diff --git a/lib/theme/mesh_theme.dart b/lib/theme/mesh_theme.dart index 7c35d57..978eb9a 100644 --- a/lib/theme/mesh_theme.dart +++ b/lib/theme/mesh_theme.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -/// MeshCore redesign palette — warm field-journal dark theme with +/// MeshCore redesign palette, warm field-journal dark theme with /// phosphor-green signal accents. Mirrors values from the redesign spec. class MeshPalette { MeshPalette._(); @@ -67,7 +67,7 @@ class MeshPalette { static const lightSignal = Color(0xFF1A7A44); } -/// Named font stacks — Flutter falls back to system fonts when the named +/// Named font stacks, Flutter falls back to system fonts when the named /// family isn't installed, keeping things working without bundled assets. class MeshFonts { MeshFonts._(); @@ -389,7 +389,7 @@ class MeshTheme { ); } - /// Mono text style — sizes default to the body size Inter is using. + /// Mono text style, sizes default to the body size Inter is using. static TextStyle mono({ double? fontSize, FontWeight? fontWeight, @@ -424,7 +424,7 @@ class MeshTheme { ); } - /// Small-caps mono label — used for section accents and chip labels. + /// Small-caps mono label, used for section accents and chip labels. static TextStyle accentLabel({Color? color, double? fontSize}) { return TextStyle( fontFamily: MeshFonts.mono, diff --git a/lib/utils/build_info.dart b/lib/utils/build_info.dart new file mode 100644 index 0000000..db1f9cd --- /dev/null +++ b/lib/utils/build_info.dart @@ -0,0 +1,35 @@ +/// Identity of the running binary, injected at build time (#397). +/// +/// Set via `--dart-define` so every build, debug or release, dev or prod, +/// carries its own identity, independent of the pubspec marketing version: +/// +/// ``` +/// flutter build apk --release \ +/// --dart-define=GIT_SHA=$(git rev-parse --short HEAD) \ +/// --dart-define=GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) \ +/// --dart-define=BUILD_TIME=$(date -u +%Y-%m-%dT%H:%MZ) +/// ``` +/// +/// Falls back to `dev` / `unknown` when the defines are absent, so a plain +/// `flutter run` still works and never crashes on a missing value. +class BuildInfo { + const BuildInfo._(); + + static const String gitSha = String.fromEnvironment( + 'GIT_SHA', + defaultValue: 'dev', + ); + + static const String gitBranch = String.fromEnvironment( + 'GIT_BRANCH', + defaultValue: 'dev', + ); + + static const String buildTime = String.fromEnvironment( + 'BUILD_TIME', + defaultValue: 'unknown', + ); + + /// One-line stamp for the UI: `branch @ sha · built