Merge pull request #467 from OffbandMesh/dev

release: promote 1.3.0+64 to main (first production release)
main v1.3.0
Strycher 2 months ago committed by GitHub
commit 266325addd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,2 @@
github: [Strycher]
ko_fi: offband

@ -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

@ -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 .

@ -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:

@ -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.

@ -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. 📡

@ -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, <bytes>]*`
/// → 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' : ''}';
}

File diff suppressed because it is too large Load Diff

@ -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, <bytes>]*
// → 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; // "<name>: "
final byPayload = maxTextPayloadBytes - prefixBytes;
final byFrame = maxFrameSize - _sendChannelTextMsgOverheadBytes;
// The wire text is "<name>: <userText>", 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)

@ -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 `<key> <value>` — firmware splits on the FIRST space; [value] is the
/// SET `<key> <value>`, 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<MapEntry<String, String>> 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;

@ -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.<field>`, 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<DiffRow> rows;
bool get isEmpty => rows.isEmpty;
List<DiffRow> get dangerRows => rows.where((r) => r.danger).toList();
List<DiffRow> 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<String> _secretBrokerFields = {ConfigKeys.brokerPassword};
const Set<String> _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<String, String?> currentFlat,
required Map<int, Map<String, String?>> currentBroker,
}) {
final rows = <DiffRow>[];
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);
}

@ -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<BrokerConfig> _parseBrokers(dynamic node) {
if (node == null) return const [];
if (node is! YamlList) {
throw const ConfigProfileFormatException('"brokers" must be a list');
}
final seenSlots = <int>{};
final brokers = <BrokerConfig>[];
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<String> 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<T>(
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;
}

@ -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<String> kDangerBrokerFields = {
ConfigKeys.brokerUsername,
ConfigKeys.brokerPassword,
ConfigKeys.brokerJwtOwner,
ConfigKeys.brokerJwtEmail,
};
/// Global flat keys requiring the danger gate.
const Set<String> 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<String, String> fields;
final Set<String> 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<FlatWrite> flats;
final List<BrokerWrites> 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 = <BrokerWrites>[];
final dangerBrokers = <BrokerWrites>[];
for (final b in w.brokers) {
final safeFields = <String, String>{
for (final e in b.fields.entries)
if (!b.dangerFields.contains(e.key)) e.key: e.value,
};
final dangerFields = <String, String>{
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 = <FlatWrite>[];
// 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 = <BrokerWrites>[];
for (final b in (mqtt?.brokers ?? const <BrokerConfig>[])) {
final fields = <String, String>{};
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);
}

@ -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<int> 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();
}
}

@ -1925,7 +1925,7 @@ const Map<String, String> _emojiAliases = {
'tick': '✅',
};
/// gemoji shortcodes plus [_emojiAliases] — what composers use.
/// gemoji shortcodes plus [_emojiAliases], what composers use.
final Map<String, String> emojiShortcodesWithAliases = {
...emojiShortcodes,
..._emojiAliases,

@ -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) {

@ -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) {

@ -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',
);
}
}

@ -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<List<int>> _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;
}
}

@ -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<String, List<String>> reactionSendersFromJson(Object? raw) {
if (raw is! Map) return {};
final result = <String, List<String>>{};
raw.forEach((key, value) {
if (key is String && value is List) {
result[key] = value.whereType<String>().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<T>({
required List<T> messages,
required ReactionInfo reactionInfo,
required String reactingSender,
required int Function(T) getTimestampSecs,
required String? Function(T) getSenderName,
required String Function(T) getMessageText,
required Map<String, int> Function(T) getReactions,
required Map<String, List<String>> Function(T) getReactionSenders,
required bool Function(T) shouldSkip,
required void Function(int index, Map<String, int> newReactions)
required void Function(
int index,
Map<String, int> newReactions,
Map<String, List<String>> 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 = <String, List<String>>{
for (final e in getReactionSenders(msg).entries)
e.key: List<String>.from(e.value),
};
final list = senders.putIfAbsent(emoji, () => <String>[]);
if (list.contains(reactingSender)) {
// Already recorded this reactor for this emoji; matched, no change.
return true;
}
list.add(reactingSender);
final currentReactions = Map<String, int>.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;
}
}

@ -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

@ -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": "Няма налични данни за телеметрията.",

@ -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.",

@ -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)."
}
}

@ -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.",

@ -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.",

@ -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.",

@ -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.",

@ -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": "テレメトリデータは利用できません。",

@ -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": "텔레메트리 데이터는 제공되지 않습니다.",

@ -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

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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';
}

@ -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.",

@ -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.",

@ -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.",

@ -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": "Полученные данные о соседях",

@ -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.",

@ -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.",

@ -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.",

@ -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": "Дані телеметрії недоступні.",

@ -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": "暂无遥测数据",

@ -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<SystemUiOverlayStyle>(
value: _systemUiOverlayStyle(context),
child: child ?? const SizedBox.shrink(),
child: KeepScreenAwake(
child: Consumer<StorageHealthService>(
builder: (context, health, _) => StorageUnavailableBanner(
show: !health.available,
child: child ?? const SizedBox.shrink(),
),
),
),
);
},
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)

@ -121,6 +121,7 @@ class AppSettings {
final bool mapShowMarkers;
final bool mapShowGuessedLocations;
final bool enableMessageTracing;
final bool keepScreenAwake;
final ClockFormat clockFormat;
final Map<String, double>? 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<String> 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

@ -51,8 +51,16 @@ class ChannelMessage {
final String? replyToText;
final Map<String, int> 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<String, List<String>> 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<String, int>? reactions,
Map<String, List<String>>? 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<String, int>? reactions,
Map<String, List<String>>? 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');

@ -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<CatalogEntry> 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<CatalogEntry> 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 = <CatalogEntry>[];
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,
);
}

@ -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.<slot>.*`). 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<BrokerConfig> 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.<slot>.<subkey>`.
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.<slot>.`.
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';
}

@ -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)

@ -31,14 +31,37 @@ class Message {
final int? pathLength;
final Uint8List pathBytes;
final Map<String, int> 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<String, List<String>> reactionSenders;
final Map<String, MessageStatus> 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<String, int>? reactions,
Map<String, List<String>>? reactionSenders,
Map<String, MessageStatus>? 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<String, int>? reactions,
Map<String, List<String>>? reactionSenders,
Map<String, MessageStatus>? 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,
);
}

@ -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);

@ -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;

@ -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),

@ -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<BleDebugLogScreen> {
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<BleDebugLogScreen> {
);
}
/// 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<void> _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,

@ -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<ChannelChatScreen> {
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<ChannelChatScreen> {
// 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<MeshCoreConnector>()
@ -421,13 +423,33 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'clearChat') {
if (value == 'notifications') {
showChannelNotifyModeDialog(context, widget.channel);
} else if (value == 'clearChat') {
context.read<MeshCoreConnector>().clearMessagesForChannel(
_currentChannel.index,
);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'notifications',
child: Row(
children: [
Icon(
channelNotifyModeIcon(
context.read<AppSettingsService>().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<ChannelChatScreen> {
/// 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<void> _blockChannelSender(ChannelMessage message) async {
final connector = context.read<MeshCoreConnector>();
final blockService = context.read<BlockService>();
@ -1131,7 +1153,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget _buildMessageComposer() {
final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName);
final maxBytes = maxChannelMessageBytes(
connector.selfName,
maxFrameBytes: connector.effectiveMaxFrameSize,
);
final settings = context.watch<AppSettingsService>().settings;
return Column(
mainAxisSize: MainAxisSize.min,
@ -1227,12 +1252,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
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<ChannelChatScreen> {
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<ChannelChatScreen> {
);
}
/// 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<MeshCoreConnector>();
@ -1406,7 +1431,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
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;

@ -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!),
),
],
),
),

@ -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<ChannelsScreen> createState() => _ChannelsScreenState();
@ -110,7 +109,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
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<ChannelsScreen>
),
],
),
body: RefreshIndicator(
onRefresh: () async {
await context.read<MeshCoreConnector>().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<MeshCoreConnector>().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<UiViewStateService>()
.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<UiViewStateService>()
.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<UiViewStateService>()
.setChannelsSearchText(value);
},
);
},
),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 300),
() {
if (!mounted) return;
context
.read<UiViewStateService>()
.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<Channel>.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<Channel>.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<ChannelsScreen>
);
}
/// 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<AppSettingsService>();
final identityKey = _notifyKeyFor(channel);
final current = settingsService.channelNotifyMode(
identityKey: identityKey,
channelName: channel.name,
);
showDialog<void>(
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<ChannelsScreen>
final parentContext = context;
final settingsService = context.read<AppSettingsService>();
final notifyMode = settingsService.channelNotifyMode(
identityKey: _notifyKeyFor(channel),
identityKey: channelNotifyKeyFor(channel),
channelName: channel.name,
);
@ -575,14 +523,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
},
),
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<ChannelsScreen>
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<ChannelsScreen>
);
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<ChannelsScreen>
),
),
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<ChannelsScreen>
value: cyr2latEnabled,
onChanged: (value) => setState(() {
cyr2latEnabled = value;
if (cyr2latEnabled) {
smazEnabled = false;
}
}),
),
if (cyr2latEnabled) ...[
@ -1573,10 +1506,6 @@ class _ChannelsScreenState extends State<ChannelsScreen>
Navigator.pop(dialogContext);
try {
await connector.setChannel(channel.index, name, psk);
await connector.setChannelSmazEnabled(
channel.index,
smazEnabled,
);
await connector.setChannelCyr2LatEnabled(
channel.index,
cyr2latEnabled,

@ -595,7 +595,9 @@ class _ChatScreenState extends State<ChatScreen> {
if (context.watch<BlockService>().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<AppSettingsService>().settings;
return Container(
@ -672,12 +674,9 @@ class _ChatScreenState extends State<ChatScreen> {
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<ChatScreen> {
}
}
}
final maxBytes = maxContactMessageBytes();
final maxBytes = maxContactMessageBytes(
maxFrameBytes: connector.effectiveMaxFrameSize,
);
final outboundText = connector.prepareContactOutboundText(
_resolveContact(connector),
outgoingText,
@ -1438,7 +1439,7 @@ class _ChatScreenState extends State<ChatScreen> {
);
if (result == null) {
return; // Cancelled — keep existing path
return; // Cancelled, keep existing path
}
if (!mounted) {
@ -1497,7 +1498,12 @@ class _ChatScreenState extends State<ChatScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelMessagePathScreen(message: pathMessage),
builder: (context) => ChannelMessagePathScreen(
message: pathMessage,
snr: message.snr,
rssi: message.rssi,
isFloodRoute: message.isFloodRoute,
),
),
);
}

@ -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 {

@ -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<ContactsScreen> createState() => _ContactsScreenState();
@ -329,7 +327,11 @@ class _ContactsScreenState extends State<ContactsScreen>
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<ContactsScreen>
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<ContactsScreen>
} 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)

@ -411,6 +411,9 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
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<MeshCoreConnector>()
@ -1359,13 +1362,13 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
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;
}

@ -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<MapScreen> {
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<MapScreen> {
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<MapScreen> {
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<MapScreen> {
// 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 = <List<int>>[
contact.path.toList(),
@ -719,7 +722,7 @@ class _MapScreenState extends State<MapScreen> {
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<MapScreen> {
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<MapScreen> {
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;
}

@ -195,7 +195,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// 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<MeshCoreConnector>().selfPublicKey;
@ -210,7 +210,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
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<PathTraceMapScreen> {
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',

@ -397,7 +397,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
///
/// 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<RepeaterSettingsScreen> {
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();

@ -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<ScannerScreen> {
centerTitle: true,
automaticallyImplyLeading: false,
),
body: SafeArea(
top: false,
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return Column(
children: [
// Bluetooth off warning
if (_bluetoothState == BluetoothAdapterState.off)
_bluetoothOffWarning(context),
body: BatteryOptimizationBanner(
child: SafeArea(
top: false,
child: Consumer<MeshCoreConnector>(
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<MeshCoreConnector>(

@ -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<SerialCaptureScreen> createState() => _SerialCaptureScreenState();
}
class _SerialCaptureScreenState extends State<SerialCaptureScreen> {
/// Capture-window options in minutes for the timed flow; 0 = until stopped.
static const List<int> _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<MeshCoreConnector>();
_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<void> _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<bool> _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<void> _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<void> _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<void> _startAndReboot() async {
final c = _connector;
if (c == null) return;
final confirmed = await showDialog<bool>(
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<void> _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<void> _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<MeshCoreConnector>();
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<int>(
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)'),
),
],
);
}
}

@ -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

@ -114,7 +114,7 @@ class _BrokerEditorScreenState extends State<BrokerEditorScreen> {
/// 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<BrokerEditorScreen> {
).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<BrokerEditorScreen> {
}
Future<void> _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<BrokerEditorScreen> {
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<BrokerEditorScreen> {
);
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<BrokerEditorScreen> {
);
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<BrokerEditorScreen> {
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<BrokerEditorScreen> {
obscureText: true,
decoration: InputDecoration(
labelText: _baseline.passwordSet
? 'Password (set — leave blank to keep)'
? 'Password (set, leave blank to keep)'
: 'Password',
border: const OutlineInputBorder(),
),

@ -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<ConfigProfileImportScreen> createState() =>
_ConfigProfileImportScreenState();
}
class _ConfigProfileImportScreenState extends State<ConfigProfileImportScreen> {
final _source = ConfigSourceService();
final _urlController = TextEditingController();
List<CatalogEntry> _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<void> _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<void> _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<void> _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),
),
),
],
);
}
}

@ -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<ConfigProfilePreviewScreen> createState() =>
_ConfigProfilePreviewScreenState();
}
class _ConfigProfilePreviewScreenState
extends State<ConfigProfilePreviewScreen> {
late ProfileWrites _writes;
ProfileDiff? _diff;
String? _error;
bool _loading = true;
bool _applying = false;
@override
void initState() {
super.initState();
_writes = enumerateProfileWrites(widget.profile);
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final currentFlat = <String, String?>{};
for (final f in _writes.flats) {
currentFlat[f.key] = await widget.service.getFlat(f.key);
}
final currentBroker = <int, Map<String, String?>>{};
for (final b in _writes.brokers) {
final m = <String, String?>{};
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<void> _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<void> _confirmDanger(
BuildContext context,
ProfileWrites dangerWrites,
ProfileDiff diff,
) async {
final theme = Theme.of(context);
final ok = await showDialog<bool>(
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')),
],
),
),
);
}
}

@ -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<MqttBrokersScreen> {
);
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<MqttBrokersScreen> {
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<MqttBrokersScreen> {
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<MqttBrokersScreen> {
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)

@ -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<ObserverSettingsView> {
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<MeshCoreConnector>();
@ -170,7 +171,7 @@ class _ObserverSettingsViewState extends State<ObserverSettingsView> {
: 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<ObserverSettingsView> {
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<ObserverSettingsView> {
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<ObserverSettingsView> {
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<ObserverSettingsView> {
String _brokerSummary(List<BrokerConfig> 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';
}

@ -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<SettingsCategory> categories;

@ -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<SettingsScreen> {
List<SettingsCategory> _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<MeshCoreConnector, bool>(
@ -87,6 +90,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
);
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
_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 <Widget>[];
return <Widget>[
_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<SettingsScreen> {
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<SettingsScreen> {
Widget _buildDebugCard(BuildContext context) {
final l10n = context.l10n;
final settingsService = context.watch<AppSettingsService>();
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -597,6 +634,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
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<SettingsScreen> {
},
),
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<SettingsScreen> {
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.

@ -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<Contact> 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')

@ -329,7 +329,7 @@ class _UsbScreenState extends State<UsbScreen> {
..addAll(ports);
});
} catch (_) {
// Silent — hot-plug failures are non-critical.
// Silent, hot-plug failures are non-critical.
}
}

@ -104,6 +104,10 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(enableMessageTracing: value));
}
Future<void> setKeepScreenAwake(bool value) async {
await updateSettings(_settings.copyWith(keepScreenAwake: value));
}
Future<void> setClockFormat(ClockFormat value) async {
await updateSettings(_settings.copyWith(clockFormat: value));
}

@ -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<void> 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<void> importKeys(Iterable<String> 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();

@ -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 `<dir>/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) -> `<url>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<ConfigCatalog> fetchCatalog(String url) async {
return parseCatalog(await _get(url));
}
/// Fetch + parse a single profile YAML.
Future<ConfigProfile> fetchProfile(String url) async {
return parseConfigProfile(await _get(url));
}
Future<String> _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();
}

@ -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 &&

@ -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<int> 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<int> 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<int> 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();

@ -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<void> 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<String, Timer> _timeoutTimers = {};
final Map<String, Timer> _sentConfirmationTimers = {};
final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {};
final Map<String, List<PathSelection>> _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();

@ -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) {

@ -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<ApplyItemResult> items;
bool get allOk => items.every((i) => i.ok);
List<ApplyItemResult> 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<ObserverApplyResult> apply(ProfileWrites writes) async {
final items = <ApplyItemResult>[];
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);
}
}

@ -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<BrokerSaveResult> saveBroker(
@ -167,10 +167,10 @@ class ObserverConfigService extends ChangeNotifier {
return const BrokerSaveResult.ok();
}
/// Definitively wipe a broker slot — `mqtt.broker.<slot>.clear` (#80).
/// Definitively wipe a broker slot, `mqtt.broker.<slot>.clear` (#80).
Future<bool> 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<BrokerConfig?> 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 <BrokerConfig>[];

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.