Compare commits

..

1 Commits

Author SHA1 Message Date
Enot (ded) Skelly d529ce9228 fix foreground service and add notification nav
wraps MaterialApp in WithForegroundService to keep alive when swiped away

persists last connected device and clears on manual disconnect to allow
reconnect after kill

added lifecycle tracking to iOS and keep android notification alive with
heartbeat

add notification navigation

change screen tests to be less brittle

address PR commnets
2026-04-13 08:09:22 -07:00
298 changed files with 21066 additions and 101612 deletions
-3
View File
@@ -1,3 +0,0 @@
github: [zjs81]
custom:
- "https://paypal.me/zjs82"
-4
View File
@@ -87,10 +87,6 @@ keystore.properties
# IDE
.vscode/launch.json
.vscode/settings.json
.contextstream/
# Cloudflare Wrangler
.wrangler
# Claude Code local working dir (worktrees, jobs, settings)
.claude/
+46 -247
View File
@@ -1,6 +1,6 @@
# MeshCore Open - Flutter Client
Open-source Flutter client for MeshCore LoRa mesh networking devices. Connects to MeshCore-compatible radios over **BLE, TCP, or USB serial** and provides direct/channel chat, contact and channel management, on-map node tracking, repeater administration, and on-device message translation.
Open-source Flutter client for MeshCore LoRa mesh networking devices.
## Build Commands
@@ -17,9 +17,6 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices. Connects t
# Build iOS
~/flutter/bin/flutter build ios
# Build versioned web release (uses build_pipe)
~/flutter/bin/dart run build_pipe
# Run static analysis
~/flutter/bin/flutter analyze
@@ -31,132 +28,43 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices. Connects t
```
lib/
├── main.dart # Entry point: MultiProvider wiring, locale + theme, initial route
├── connector/ # Unified BLE/TCP/USB transport layer
── meshcore_connector.dart # Central state holder + ChangeNotifier (all transports)
│ ├── meshcore_connector_tcp.dart # TCP transport helper
│ ├── meshcore_connector_usb.dart # USB serial transport helper
│ ├── meshcore_protocol.dart # Frame size + version constants
── meshcore_uuids.dart # Nordic UART UUIDs + scan name prefixes
├── models/ # Plain data classes (Contact, Channel, Message, Community, …)
├── services/ # ChangeNotifier services + IO services (retry, translation, ML, …)
── storage/ # SharedPreferences-backed stores, scoped per device key
├── helpers/ # Pure utilities (Smaz compression, GIF parsing, scroll helpers, path hop resolution)
├── utils/ # Platform / IO / UX utilities (logger, GPX export, dialogs)
├── theme/ # MeshPalette (defined, not yet wired in main.dart)
├── l10n/ # ARB localization for 18 locales
├── icons/ # Custom icon widgets
├── widgets/ # Reusable widgets (AppBar, BatteryUi, QR, jump-to-bottom, …)
└── screens/ # ~26 screens — see Screens section below
├── main.dart # App entry point, MaterialApp setup with Provider
├── connector/
── meshcore_connector.dart # BLE communication layer (MeshCoreConnector)
├── screens/
│ ├── scanner_screen.dart # BLE device scanning (home screen)
│ ├── device_screen.dart # Connected device hub with navigation
── chat_screen.dart # Chat interface (placeholder)
│ ├── contacts_screen.dart # Contacts list (placeholder)
│ └── settings_screen.dart # Device info and app settings
── widgets/
└── device_tile.dart # Device list item with signal strength
```
## Screens
All screens are fully implemented (no remaining placeholders).
### Connection / Scanning
| Screen | Purpose |
|---|---|
| `scanner_screen.dart` | BLE device scan and connect — main entry point |
| `tcp_screen.dart` | Connect to a MeshCore device over TCP/IP |
| `usb_screen.dart` | Connect to a MeshCore device over USB serial |
| `discovery_screen.dart` | Browse all discovered (non-contact) mesh nodes |
| `chrome_required_screen.dart` | Web gate for non-Chrome browsers (BLE unavailable) |
### Chat / Messaging
| Screen | Purpose |
|---|---|
| `chat_screen.dart` | Direct (private) messaging with a contact |
| `channel_chat_screen.dart` | Group messaging inside a named channel |
| `channels_screen.dart` | List and manage channels (add/edit/delete) |
| `channel_message_path_screen.dart` | Hop-by-hop route a channel message took, with map overlay |
### Contacts / Neighbors
| Screen | Purpose |
|---|---|
| `contacts_screen.dart` | Full contacts list with previews and management |
| `neighbors_screen.dart` | Nodes directly heard by the connected radio (one-hop) |
### Repeater Management
| Screen | Purpose |
|---|---|
| `repeater_hub_screen.dart` | Top-level repeater hub; navigates to sub-screens |
| `repeater_status_screen.dart` | Live status of a managed repeater node |
| `repeater_cli_screen.dart` | Raw command-line interface to a repeater |
| `repeater_settings_screen.dart` | Full radio/node settings editor for a repeater |
### Map / Location
| Screen | Purpose |
|---|---|
| `map_screen.dart` | Main map view of contacts/nodes with live GPS positions |
| `line_of_sight_map_screen.dart` | Terrain LOS analysis between configurable endpoints |
| `path_trace_map.dart` | Animates the hop path a direct message traveled |
| `map_cache_screen.dart` | Download/clear offline map tile cache |
| `community_qr_scanner_screen.dart` | Scan QR to join a mesh community/channel |
### Settings / Debug / Diagnostics
| Screen | Purpose |
|---|---|
| `settings_screen.dart` | Connected device settings: radio params, identity, GPS |
| `app_settings_screen.dart` | App preferences: theme, units, map source, notifications |
| `app_debug_log_screen.dart` | In-app log viewer (app-layer messages) |
| `ble_debug_log_screen.dart` | In-app log viewer (raw BLE frame traffic) |
| `companion_radio_stats_screen.dart` | RF stats (RSSI, SNR, packet counts) for paired radio |
| `telemetry_screen.dart` | Battery / sensor / environmental telemetry for a contact |
## Architecture
### State Management
`Provider` with `ChangeNotifier`. `main.dart` wires a `MultiProvider` with the following:
| Provider | Role |
|---|---|
| `MeshCoreConnector` | Active transport (BLE/TCP/USB), connection state, frame I/O |
| `MessageRetryService` | ACK tracking and retry scheduling with backoff |
| `PathHistoryService` | Per-contact routing history (LRU cache, 50 contacts) |
| `AppSettingsService` | App preferences (theme, units, locale, notifications) |
| `BleDebugLogService` | Raw BLE frame log buffer |
| `AppDebugLogService` | Structured app log buffer |
| `ChatTextScaleService` | Pinch-to-zoom text scale for chat screens |
| `TranslationService` | On-device LLM translation (llamadart) |
| `UiViewStateService` | Contacts/channels sort/filter/search state |
| `TimeoutPredictionService` | ML linear regression for ACK timeout prediction |
| `StorageService` | Path history + delivery observation persistence |
| `MapTileCacheService` | OSM tile pre-cache |
Screens consume these via `Consumer<T>` (or `context.watch<T>()` / `context.read<T>()`) for reactive UI.
### Storage / Persistence
All stores in `lib/storage/` use `PrefsManager` (a `SharedPreferences` singleton initialized in `main()`). Most stores **scope keys by the first 10 hex chars of the connected device's public key**, so per-radio data is isolated.
| Store | Persists |
|---|---|
| `message_store`, `channel_message_store` | Direct + channel messages |
| `contact_store`, `contact_discovery_store` | Known + discovered contacts |
| `channel_store`, `channel_order_store`, `channel_settings_store` | Channels, display order, per-channel Smaz toggle |
| `community_store` | Communities (32-byte shared secrets) |
| `contact_group_store`, `contact_settings_store` | Groups, per-contact Smaz toggle |
| `unread_store` | Per-contact unread counts (debounced writes) |
GGUF translation models are stored as files (not SharedPreferences) via `translation_file_store`.
- **Provider** with `ChangeNotifier` pattern
- `MeshCoreConnector` is the central state holder for BLE connection
- Screens use `Consumer<MeshCoreConnector>` for reactive UI updates
### Theming
- Material 3 design (`useMaterial3: true`)
- System-based dark/light mode (`ThemeMode.system`)
- Blue color scheme seed
- `lib/theme/mesh_theme.dart` defines a warm-dark `MeshPalette` (phosphor-green accents) but is **not currently wired** in `main.dart` — available for a future redesign
### Localization
## BLE Protocol
18 locales supported via Flutter's standard ARB pipeline (`lib/l10n/`): en, de, es, fr, it, pt, ru, uk, bg, hu, ja, ko, nl, pl, sk, sl, sv, zh. Language override comes from `AppSettingsService.settings.languageOverride`. Use the `context.l10n` extension (`lib/l10n/l10n.dart`) for translated strings; contact-type names live in `contact_localization.dart`.
### Nordic UART Service (NUS)
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
- **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device)
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
## Transports
### Device Discovery
- Scans for devices with known name prefixes
- Filters by `platformName` or `advertisementData.advName`
`MeshCoreConnector` unifies all three transports under one `ChangeNotifier`. There is **no shared base class** — selection is via the `MeshCoreTransportType { bluetooth, usb, tcp }` enum, and BLE/TCP/USB share the same connection-state enum, send/receive API, and frame protocol.
### Connection State
### Connection States
```dart
enum MeshCoreConnectionState {
disconnected,
@@ -167,137 +75,28 @@ enum MeshCoreConnectionState {
}
```
### Frame I/O (all transports)
- **Send**: `MeshCoreConnector.sendFrame(Uint8List data, {String? channelSendQueueId, bool expectsGenericAck})`
- **Receive**: `Stream<Uint8List> get receivedFrames`
- **Protocol constants** (`meshcore_protocol.dart`): `maxFrameSize = 172`, `maxTextPayloadBytes = 160`, `appProtocolVersion = 4`
### BLE — Nordic UART Service (NUS)
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
- **RX Characteristic** (write to device): `6e400002-b5a3-f393-e0a9-e50e24dcca9e`
- **TX Characteristic** (notify from device): `6e400003-b5a3-f393-e0a9-e50e24dcca9e`
- **Discovery**: scans for devices whose name starts with `MeshCore-`, `Whisper-`, `WisCore-`, `Seeed`, `Lilygo`, `HT-`, or `LowMesh_MC_` (filters on both `platformName` and `advertisementData.advName`)
- **Linux**: `linux_ble_pairing_service.dart` falls back to `bluetoothctl` when BlueZ agent prompts fail
### TCP
- Manual host/port entry, persisted via `AppSettingsService` (`tcpServerAddress`, `tcpServerPort`)
- UI hint: `192.168.40.10` / port `5000`
- Disabled on web (`PlatformInfo.isWeb`)
- API: `MeshCoreConnector.connectTcp(host: ..., port: ...)`
### USB Serial (flserial)
- Default baud rate: `115200`
- Port enumeration: `MeshCoreConnector.listUsbPorts()`
- COBS-framed packets via `usb_serial_frame_codec.dart`
- macOS device-name resolution via `ioreg` (`utils/macos_usb_device_names.dart`)
- API: `MeshCoreConnector.connectUsb(portName: ..., baudRate: 115200)`
### Frame I/O
- **Send**: `MeshCoreConnector.sendFrame(Uint8List data)`
- **Receive**: `MeshCoreConnector.receivedFrames` stream of `Uint8List`
## Dependencies
App version: `9.5.0+13` — Dart SDK constraint: `^3.9.2`
**Connectivity**
| Package | Version | Purpose |
|---------|---------|---------|
| flutter_blue_plus | ^2.1.0 | BLE scanning, connecting, and UART data transfer |
| flutter_blue_plus_platform_interface | ^9.0.2 | Platform-interface layer required by flutter_blue_plus |
| flserial | git (MeshEnvy fork) | USB serial transport for wired device connections (TODO: upstream pending) |
**State / Storage**
| Package | Version | Purpose |
|---------|---------|---------|
| provider | ^6.1.5+1 | ChangeNotifier-based state management across screens |
| shared_preferences | ^2.2.2 | Persistent key-value storage for user settings |
| path_provider | ^2.1.5 | Locates platform-appropriate directories for file I/O |
**Crypto**
| Package | Version | Purpose |
|---------|---------|---------|
| crypto | ^3.0.3 | SHA/HMAC hashing used in message authentication |
| pointycastle | ^4.0.0 | AES encryption/decryption for channel and direct messages |
| uuid | ^4.3.3 | Generates UUIDs for message and contact identity |
**Maps & Location**
| Package | Version | Purpose |
|---------|---------|---------|
| flutter_map | ^8.2.2 | Interactive tile map for node positions and path traces |
| latlong2 | ^0.9.1 | LatLng coordinate type used throughout map and GPS code |
| gpx | ^2.3.0 | Export node paths as GPX track files |
**UI**
| Package | Version | Purpose |
|---------|---------|---------|
| material_symbols_icons | ^4.2928.1 | Extended Material Symbols icon set (line-of-sight, etc.) |
| flutter_svg | ^2.0.10+1 | Renders SVG assets (custom icons such as LoS indicator) |
| cached_network_image | ^3.4.1 | Caches map tile images downloaded over the network |
| flutter_cache_manager | ^3.4.1 | Underlying cache manager used by cached_network_image |
| flutter_linkify | ^6.0.0 | Auto-detects and makes URLs tappable in chat messages |
| mobile_scanner | ^7.1.4 | QR/barcode scanning for contact and channel import |
| qr_flutter | ^4.1.0 | Generates QR codes for sharing contacts and channels |
| cupertino_icons | ^1.0.8 | iOS-style icon font (bundled for completeness) |
| characters | ^1.4.0 | Unicode-aware string operations for message text handling |
**Notifications / Background**
| Package | Version | Purpose |
|---------|---------|---------|
| flutter_local_notifications | ^22.0.0 | Shows local push notifications for incoming messages |
| flutter_foreground_task | ^9.2.0 | Keeps the app alive in background to maintain BLE/USB connection |
**ML / AI**
| Package | Version | Purpose |
|---------|---------|---------|
| ml_algo | ^16.0.0 | OLS regression used in `timeout_prediction_service.dart` to predict message ACK timeouts |
| ml_dataframe | ^1.0.0 | DataFrame input format required by ml_algo |
| llamadart | ^0.8.0 | On-device LLM inference used in `translation_service.dart` for message translation |
| flutter_langdetect | ^0.0.1 | Detects a message's source language in `translation_service.dart` before translating |
**Misc**
| Package | Version | Purpose |
|---------|---------|---------|
| http | ^1.2.0 | Fetches tile URLs and any remote API calls |
| url_launcher | ^6.3.0 | Opens URLs in the system browser from linkified chat text |
| share_plus | ^13.1.0 | Shares files (e.g. exported GPX tracks) via the system share sheet |
| package_info_plus | ^10.1.0 | Reads app version/build number displayed in settings |
| web | ^1.1.1 | Web-platform APIs for USB serial and browser detection on Flutter Web |
| intl | any | Internationalization and locale formatting (required by flutter_localizations) |
| build_pipe | ^0.3.1 | CI/CD build pipeline configuration (web release builds with versioned assets) |
| flutter_blue_plus | ^2.1.0 | BLE communication |
| provider | ^6.1.5+1 | State management |
| cupertino_icons | ^1.0.8 | iOS-style icons |
## Platform Configuration
### Android (`android/app/src/main/AndroidManifest.xml`)
- `INTERNET` (map tiles, translation model downloads)
- `BLUETOOTH`, `BLUETOOTH_ADMIN` (API ≤ 30)
- `BLUETOOTH_SCAN` (with `neverForLocation`), `BLUETOOTH_CONNECT`, `BLUETOOTH_ADVERTISE` (API 31+)
- `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` (BLE scanning on API ≤ 30)
- `POST_NOTIFICATIONS` (API 33+)
- `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE` (background BLE/USB connection)
- `WAKE_LOCK`
- `CAMERA` (QR scanning, declared as optional feature)
- USB host hardware feature (optional)
`flutter_foreground_task` registers a `ForegroundService` with `foregroundServiceType="connectedDevice"` and `stopWithTask="false"`.
**Build config (`android/app/build.gradle.kts`)**: `applicationId = com.meshcore.meshcore_open`, NDK `29.0.14206865`, Java 8 core-library desugaring (`desugar_jdk_libs:2.1.4`), release signing via `key.properties` (debug fallback).
- `BLUETOOTH`, `BLUETOOTH_ADMIN` (API 30 and below)
- `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `BLUETOOTH_ADVERTISE` (API 31+)
- `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` (for BLE scanning)
### iOS (`ios/Runner/Info.plist`)
- `NSBluetoothAlwaysUsageDescription`, `NSBluetoothPeripheralUsageDescription`
- `NSCameraUsageDescription` (QR scanning to join communities)
- Background modes: `bluetooth-central`
- `LSApplicationQueriesSchemes`: `http`, `https`
### Web (`web/`)
PWA scaffold present but boilerplate (`manifest.json` and `index.html` are unmodified Flutter defaults). BLE is unsupported in browsers; TCP and Web Serial USB may work in Chrome only. `ChromeRequiredScreen` gates non-Chrome web users. Versioned releases are produced via `build_pipe` (`?v=<pubspec version>` cache busting, no service worker).
### Desktop
`linux/`, `windows/`, and `macos/` directories are present as Flutter scaffolds. No app-specific native config has been added; BLE on desktop has not been validated.
- `NSBluetoothAlwaysUsageDescription`
- `NSBluetoothPeripheralUsageDescription`
## Coding Conventions
@@ -324,14 +123,14 @@ PWA scaffold present but boilerplate (`manifest.json` and `index.html` are unmod
| File | Purpose |
|------|---------|
| `lib/main.dart` | App configuration, MultiProvider setup, theme, locale, initial route |
| `lib/connector/meshcore_connector.dart` | Unified BLE/TCP/USB transport state holder |
| `lib/connector/meshcore_protocol.dart` | Frame size limits and protocol version |
| `lib/connector/meshcore_uuids.dart` | NUS UUIDs and BLE scan name prefixes |
| `lib/services/app_settings_service.dart` | App-wide settings (`AppSettings` JSON in SharedPreferences) |
| `lib/services/storage_service.dart` | Path history + delivery observation persistence |
| `lib/services/message_retry_service.dart` | ACK tracking + retry scheduling |
| `lib/services/translation_service.dart` | On-device LLM translation (llamadart) |
| `lib/storage/prefs_manager.dart` | SharedPreferences singleton initialized in `main()` |
| `lib/screens/scanner_screen.dart` | Home screen — BLE scan and connect |
| `pubspec.yaml` | Dependencies and project metadata (current version `9.5.0+13`) |
| `lib/connector/meshcore_connector.dart` | All BLE logic - scanning, connecting, data transfer |
| `lib/screens/scanner_screen.dart` | Entry point UI, device list |
| `lib/main.dart` | App configuration, theme, Provider setup |
| `pubspec.yaml` | Dependencies and project metadata |
## Placeholder Screens
The following screens are implemented as placeholders and need full implementation:
- `chat_screen.dart` - Mesh chat functionality
- `contacts_screen.dart` - Contact management
- `settings_screen.dart` - Radio settings, node identity, location (partially implemented)
+3 -10
View File
@@ -6,8 +6,6 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices.
MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
**Website:** [meshcoreopen.org](https://meshcoreopen.org/)
<a href="http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/zjs81/meshcore-open">
<img src="assets/badges/badge_obtainium.png" height="80" align="center" alt="Get it on Obtainium"/>
</a>
@@ -48,7 +46,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
- **Live Map View**: Real-time visualization of mesh network nodes on an interactive map
- **Node Filtering**: Filter by node type (chat, repeater, sensor) and time range
- **Location Sharing**: Share GPS coordinates and custom markers with contacts
- **Offline Maps**: Download map tiles for offline use in remote areas (with [StadiaMaps](https://stadiamaps.com/pricing/) Free Subscription API-Key)
- **Offline Maps**: Download map tiles for offline use in remote areas
- **MGRS Coordinates**: Support for Military Grid Reference System coordinate format
### Device Management
@@ -94,12 +92,12 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|---------|---------|
| flutter_blue_plus | Bluetooth Low Energy communication |
| provider | State management |
| shared_preferences | Local key-value storage (scoped per device) |
| sqflite | Local database storage |
| flutter_map | Interactive map display |
| latlong2 | Geographic coordinate handling |
| flutter_local_notifications | Background notification support |
| smaz | Message compression |
| pointycastle | Cryptographic operations |
| llamadart | On-device LLM message translation |
| intl | Internationalization and date formatting |
## Getting Started
@@ -193,7 +191,6 @@ Devices are discovered by scanning for BLE advertisements with known MeshCore de
- `WisCore-`
- `HT-`
- `LowMesh_MC_`
- `NRF52`
New device prefixes can be added in `lib/connector/meshcore_uuids.dart`.
@@ -224,10 +221,6 @@ Messages are transmitted as binary frames using a custom protocol optimized for
This is an open-source project. Contributions are welcome!
## SWHID and Archive badge
[![SWH](https://archive.softwareheritage.org/badge/origin/https://github.com/zjs81/meshcore-open/)](https://archive.softwareheritage.org/browse/origin/?origin_url=https://github.com/zjs81/meshcore-open)
[![SWH](https://archive.softwareheritage.org/badge/swh:1:dir:d37a80b06359730864150ad2aeadd46cce9abd55/)](https://archive.softwareheritage.org/swh:1:dir:d37a80b06359730864150ad2aeadd46cce9abd55;origin=https://github.com/zjs81/meshcore-open;visit=swh:1:snp:47656c4b55ab40a689ff8d2f045196725f05096b;anchor=swh:1:rev:0fe250230905fdd05dbedc0f546736990beacf53)
### Development Guidelines
- Follow the Flutter style guide
+3 -23
View File
@@ -43,22 +43,9 @@ android {
// arguments += listOf("-DANDROID_STL=c++_shared")
// }
// }
// arm64-v8a only, deliberately.
//
// * ONNX Runtime (flutter_onnxruntime, used by the AEIC-SE image codec)
// ships a per-ABI .so. arm64-v8a alone costs ~18 MB of APK; a
// universal APK carrying armeabi-v7a and x86_64 as well costs ~56 MB.
// * llamadart only declares android-arm64 and android-x64 backends in
// pubspec.yaml's `hooks.user_defines`, so an armeabi-v7a build already
// has no translation backend at all.
// * The image codec needs ~2.7 GiB peak resident, which no 32-bit
// address space can provide regardless of ABI.
//
// Consequence: this APK will not install on 32-bit-only ARM devices or on
// x86_64 emulators. For emulator work, temporarily add "x86_64" here.
ndk {
abiFilters += listOf("arm64-v8a")
}
// ndk {
// abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64")
// }
}
signingConfigs {
@@ -80,13 +67,6 @@ android {
} else {
signingConfigs.getByName("debug")
}
// ONNX Runtime resolves its Java classes from native code by name.
// Without these rules R8 renames them and the process SIGABRTs with
// "java_class == null" the instant the codec runs a model.
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
-19
View File
@@ -1,19 +0,0 @@
# ONNX Runtime looks its Java classes up from native code with FindClass /
# GetMethodID, by literal name. R8 renames them, the lookup returns null, and
# the process dies with:
#
# JNI DETECTED ERROR IN APPLICATION: java_class == null
# at art::JNI<false>::GetMethodID
# from convertToTensorInfo -> Java_ai_onnxruntime_OrtSession_run
#
# It is a hard SIGABRT in native code, so nothing in Dart can catch it: the app
# vanishes the moment the codec touches the model. Keep the whole package
# these are the types the native layer reflects on to build tensors and read
# results back.
-keep class ai.onnxruntime.** { *; }
-keepclassmembers class ai.onnxruntime.** { *; }
-dontwarn ai.onnxruntime.**
# The Flutter plugin's platform-channel handler, reached the same way.
-keep class com.masicai.flutteronnxruntime.** { *; }
-dontwarn com.masicai.flutteronnxruntime.**
+1 -1
View File
@@ -22,7 +22,7 @@
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
<application
android:label="MeshCore Open"
android:label="meshcore_open"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<service
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#9F9D95</color>
</resources>
-4
View File
@@ -1,7 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
-93
View File
@@ -1,93 +0,0 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
-93
View File
@@ -1,93 +0,0 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 KiB

+1 -2
View File
@@ -14,8 +14,7 @@ MeshCore Open is an open-source Flutter client for MeshCore LoRa mesh networking
8. [Notifications](notifications.md) - System notifications, unread badges, and notification preferences
9. [Repeater Management](repeater-management.md) - Repeater hub, status, CLI, telemetry, and neighbors
10. [Additional Features](additional-features.md) - GIF picker, localization, debug logs, SMAZ compression, and more
11. [Routing Paths](routing-paths.md) - Path encoding, validation, device capability detection, and storage
12. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture
11. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture
## App Overview
+3 -78
View File
@@ -25,8 +25,8 @@ A bottom sheet with a search field and a grid of GIF thumbnails.
### How to Access
App Settings → Appearance → Language
### Supported Languages (18)
English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian, Hungarian, Japanese, Korean
### Supported Languages (15)
English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian
### How It Works
- All UI strings go through Flutter's ARB localization system
@@ -183,80 +183,5 @@ An ML-based service that predicts expected delivery timeouts:
- Applies a **1.5x safety margin** to raw predictions (the actual timeout issued is 1.5× the model's predicted delivery time)
- Features with zero variance are automatically excluded from training
- Blends per-contact statistics with ML predictions
- Falls back to `3000 + 3000 × pathLength` ms when insufficient data. Note: `pathLength` here refers to the stored hop count in the app's model/storage (number of hops), not the on-air encoded byte length.
- Falls back to `3000 + 3000 × pathLength` ms when insufficient data
- Observations are persisted to storage via a 2-second debounced timer (observations within 2s of app termination may be lost)
---
## On-Device Message Translation
### What It Is
An optional on-device translation service powered by an embedded LLM (llamadart, running GGUF models). Translation runs entirely on-device — no data leaves the app.
### How to Access
Tap the translate button on any received message. On first use, the GGUF model file is downloaded and cached locally.
### How It Works
- Model files are managed by `TranslationFileStore`; download progress is shown in-place
- Before translating, the source language is automatically detected using the `flutter_langdetect` package. If the detected language already matches the target language, translation is skipped
- Translation runs via `TranslationService` using the llamadart CPU backend (arm64 and x64 on Android)
- Translated text is shown in `TranslatedMessageContent` as an inline overlay on the original message bubble
- Each translation is cached; re-tapping shows the cached result without re-running inference
---
## Emoji Reactions
### How to Access
Long-press a message bubble in any direct or channel chat, then select a reaction emoji.
### What the User Sees
An emoji picker inline with common reactions. Selected reactions appear below the message bubble with a count.
### How It Works
- Implemented via `emoji_picker.dart` and `reaction_helper.dart`
- Reactions are transmitted as a special message type visible to all participants with MeshCore Open
---
## Linkification
### What It Does
URLs and `meshcore://` URIs in received messages are automatically detected and rendered as tappable links.
### How It Works
- Powered by the `flutter_linkify` package via `link_handler.dart`
- Tapping a URL opens the system browser; tapping a `meshcore://` URI imports the contact
---
## GPX Export
### How to Access
Settings → Export section (three options: Export Repeaters, Export Contacts, Export All).
### What It Does
Exports contacts with GPS coordinates to a `.gpx` file via the OS share sheet. Not available on web.
---
## Pinch-to-Zoom Chat Text
### What It Does
Users can pinch to scale all chat text up or down within a session.
### How It Works
- Implemented via `ChatTextScaleService` and `ChatZoomWrapper`
- Scale range: 0.8× to 1.8×
- The chosen scale persists across the session via the service
---
## Background Service (Android)
### What It Does
On Android, a foreground service (`background_service.dart`) keeps the BLE connection and message handling alive when the app is in the background. On other platforms this is a no-op.
### User Impact
- A persistent notification appears while the service is running
- Messages are received and retry logic continues even when the app is not in the foreground
+4 -12
View File
@@ -33,6 +33,7 @@ RX (device → host): [0x3E][len_lo][len_hi][payload...]
- Length: 2-byte little-endian, payload only
- Max payload: 172 bytes
- TCP: `tcpNoDelay: true` (Nagle disabled), writes serialized to prevent interleaving
- USB: 10ms post-write delay between frames
## Connection State Machine
@@ -52,12 +53,9 @@ enum MeshCoreConnectionState {
- `MeshCore-`
- `Whisper-`
- `WisCore-`
- `Seeed`
- `Lilygo`
- `HT-`
- `LowMesh_MC_`
- `NRF52`
2. **Connect** with 15-second timeout (6 seconds on Linux)
2. **Connect** with 15-second timeout
3. **Request MTU** 185 bytes (non-web only)
4. **Discover services** and locate NUS
5. **Enable TX notifications** (up to 3 attempts on native)
@@ -81,7 +79,7 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
| Max path size | 64 bytes | Maximum path data |
| Max name size | 32 bytes | Maximum node name |
| Max text payload | 160 bytes | Firmware `MAX_TEXT_LEN` |
| App protocol version | 4 | Sent in device query |
| App protocol version | 3 | Sent in device query |
| Contact frame size | 148 bytes | Fixed-size contact record |
## Command Codes (App → Device)
@@ -116,17 +114,13 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
| 32 | CMD_SET_CHANNEL | Set channel name and PSK |
| 36 | CMD_SEND_TRACE_PATH | Request path trace |
| 38 | CMD_SET_OTHER_PARAMS | Set misc parameters |
| 39 | CMD_SEND_TELEMETRY_REQ | Request sensor telemetry |
| 39 | CMD_GET_TELEMETRY_REQ | Request sensor telemetry |
| 40 | CMD_GET_CUSTOM_VAR | Get custom variables |
| 41 | CMD_SET_CUSTOM_VAR | Set a custom variable |
| 50 | CMD_SEND_BINARY_REQ | Send binary request |
| 54 | CMD_SET_FLOOD_SCOPE | Set flood routing scope (v8+) |
| 55 | CMD_SEND_CONTROL_DATA | Send control data (e.g. zero-hop discovery, v8+) |
| 56 | CMD_GET_STATS | Request companion radio stats |
| 57 | CMD_SEND_ANON_REQ | Send anonymous request |
| 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration |
| 59 | CMD_GET_AUTO_ADD_CONFIG | Get auto-add configuration |
| 61 | CMD_SET_PATH_HASH_MODE | Set path hash width (bytes per hop) |
## Response / Push Codes (Device → App)
@@ -150,7 +144,6 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
| 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 | Incoming channel message (v3) |
| 18 | RESP_CODE_CHANNEL_INFO | Channel definition |
| 21 | RESP_CODE_CUSTOM_VARS | Custom variables |
| 24 | RESP_CODE_STATS | Companion radio stats |
| 25 | RESP_CODE_AUTO_ADD_CONFIG | Auto-add flags |
| 0x80 | PUSH_CODE_ADVERT | Known contact re-seen |
| 0x81 | PUSH_CODE_PATH_UPDATED | Better path found; carries the 32-byte public key of the updated contact |
@@ -164,7 +157,6 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
| 0x8A | PUSH_CODE_NEW_ADVERT | New node discovered |
| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Sensor telemetry data |
| 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary data response |
| 0x8E | PUSH_CODE_CONTROL_DATA | Control data push (e.g. zero-hop discovery response) |
## Data Models
+21 -11
View File
@@ -4,7 +4,7 @@
Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh.
The number of active channels is determined by the firmware (default 40); the device reports its actual limit at login.
Up to 8 channels (indices 07) can be active simultaneously on one device.
## How to Access
@@ -17,7 +17,7 @@ QuickSwitchBar tab 1 (middle) from any main screen.
| Public | Globe | Green | Fixed well-known PSK; any device can join |
| Hashtag | Hash tag | Blue | PSK derived from the hashtag name via SHA-256; discoverable by convention |
| Private | Lock | Blue | Random PSK; requires out-of-band sharing of the 32-hex key |
| Community | Groups/Tag | Magenta | PSK derived via HMAC-SHA256 from a community's shared secret |
| Community | Groups/Tag | Purple | PSK derived via HMAC-SHA256 from a community's shared secret |
## Channels List Screen
@@ -26,12 +26,13 @@ QuickSwitchBar tab 1 (middle) from any main screen.
- **Search bar** with live text filtering (300ms debounce)
- **Sort/filter button**
- **Scrollable list of channel cards**, each showing:
- Type icon with color coding (magenta badge overlay for community channels)
- Type icon with color coding (purple badge overlay for community channels)
- Channel name (or "Channel N" if unnamed)
- Subtitle: "Public channel", "Hashtag channel", "Private channel", or "Community channel - {name}"
- Unread badge (if messages are unread)
- Drag handle (when manual sort is active)
- **"+" FAB** to add a new channel
- **Overflow menu**: Disconnect, Manage Communities, Settings
- **Overflow menu**: Disconnect, Manage Communities (only shown when at least one community exists), Settings
If no channels exist, an empty state with an "Add Public Channel" shortcut is shown. If a search produces no results, a separate "no results" empty state with a search-off icon is shown.
@@ -59,7 +60,7 @@ Tap the "+" FAB to open a dialog with six options:
| Action | Description |
|---|---|
| Edit | Change name, PSK (with a dice icon to generate a random PSK), SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit), or Cyr2Lat encoding toggle (transliterates Cyrillic to Latin for compatibility) |
| Edit | Change name, PSK (with a dice icon to generate a random PSK), or SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit) |
| Mute / Unmute | Toggle push notification suppression for this channel |
| Delete | Remove the channel from the device (confirmation required) |
@@ -69,9 +70,9 @@ Tap a channel card to open the channel chat screen.
### App Bar
- Type icon: globe for public channels, tag (#) for all other channel types
- Type icon (public/private/hashtag)
- Channel name
- Subtitle: "{Public|Private} {N} unread" (e.g., "Public • 3 unread")
- Subtitle: "{type} - {N} unread"
### Message Display
@@ -100,7 +101,8 @@ Tap a channel card to open the channel chat screen.
### Message Path Viewing
- **All platforms**: Long-press (or right-click on desktop) a message bubble → "Path"
- **Mobile**: Tap a message bubble to view its routing path
- **Desktop**: Long-press/right-click → "Path" (tapping the bubble does nothing on desktop)
- Opens the Channel Message Path Screen (see [Additional Features](additional-features.md))
### Context Actions (Long-Press / Right-Click)
@@ -108,12 +110,20 @@ Tap a channel card to open the channel chat screen.
| Action | Availability | Description |
|---|---|---|
| Reply | All messages | Triggers reply mode |
| Path | All messages | Opens message path view |
| Path | Desktop only | Opens message path view |
| Add Reaction | Incoming messages only | Opens emoji picker (cannot react to your own messages) |
| Copy | All messages | Copies text to clipboard |
| Mark as Unread | Incoming messages only | Marks this message and all subsequent incoming messages as unread |
| Delete | All messages | Removes locally (not from mesh) |
### Message Path Viewing
Tap a message bubble to open the Channel Message Path Screen, which shows:
- Each hop in the path as a visual chain
- Known contacts identified by name at each hop
- Observed vs. declared hop counts
- Alternative path variants (if received via multiple paths)
- Map view buttons for geographic path visualization
## Communities
Communities are a layer above channels that provide a private namespace.
@@ -141,7 +151,7 @@ From the channels screen overflow menu → "Manage Communities". Opens a draggab
- **Tap a community** to directly show its QR code for sharing
- **Popup menu** per community:
- **Show QR** — displays the QR code for sharing with new members
- **Leave Community** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed)
- **Delete** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed)
## How Channels Differ from Direct Messages
+9 -10
View File
@@ -18,15 +18,17 @@ From the Contacts screen, tap any Chat-type contact to open the ChatScreen.
- **Title**: Contact name
- **Subtitle**: Current routing path label (e.g., "2 hops", "flood (auto)", "direct (forced)") and unread count. Tapping the subtitle shows the full path details.
- **Action button**:
- **Overflow menu** (⋮ icon): Contains Routing, Info, Telemetry, Settings, and Clear Chat. Routing opens the routing sheet where you can switch between Auto, Direct, and Flood routing and manage recent paths (hop count, round-trip time, age, success count, color-coded by repeater). Info shows a dialog with contact type, path, GPS coordinates, and public key.
- **Action buttons**:
- **Routing mode** (waves icon): Switch between Auto, Direct, and Flood routing
- **Path management** (timeline icon): View recent paths with hop count, round-trip time, age, and success count. Paths are color-coded by direct repeater (green/yellow/red/blue for ranked repeaters, grey for unknown). Tap a path to activate it (the device verifies and confirms via snackbar), long-press to view full path details, set custom paths, or force flood mode. A warning banner appears when history reaches 100 entries.
- **Info** (info icon): Contact info dialog showing type, path, GPS coordinates, public key, and SMAZ compression toggle
### Message List
- Scrollable list with newest messages at the bottom
- **Outgoing messages**: Right-aligned, primary color background. **Failed messages** change to a red-toned error container background
- **Incoming messages**: Left-aligned, grey background with a colored avatar (initial letter or first emoji of sender name; color is deterministic from a hash of the sender name)
- Bubble width capped at 72% of screen width
- Bubble width capped at 65% of screen width
- Hyperlinks rendered as tappable green underlined text
- **Pinch-to-zoom**: Two-finger zoom (0.8x1.8x) and double-tap to reset
- **Jump to bottom**: Floating button appears when scrolled away from the bottom
@@ -35,7 +37,6 @@ From the Contacts screen, tap any Chat-type contact to open the ChatScreen.
### Input Bar
- **GIF button** (left): Opens GIF picker bottom sheet
- **Translation button** (optional, between GIF and text field): Shown only when translation is enabled in App Settings. Tap to configure outgoing-message translation language and on/off toggle.
- **Text field** (center): Auto-capitalization, enforces UTF-8 byte limit in real-time
- **Send button** (right): Submits the message
- On desktop: Enter/Numpad Enter also submits
@@ -65,8 +66,8 @@ Outgoing messages display a status indicator:
When enabled in App Settings, additional metadata appears inside each bubble:
- Timestamp (HH:MM)
- Retry count (e.g., "Retry 2 of 4") — only shown for outgoing messages where at least one retry has occurred
- Status icon (outgoing only)
- Retry count (e.g., "Retry 2 of 4")
- Status icon
- Round-trip time in seconds (if delivered)
## Message Length Limits
@@ -85,7 +86,7 @@ When a direct message is sent:
1. The app computes an expected ACK hash: `SHA256([timestamp][attempt][text][selfPubKey])[0:4]` — matching the firmware's hash calculation. If SMAZ compression is enabled, the compressed text (not the original) is hashed
2. On device acknowledgment (`RESP_CODE_SENT`), the message transitions to "sent" and a timeout timer starts
3. **Timeout duration**: Preferably from the ML timeout prediction service; otherwise from the device's own `est_timeout` in `RESP_CODE_SENT` (clamped to the physics range); otherwise calculated from LoRa airtime physics: `500 + (airtime × 6 + 250) × (pathLength + 1)` ms for direct paths, `500 + 16 × airtime` ms for flood (airtime is estimated from the radio's current spreading factor, bandwidth, and coding rate). The result is capped at 45 seconds.
3. **Timeout duration**: Preferably from the ML timeout prediction service; otherwise `3000 + 3000 × path_length` milliseconds (15000ms for flood)
4. On timeout, the message is retried with **exponential backoff**: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s, 16s...)
5. **Max retries**: Configurable (default 5, range 210)
6. After max retries, the message is marked "failed" — but a **30-second grace window** remains during which a late ACK can still resolve the message to "delivered"
@@ -112,10 +113,8 @@ Add emoji reactions to incoming messages (not your own):
| Action | Availability | Description |
|---|---|---|
| Add reaction | Incoming messages only | Opens emoji picker |
| View path | All platforms: long-press/right-click menu | Shows message routing path |
| View path | Mobile: tap bubble directly; Desktop: long-press/right-click menu | Shows message routing path |
| Copy | All messages | Copies text to clipboard |
| Translate | Incoming messages only (when translation is enabled and not yet translated) | Translates the message on-demand using the on-device model |
| Mark as Unread | Incoming messages only | Marks this message and all subsequent incoming messages as unread |
| Delete | All messages | Removes locally (not from mesh) |
| Retry | Failed outgoing messages | Re-sends the message |
| Open chat with sender | Room server chats | Opens 1:1 chat with the message sender |
+18 -18
View File
@@ -6,17 +6,18 @@ The Contacts screen is the primary hub for managing mesh nodes your radio has a
## How to Access
- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens (Channels is shown first after connecting)
- Automatically shown after connecting to a device
- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens
- Back navigation from Chat or Settings screens
## Contact Types
| Type | Avatar Color | Icon | Description |
|---|---|---|---|
| Chat | Blue | Initials / emoji | Another user's mesh radio |
| Repeater | Amber | Cell tower | A mesh repeater/relay node |
| Room | Magenta | Meeting room | A room server for group chat |
| Sensor | Teal | Sensors | A sensor device |
| Chat | Blue | Chat bubble | Another user's mesh radio |
| Repeater | Orange | Cell tower | A mesh repeater/relay node |
| Room | Purple | Group | A room server for group chat |
| Sensor | Green | Sensors | A sensor device |
## Contact List
@@ -72,31 +73,30 @@ Groups are stored per radio identity (scoped by public key).
| Action | Availability | Description |
|---|---|---|
| Ping | Repeaters only | Opens PathTraceMapScreen targeting the repeater |
| Path Trace | Rooms (always); Chat/Sensor only if `pathLength > 0` | Opens PathTraceMapScreen. For rooms, label shows "Ping" when no path bytes are known, "Path Trace" when path bytes are available |
| Path Trace / Ping | Repeaters, Rooms (always); Chat if `pathLength > 0` | Opens PathTraceMapScreen. Label shows "Ping" when no path bytes are known, "Path Trace" otherwise |
| Manage Repeater | Repeaters only | Login dialog → RepeaterHubScreen |
| Room Login | Rooms only | Login dialog → ChatScreen |
| Room Management | Rooms only | Login dialog → RepeaterHubScreen (management mode) |
| Open Chat | Chat/Sensor | Same as single tap |
| Add/Remove Favorite | All types | Toggles the favorite flag |
| Share Contact | All types | Requests advert from device → copies `meshcore://<hex>` URI to clipboard |
| Share Contact | All types | Copies `meshcore://<hex>` URI to clipboard |
| Share Contact Zero-Hop | All types | Broadcasts the contact's advertisement one hop |
| Delete Contact | All types | Confirmation dialog → removes from device and clears messages |
## App Bar Menus
The Contacts screen has a single **three-dot overflow menu** (`⋮`) in the app bar:
The Contacts screen has **two separate popup menus** in the app bar:
- Discovered Contacts — opens the DiscoveryScreen
- Add Contact from Clipboard — reads a `meshcore://<hex>` URI from clipboard and imports it
- *(divider)*
**Antenna icon menu** (contact sharing):
- Zero-Hop Advert — broadcasts your advertisement to immediately adjacent nodes
- Flood Advert — broadcasts across the full mesh network
- Copy Advert to Clipboard — copies your `meshcore://<hex>` URI for sharing externally
- *(divider)*
- Disconnect — disconnects from the device
- Settings — opens the Settings screen
- Add Contact from Clipboard — reads a `meshcore://<hex>` URI from clipboard and imports it
A **floating action button** (person-add icon) provides a shortcut sheet to "Add Contact from Clipboard" or "Discovered Contacts".
**Three-dot overflow menu**:
- Disconnect — disconnects from the device
- Discovered Contacts — opens the DiscoveryScreen
- Settings — opens the Settings screen
## Adding Contacts
@@ -104,10 +104,10 @@ A **floating action button** (person-add icon) provides a shortcut sheet to "Add
When the radio hears an advertisement, the contact appears automatically if auto-add is enabled for that type (configurable in Settings → Contact Settings).
### Import from Clipboard
Overflow menu (or the FAB shortcut) → "Add Contact from Clipboard". Reads a `meshcore://<hex>` URI from clipboard and imports it to the device.
Antenna menu → "Add Contact from Clipboard". Reads a `meshcore://<hex>` URI from clipboard and imports it to the device.
### Import from Discovered Contacts
Overflow menu → "Discovered Contacts". Shows nodes heard passively that haven't been added yet. Tap to immediately import (no confirmation dialog), or long-press for more options (Copy URI, Delete). The Discovery screen has its own search bar, type filters (Users, Repeaters, Rooms), and sort options (Last Seen, A-Z). An overflow "Delete All" option clears all discovered contacts.
Overflow menu → "Discovered Contacts". Shows nodes heard passively that haven't been added yet. Tap to immediately import (no confirmation dialog), or long-press for more options (Add, Copy URI, Delete). The Discovery screen has its own search bar, type filters (Users, Repeaters, Rooms, Favorites), and sort options (Last Seen, A-Z). An overflow "Delete All" option clears all discovered contacts.
## Contact Sharing Format
+21 -28
View File
@@ -25,7 +25,7 @@ All contacts with known GPS coordinates are plotted:
| Room | Purple | Meeting room |
| Sensor | Orange | Sensors |
Node name labels appear automatically at zoom level 14 and above.
Node name labels appear automatically at zoom level 12 and above.
### Shared Map Pins (Flag Icons)
Location pins shared in chat messages are displayed as flags:
@@ -35,9 +35,9 @@ Location pins shared in chat messages are displayed as flags:
Tap a pin to see its info. Options to "Hide" (session only) or "Remove" (persistent).
### Predicted / Guessed Locations
### Predicted / Guessed Locations (Semi-Transparent)
Many contacts on the mesh don't have GPS hardware, so the map has no explicit coordinates for them. Instead of leaving these contacts invisible, the app **infers an approximate position** by analyzing the repeater path the contact's messages travel through. These inferred positions are displayed as markers with a `not_listed_location` icon and a muted grey or colored border, visually distinct from confirmed-location markers.
Many contacts on the mesh don't have GPS hardware, so the map has no explicit coordinates for them. Instead of leaving these contacts invisible, the app **infers an approximate position** by analyzing the repeater path the contact's messages travel through. These inferred positions are displayed as semi-transparent markers with a `not_listed_location` icon, visually distinct from confirmed-location markers.
#### Why guessed locations exist
@@ -55,19 +55,19 @@ In a mesh network, every message hops through one or more repeaters on its way t
5. **Compute the estimated position**:
- **Single anchor**: The contact is placed on a small circle (330m radius) around the repeater. The angle on the circle is deterministic — derived from an FNV-1a hash of the contact's public key — so the same contact always appears at the same offset, preventing markers from stacking on top of each other.
- **Two or more anchors**: The position is a weighted average of all anchor coordinates (each subsequent anchor weighted at half the previous one, biasing toward the first), with a smaller offset radius (120m for 2 anchors, 80m for 3+) applied for visual separation.
- **Two or more anchors**: The position is the average (centroid) of all anchor coordinates, with a smaller offset radius (80120m) applied for visual separation.
6. **Assign confidence level**:
- **High confidence** (2+ anchors): The marker border uses the node's type color (brighter border).
- **Low confidence** (1 anchor): The marker border is rendered in a muted grey.
- **High confidence** (2+ anchors): Displayed at 55% opacity.
- **Low confidence** (1 anchor): Displayed at 30% opacity.
7. **Cache the result**: The computation is cached using a key derived from the contact's paths, anchor positions, path-history version, and radio parameters. The cache is only invalidated when any of these inputs change, avoiding recomputation on every UI rebuild.
#### How to read guessed locations on the map
- **Marker with `not_listed_location` icon**: This is a guessed position, not a confirmed GPS fix.
- **Colored border** (type color): Higher confidence — the contact was seen through 2 or more repeaters with known positions.
- **Grey border**: Lower confidence — based on a single repeater anchor only.
- **Semi-transparent marker** with a `not_listed_location` icon: This is a guessed position, not a confirmed GPS fix.
- **More opaque** (55%): Higher confidence — the contact was seen through 2 or more repeaters with known positions.
- **More transparent** (30%): Lower confidence — based on a single repeater anchor only.
- Coordinates shown in the marker info dialog are prefixed with `~` to indicate they are estimated.
- Guessed locations can be toggled on/off in the map filter dialog (FAB → "Guessed locations" toggle).
@@ -88,10 +88,10 @@ Shows a bottom sheet with:
- **Set as my location**: Updates your device's advertised location
### Filter Dialog (FAB)
Toggle visibility of: chat nodes, repeaters, other nodes, guessed locations, discovery contacts, overlapping markers (stacked markers at similar coordinates), and shared map pins (flag markers).
Toggle visibility of: chat nodes, repeaters, other nodes, guessed locations, discovery contacts.
Additional filters:
- **Key prefix filter**: Show only contacts whose public key starts with a given prefix
- **Last-seen time slider**: Exponential scale from near-zero to 6 months, with "all time" at the top end
- **Last-seen time slider**: From 1 hour to "all time"
### Legend Card (Top-Right)
Shows node count and pin count. Tappable to expand a legend of all marker types.
@@ -110,16 +110,9 @@ A map with a polyline showing the route from your node through repeater hops to
- **Green circles**: Hops with known GPS coordinates
- **Orange circles** (`~HH`): Inferred positions (no GPS but deducible from contacts)
- **Red endpoint**: Target contact with known GPS
- **Magenta endpoint**: Target with guessed position
- **Purple semi-transparent endpoint**: Target with guessed position
A bottom panel shows each hop pair with SNR quality icons and total path distance. When multiple observed paths are available, a **Single / Combined** toggle appears at the top of the map. In Combined view, all paths are overlaid; shared segments are highlighted with a white halo and a path count badge appears on shared nodes.
The bottom panel also provides **packet animation controls**:
- **Animation toggle** (on/off)
- **Step back / Play / Step forward / Replay** buttons
- **Follow packet lock** — keeps the map camera centered on the moving packet dot
- **Speed selector** (0.5×, 1×, 2×, 4×)
- A live **"Hop x of y · from → to"** label that tracks the active segment
A legend card at the bottom lists each hop pair with SNR quality icons and total path distance.
### How It Works
Sends a trace request frame over the mesh. The repeater network traces the path hop-by-hop and returns per-hop SNR data. For hops without GPS, positions are inferred by averaging GPS coordinates of contacts sharing that last-hop byte.
@@ -132,17 +125,17 @@ Sends a trace request frame over the mesh. The repeater network traces the path
From the main map, tap the terrain/antenna icon.
### What the User Sees
A full-screen map with a draggable bottom sheet containing:
- **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow); obstruction points are marked as clickable dots on the chart
- **Status summary**: Clear (green), Marginal (amber, within 5 m of obstruction), or Blocked (red) with distance and clearance/obstruction amount
- **Options section** (collapsible): Node toggles, endpoint dropdowns, antenna height sliders (0400 ft), Run LOS button
A full-screen map with a collapsible control panel containing:
- **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow)
- **Status**: Clear (green) or blocked (red) with distance and minimum clearance
- **Options panel**: Node toggles, endpoint dropdowns, antenna height sliders (0400 ft), Run LOS button
### Key Interactions
- **Long-press the map** to add custom endpoints (pushpin markers, renameable/deleteable)
- **Long-press the map** to add custom endpoints (orange pushpin markers, renameable/deleteable)
- **Tap a marker** to select it as Point A or B; LOS runs automatically when both are set
- **Antenna heights** are adjustable for both endpoints
- **Map line** between endpoints is colored green (clear), amber (marginal), or red (blocked)
- Terrain elevation is fetched from the Open-Meteo API (21, 41, or 81 sample points depending on link distance, cached 24 hours)
- **Map line** between endpoints is colored green (clear) or red (blocked)
- Terrain elevation is fetched from the Open-Meteo API (2181 sample points, cached 24 hours)
- K-factor is adjusted per radio frequency from a baseline of 4/3 at 915 MHz
---
@@ -156,7 +149,7 @@ Settings → App Settings → Map Display → Offline Map Cache
- Map with a blue polygon overlay showing previously selected cache bounds
- Bounding box coordinates card
- **Cache Area** controls: "Use Current View" and Clear buttons
- **Zoom Range** range slider (318, dual-handle for min and max) with estimated tile count
- **Zoom Range** slider (318) with estimated tile count
- **Download progress** bar (when downloading)
- **Download Tiles** and **Clear Cache** buttons
+27 -12
View File
@@ -5,7 +5,7 @@
The app follows this general flow:
```
Launch → Scanner Screen → [Connect via BLE/USB/TCP] → Channels Screen
Launch → Scanner Screen → [Connect via BLE/USB/TCP] → Contacts Screen
```
After connecting, the three main screens (Contacts, Channels, Map) are accessible via a persistent bottom navigation bar called the **QuickSwitchBar**.
@@ -22,27 +22,42 @@ The QuickSwitchBar is a Material 3 `NavigationBar` with a frosted-glass visual t
Tapping a tab replaces the current screen with a subtle fade + slight horizontal nudge transition (220ms forward, 200ms reverse). The back button is suppressed on all three main screens — navigation between them is flat, not stacked. All icons use outline variants (`people_outline`, `tag`, `map_outlined`) following Material 3 conventions.
## Disconnection
## Device Screen
- The disconnect button (available in the overflow menu of each main screen) shows a confirmation dialog before disconnecting
The Device Screen is a transitional hub that shows after connection. In practice, the app navigates directly to Contacts after connecting, but the Device Screen is reachable via the QuickSwitchBar.
### What the User Sees
**App Bar**:
- Left: Battery indicator chip (tappable — toggles between percentage and voltage display). Icon changes based on level: `battery_unknown` when data unavailable, `battery_alert` (orange) at 15% or below, `battery_full` otherwise
- Left-aligned title (`centerTitle: false`): Two-line layout — small grey "MeshCore" label above the device name in bold
- Right: Disconnect button (`bluetooth_disabled` crossed-out icon) and Settings button (tune icon)
**Body**:
- **Connection Card**: Device avatar, device name, device ID, "Connected" chip, and battery chip
- **Quick Switch** section: The QuickSwitchBar widget for navigating to Contacts/Channels/Map
### Disconnection
- The disconnect button shows a confirmation dialog before disconnecting
- If the device disconnects unexpectedly, the app automatically navigates back to the Scanner screen (fires after the current frame completes via a post-frame callback)
- This auto-navigation behavior (`DisconnectNavigationMixin`) is shared across all main screens
## Theme and Locale
- **Theme mode** is user-configurable in App Settings (System / Light / Dark) — not locked to system
- **Language** can be overridden to one of 18 supported languages, or follow the system locale
- **Language** can be overridden to one of 15 supported languages, or follow the system locale
- On web, if a non-Chromium browser is detected, the app shows a `ChromeRequiredScreen` instead of the Scanner (Web Bluetooth requires Chromium)
## Full Navigation Graph
```
ScannerScreen (root, always on stack)
├─ [BLE connect] → push → ChannelsScreen
├─ [TCP icon button] → push → TcpScreen
│ └─ [TCP connected] → pushReplacement → ChannelsScreen
└─ [USB icon button] → push → UsbScreen
└─ [USB connected] → pushReplacement → ChannelsScreen
├─ [BLE connect] → push → ContactsScreen
├─ [TCP FAB] → push → TcpScreen
│ └─ [TCP connected] → pushReplacement → ContactsScreen
└─ [USB FAB] → push → UsbScreen
└─ [USB connected] → pushReplacement → ContactsScreen
ContactsScreen (selected=0)
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
@@ -60,9 +75,9 @@ ChannelsScreen (selected=1)
MapScreen (selected=2)
├─ [quick-switch 0] → pushReplacement → ContactsScreen
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
├─ [radar menu item] → enters in-map path trace mode (push → PathTraceMapScreen after path is built)
├─ [terrain menu item] → push → LineOfSightMapScreen
└─ [long-press] → share marker sheet
├─ [radar button] → push → PathTraceMapScreen
├─ [terrain button] → push → LineOfSightMapScreen
└─ [long-press] → share marker / set location
Settings (push from any main screen)
└─ [App Settings] → push → AppSettingsScreen
+3 -3
View File
@@ -22,7 +22,7 @@ MeshCore Open provides both **system notifications** (push-style OS alerts) and
### 3. Advertisement Notifications
- **Triggered when**: A new node is discovered on the mesh for the first time
- **Title**: "New [type] discovered" (e.g., "New Chat discovered")
- **Title**: "New [type] discovered" (e.g., "New chat node discovered")
- **Body**: Contact's name
- **Priority**: Default
- **Android channel**: `adverts`
@@ -43,7 +43,7 @@ Red numeric badges appear throughout the UI:
- **Contacts list**: Each contact row shows a red pill badge (e.g., "3") for unread messages
- **Channels list**: Each channel row shows an unread badge
- **Chat screen subtitle**: Shows unread count inline
- Badges cap at "9999+" for display
- Badges cap at "99+" for display
### How Unread Counts Work
@@ -73,7 +73,7 @@ There is no per-contact muting.
The notification system prevents notification storms:
- **Minimum interval**: 3 seconds between individual notifications
- **Batch window**: If multiple notifications arrive within 5 seconds, they are combined into a single summary notification on a fourth Android channel (`batch_summary`). The title is "MeshCore Activity" and the body lists the grouped counts (e.g., "2 messages, 1 channel message, 3 new nodes"). Batch summaries are Android-only; queued notifications that overflow the batch window are silently dropped on other platforms
- **Batch window**: If multiple notifications arrive within 5 seconds, they are combined into a single summary notification on a fourth Android channel (`batch_summary`): "MeshCore Activity2 messages, 1 channel message, 3 new nodes". Note: batch summaries are Android-only; on Apple platforms individual notifications are shown
## Notification Clearing
+42 -77
View File
@@ -17,8 +17,8 @@ From the Contacts screen:
- Password field with show/hide toggle
- "Save password" checkbox (persists for future logins). If a saved password exists, it is pre-filled and the checkbox is pre-checked, making login one-tap
- Routing mode selector and "Manage Paths" link are available directly in the dialog (configure routing before login)
- Auto-retries up to 5 times on timeout, showing progress ("Attempt 2 of 5"). A wrong password (explicit failure response) stops immediately — only timeouts trigger retries
- If auto-clock-sync is enabled for this repeater (configured in Repeater Settings), a `clock sync` command is sent automatically on successful login
- Auto-retries up to 5 times on timeout, showing progress ("Attempt 2 of 5"). A wrong password stops immediately after the first attempt — only timeouts trigger retries
- After 5 failed attempts, further login attempts are blocked
---
@@ -28,17 +28,15 @@ The central management screen showing:
- **Header card**: Repeater name, short public key, path label, GPS coordinates (if known)
- **Battery chemistry selector**: NMC / LiFePO4 / LiPo (saved per repeater)
- **Management tool cards** (full-width cards with chevron arrows, not a grid). Title dynamically shows "Repeater Management" or "Room Management" (admin) or "Repeater Guest" / "Room Guest" (guest) based on contact type and login result:
- **Management tool cards** (full-width cards with chevron arrows, not a grid). Title dynamically shows "Repeater Management" or "Room Management" based on contact type:
| Card | Destination | Visibility |
|---|---|---|
| Status | Repeater Status Screen | All users |
| Telemetry | Telemetry Screen | All users |
| Neighbors | Neighbors Screen | All users |
| CLI | Repeater CLI Screen | Admin only |
| Settings | Repeater Settings Screen | Admin only |
The battery chemistry selector and CLI/Settings cards are hidden from guest users.
| Card | Destination |
|---|---|
| Status | Repeater Status Screen |
| Telemetry | Telemetry Screen |
| CLI | Repeater CLI Screen |
| Neighbors | Neighbors Screen |
| Settings | Repeater Settings Screen |
---
@@ -49,28 +47,26 @@ The battery chemistry selector and CLI/Settings cards are hidden from guest user
Three information cards:
**System Information**:
- Battery percentage and voltage (e.g. "85% / 3.95V"), using the battery chemistry set in the hub screen
- Clock at login time
- Uptime (days/hours/minutes/seconds)
- Battery percentage
- Uptime
- Queue length
- Debug flags (error event count)
- Error flags
- Clock at login time
**Radio Statistics**:
- Last RSSI and SNR
- Noise floor
- TX airtime and RX airtime
- TX and RX airtime
**Packet Statistics**:
- Packets sent and received, each broken down by flood vs. direct
- Duplicates, broken down by flood vs. direct
- Channel utilization (% of uptime used by TX + RX)
- Packets sent, received, and duplicates
- Broken down by flood vs. direct
### Key Interactions
- Auto-queries the repeater on open; shows a loading spinner until data arrives
- On timeout: red snackbar error. On success: data appears in-place (no extra snackbar)
- Pull-to-refresh or refresh button in the app bar to re-query
- On timeout: red snackbar error. On success: data appears with a green snackbar confirmation
- Pull-to-refresh or refresh button to re-query
- Routing mode popup and path management dialog in app bar (these controls appear on **all** management sub-screens, not just Status)
- Accepts both binary `RESP_CODE_STATUS_RESPONSE` frames and legacy JSON text responses
---
@@ -80,7 +76,7 @@ A terminal-style interface for sending commands directly to the repeater.
### What the User Sees
- **Quick-command bar** (horizontal scroll): Shortcut buttons for 9 common commands (advert, get name, get radio, get tx, discover.neighbors, neighbors, ver, clock, clock sync)
- **Quick-command bar** (horizontal scroll): Shortcut buttons for common commands (get name, get radio, get tx, neighbors, ver, advert, clock)
- **Command history list**: Sent commands in primary color, responses in secondary color
- **Input bar**: Up/down history arrows, monospace text field with `> ` prefix, send button
@@ -89,20 +85,16 @@ A terminal-style interface for sending commands directly to the repeater.
- Type a command and press send (or Enter on desktop)
- Up/down arrows navigate through command history
- Quick-command buttons populate and send common commands
- Overflow menu (three-dot icon): "Debug next command" option shows raw frame debug info for the next typed command (shows error snackbar if input field is empty)
- Bug report icon: Shows raw frame debug info for the next typed command (shows error snackbar if input field is empty)
- Help icon: Opens a scrollable reference of all known CLI commands. Tapping any command populates the input field immediately
- Clear icon: Wipes the command/response history
- Failed/timed-out commands are automatically retried once
### Available CLI Commands
The in-app help reference (help icon) documents all known commands. Categories:
**General**: `advert`, `reboot`, `clock`, `password`, `ver`, `clear stats`
**General**: `advert`, `advert.zerohop`, `reboot`, `clock`, `clock sync`, `password`, `ver`, `clear stats`, `erase`, `poweroff`, `shutdown`, `clkreboot`, `start ota`, `time`, `board`, `discover.neighbors`, `powersaving`, `stats-packets`, `stats-radio`, `stats-core`
**Get**: `get name`, `get role`, `get public.key`, `get prv.key`, `get repeat`, `get tx`, `get freq`, `get radio`, `get radio.rxgain`, `get af`, `get dutycycle`, `get int.thresh`, `get agc.reset.interval`, `get multi.acks`, `get allow.read.only`, `get advert.interval`, `get flood.advert.interval`, `get guest.password`, `get lat`, `get lon`, `get rxdelay`, `get txdelay`, `get direct.txdelay`, `get flood.max`, `get owner.info`, `get path.hash.mode`, `get loop.detect`, `get acl`, `get bridge.*`, `get adc.multiplier`, `get bootloader.ver`
**Set**: `set name`, `set af`, `set tx`, `set repeat`, `set allow.read.only`, `set flood.max`, `set int.thresh`, `set agc.reset.interval`, `set multi.acks`, `set advert.interval`, `set flood.advert.interval`, `set guest.password`, `set lat`, `set lon`, `set freq`, `set radio`, `set rxdelay`, `set txdelay`, `set direct.txdelay`, `set radio.rxgain`, `set dutycycle`, `set loop.detect`, `set path.hash.mode`, `set owner.info`, `set prv.key`, `set bridge.*`, `set adc.multiplier`, `tempradio`, `setperm`
**Settings**: `set name`, `set af`, `set tx`, `set repeat`, `set allow.read.only`, `set flood.max`, `set int.thresh`, `set agc.reset.interval`, `set multi.acks`, `set advert.interval`, `set flood.advert.interval`, `set guest.password`, `set lat`, `set lon`, `set radio`, `set rxdelay`, `set txdelay`, `set direct.txdelay`, `set bridge.*`, `set adc.multiplier`, `tempradio`, `setperm`
**Bridge**: `get bridge.type`
@@ -110,13 +102,9 @@ The in-app help reference (help icon) documents all known commands. Categories:
**Neighbors**: `neighbors`, `neighbor.remove`
**Power Management**: `get pwrmgt.support`, `get pwrmgt.source`, `get pwrmgt.bootreason`, `get pwrmgt.bootmv`
**Region Management**: `region`, `region load/get/put/remove/allowf/denyf/home/save`
**Sensors**: `sensor get {key}`, `sensor set {key} {value}`, `sensor list [start]`
**GPS Management**: `gps`, `gps {on|off}`, `gps sync`, `gps setloc`, `gps advert`, `gps advert {none|share|prefs}`
**Region Management**: `region`, `region load`, `region get`, `region put`, `region remove`, `region allowf`, `region denyf`, `region home`, `region save`, `region default`, `region list allowed`, `region list denied`
**GPS**: `gps`, `gps on/off/sync/setloc/advert`
---
@@ -159,63 +147,40 @@ A card titled "Repeater's Neighbors - N" listing each neighbor as:
### What the User Sees
Nine configuration cards, each with its own per-field refresh button(s):
Five configuration cards:
**1. Basic Settings**
- Name field
- Admin password field (write-only; always sent when non-empty)
- Guest password field (write-only; always sent when non-empty)
- Admin password field
- Guest password field
**2. Radio Settings**
- Frequency (MHz)
- TX Power (dBm) — has its own independent refresh button
- TX Power (dBm)
- Bandwidth dropdown (kHz)
- Spreading Factor (SF5SF12)
- Coding Rate (4/54/8)
- RX Gain boost toggle
**3. Location Settings**
- Latitude and longitude fields, each with an independent refresh button
- Latitude and longitude fields
**4. Features**
- Packet forwarding toggle (`set repeat`)
- Guest access toggle (`set allow.read.only`)
- Multi-ACKs toggle (`set multi.acks`)
- Auto clock sync after login toggle (local app setting only, not sent to repeater)
- Packet forwarding toggle
- Guest access toggle
**5. Network Health**
- Loop detection dropdown (off / minimal / moderate / strict; `set loop.detect`)
- Duty cycle slider (1100%; `set dutycycle`)
**6. Advertisement Settings**
**5. Advertisement Settings**
- Local advert interval slider (60240 minutes) with enable/disable toggle
- Flood advert interval slider (3168 hours) with enable/disable toggle
- Flood max hops slider (064; `set flood.max`)
**7. Owner Info**
- Multi-line text field for operator contact info (`set owner.info`); newlines sent as `|`
**8. Actions** (one-tap, no save needed)
- Send Advertisement (`advert`)
- Send Zero-Hop Advertisement (`advert.zerohop`)
- Clock Sync (`clock sync`)
**9. Advanced** (collapsed by default)
- Path hash mode dropdown (02; `set path.hash.mode`)
- TX delay field (`set txdelay`)
- Direct TX delay field (`set direct.txdelay`)
- Interference threshold field (`set int.thresh`)
- AGC reset interval slider (0240s in multiples of 4; `set agc.reset.interval`)
**Danger Zone** (red-styled card)
- Reboot repeater (sends `reboot` with confirmation dialog)
- Erase filesystem (serial-only; shows a confirmation dialog, then an informational snackbar — no command is sent over the air)
**6. Danger Zone** (red-styled card)
- Reboot repeater
- Erase filesystem (serial-only warning)
### Key Interactions
- **Settings are NOT auto-fetched on open**. Name is pre-filled from cached contact data. Each section has its own refresh button to fetch live values from the repeater
- TX Power, RX Gain, latitude, longitude, and advanced fields each have independent inline refresh buttons
- Save button in app bar appears when any change is detected; failed commands keep those fields dirty for retry
- Settings are sent sequentially with 200ms delays between commands; firmware responses are checked and partial failures are reported in a snackbar
- Some changes (e.g. radio frequency) require a reboot; the firmware response triggers an orange "reboot needed" snackbar
- **Settings are NOT auto-fetched on open**. Only name and location are pre-filled from locally cached contact data. You must tap each section's refresh button to fetch live values from the repeater
- TX Power has its own separate refresh button, independent from the main Radio Settings refresh
- Save button appears when changes are detected
- Settings are sent sequentially with 200ms delays between commands (fire-and-forget, no per-command acknowledgment wait)
- Validation prevents invalid values (e.g., frequency range, LoRa parameter compatibility)
- Advertisement interval sliders reset to defaults when re-enabled (local: 60 min, flood: 3 hours)
- **Erase Filesystem** does NOT send any command over the air — tapping it only shows a snackbar explaining the operation requires physical serial access
- **Erase Filesystem** does NOT send any command over the air — tapping it only shows a snackbar explaining the operation requires physical serial access. It is effectively non-functional when connected wirelessly
-79
View File
@@ -1,79 +0,0 @@
# Routing Paths
This page covers how MeshCore Open represents, selects, validates, and stores routing paths in the UI and data layer.
## Path Routing
MeshCore supports variable-length multi-byte routing paths so the app can scale from small meshes to very large node sets.
### Hash Width and Multi-Byte Paths
The device capability determines the hash width (number of bytes per hop):
| Width | Max Unique Nodes | Typical Use |
|-------|-----------------|-------------|
| 1 byte | 256 | Single-byte node IDs |
| 2 bytes | 65,536 | Medium meshes |
| 3 bytes | 16.7M | Large networks |
| 4 bytes | 4.3G | Very large meshes / future-proofing |
### Device Capability Detection
On device connection, the app reads the firmware capability to set `pathHashByteWidth`:
```dart
// Read from device info response (offset 81)
final modeRaw = firmwareBytes.length >= 82
? (firmwareBytes[81] & 0xFF)
: 0;
final mode = modeRaw.clamp(0, 3);
_pathHashByteWidth = mode + 1; // 1, 2, 3, or 4 bytes per hop
```
The connector reads a single-mode byte and clamps to `0..3`, so the supported hop width is `1..4` bytes. UI code also clamps widths when rendering (typically to `1..4`) so 4-byte hops are handled end-to-end in the current codebase.
### Path Data Structure
Paths in messages and storage consist of:
- **`pathLength` (model/storage)**: Hop count (number of hops). Negative values (e.g. `-1`) are used as a flood sentinel.
- **On-air `path_len` byte**: A packed byte that encodes hop count + hash width and is decoded into `pathLength` + `pathBytes` when parsing frames.
- **`pathBytes`**: Raw bytes of the path (concatenated hop prefixes), grouped by `pathHashByteWidth`.
- **`hopCount`**: Derived display value computed from bytes and width: `(byteCount + hashByteWidth - 1) ~/ hashByteWidth`.
- **Example**: With `pathHashByteWidth=2`, a 3-hop path has 6 bytes (`pathBytes.length = 6`) and `pathLength = 3`:
- `pathBytes = [0xA1, 0xA2, 0xB1, 0xB2, 0xC1, 0xC2]`
- Hops: `[0xA1A2]`, `[0xB1B2]`, `[0xC1C2]`
### Hop Count Calculation
Convert path byte length to hop count:
```dart
int hopCount = (byteCount + hashByteWidth - 1) ~/ hashByteWidth;
```
Use this consistently when displaying hop counts in UI. Do not treat `pathLength` as a hop count when the path uses multi-byte hop hashes.
### Path Usage in Different Message Types
- **Direct messages**: Extract path from decrypted payload to trace sender route.
- **Channel messages**: Decrypt hop-by-hop routing chain from payload; the header carries the encoded byte length for the path blob, not the derived hop count.
- **Contact storage**: Path length in byte 32, raw path bytes in bytes 33-96, grouped by detected `pathHashByteWidth`.
### UI Hop Count Display
⚠️ **Important**: In screens like "Channel Message Path", prefer the actual decoded `pathBytes` hop count over `pathLength` metadata:
```dart
// Preferred: use actual observed path length
final effectiveHopCount = (pathBytes.length + width - 1) ~/ width;
// Avoid: using encoded byte length as if it were a hop count
// pathLength is bytes; converting it twice causes inflated counts
```
Example scenario:
- Radio header reports `pathLength: 32` bytes
- Decoded path bytes: `[0xAB, 0xCD]` (2 bytes = 1 hop with width=2)
- **Display**: "1 hop" (from `pathBytes`), not "32 hops" (which would double-count the encoded length)
+14 -16
View File
@@ -28,11 +28,9 @@ The BLE Scanner is the app's home screen, displayed immediately on launch.
**Device List**: When no devices are found, shows a large Bluetooth icon with a prompt. The prompt text is dynamic: "Searching for devices..." while actively scanning, or "Tap Scan to search" when idle. When devices are found, shows a scrollable list of `DeviceTile` widgets.
**App Bar Actions**: Icon buttons in the top-right corner of the app bar:
- **USB** icon button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
- **TCP/IP** icon button - Opens TCP connection screen (all non-web platforms)
**Bottom FAB**: A single floating action button:
**Bottom FAB Row**: Up to three floating action buttons:
- **USB** button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
- **TCP/IP** button - Opens TCP connection screen (all non-web platforms)
- **BLE Scan** button - Toggles BLE scanning on/off; shows a spinner when scanning. **Disabled** (greyed out, not tappable) when Bluetooth is off
### Device Tile
@@ -53,7 +51,7 @@ Note: The weak (-80 to -90 dBm) and poor (< -90 dBm) tiers share the same icon s
### How Scanning Works
- Filters for devices advertising the Nordic UART Service UUID (so community forks with non-standard names are still found). Known name prefixes used by stock firmware builds for reference: `MeshCore-`, `Whisper-`, `WisCore-`, `Seeed`, `Lilygo`, `HT-`, `LowMesh_MC_`, `NRF52`
- Filters for devices with names starting with `MeshCore-` or `Whisper-`
- Uses low-latency scan mode on Android
- Scans for 10 seconds then auto-stops
- On iOS/macOS, waits for BLE adapter initialization before starting
@@ -63,11 +61,11 @@ Note: The weak (-80 to -90 dBm) and poor (< -90 dBm) tiers share the same icon s
Tap a device tile or its Connect button:
1. The connector stops scanning and transitions to "connecting"
2. Connects to the device with a 15-second timeout (6 seconds on Linux)
2. Connects to the device with a 15-second timeout
3. Requests MTU 185 bytes for optimal throughput
4. Discovers BLE services and locates the Nordic UART Service
5. Subscribes to TX notifications for receiving data
6. On success, automatically navigates to the Channels screen
6. On success, automatically navigates to the Contacts screen
7. On failure, shows a red error snackbar
---
@@ -76,7 +74,7 @@ Tap a device tile or its Connect button:
### How to Access
From the Scanner screen, tap the **USB** icon button in the app bar.
From the Scanner screen, tap the **USB** FAB button.
### What the User Sees
@@ -84,15 +82,15 @@ From the Scanner screen, tap the **USB** icon button in the app bar.
- A list of detected USB serial ports, each showing:
- Friendly display name
- Raw port name (subtitle, only shown when it differs from the display name)
- Chevron trailing icon (the entire tile is tappable to connect)
- Transport switcher buttons (outlined, not FABs) to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP)
- "Connect" button
- FABs at the bottom to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP)
### Key Interactions
- On desktop (Windows, Linux, macOS): ports are polled every 2 seconds for hot-plug detection (polling pauses while connecting/connected)
- On mobile: tap the "Scan" FAB to manually refresh
- Tap a port tile to connect
- On successful connection, navigates to Channels screen
- Tap a port or its Connect button to connect
- On successful connection, navigates to Contacts screen
- On connection failure, the port list automatically refreshes
- Platform-specific error messages for common USB failures (permission denied, device missing, device detached, device busy, driver missing, port invalid, timeout, and more)
@@ -102,7 +100,7 @@ From the Scanner screen, tap the **USB** icon button in the app bar.
### How to Access
From the Scanner screen, tap the **TCP/IP** icon button in the app bar.
From the Scanner screen, tap the **TCP/IP** FAB button.
### What the User Sees
@@ -110,7 +108,7 @@ From the Scanner screen, tap the **TCP/IP** icon button in the app bar.
- **Host address** text field
- **Port number** text field
- **Connect** button
- Transport switcher buttons (outlined, not FABs) to switch to USB or BLE
- FABs at the bottom to switch to USB or BLE
### Key Interactions
@@ -121,6 +119,6 @@ From the Scanner screen, tap the **TCP/IP** icon button in the app bar.
- Validation errors are shown as red snackbars
- The Connect button shows a spinner and "Connecting..." label while in progress
- The status bar shows the specific host:port being connected to (e.g., "Connecting to 192.168.1.1:5000")
- On success, navigates to Channels screen and saves the host/port to settings
- On success, navigates to Contacts screen and saves the host/port to settings
- On connection, the status bar shows the active TCP endpoint (e.g., "Connected to 192.168.1.1:5000")
- Error messages for timeout, unsupported platform, and connection failures
+64 -96
View File
@@ -12,13 +12,12 @@ Settings are only accessible while a device is connected.
The settings screen is a scrollable list of cards:
1. [Device Info](#device-info)
2. [Node Settings](#node-settings)
3. [Location](#location)
4. [App Settings](#app-settings) (link to sub-screen)
5. [Actions](#actions)
2. [App Settings](#app-settings) (link to sub-screen)
3. [Node Settings](#node-settings)
4. [Actions](#actions)
5. [Debug](#debug)
6. [Export](#export)
7. [Debug](#debug)
8. [About](#about)
7. [About](#about)
---
@@ -41,6 +40,46 @@ Battery shows an alert icon and orange text when at 15% or below. The toggle onl
---
## App Settings
A dedicated sub-screen for app-level preferences (nothing here is sent to the device). All settings persist locally via SharedPreferences.
### Appearance
- **Theme**: System / Light / Dark
- **Language**: System default or one of 15 languages (English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian)
- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
### Notifications
- **Master enable/disable**: Requests OS permission when enabling
- **Message notifications**: New direct message alerts
- **Channel message notifications**: New channel message alerts
- **Advertisement notifications**: New node discovery alerts
### Messaging
- **Clear Path on Max Retry**: Erases the stored routing path after all retries fail
- **Auto Route Rotation**: Enables weighted routing algorithm. When enabled, expands to show five slider sub-settings (hidden when off):
- Max Route Weight (110, default 5, integer steps)
- Initial Route Weight (0.55.0, default 3.0)
- Success Increment (0.12.0, default 0.5, 0.1 steps)
- Failure Decrement (0.12.0, default 0.2, 0.1 steps)
- Max Message Retries (210, default 5)
### Battery
- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
### Map Display
- **Show Repeaters**: Toggle repeater markers on map
- **Show Chat Nodes**: Toggle chat node markers
- **Show Other Nodes**: Toggle room/sensor markers
- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
- **Units**: Metric / Imperial
- **Offline Map Cache**: Navigate to tile download screen
### Debug
- **App Debug Logging**: Enable the in-app debug log
---
## Node Settings
These settings are sent directly to the connected device firmware.
@@ -52,7 +91,7 @@ These settings are sent directly to the connected device firmware.
### Radio Settings
Opens a dialog pre-populated with the device's current radio settings. Contains:
- **Preset dropdown**: Regional presets — selecting a preset immediately fills all fields below. Includes presets for Australia, Australia (Narrow), Australia (Mid), Australia SA WA QLD, Czech Republic, EU 433MHz, EU/UK (Long Range), EU/UK (Medium Range), EU/UK (Narrow), New Zealand, New Zealand (Narrow), Portugal 433, Portugal 869, numerous Russia city presets, Switzerland, USA Arizona, USA/Canada, and Vietnam
- **Preset dropdown**: 19 regional presets — selecting a preset immediately fills all fields below. Full list: Australia, Australia (Narrow), Australia SA/WA/QLD, Czech Republic, EU 433MHz, EU/UK (Long Range), EU/UK (Medium Range), EU/UK (Narrow), New Zealand, New Zealand (Narrow), Portugal 433, Portugal 869, Switzerland, USA Arizona, USA/Canada, Vietnam, Off-Grid 433, Off-Grid 869, Off-Grid 918
- **Frequency** (MHz): Free text, validated 3002500 MHz
- **Bandwidth**: Dropdown (7.8 / 10.4 / 15.6 / 20.8 / 31.25 / 41.7 / 62.5 / 125 / 250 / 500 kHz)
- **Spreading Factor**: SF5SF12
@@ -60,13 +99,6 @@ Opens a dialog pre-populated with the device's current radio settings. Contains:
- **TX Power** (dBm): Validated 0 to device max (typically 22 dBm)
- **Client Repeat** toggle: Only shown on firmware v9+; requires frequency to be exactly 433.000, 869.000, or 918.000 MHz (the Off-Grid presets). Save is blocked with a warning if enabled on other frequencies
### Companion Radio Stats
Opens the RF statistics screen (RSSI, SNR, packet counts) for the paired radio. Only enabled when connected to a device that supports companion radio stats.
---
## Location
### Location
Opens a dialog pre-populated with the device's current coordinates (if known):
- Latitude and longitude fields (decimal, 6 decimal places). If only one field is provided, the other uses the device's current value
@@ -83,72 +115,8 @@ Five toggles controlling which node types are auto-added when heard:
- Auto-add Sensors
- Overwrite Oldest (when contact list is full)
### Privacy
Opens a dialog with controls for how the node shares telemetry and location data:
- **Advert Location**: Toggle whether the node broadcasts its location in advertisements
- **Multi-Ack**: Toggle multi-ack delivery confirmations
- **Telemetry Base Mode**: Deny All / Allow by Contact / Allow All
- **Telemetry Location Mode**: Deny All / Allow by Contact / Allow All
- **Telemetry Environment Mode**: Deny All / Allow by Contact / Allow All
Settings take effect when saved. A snackbar confirms the update.
---
## App Settings
A dedicated sub-screen for app-level preferences (nothing here is sent to the device). All settings persist locally via SharedPreferences.
### Appearance
- **Theme**: System / Light / Dark
- **Language**: System default or one of 18 languages (English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian, Hungarian, Japanese, Korean)
### Notifications
- **Master enable/disable**: Requests OS permission when enabling
- **Message notifications**: New direct message alerts
- **Channel message notifications**: New channel message alerts
- **Advertisement notifications**: New node discovery alerts
### Messaging
- **Clear Path on Max Retry**: Erases the stored routing path after all retries fail
- **Jump to Oldest Unread**: When opening a chat, scrolls to the oldest unread message instead of the newest
- **Auto Route Rotation**: Enables weighted routing algorithm. When enabled, expands to show five slider sub-settings (hidden when off):
- Max Route Weight (110, default 5, integer steps)
- Initial Route Weight (0.55.0, default 3.0)
- Success Increment (0.12.0, default 0.5, 0.1 steps)
- Failure Decrement (0.12.0, default 0.2, 0.1 steps)
- Max Message Retries (210, default 5)
- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
### Battery
- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
### Map Display
- **Show Repeaters**: Toggle repeater markers on map
- **Show Chat Nodes**: Toggle chat node markers
- **Show Other Nodes**: Toggle room/sensor markers
- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
- **Units**: Metric / Imperial
- **Raster Tile Source**: Sets the MAP theme and with that from where to get the map tile data:
- OpenStreetMap (Auto/Standard/Dark) is provided by the free OpenStreetMap tile server. (Can only be used for live view or already cached.)
- Stamen Terrain / AlidadeSmooth Dark / Outdoors / OSM Bright are [StadiaMaps.com raster tile maps](https://stadiamaps.com/products/maps/interactive-basemaps/) for which you can choose an Hosted Endpoint (Worldwide / Europe hosted) and have to provide the API key to your subscription. (You can cache these maps for offline Map usage.)
There will be no account provided by meshcore-open. You will have to get you own subscription. StadiaMaps offers a [free](https://stadiamaps.com/pricing/) subscription to download up to 200'000 Standart Raster Basemap tiles.
- **Offline Map Cache**: Navigate to tile download screen
### Translation
Not shown on web. Controls on-device message translation powered by a locally-downloaded ML model:
- **Enable Translation**: Translates incoming messages into the selected target language
- **Translate Composer**: Translates outgoing messages from the target language back before sending
- **Target Language**: Language to translate into (searchable list; defaults to the app language)
- **Downloaded Model**: Dropdown to select among already-downloaded translation models
- **Preset Model**: Download a curated preset model with one tap
- **Custom Model URL**: Enter a URL to download a custom GGUF-format model; shows download progress and a cancel button
### Cyrillic-to-Latin (Cyr2Lat)
Controls character substitution profiles used to render Cyrillic text in Latin characters. A dropdown selects the active profile; Add, Edit, and Delete buttons manage the profile list (the last remaining profile cannot be deleted). Each profile stores a JSON character map.
### Debug
- **App Debug Logging**: Enable the in-app debug log
### Privacy Mode
Opens a confirmation dialog with three buttons: Cancel, Enable, and Disable. Both states can be set from the same dialog regardless of current state. A snackbar confirms which state was applied. When on, the node stops broadcasting its location in advertisements.
---
@@ -158,24 +126,10 @@ One-tap device operations:
| Action | Description |
|---|---|
| Send Advertisement | Floods the mesh with your node's advertisement |
| Sync Time | Sends current Unix timestamp to the device |
| Refresh Contacts | Re-requests the full contact list |
| Reboot Device | Confirmation dialog → reboots the device (shown in warning color) |
| Delete All Paths | Confirmation dialog → clears all stored routing paths (shown in alert color) |
---
## Export
Three GPX export options (not available on web):
| Option | Exports |
|---|---|
| Export Repeaters | Repeaters and Rooms with GPS coordinates |
| Export Contacts | Chat contacts with GPS coordinates |
| Export All | All contacts with GPS coordinates |
Each creates a `.gpx` file and opens the OS share sheet. Feedback via snackbar for four outcomes: success, no contacts with coordinates, feature not available (web), or error.
| Reboot Device | Confirmation dialog → reboots the device (shown in orange) |
---
@@ -196,6 +150,20 @@ Structured log entries (Info / Warning / Error), with tag, message, and timestam
---
## Export
Three GPX export options (not available on web):
| Option | Exports |
|---|---|
| Export Repeaters | Repeaters and Rooms with GPS coordinates |
| Export Contacts | Chat contacts with GPS coordinates |
| Export All | All contacts with GPS coordinates |
Each creates a `.gpx` file and opens the OS share sheet. Feedback via snackbar for four outcomes: success, no contacts with coordinates, feature not available (web), or error.
---
## About
Shows the standard Flutter about dialog with app name, version, and legal notice.
-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>X74VJ4UAST</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>destination</key>
<string>export</string>
</dict>
</plist>
+2
View File
@@ -20,5 +20,7 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
+1 -4
View File
@@ -1,4 +1,4 @@
platform :ios, '16.4'
platform :ios, '15.5'
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -32,8 +32,5 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.4'
end
end
end
+124 -1
View File
@@ -1,22 +1,145 @@
PODS:
- Flutter (1.0.0)
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- flutter_foreground_task (0.0.1):
- Flutter
- flutter_local_notifications (0.0.1):
- Flutter
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleMLKit/BarcodeScanning (7.0.0):
- GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 6.0.0)
- GoogleMLKit/MLKitCore (7.0.0):
- MLKitCommon (~> 12.0.0)
- GoogleToolboxForMac/Defines (4.2.1)
- GoogleToolboxForMac/Logger (4.2.1):
- GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMSessionFetcher/Core (3.5.0)
- MLImage (1.0.0-beta6)
- MLKitBarcodeScanning (6.0.0):
- MLKitCommon (~> 12.0)
- MLKitVision (~> 8.0)
- MLKitCommon (12.0.0):
- GoogleDataTransport (~> 10.0)
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GoogleUtilities/Logger (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitVision (8.0.0):
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta6)
- MLKitCommon (~> 12.0)
- mobile_scanner (6.0.2):
- Flutter
- GoogleMLKit/BarcodeScanning (~> 7.0.0)
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- package_info_plus (0.4.5):
- Flutter
- PromisesObjC (2.4.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GTMSessionFetcher
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
- MLKitVision
- nanopb
- PromisesObjC
EXTERNAL SOURCES:
Flutter:
:path: Flutter
flutter_blue_plus_darwin:
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
flutter_foreground_task:
:path: ".symlinks/plugins/flutter_foreground_task/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: e42b502c78c33aa1ed9d42eaea8960ce2139504b
PODFILE CHECKSUM: 570da2a631486c6bd6496bed1e605e63e2471be5
COCOAPODS: 1.16.2
+21 -50
View File
@@ -11,7 +11,6 @@
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -53,7 +52,6 @@
718BC7DCCFC5C370705C12E5 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
@@ -69,7 +67,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
9A698254711B63C3940A64CB /* libPods-Runner.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -88,7 +85,6 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -183,16 +179,13 @@
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
F0D7F2413C6E4B7A9B1C2D3E /* Fix Native Asset Minimum OS */,
B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@@ -226,9 +219,6 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -292,6 +282,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
DE3B2E091393835C0B38492E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -314,22 +321,6 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
F0D7F2413C6E4B7A9B1C2D3E /* Fix Native Asset Minimum OS */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}",
);
name = "Fix Native Asset Minimum OS";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\nFRAMEWORKS_DIR=\"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}\"\nMIN_OS=\"${IPHONEOS_DEPLOYMENT_TARGET}\"\nif [ ! -d \"$FRAMEWORKS_DIR\" ] || [ -z \"$MIN_OS\" ]; then\n exit 0\nfi\nfind \"$FRAMEWORKS_DIR\" -maxdepth 2 -name Info.plist | while read -r plist; do\n bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \"$plist\" 2>/dev/null || true)\n case \"$bundle_id\" in\n io.flutter.flutter.native-assets.*)\n /usr/libexec/PlistBuddy -c \"Set :MinimumOSVersion $MIN_OS\" \"$plist\" 2>/dev/null || \\\n /usr/libexec/PlistBuddy -c \"Add :MinimumOSVersion string $MIN_OS\" \"$plist\"\n ;;\n esac\ndone\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -423,7 +414,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -438,9 +429,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = X74VJ4UAST;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -551,7 +540,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -602,7 +591,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -619,9 +608,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = X74VJ4UAST;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -643,9 +630,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = X74VJ4UAST;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -694,20 +679,6 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -5,24 +5,6 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
+2 -5
View File
@@ -2,15 +2,12 @@ import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
+19 -42
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -24,48 +22,8 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>http</string>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to scan QR codes for joining communities.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app lets you pick a photo to compress and send over the mesh.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -83,5 +41,24 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to scan QR codes for joining communities.</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>http</string>
<string>https</string>
</array>
</dict>
</plist>
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,6 @@ class MeshCoreUsbManager {
String? get activePortKey => _activePortKey;
String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
bool get isConnected => _service.isConnected;
Object? get lastError => _service.lastError;
Stream<Uint8List> get frameStream => _service.frameStream;
// --- Configuration ---
+24 -115
View File
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import 'package:flutter/widgets.dart';
// Buffer Reader - sequential binary data reader with pointer tracking
@@ -207,8 +206,6 @@ const int cmdSendTelemetryReq = 39;
const int cmdGetCustomVar = 40;
const int cmdSetCustomVar = 41;
const int cmdSendBinaryReq = 50;
const int cmdSetFloodScope = 54;
const int cmdSendControlData = 55;
const int cmdGetStats = 56;
const int cmdSendAnonReq = 57;
const int cmdSetAutoAddConfig = 58;
@@ -227,18 +224,6 @@ const int reqTypeGetTelemetry = 0x03;
const int reqTypeGetAccessList = 0x05;
const int reqTypeGetNeighbors = 0x06;
Uint8List buildTelemetryBinaryPayload() {
// Room servers/repeaters read byte 1 as an inverse telemetry permission mask.
// Zero means "request every telemetry field allowed for this contact".
return Uint8List.fromList([reqTypeGetTelemetry, 0x00, 0x00, 0x00, 0x00]);
}
const int anonReqTypeRegions = 0x01;
// Control data sub-types used by MeshCore discovery packets.
const int controlSubtypeDiscoverReq = 0x08;
const int controlSubtypeDiscoverResp = 0x09;
// Repeater response codes
const int respServerLoginOk = 0;
@@ -281,7 +266,6 @@ const int pushCodeTraceData = 0x89;
const int pushCodeNewAdvert = 0x8A;
const int pushCodeTelemetryResponse = 0x8B;
const int pushCodeBinaryResponse = 0x8C;
const int pushCodeControlData = 0x8E;
// Contact/advertisement types
const int advTypeChat = 1;
@@ -336,7 +320,7 @@ const int maxPathSize = 64;
const int pathHashSize = 1;
const int maxNameSize = 32;
const int maxFrameSize = 172;
const int appProtocolVersion = 4;
const int appProtocolVersion = 3;
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
const int maxTextPayloadBytes = 160;
const int _sendTextMsgOverheadBytes =
@@ -467,13 +451,8 @@ String pubKeyToHex(Uint8List pubKey) {
// Helper to convert hex string to public key
Uint8List hexToPubKey(String hex) {
if (hex.length != pubKeySize * 2) {
throw FormatException(
'Public key hex must be ${pubKeySize * 2} chars, got ${hex.length}',
);
}
final result = Uint8List(pubKeySize);
for (int i = 0; i < pubKeySize; i++) {
for (int i = 0; i < pubKeySize && i * 2 + 1 < hex.length; i++) {
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return result;
@@ -587,9 +566,9 @@ Uint8List buildGetStatsFrame(int statsType) {
return Uint8List.fromList([cmdGetStats, statsType & 0xFF]);
}
/// Path hash width on air: [61][0][mode], mode 0..3 (mode+1) bytes per hop hash.
/// Path hash width on air: [61][0][mode], mode 0..2 (mode+1) bytes per hop hash.
Uint8List buildSetPathHashModeFrame(int mode) {
final m = mode.clamp(0, 3).toInt();
final m = mode.clamp(0, 2);
return Uint8List.fromList([cmdSetPathHashMode, 0, m]);
}
@@ -741,19 +720,25 @@ Uint8List buildUpdateContactPathFrame(
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(timestamp);
// Optional [Lat x4, Lon x4][timestamp x4] tail per the doc comment above.
// Emit 8 bytes of position (zero-filled when only lastModified is provided)
// followed by an optional 4-byte timestamp. Earlier code emitted the
// position block twice, which corrupted the tail and caused the firmware
// to parse the second lat as the timestamp. See #427.
final hasLocation = lat != null && lon != null;
if (hasLocation || lastModified != null) {
writer.writeInt32LE(hasLocation ? (lat * 1e6).round() : 0);
writer.writeInt32LE(hasLocation ? (lon * 1e6).round() : 0);
if (lastModified != null) {
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(lastModifiedTimestamp);
}
if ((lat == null || lon == null) && lastModified != null) {
// If lat/lon not provided, write zeros
writer.writeInt32LE(0);
writer.writeInt32LE(0);
} else {
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
// Latitude
final latitude = lat ?? 0.0;
writer.writeInt32LE((latitude * 1e6).round());
// Longitude
final longitude = lon ?? 0.0;
writer.writeInt32LE((longitude * 1e6).round());
}
if (lastModified != null) {
// Last modified
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(lastModifiedTimestamp);
}
return writer.toBytes();
@@ -876,67 +861,6 @@ Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
return writer.toBytes();
}
Uint8List buildSendControlDataFrame(Uint8List payload) {
final writer = BufferWriter();
writer.writeByte(cmdSendControlData);
writer.writeBytes(payload);
return writer.toBytes();
}
Uint8List buildDiscoveryRequestPayload(
int tag, {
bool prefixOnly = false,
int typeMask = 1 << advTypeRepeater,
}) {
final writer = BufferWriter();
// The high bit must be set for CMD_SEND_CONTROL_DATA; DISCOVER_REQ uses
// subtype 0x8, with the low bit selecting short/full public keys in replies.
writer.writeByte(
(controlSubtypeDiscoverReq << 4) | (prefixOnly ? 0x01 : 0x00),
);
writer.writeByte(typeMask);
writer.writeUInt32LE(tag);
writer.writeUInt32LE(0); // since=0 asks nearby nodes for any recent advert.
return writer.toBytes();
}
Uint8List _reversePathByHop(Uint8List path, int pathHashWidth) {
if (path.isEmpty) return Uint8List(0);
final width = pathHashWidth.clamp(1, 4).toInt();
if (path.length % width != 0) {
return Uint8List.fromList(path.reversed.toList());
}
final reversed = Uint8List(path.length);
final hops = path.length ~/ width;
for (var i = 0; i < hops; i++) {
final from = (hops - 1 - i) * width;
reversed.setRange(i * width, (i + 1) * width, path, from);
}
return reversed;
}
// Build CMD_SEND_ANON_REQ frame.
// Payload format for regions: [anon_req_type][reply_path_len][reply_path...].
Uint8List buildSendAnonReqFrame(
Uint8List repeaterPubKey, {
required int requestType,
Uint8List? replyPath,
int replyHopCount = 0,
int pathHashWidth = pathHashSize,
}) {
final width = pathHashWidth.clamp(1, 4).toInt();
final path = replyPath ?? Uint8List(0);
final encodedPathLen = ((width - 1) << 6) | (replyHopCount & 0x3F);
final writer = BufferWriter();
writer.writeByte(cmdSendAnonReq);
writer.writeBytes(repeaterPubKey);
writer.writeByte(requestType);
writer.writeByte(encodedPathLen);
writer.writeBytes(_reversePathByHop(path, width));
return writer.toBytes();
}
//Build a trace request frame
//[cmd][tag x4][auth x4][flag][payload]
Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) {
@@ -1027,22 +951,7 @@ Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
writer.writeBytes(Uint8List(3)); // reserved bytes
writer.writeBytes(pubKey);
} else {
writer.writeBytes(Uint8List(3)); // reserved bytes
writer.writeBytes(Uint8List(4)); // reserved bytes
}
return writer.toBytes();
}
//Build CMD_SET_FLOOD_SCOPE
// Format: [cmd][scope]
Uint8List buildSetFloodScopeFrame(String region) {
if (region == '') {
// reset scope
return Uint8List.fromList([cmdSetFloodScope, 0]);
}
final name = region.startsWith('#') ? region : '#$region';
final hash = crypto.sha256.convert(utf8.encode(name)).bytes;
final scope = Uint8List.fromList(hash.sublist(0, 16));
return Uint8List.fromList([cmdSetFloodScope, 0, ...scope]);
}
-5
View File
@@ -3,10 +3,6 @@ class MeshCoreUuids {
static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
/// Known advertised-name prefixes used by stock MeshCore firmware builds.
/// Discovery no longer filters on these (it filters on the [service] UUID so
/// that community forks with custom names are still found); kept for
/// reference and possible future display heuristics.
static const List<String> deviceNamePrefixes = [
"MeshCore-",
"Whisper-",
@@ -15,6 +11,5 @@ class MeshCoreUuids {
"Lilygo",
"HT-",
"LowMesh_MC_",
"NRF52",
];
}
+9 -209
View File
@@ -96,34 +96,6 @@ class CayenneLpp {
}
switch (type) {
case lppDigitalInput:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt8(),
});
break;
case lppDigitalOutput:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt8(),
});
break;
case lppAnalogInput:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readInt16BE() / 100,
});
break;
case lppAnalogOutput:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readInt16BE() / 100,
});
break;
case lppGenericSensor:
telemetry.add({
'channel': channel,
@@ -159,17 +131,6 @@ class CayenneLpp {
'value': buffer.readUInt8() / 2,
});
break;
case lppAccelerometer:
telemetry.add({
'channel': channel,
'type': type,
'value': {
'x': buffer.readInt16BE() / 1000,
'y': buffer.readInt16BE() / 1000,
'z': buffer.readInt16BE() / 1000,
},
});
break;
case lppBarometricPressure:
telemetry.add({
'channel': channel,
@@ -177,13 +138,6 @@ class CayenneLpp {
'value': buffer.readUInt16BE() / 10,
});
break;
case lppAltitude:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readInt16BE(),
});
break;
case lppVoltage:
telemetry.add({
'channel': channel,
@@ -198,13 +152,6 @@ class CayenneLpp {
'value': buffer.readInt16BE() / 1000,
});
break;
case lppFrequency:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt32BE(),
});
break;
case lppPercentage:
telemetry.add({
'channel': channel,
@@ -226,56 +173,6 @@ class CayenneLpp {
'value': buffer.readUInt16BE(),
});
break;
case lppDistance:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt32BE() / 1000,
});
break;
case lppEnergy:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt32BE() / 1000,
});
break;
case lppDirection:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt16BE(),
});
break;
case lppUnixTime:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt32BE(),
});
break;
case lppGyrometer:
telemetry.add({
'channel': channel,
'type': type,
'value': {
'x': buffer.readInt16BE() / 100,
'y': buffer.readInt16BE() / 100,
'z': buffer.readInt16BE() / 100,
},
});
break;
case lppColour:
telemetry.add({
'channel': channel,
'type': type,
'value': {
'red': buffer.readUInt8(),
'green': buffer.readUInt8(),
'blue': buffer.readUInt8(),
},
});
break;
case lppGps:
telemetry.add({
'channel': channel,
@@ -287,24 +184,6 @@ class CayenneLpp {
},
});
break;
case lppSwitch:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt8(),
});
break;
case lppPolyline:
final size = buffer.readUInt8();
telemetry.add({
'channel': channel,
'type': type,
'value': {
'size': size,
'data': _bytesToHex(_readPolylinePayload(buffer, size)),
},
});
break;
default:
return telemetry;
}
@@ -337,19 +216,6 @@ class CayenneLpp {
);
switch (type) {
case lppDigitalInput:
channelData['values']['digitalInput'] = buffer.readUInt8();
break;
case lppDigitalOutput:
channelData['values']['digitalOutput'] = buffer.readUInt8();
break;
case lppAnalogInput:
channelData['values']['analogInput'] = buffer.readInt16BE() / 100.0;
break;
case lppAnalogOutput:
channelData['values']['analogOutput'] =
buffer.readInt16BE() / 100.0;
break;
case lppGenericSensor:
channelData['values']['generic'] = buffer.readUInt32BE();
break;
@@ -365,29 +231,15 @@ class CayenneLpp {
case lppRelativeHumidity:
channelData['values']['humidity'] = buffer.readUInt8() / 2.0;
break;
case lppAccelerometer:
channelData['values']['accelerometer'] = {
'x': buffer.readInt16BE() / 1000.0,
'y': buffer.readInt16BE() / 1000.0,
'z': buffer.readInt16BE() / 1000.0,
};
break;
case lppBarometricPressure:
channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0;
break;
case lppAltitude:
// MeshCore encodes standalone barometric altitude as LPP type 121.
channelData['values']['altitude'] = buffer.readInt16BE();
break;
case lppVoltage:
channelData['values']['voltage'] = buffer.readInt16BE() / 100.0;
break;
case lppCurrent:
channelData['values']['current'] = buffer.readInt16BE() / 1000.0;
break;
case lppFrequency:
channelData['values']['frequency'] = buffer.readUInt32BE();
break;
case lppPercentage:
channelData['values']['percentage'] = buffer.readUInt8();
break;
@@ -397,32 +249,6 @@ class CayenneLpp {
case lppPower:
channelData['values']['power'] = buffer.readUInt16BE();
break;
case lppDistance:
channelData['values']['distance'] = buffer.readUInt32BE() / 1000.0;
break;
case lppEnergy:
channelData['values']['energy'] = buffer.readUInt32BE() / 1000.0;
break;
case lppDirection:
channelData['values']['direction'] = buffer.readUInt16BE();
break;
case lppUnixTime:
channelData['values']['time'] = buffer.readUInt32BE();
break;
case lppGyrometer:
channelData['values']['gyrometer'] = {
'x': buffer.readInt16BE() / 100.0,
'y': buffer.readInt16BE() / 100.0,
'z': buffer.readInt16BE() / 100.0,
};
break;
case lppColour:
channelData['values']['colour'] = {
'red': buffer.readUInt8(),
'green': buffer.readUInt8(),
'blue': buffer.readUInt8(),
};
break;
case lppGps:
channelData['values']['gps'] = {
'latitude': buffer.readInt24BE() / 10000.0,
@@ -430,48 +256,22 @@ class CayenneLpp {
'altitude': buffer.readInt24BE() / 100.0,
};
break;
case lppSwitch:
channelData['values']['switch'] = buffer.readUInt8() != 0;
break;
case lppPolyline:
final size = buffer.readUInt8();
channelData['values']['polyline'] = {
'size': size,
'data': _bytesToHex(_readPolylinePayload(buffer, size)),
};
break;
// Add more types as needed...
default:
// Stop parsing to avoid losing alignment on an unknown LPP type.
return _sortedChannelValues(channels);
//Stopped parsing to avoid misalignment
return channels.values.toList();
}
}
return _sortedChannelValues(channels);
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut;
} catch (e) {
// Handle parsing errors, possibly due to malformed data
appLogger.error('Error parsing Cayenne LPP data: $e');
// Preserve any fields parsed before the malformed value.
return _sortedChannelValues(channels);
return <
Map<String, dynamic>
>[]; // Return an empty list on error to avoid crashing the app
}
}
static Uint8List _readPolylinePayload(BufferReader buffer, int size) {
final declaredPayloadSize = size > 0 ? size - 1 : 0;
final availablePayloadSize = declaredPayloadSize <= buffer.remaining
? declaredPayloadSize
: buffer.remaining;
return buffer.readBytes(availablePayloadSize);
}
static List<Map<String, dynamic>> _sortedChannelValues(
Map<int, Map<String, dynamic>> channels,
) {
final channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut;
}
static String _bytesToHex(Uint8List bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
}
-19
View File
@@ -49,25 +49,6 @@ class ChatScrollController extends ScrollController {
}
}
/// Jumps toward an off-screen message so that lazy ListView.builder builds
/// items near it. Only visible + cacheExtent items have real heights, so we
/// use proportion of maxScrollExtent (itself an estimate from built items'
/// avg height). Call [onJumped] on the next frame to ensureVisible/scroll
/// to the exact target.
void jumpToEstimatedOffset({
required int unreadCount,
required int totalMessages,
required VoidCallback onJumped,
}) {
if (!hasClients || totalMessages == 0) return;
final maxExtent = position.maxScrollExtent;
final jumpOffset = maxExtent * (unreadCount / totalMessages);
if (jumpOffset > 100) {
jumpTo(jumpOffset);
}
WidgetsBinding.instance.addPostFrameCallback((_) => onJumped());
}
void scrollToBottomIfAtBottom() {
// Only scroll if jump button is NOT showing (i.e., already at bottom)
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
-59
View File
@@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
import '../connector/meshcore_protocol.dart';
import '../utils/emoji_utils.dart';
IconData contactTypeIcon(int type) {
switch (type) {
case advTypeChat:
return Icons.chat;
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.group;
case advTypeSensor:
return Icons.sensors;
default:
return Icons.device_unknown;
}
}
Color contactTypeColor(int type) {
switch (type) {
case advTypeChat:
return Colors.blue;
case advTypeRepeater:
return Colors.orange;
case advTypeRoom:
return Colors.purple;
case advTypeSensor:
return Colors.green;
default:
return Colors.grey;
}
}
Color colorForName(String name) {
const colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.purple,
Colors.pink,
Colors.teal,
Colors.indigo,
Colors.cyan,
Colors.amber,
Colors.deepOrange,
];
return colors[name.hashCode.abs() % colors.length];
}
String firstCharacterOrEmoji(String name) {
if (name.isEmpty) return '?';
final emoji = firstEmoji(name);
if (emoji != null) return emoji;
final runes = name.runes.toList();
if (runes.isEmpty) return '?';
return String.fromCharCode(runes[0]).toUpperCase();
}
-63
View File
@@ -1,63 +0,0 @@
class Cyr2Lat {
static Map<String, String> _charMap = {
'А': 'A',
'В': 'B',
'Е': 'E',
'Ё': 'E',
'З': '3',
'К': 'K',
'М': 'M',
'Н': 'H',
'О': 'O',
'Р': 'P',
'С': 'C',
'Т': 'T',
'Х': 'X',
'Ь': 'b',
'а': 'a',
'е': 'e',
'ё': 'e',
'о': 'o',
'р': 'p',
'с': 'c',
'у': 'y',
'х': 'x',
};
static final RegExp _prefixRegExp = RegExp(r'\@\[[\S\s]+\] ');
static void setCharMap(Map<String, String> charMap) {
_charMap = Map.from(charMap);
}
static String encode(String text) {
if (text.isEmpty) return text;
final buffer = StringBuffer();
final senderName = extractSenderName(text);
final msgText = removeSenderName(text);
for (final rune in msgText.runes) {
final char = String.fromCharCode(rune);
buffer.write(_charMap[char] ?? char);
}
return senderName + buffer.toString();
}
static String removeSenderName(String text) {
final match = _prefixRegExp.matchAsPrefix(text);
if (match != null) {
return text.substring(match.end);
}
return text;
}
static String extractSenderName(String text) {
final match = _prefixRegExp.matchAsPrefix(text);
if (match != null) {
return match.group(0) ?? '';
}
return '';
}
}
-78
View File
@@ -1,78 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../l10n/l10n.dart';
import '../widgets/image_send_codec_binding.dart';
import '../widgets/image_send_preview_sheet.dart';
/// Shared "attach an image" flow, used by both the direct-message and channel
/// chat screens so the two surfaces cannot drift apart.
///
/// Picks a photo, then shows [ImageSendPreviewSheet] so the user sees the
/// packet count and airtime *before* committing to a send that may occupy the
/// channel for seconds. Returns null if the user backed out at either step.
///
/// The caller owns the actual transmission: this only produces the payload.
Future<ImageSendPreviewResult?> pickAndPreviewImage({
required BuildContext context,
required ImageSendCodec codec,
ImagePicker? picker,
}) async {
final XFile? picked;
try {
picked = await (picker ?? ImagePicker()).pickImage(
source: ImageSource.gallery,
// The codec centre-crops to 512x512 anyway, so there is nothing to gain
// from decoding a 12 MP original -- but stay well above 512 so the crop
// still has detail to work with.
maxWidth: 2048,
maxHeight: 2048,
);
} on Exception catch (e) {
debugPrint('image pick failed: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_imagePickFailed)),
);
}
return null;
}
if (picked == null) return null; // user cancelled the picker
final Uint8List bytes;
int originalBytes;
try {
bytes = await picked.readAsBytes();
originalBytes = bytes.length;
if (!kIsWeb) {
// length() is the on-disk size, which is what we want to show against the
// transmitted size; fall back to the in-memory length if it fails.
try {
originalBytes = await File(picked.path).length();
} on FileSystemException {
// keep the in-memory length
}
}
} on Exception catch (e) {
debugPrint('image read failed: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_imagePickFailed)),
);
}
return null;
}
if (!context.mounted) return null;
return showImageSendPreviewSheet(
context: context,
imageBytes: bytes,
originalFileBytes: originalBytes,
codec: codec,
);
}
+11 -20
View File
@@ -1,10 +1,8 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/l10n.dart';
import '../utils/platform_info.dart';
import '../helpers/snack_bar_builder.dart';
class LinkHandler {
static TextStyle defaultLinkStyle(BuildContext context, TextStyle base) {
@@ -21,7 +19,6 @@ class LinkHandler {
required String text,
required TextStyle style,
TextStyle? linkStyle,
VoidCallback? onSecondaryTap,
}) {
final effectiveLinkStyle = linkStyle ?? defaultLinkStyle(context, style);
const options = LinkifyOptions(humanize: false, defaultToHttps: false);
@@ -29,7 +26,7 @@ class LinkHandler {
void onOpen(LinkableElement link) => handleLinkTap(context, link.url);
if (PlatformInfo.isDesktop) {
final linkify = SelectableLinkify(
return SelectableLinkify(
text: text,
style: style,
linkStyle: effectiveLinkStyle,
@@ -37,14 +34,6 @@ class LinkHandler {
linkifiers: linkifiers,
onOpen: onOpen,
);
if (onSecondaryTap == null) return linkify;
return Listener(
onPointerDown: (event) {
if (event.buttons & kSecondaryMouseButton != 0) onSecondaryTap();
},
behavior: HitTestBehavior.translucent,
child: linkify,
);
}
return Linkify(
text: text,
@@ -104,19 +93,21 @@ class LinkHandler {
final uri = Uri.parse(url);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
if (context.mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_couldNotOpenLink(url)),
backgroundColor: Colors.red,
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.chat_couldNotOpenLink(url)),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
if (context.mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_invalidLink),
backgroundColor: Colors.red,
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.chat_invalidLink),
backgroundColor: Colors.red,
),
);
}
}
+16 -76
View File
@@ -1,7 +1,5 @@
import 'package:flutter/foundation.dart';
import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
import '../connector/meshcore_protocol.dart';
class PathHelper {
static String formatPathHex(List<int> pathBytes) {
@@ -10,82 +8,24 @@ class PathHelper {
.join(',');
}
static String hopHex(int byte) {
return byte.toRadixString(16).padLeft(2, '0').toUpperCase();
}
static String formatHopHex(List<int> hopBytes) {
return hopBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join();
}
static String? hopName(int byte, List<Contact> allContacts) {
final matches = allContacts
.where(
(c) =>
c.publicKey.isNotEmpty &&
c.publicKey.first == byte &&
(c.type == advTypeRepeater || c.type == advTypeRoom),
)
.toList();
if (matches.isEmpty) return null;
if (matches.length == 1) return matches.first.name;
return matches.map((c) => c.name).join(' | ');
}
static List<Uint8List> splitPathBytes(
List<int> pathBytes,
int hashByteWidth,
) {
if (pathBytes.isEmpty) return const [];
final width = hashByteWidth.clamp(1, 4).toInt();
final hops = <Uint8List>[];
for (int i = 0; i < pathBytes.length; i += width) {
final endIdx = (i + width).clamp(0, pathBytes.length).toInt();
final hopBytes = pathBytes.sublist(i, endIdx);
if (hopBytes.isNotEmpty) {
hops.add(Uint8List.fromList(hopBytes));
}
}
return hops;
}
/// Resolves path bytes to contact names, supporting multi-byte hash widths.
///
/// Groups path bytes according to [hashByteWidth]:
/// - 1: Single byte per hop (256 unique nodes)
/// - 2: Two bytes per hop (65K unique nodes)
/// - 3: Three bytes per hop (16M unique nodes)
/// - 4: Four bytes per hop (4.3G unique nodes)
static String resolvePathNames(
List<int> pathBytes,
List<Contact> allContacts,
int hashByteWidth,
) {
if (pathBytes.isEmpty) return '';
final parts = <String>[];
for (final hopBytes in splitPathBytes(pathBytes, hashByteWidth)) {
final hex = formatHopHex(hopBytes);
final matches = allContacts.where((c) {
if (c.publicKey.length < hopBytes.length) return false;
if (c.type != advTypeRepeater && c.type != advTypeRoom) return false;
return listEquals(c.publicKey.sublist(0, hopBytes.length), hopBytes);
}).toList();
if (matches.isEmpty) {
parts.add(hex);
} else if (matches.length == 1) {
parts.add(matches.first.name);
} else {
parts.add(matches.map((c) => c.name).join(' | '));
}
}
return parts.join(' \u2192 ');
return pathBytes
.map((b) {
final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase();
final matches = allContacts
.where(
(c) =>
c.publicKey.first == b &&
(c.type == advTypeRepeater || c.type == advTypeRoom),
)
.toList();
if (matches.isEmpty) return hex;
if (matches.length == 1) return matches.first.name;
return matches.map((c) => c.name).join(' | ');
})
.join(' \u2192 ');
}
}
-76
View File
@@ -1,76 +0,0 @@
import 'package:latlong2/latlong.dart';
import '../connector/meshcore_protocol.dart';
import 'path_helper.dart';
import '../models/contact.dart';
class PathHopResolver {
const PathHopResolver._();
static List<Contact?> resolve({
required List<int> pathBytes,
required List<Contact> contacts,
LatLng? endpoint,
bool resolveFromEnd = false,
int pathHashByteWidth = 1,
}) {
final width = pathHashByteWidth.clamp(1, 4).toInt();
final candidatesByPrefix = <String, List<Contact>>{};
for (final contact in contacts) {
if (contact.publicKey.length < width) continue;
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
continue;
}
final prefix = PathHelper.formatHopHex(
contact.publicKey.sublist(0, width),
);
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
}
for (final candidates in candidatesByPrefix.values) {
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
}
final hops = PathHelper.splitPathBytes(pathBytes, width);
final resolved = List<Contact?>.filled(hops.length, null);
final indexes = resolveFromEnd
? List<int>.generate(hops.length, (i) => hops.length - 1 - i)
: List<int>.generate(hops.length, (i) => i);
final distance = Distance();
var previousPosition = endpoint;
for (final index in indexes) {
final candidates =
candidatesByPrefix[PathHelper.formatHopHex(hops[index])];
if (candidates == null || candidates.isEmpty) continue;
var bestIndex = 0;
if (previousPosition != null && candidates.length > 1) {
double? nearestDistance;
for (var i = 0; i < candidates.length; i++) {
final position = _positionOf(candidates[i]);
if (position == null) continue;
final candidateDistance = distance(previousPosition, position);
if (nearestDistance == null || candidateDistance < nearestDistance) {
nearestDistance = candidateDistance;
bestIndex = i;
}
}
}
final contact = candidates.removeAt(bestIndex);
resolved[index] = contact;
previousPosition = _positionOf(contact) ?? previousPosition;
}
return resolved;
}
static LatLng? _positionOf(Contact contact) {
if (!contact.hasLocation ||
contact.latitude == null ||
contact.longitude == null) {
return null;
}
return LatLng(contact.latitude!, contact.longitude!);
}
}
-68
View File
@@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
// showDismissibleSnackBar shows a [SnackBar] with tap to dismiss
// all other properties are default and optional
void showDismissibleSnackBar(
BuildContext context, {
Key? key,
required Widget content,
Color? backgroundColor,
double? elevation,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? padding,
double? width,
ShapeBorder? shape,
HitTestBehavior? hitTestBehavior,
SnackBarBehavior? behavior,
SnackBarAction? action,
double? actionOverflowThreshold,
bool? showCloseIcon,
Color? closeIconColor,
Duration? duration,
bool? persist,
Animation<double>? animation,
void Function()? onVisible,
DismissDirection? dismissDirection,
Clip? clipBehavior,
}) {
// Callers often reach here after an async gap; the context may already be
// unmounted, or deactivated (popped but not yet disposed) ancestor
// lookups on a deactivated element throw. Showing nothing is the right
// outcome in both cases.
if (!context.mounted) return;
var isActive = true;
assert(() {
isActive = (context as Element).debugIsActive;
return true;
}());
if (!isActive) return;
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return;
messenger.showSnackBar(
SnackBar(
key: key,
content: GestureDetector(
onTap: () => messenger.hideCurrentSnackBar(),
child: content,
),
backgroundColor: backgroundColor,
elevation: elevation,
margin: margin,
padding: padding,
width: width,
shape: shape,
hitTestBehavior: hitTestBehavior,
behavior: behavior,
action: action,
actionOverflowThreshold: actionOverflowThreshold,
showCloseIcon: showCloseIcon,
closeIconColor: closeIconColor,
duration: duration ?? const Duration(seconds: 4),
persist: persist,
animation: animation,
onVisible: onVisible,
dismissDirection: dismissDirection ?? DismissDirection.down,
clipBehavior: clipBehavior ?? Clip.hardEdge,
),
);
}
+3 -16
View File
@@ -4,14 +4,8 @@ import 'package:flutter/services.dart';
class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
final int maxBytes;
final String Function(String)? encoder;
const Utf8LengthLimitingTextInputFormatter(this.maxBytes, {this.encoder});
int _effectiveByteLength(String text) {
final effective = encoder != null ? encoder!(text) : text;
return utf8.encode(effective).length;
}
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
@override
TextEditingValue formatEditUpdate(
@@ -19,7 +13,8 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
TextEditingValue newValue,
) {
if (maxBytes <= 0) return oldValue;
if (_effectiveByteLength(newValue.text) <= maxBytes) return newValue;
final bytes = utf8.encode(newValue.text);
if (bytes.length <= maxBytes) return newValue;
final truncated = _truncateToMaxBytes(newValue.text, maxBytes);
return TextEditingValue(
@@ -30,14 +25,6 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
}
String _truncateToMaxBytes(String text, int limit) {
if (encoder != null) {
final runes = text.runes.toList();
while (runes.isNotEmpty &&
_effectiveByteLength(String.fromCharCodes(runes)) > maxBytes) {
runes.removeLast();
}
return String.fromCharCodes(runes);
}
final buffer = StringBuffer();
var used = 0;
for (final rune in text.runes) {
+180 -946
View File
File diff suppressed because it is too large Load Diff
+268 -1140
View File
File diff suppressed because it is too large Load Diff
+102 -874
View File
File diff suppressed because it is too large Load Diff
+231 -1103
View File
File diff suppressed because it is too large Load Diff
+164 -1064
View File
File diff suppressed because it is too large Load Diff
+834 -1599
View File
File diff suppressed because it is too large Load Diff
+251 -1151
View File
File diff suppressed because it is too large Load Diff
+350 -1212
View File
File diff suppressed because it is too large Load Diff
+130 -992
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+59 -825
View File
File diff suppressed because it is too large Load Diff
+41 -807
View File
File diff suppressed because it is too large Load Diff
+38 -804
View File
File diff suppressed because it is too large Load Diff
+12 -841
View File
@@ -39,8 +39,6 @@
"common_notAvailable": "—",
"common_voltageValue": "{volts} В",
"common_percentValue": "{percent}%",
"common_autoRefresh": "Автообновление",
"common_interval": "Интервал",
"scanner_title": "MeshCore Open",
"scanner_scanning": "Поиск устройств...",
"scanner_connecting": "Подключение...",
@@ -77,16 +75,12 @@
"settings_locationIntervalInvalid": "Интервал должен составлять не менее 60 секунд и не более 86400 секунд.",
"settings_latitude": "Широта",
"settings_longitude": "Долгота",
"settings_autoZeroHopAdvertOnGpsUpdate": "Авто-объявление без хопов при обновлении GPS",
"settings_autoZeroHopAdvertOnGpsUpdateSubtitle": "Когда GPS-местоположение меняется, отправлять объявление без хопов (требуется геопозиция в объявлении).",
"settings_privacyMode": "Режим конфиденциальности",
"settings_privacyModeSubtitle": "Скрыть имя/позицию в анонсировании",
"settings_privacyModeToggle": "Включите режим конфиденциальности, чтобы скрыть свое имя и местоположение в анонсировании.",
"settings_privacyModeEnabled": "Режим конфиденциальности включен",
"settings_privacyModeDisabled": "Режим конфиденциальности выключен",
"settings_actions": "Действия",
"settings_deleteAllPaths": "Delete All Paths",
"settings_deleteAllPathsSubtitle": "Clear all path data from contacts.",
"settings_sendAdvertisement": "Отправить анонсирование",
"settings_sendAdvertisementSubtitle": "Отправить анонсирование о присутствии сейчас",
"settings_advertisementSent": "Анонсирование отправлено",
@@ -192,19 +186,6 @@
"appSettings_last6Hours": "Последние 6 часов",
"appSettings_last24Hours": "Последние 24 часа",
"appSettings_lastWeek": "Последнюю неделю",
"appSettings_rasterTileSource": "Источник растровых тайлов",
"appSettings_stadiaEndpoint": "Конечная точка Stadia",
"appSettings_stadiaApiKey": "Ключ API Stadia",
"appSettings_stadiaApiKeyRequired": "Требуется для использования Stadia Maps",
"appSettings_stadiaApiKeyConfigured": "Настроено: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Введите свой ключ API Stadia Maps. Приложение использует его для запросов растровых тайлов.",
"appSettings_offlineMapCache": "Кэш офлайн-карты",
"appSettings_noAreaSelected": "Область не выбрана",
"appSettings_areaSelectedZoom": "Область выбрана (масштаб {minZoom}{maxZoom})",
@@ -248,8 +229,11 @@
"channels_searchChannels": "Поиск каналов...",
"channels_noChannelsFound": "Каналы не найдены",
"channels_channelIndex": "Канал {index}",
"channels_hashtagChannel": "Хэштег-канал",
"channels_public": "Публичный",
"channels_private": "Приватный",
"channels_publicChannel": "Публичный канал",
"channels_privateChannel": "Приватный канал",
"channels_editChannel": "Изменить канал",
"channels_muteChannel": "Отключить уведомления канала",
"channels_unmuteChannel": "Включить уведомления канала",
@@ -268,22 +252,6 @@
"channels_channelAdded": "Канал \"{name}\" добавлен",
"channels_editChannelTitle": "Изменить канал {index}",
"channels_smazCompression": "Сжатие SMAZ",
"channels_cyr2latCompression": "Сжатие Cyr2Lat",
"channels_cyr2latCompressionDscr": "Заменяет некоторые кириллические символы на латиницу при отправке.",
"channels_cyr2latSettingsHeading": "Настройка Cyr2Lat",
"channels_cyr2latSettingsSubheading": "Список замен",
"channels_cyr2latSettingsDscr": "Редактировать JSON-конфигурацию замены символов",
"channels_cyr2latSettingsDialogHint": "JSON-карта замен",
"channels_cyr2latSettingsDialogWrongJSON": "Некорректный JSON: {error}",
"settings_cyr2latProfileAdd": "Добавить профиль Cyr2Lat",
"settings_cyr2latProfileName": "Название профиля",
"settings_cyr2latProfileNameEmpty": "Название профиля не может быть пустым",
"settings_cyr2latProfileAdded": "Профиль добавлен",
"settings_cyr2latProfileUpdated": "Профиль успешно обновлен",
"settings_cyr2latProfileEdit": "Редактировать профиль Cyr2Lat",
"settings_cyr2latProfileDelete": "Удалить профиль Cyr2Lat",
"settings_cyr2latProfileDeleted": "Профиль успешно удален",
"settings_cyr2latProfileDeleteDscr": "Вы действительно хотите удалить профиль \"{name}\"?",
"channels_channelUpdated": "Канал \"{name}\" обновлён",
"channels_publicChannelAdded": "Публичный канал добавлен",
"channels_sortBy": "Сортировка",
@@ -390,8 +358,6 @@
"chat_direct": "Прямой",
"chat_poiShared": "Точка интереса отправлена",
"chat_unread": "Непрочитанных: {count}",
"chat_markAsUnread": "Пометить как непрочитанные",
"chat_newMessages": "Новые сообщения",
"map_title": "Карта нод",
"map_noNodesWithLocation": "Нет нод с данными о местоположении",
"map_nodesNeedGps": "Ноды должны передавать свои GPS-координаты, чтобы отображаться на карте",
@@ -453,56 +419,6 @@
"mapCache_downloadTilesButton": "Загрузить плитки",
"mapCache_clearCacheButton": "Очистить кэш",
"mapCache_failedDownloads": "Неудачных загрузок: {count}",
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "С {north}, Ю {south}, В {east}, З {west}",
"time_justNow": "Только что",
"time_minutesAgo": "{minutes} мин назад",
@@ -753,43 +669,6 @@
"telemetry_voltageValue": "{volts}В",
"telemetry_currentValue": "{amps}А",
"telemetry_temperatureValue": "{celsius}°C / {fahrenheit}°F",
"telemetry_digitalInputLabel": "Цифровой вход",
"telemetry_digitalOutputLabel": "Цифровой выход",
"telemetry_analogInputLabel": "Аналоговый вход",
"telemetry_analogOutputLabel": "Аналоговый выход",
"telemetry_genericLabel": "Общий датчик",
"telemetry_luminosityLabel": "Освещённость",
"telemetry_presenceLabel": "Присутствие",
"telemetry_humidityLabel": "Влажность",
"telemetry_accelerometerLabel": "Акселерометр",
"telemetry_pressureLabel": "Давление",
"telemetry_altitudeLabel": "Высота",
"telemetry_frequencyLabel": "Частота",
"telemetry_percentageLabel": "Процент",
"telemetry_concentrationLabel": "Концентрация",
"telemetry_powerLabel": "Мощность",
"telemetry_distanceLabel": "Расстояние",
"telemetry_energyLabel": "Энергия",
"telemetry_directionLabel": "Направление",
"telemetry_timeLabel": "Время",
"telemetry_gyrometerLabel": "Гирометр",
"telemetry_colourLabel": "Цвет",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "Переключатель",
"telemetry_polylineLabel": "Полилиния",
"telemetry_altitudeValue": "{meters} м",
"telemetry_frequencyValue": "{hertz} Гц",
"telemetry_pressureValue": "{hpa} гПа",
"telemetry_luminosityValue": "{lux} лк",
"telemetry_powerValue": "{watts} Вт",
"telemetry_distanceValue": "{meters} м",
"telemetry_energyValue": "{kilowattHours} кВт⋅ч",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "Количество запросов",
"telemetry_error": "Не удалось получить данные",
"neighbors_receivedData": "Полученные данные о соседях",
"neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.",
"neighbors_errorLoading": "Ошибка загрузки соседей: {error}",
@@ -1283,6 +1162,13 @@
"contact_clearChat": "Очистить чат",
"contact_lastSeen": "Последний раз видели",
"contact_teleBaseSubtitle": "Разрешить обмен уровнем заряда батареи и базовой телеметрией",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeight": "Максимальный допустимый вес маршрута",
"appSettings_maxRouteWeightSubtitle": "Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.",
"appSettings_initialRouteWeightSubtitle": "Начальный вес для новых, только что открытых маршрутов",
@@ -1295,6 +1181,7 @@
"appSettings_maxMessageRetriesSubtitle": "Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Режим телеметрии обновлен",
"settings_multiAck": "Мульти-ACK: {value}",
"map_showOverlaps": "Перекрытия ключа повтора",
"map_runTraceWithReturnPath": "Вернуться обратно по тому же пути",
"@radioStats_noiseFloor": {
@@ -1372,9 +1259,6 @@
"translation_title": "Перевод",
"translation_enableTitle": "Включить перевод",
"translation_composerSubtitle": "Управляет исходным состоянием значка перевода, предоставляемого редактором.",
"translation_autoIncomingTitle": "Автоматически переводить сообщения",
"translation_autoIncomingSubtitle": "Автоматически переводит сообщения для уведомлений, а также для чатов и каналов.",
"translation_translateMessage": "Перевести сообщение",
"translation_targetLanguage": "Целевой язык",
"translation_useAppLanguage": "Используйте язык приложения",
"translation_downloadedModelLabel": "Загруженная модель",
@@ -1417,718 +1301,5 @@
"scanner_linuxPairingHidePin": "Скрыть PIN",
"scanner_linuxPairingPinTitle": "PIN‑код сопряжения Bluetooth",
"repeater_cliQuickDiscovery": "Обнаружить Соседей",
"repeater_cliQuickClockSync": "Синхронизация часов",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLogin": "Синхронизация часов после входа в систему",
"repeater_clockSyncAfterLoginSubtitle": "Автоматически отправлять сообщение \"синхронизация времени\" после успешной авторизации.",
"chat_sendMessage": "Отправить сообщение",
"repeater_guest": "Информация о ретрансляторе",
"room_guest": "Информация о сервере",
"repeater_guestTools": "Инструменты для гостей",
"common_done": "Готово",
"background_serviceTitle": "MeshCore работает",
"background_serviceText": "Поддерживает BLE-соединение",
"appSettings_translationModelDeleted": "Удалено {name}",
"@appSettings_translationModelDeleted": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"appSettings_translationModelDeleteFailed": "Не удалось удалить: {error}",
"@appSettings_translationModelDeleteFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"channels_channelUpdateFailed": "Не удалось обновить канал: {error}",
"@channels_channelUpdateFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"map_type": "Тип",
"map_path": "Путь",
"map_location": "Местоположение",
"map_estLocation": "Прибл. местоположение",
"map_publicKey": "Публичный ключ",
"map_publicKeyPrefixHint": "напр. ab12",
"contact_typeChat": "Чат",
"contact_typeRepeater": "Ретранслятор",
"contact_typeRoom": "Комната",
"contact_typeSensor": "Датчик",
"contact_typeUnknown": "Неизвестно",
"channels_via": "через {path}",
"chat_score": "Оценка",
"settings_multiAck": "Несколько подтверждений",
"map_sharedAt": "Поделено",
"@losBlockedSpotChip": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"@losSelectedObstructionDetails": {
"placeholders": {
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
},
"distanceFromA": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"distanceFromB": {
"type": "String"
}
}
},
"losBlockedSpotsHint": "Щелкните по заблокированной области, чтобы выделить ее на карте.",
"losBlockedSpotsTitle": "Зарезервированные места",
"losSelectedObstructionTitle": "Выбранный объект, препятствующий движению",
"losBlockedSpotChip": "{distance} {distanceUnit} • {obstruction} {heightUnit}",
"losSelectedObstructionDetails": "Blocked by {obstruction} {heightUnit}, {distanceFromA} from A and {distanceFromB} from B ({distanceUnit}).",
"repeater_rxGain": "Увеличенная эффективность RX",
"repeater_rxGainHelper": "Более высокая чувствительность, больший ток потребления (только для SX1262/SX1268)",
"repeater_refreshRxGain": "Обновите усиление RX",
"repeater_multiAcks": "Несколько подтверждений",
"repeater_multiAcksSubtitle": "Обеспечьте доставку сообщений по нескольким каналам для повышения эффективности.",
"repeater_refreshMultiAcks": "Обновление нескольких подтверждений",
"repeater_networkHealth": "Состояние сети",
"repeater_loopDetect": "Обнаружение циклов",
"repeater_loopDetectHelper": "Создайте пакеты данных, которые выглядят как циклы маршрутизации.",
"repeater_loopDetectOff": "Отключено",
"repeater_loopDetectMinimal": "Минимальный",
"repeater_loopDetectModerate": "Умеренный",
"repeater_loopDetectStrict": "Строгий",
"repeater_dutyCycle": "Цикл работы",
"repeater_dutyCycleHelper": "Максимальный процент времени, выделенного на трансляцию.",
"repeater_dutyCyclePercent": "{percent}%",
"@repeater_dutyCyclePercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"repeater_ownerInfo": "Информация о операторе",
"repeater_ownerInfoHelper": "Общая метаинформация для этого ретранслятора",
"repeater_refreshOwnerInfo": "Обновить информацию о операторе",
"repeater_floodMax": "Максимальное количество прыжков при наводнении",
"repeater_floodMaxHelper": "Максимальное количество пакетов, которые могут быть отправлены в одном потоке (0-64)",
"repeater_advancedSettings": "Продвинутый",
"repeater_advancedSettingsSubtitle": "Регуляторы для опытных операторов",
"repeater_pathHashMode": "Режим хеширования пути",
"repeater_pathHashModeHelper": "Байты, используемые для кодирования идентификатора этого ретранслятора в тегах flood-маршрута/обнаружения циклов. 0 = 1 байт (256 идентификаторов, до 64 переходов), 1 = 2 байта (65 000 идентификаторов, до 32 переходов), 2 = 3 байта (16 миллионов идентификаторов, до 21 перехода). Прошивки до v1.14 всегда использовали 1-байтовые маршруты; v1.14 и новее можно настроить на 2- или 3-байтовые маршруты.",
"repeater_txDelay": "Задержка в работе системы Flood TX",
"repeater_txDelayHelper": "Передача с увеличенным интервалом для трафика во время наводнения, в качестве коэффициента, умножающего время передачи пакета (от 0 до 2, по умолчанию 0,5). Более высокое значение означает меньшее количество столкновений, но более медленную передачу.",
"repeater_directTxDelay": "Прямая задержка сигнала TX",
"repeater_directTxDelayHelper": "Передача промежуточных данных для прямого (немассового) трафика, в качестве коэффициента, равного времени передачи пакета (от 0 до 2, по умолчанию 0,3).",
"repeater_intThresh": "Пороговое значение помех",
"repeater_intThreshHelper": "Порог устанавливается для калибровки уровня шума радио, чтобы оно отсеивало помехи, превышающие этот уровень. Значение \"0\" означает отключение – используйте только в случае, если вы наблюдаете ошибки при приеме сигнала в шумном диапазоне.",
"repeater_agcResetInterval": "Интервал сброса AGC",
"repeater_agcResetIntervalHelper": "Как часто следует сбрасывать автоматическую регулировку усиления радио, чтобы вернуться к нормальному состоянию после заклинивания? Интервал сброса составляет несколько секунд, кратный 4. Отключение периодического сброса осуществляется с помощью параметра 0.",
"repeater_actionsTitle": "Действия",
"repeater_sendAdvert": "Отправить объявление о наводнении",
"repeater_sendAdvertSubtitle": "Разместите рекламу о наводнении в эфире по всей сети.",
"repeater_sendAdvertZeroHop": "Опубликуйте рекламу, не требующую промежуточного распространения.",
"repeater_sendAdvertZeroHopSubtitle": "Разместите рекламу, распространяемую одним способом (без использования ретрансляторов).",
"repeater_clockSync": "Синхронизировать время сейчас",
"repeater_clockSyncSubtitle": "Установите время на вашем телефоне, чтобы оно совпадало со временем ретранслятора.",
"repeater_actionSucceeded": "{action} succeeded",
"@repeater_actionSucceeded": {
"placeholders": {
"action": {
"type": "String"
}
}
},
"repeater_actionFailed": "{action} failed: {error}",
"@repeater_actionFailed": {
"placeholders": {
"action": {
"type": "String"
},
"error": {
"type": "String"
}
}
},
"repeater_settingsSavedRebootNeeded": "Настройки сохранены — перезагрузите ретранслятор, чтобы применить их.",
"repeater_settingsPartialFailure": "Некоторые настройки не удалось применить: {failures}",
"@repeater_settingsPartialFailure": {
"placeholders": {
"failures": {
"type": "String"
}
}
},
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"@common_percentValue": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"@settings_aboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"@telemetry_temperatureValue": {
"placeholders": {
"celsius": {
"type": "String"
},
"fahrenheit": {
"type": "String"
}
}
},
"@channelPath_timeWithDate": {
"placeholders": {
"day": {
"type": "int"
},
"month": {
"type": "int"
},
"time": {
"type": "String"
}
}
},
"@channelPath_timeOnly": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"@channelPath_selectedPathLabel": {
"placeholders": {
"label": {
"type": "String"
},
"prefixes": {
"type": "String"
}
}
},
"repeater_getCategory": "Получить значения",
"repeater_powerMgmt": "Управление энергопотреблением",
"repeater_sensors": "Датчики",
"repeater_cliHelpPowerOff": "Отключает устройство. (ожидается отсутствие ответа).",
"repeater_cliHelpClkReboot": "Сбрасывает часы до известной эпохи и перезапускает устройство.",
"repeater_cliHelpAdvertZeroHop": "Отправляет рекламу, распространяемую только среди ближайших соседей (без промежуточных узлов).",
"repeater_cliHelpStartOta": "Запускает обновление прошивки по воздуху на поддерживаемых устройствах.",
"repeater_cliHelpTime": "Устанавливает время устройства в соответствии с заданными секундами от начала эпохи Unix. Время не может сброситься назад.",
"repeater_cliHelpBoard": "Отображает информацию о производителе платы / идентификатор аппаратного обеспечения.",
"repeater_cliHelpDiscoverNeighbors": "Отправляет запрос на обнаружение соседних узлов. (Только для ретранслятора)",
"repeater_cliHelpPowersavingOnOff": "Включает или выключает режим экономии энергии (если он поддерживается).",
"repeater_cliHelpPowersaving": "Показывает, включен ли режим экономии энергии.",
"repeater_cliHelpErase": "(Только для серийного использования) Форматирует файловую систему устройства. Удаляет все настройки и контакты.",
"repeater_cliHelpSetDutyCycle": "Устанавливает максимальный допустимый цикл передачи данных в процентах (от 1 до 100). Внутренне корректирует коэффициент времени передачи.",
"repeater_cliHelpSetPrvKey": "(Только для серийного использования) Заменяет приватный ключ, идентифицирующий устройство. Требуется перезагрузка для применения. Генерирует новый публичный ключ.",
"repeater_cliHelpSetRadioRxGain": "(Только для SX126x) Переключает усиление RX для повышения чувствительности при больших токах потребления.",
"repeater_cliHelpSetOwnerInfo": "Указывает строку с контактной информацией владельца, которая должна быть включена в объявления. Используйте '|' для переносов строк.",
"repeater_cliHelpSetPathHashMode": "Устанавливает режим хеширования пути. 0 = устаревший, 1 = стандартный, 2 = строгий. Влияет на то, как определяются маршруты.",
"repeater_cliHelpSetLoopDetect": "Устанавливает чувствительность обнаружения циклов маршрутизации: \"выключено\", \"минимальная\", \"умеренная\" или \"строгая\".",
"repeater_cliHelpSetFreq": "(Только для настройки) Быстро устанавливает только частоту. Требуется перезагрузка. Рекомендуется использовать функцию \"настройка радио\" для полного набора параметров.",
"repeater_cliHelpSetBridgeChannel": "(Только для моста ESPNow) Устанавливает канал Wi-Fi (от 1 до 14), используемый мостом.",
"repeater_cliHelpGetName": "Отображает имя настроенного узла.",
"repeater_cliHelpGetRole": "Отображает роль прошивки (ретранслятор, сервер для комнаты и т.д.).",
"repeater_cliHelpGetPublicKey": "Отображает открытый ключ устройства.",
"repeater_cliHelpGetPrvKey": "(Только для серийного использования) Отображает приватный ключ устройства. Рассматривайте его как секретную информацию.",
"repeater_cliHelpGetRepeat": "Отображает, включена ли функция перенаправления пакетов (функция ретранслятора) или нет.",
"repeater_cliHelpGetTx": "Отображает текущую мощность передатчика в дБм.",
"repeater_cliHelpGetFreq": "Отображает настроенную частоту радиосигнала в мегагерцах.",
"repeater_cliHelpGetRadio": "Отображает все параметры радиосигнала: частоту, полосу пропускания, коэффициент модуляции, скорость кодирования.",
"repeater_cliHelpGetRadioRxGain": "(Только для SX126x) Отображает состояние усиления сигнала на входе RX.",
"repeater_cliHelpGetAf": "Отображает текущий коэффициент времени эфира.",
"repeater_cliHelpGetDutyCycle": "Отображает текущий допустимый цикл работы в процентах.",
"repeater_cliHelpGetIntThresh": "Отображает порог помех в децибелах.",
"repeater_cliHelpGetAgcResetInterval": "Отображает интервал сброса автоматической регулировки усиления в секундах.",
"repeater_cliHelpGetMultiAcks": "Показывает, включен ли режим двойной подтверждения (1) или выключен (0).",
"repeater_cliHelpGetAllowReadOnly": "Отображает, разрешен ли доступ для чтения только для гостей.",
"repeater_cliHelpGetAdvertInterval": "Отображает продолжительность рекламного блока в минутах.",
"repeater_cliHelpGetFloodAdvertInterval": "Отображает интервал времени показа рекламного ролика в часах.",
"repeater_cliHelpGetGuestPassword": "Отображает установленный пароль для гостя.",
"repeater_cliHelpGetLat": "Отображает заданную широту.",
"repeater_cliHelpGetLon": "Отображает заданную долготу.",
"repeater_cliHelpGetRxDelay": "Отображает базовое значение задержки.",
"repeater_cliHelpGetTxDelay": "Отображает коэффициент задержки при работе в режиме затопления.",
"repeater_cliHelpGetDirectTxDelay": "Отображает коэффициент задержки в режиме прямого подключения.",
"repeater_cliHelpGetFloodMax": "Отображает максимальное количество переходов при затоплении.",
"repeater_cliHelpGetOwnerInfo": "Отображает строку с контактной информацией владельца.",
"repeater_cliHelpGetPathHashMode": "Отображает режим работы с хэшем пути (0/1/2).",
"repeater_cliHelpGetLoopDetect": "Отображает чувствительность к обнаружению циклов.",
"repeater_cliHelpGetAcl": "(Только для серий) Перечисляет записи управления доступом на ретрансляторе.",
"repeater_cliHelpGetBridgeEnabled": "Показывает, включена ли функция моста.",
"repeater_cliHelpGetBridgeDelay": "Отображает задержку в миллисекундах.",
"repeater_cliHelpGetBridgeSource": "Отображает, какие пакеты RX или TX передаются через мост.",
"repeater_cliHelpGetBridgeBaud": "(Только для интерфейса RS232) Отображает скорость передачи данных на интерфейсе RS232.",
"repeater_cliHelpGetBridgeChannel": "(Только для моста ESPNow) Отображает канал WiFi, используемый мостом.",
"repeater_cliHelpGetBridgeSecret": "(Только для моста ESPNow) Отображает общий секрет, используемый мостом.",
"repeater_cliHelpGetBootloaderVer": "(Только для NRF52) Отображает версию загрузчика.",
"repeater_cliHelpGetAdcMultiplier": "Отображает коэффициент умножения аналого-цифрового преобразователя (масштабирование напряжения от батареи).",
"repeater_cliHelpGetPwrMgtSupport": "Сообщает, есть ли у совета поддержки функций управления питанием.",
"repeater_cliHelpGetPwrMgtSource": "Отображает текущий источник питания: внешний или аккумулятор.",
"repeater_cliHelpGetPwrMgtBootReason": "Отображает последние причины сброса и выключения.",
"repeater_cliHelpGetPwrMgtBootMv": "Отображает напряжение батареи при запуске системы в милливольтах (мВ).",
"repeater_cliHelpSensorGet": "Считывает пользовательское значение для датчика по указанному ключу.",
"repeater_cliHelpSensorSet": "Создает пользовательские настройки для датчика.",
"repeater_cliHelpSensorList": "Перечисляет все пользовательские настройки датчиков, разбитые на страницы с возможностью указания начального индекса.",
"repeater_cliHelpRegionDefault": "Отображает текущий область действия по умолчанию.",
"repeater_cliHelpRegionDefaultSet": "Устанавливает значение региона по умолчанию. Используйте \"<null>\", чтобы сбросить значение.",
"repeater_cliHelpRegionListAllowed": "Перечисляет регионы, где разрешено движение транспорта во время наводнений.",
"repeater_cliHelpRegionListDenied": "Перечисляет регионы, где запрещено движение транспорта во время наводнений.",
"repeater_cliHelpStatsPackets": "(Только для серийной версии) Отображает статистику на уровне пакетов.",
"repeater_cliHelpStatsRadio": "(Только для серий) Отображает статистику радио.",
"repeater_cliHelpStatsCore": "(Только для серийного оборудования) Отображает основные статистические данные прошивки.",
"settings_companionDebugLogSubtitle": "Команды, ответы и необработанные данные, используемые для протоколов BLE, TCP и USB.",
"repeater_chanUtil": "Использование канала",
"settings_companionDebugLog": "Журнал отладки (для сопутствующего приложения)",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"messageStatus_pending": "Отправка",
"common_undo": "Отменить",
"messageStatus_delivered": "Доставлено",
"messageStatus_sent": "Отправлено",
"messageStatus_failed": "Не удалось отправить",
"messageStatus_repeated": "Услышал несколько раз",
"contacts_moreOptions": "Больше вариантов",
"contacts_searchOpen": "Найти контакты",
"contacts_searchClose": "Закрыть поиск",
"routing_title": "Маршрутизация",
"routing_modeAuto": "Авто",
"routing_modeFlood": "Наводнение",
"routing_modeManual": "Инструкция",
"routing_modeAutoHint": "Автоматически выбирает наиболее известный путь, и если такой путь неизвестен, использует алгоритм поиска пути.",
"routing_modeFloodHint": "Передача сигнала через все ретрансляторы. Самый надежный способ, но требует больше времени на передачу.",
"routing_modeManualHint": "Всегда следует точно по указанному вами маршруту.",
"routing_currentRoute": "Текущий маршрут",
"routing_directNoHops": "Прямое соединение – без использования ретрансляторов",
"routing_noPathYet": "Пока нет пути. Следующее сообщение будет отправлено до тех пор, пока не будет обнаружен маршрут.",
"routing_floodBroadcast": "Транслируется через все ретрансляторы",
"routing_editPath": "Изменить путь",
"routing_forgetPath": "Забудьте о маршруте",
"routing_knownPaths": "Известные маршруты",
"routing_knownPathsHint": "Создайте маршрут для переключения на этот пункт.",
"routing_inUse": "В эксплуатации",
"routing_qualityStrong": "Сильный первый скачок",
"routing_qualityGood": "Хорошее начало",
"routing_qualityFair": "Первый хороший урожай",
"routing_qualityWorked": "Осуществлено",
"routing_qualityFlood": "Узнал из новостей, распространяющихся в интернете.",
"routing_qualityUntested": "Непроверенный",
"routing_neverWorked": "никогда не было подтверждено",
"routing_floodDelivery": "Доставка при затоплении",
"pathEditor_title": "Создать маршрут",
"pathEditor_hopCounter": "{count} из 64 хмеля",
"pathEditor_noHops": "На данный момент хмель еще не добавлен. Чтобы добавить его, нажмите на соответствующие кнопки ниже в нужном порядке, или сохраните рецепт без хмеля, чтобы отправить его напрямую.",
"pathEditor_addHops": "Добавляйте хмель в соответствии с указанным порядком.",
"pathEditor_searchRepeaters": "Поиск повторителей",
"pathEditor_advancedHex": "Продвинутый уровень: прямой путь в шестнадцатеричном формате",
"pathEditor_hexLabel": "Префиксы шестнадцатеричной системы",
"pathEditor_hexHelper": "Два шестнадцатеричных символа на каждом шаге, разделенные запятыми.",
"pathEditor_invalidTokens": "Неверно: {tokens}",
"routing_lastWorked": "worked {when}",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"pathEditor_tooManyHops": "Максимальное количество ингредиентов – 64",
"pathEditor_usePath": "Используйте этот путь",
"pathEditor_removeHop": "Удалить хмель",
"pathEditor_unknownHop": "Неизвестный ретранслятор",
"map_zoomIn": "Увеличить масштаб",
"map_zoomOut": "Увеличить масштаб",
"map_centerMap": "Карта центра",
"chrome_bluetoothRequiresChromium": "Для работы Web Bluetooth требуется браузер на основе Chromium.",
"channels_communityShortId": "Идентификатор: {id}...",
"pathTrace_legendGpsConfirmed": "GPS подтверждено",
"pathTrace_legendInferred": "Выведенная позиция",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"map_searchHint": "Поиск по имени или ID узла",
"map_online": "Онлайн",
"scanner_bluetoothWebUnsupported": "Bluetooth недоступен в браузере. Подключитесь через USB.",
"map_activity": "Активность",
"map_recent": "Недавно",
"map_stale": "Устаревший",
"map_visible": "Видимый",
"map_hidden": "Скрытый",
"map_centerOnNode": "Центрировать на узле",
"map_details": "Детали",
"map_noGps": "Без GPS",
"map_noResults": "Не найдено соответствующих узлов",
"pathMap_viewSingle": "Одиночный",
"pathMap_viewCombined": "Объединённые",
"pathMap_play": "Воспроизвести",
"pathMap_pause": "Пауза",
"pathMap_replay": "Повтор",
"pathMap_stepBack": "Предыдущий хоп",
"pathMap_stepForward": "Следующий хоп",
"pathMap_animationOn": "Показать анимацию пакета",
"pathMap_animationOff": "Скрыть анимацию пакета",
"pathMap_hopOf": "Хоп {current} из {total}",
"pathMap_observedPaths": "Наблюдаемые маршруты: {count}",
"pathMap_primary": "Основной",
"pathMap_alternate": "Альт {index}",
"pathMap_hopCount": "{count, plural, one{{count} хоп} few{{count} хопа} many{{count} хопов} other{{count} хопов}}",
"pathMap_legendShared": "Общий сегмент",
"pathMap_legendEstimated": "Расчётный сегмент",
"pathMap_sharedNodeCount": "Используется в {count} маршрутах",
"pathMap_partialAnimation": "{count, plural, one{{count} хоп не имеет координат — показанный путь неполный} few{{count} хопа не имеют координат — показанный путь неполный} many{{count} хопов не имеют координат — показанный путь неполный} other{{count} хопов не имеют координат — показанный путь неполный}}",
"pathMap_showAllPaths": "Показать всё",
"pathMap_hidePath": "Скрыть путь",
"pathMap_collapsePanel": "Скрыть панель",
"pathMap_showPath": "Показать маршрут",
"pathMap_expandPanel": "Расширить панель",
"pathMap_noLocation": "Нет координат",
"pathMap_followPacket": "Следить за пакетом",
"pathMap_unfollowPacket": "Не следить за пакетом",
"pathMap_gpsCount": "{confirmed}/{total} GPS",
"@settings_deleteRegionConfirm": {
"placeholders": {
"region": {
"type": "String"
}
}
},
"@channels_regionSetTo": {
"placeholders": {
"region": {
"type": "String",
"example": "de-mitte"
}
}
},
"@chat_imagePickFailed": {
"description": "Shown when picking or reading a photo to send fails"
},
"@repeater_pubKeyPrefixHelper": {
"placeholders": {
"tries": {
"type": "int"
}
}
},
"@imageSend_packetsCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@imageSend_range": {
"placeholders": {
"min": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"@imageSend_longSendBody": {
"placeholders": {
"duration": {
"type": "String"
}
}
},
"@imageSend_sentConfirmation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@imageSend_sendFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"@imageSend_sendingProgress": {
"placeholders": {
"sent": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@receivedImage_senderPrefix": {
"placeholders": {
"prefix": {
"type": "String"
}
}
},
"@receivedImage_incoming": {
"placeholders": {
"received": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@receivedImage_incomplete": {
"placeholders": {
"received": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@receivedImage_awaiting": {
"placeholders": {
"bytes": {
"type": "int"
},
"packets": {
"type": "int"
}
}
},
"@imageSend_secondsValue": {
"placeholders": {
"seconds": {
"type": "String"
}
}
},
"@imageSend_minutesSecondsValue": {
"placeholders": {
"minutes": {
"type": "String"
},
"seconds": {
"type": "String"
}
}
},
"settings_regionSettings": "Регионы",
"settings_regionSettingsSubtitle": "Управление сохранёнными регионами",
"settings_regionManagement_screenTitle": "Управление регионами",
"settings_regionNameHint": "Введите название региона",
"settings_regionAddRegion": "Добавить регион",
"settings_regionFetchRegions": "Извлекать регионы с ретрансляторов",
"settings_regionFetchRegionsFail": "Не были найдены никакие регионы",
"settings_regionFetchRegionsAlreadyExists": "Этот регион уже был добавлен",
"settings_regionName": "Название региона",
"settings_regionDeleted": "Регион удалён",
"settings_deleteRegion": "Удалить регион",
"settings_deleteRegionConfirm": "Удалить \"{region}\" из списка регионов?",
"settings_infoHardware": "Оборудование",
"settings_infoFirmware": "Производственное программное обеспечение",
"repeater_pathHashModeOption0": "0 - 1 байт",
"repeater_pathHashModeOption1": "1 - 2 байта",
"repeater_pathHashModeOption2": "2 - 3 байта",
"repeater_pathHashModeOption3": "3 - 4 байта",
"appSettings_batteryLipoHv": "LiPo HV (3,04,35 В)",
"channels_regionSetTo": "РЕГИОН: {region}",
"channels_regionNotSet": "Регион: отсутствует",
"channels_regionSelect_Title": "Выберите регион",
"channels_clearRegion": "Чистый регион",
"chat_sendImage": "Отправьте изображение",
"chat_imagePickFailed": "Не удалось открыть это изображение",
"chat_receivedGif": "Получен GIF",
"repeater_keySettings": "Изменить ключи идентичности",
"repeater_keySettingsSubtitle": "Измените пару публичных/частных ключей",
"repeater_prvKey": "Личный ключ",
"repeater_prvKeyHelper": "Для повторителя генерируется новый приватный ключ — шестнадцатеричная строка из 128 символов.",
"repeater_generatePrvKey": "Сгенерируйте случайный ключ-пару",
"repeater_stopGeneratingPrvKey": "Прервать поиск ключной пары",
"repeater_pubKey": "Публичный ключ",
"repeater_pubKeyHelper": "Это публичный ключ, который соответствует сгенерированному приватному ключу. Вы не можете установить его напрямую.",
"repeater_pubKeyPrefix": "Желаемый префикс",
"repeater_pubKeyPrefixHelper": "Найдите публичный ключ, начинающийся этими шестнадцатеричными цифрами. Ожидаемое количество попыток: {tries}.",
"imageMessages_enableTitle": "Включить изображения в сообщения",
"imageMessages_enableSubtitle": "Отправьте изображения через сеть. Требуется однократная загрузка шаблона изображения.",
"imageMessages_modelSectionTitle": "Модель изображения",
"imageMessages_downloadModel": "Скачать",
"imageMessages_cancelDownload": "Отмена",
"imageMessages_removeModel": "Удалить модель",
"imageMessages_modelReady": "Готов",
"imageMessages_modelNotPublished": "Пока не опубликовано — эта сборка не может скачать это.",
"imageMessages_downloadFailed": "Модель изображения не удалось скачать.",
"imageMessages_autoProcessTitle": "Автоматически обрабатывать изображения",
"imageMessages_autoProcessSubtitle": "Примерно каждое изображение реконструируйте сразу после его поступления. Для этого на каждый раз используется около 2 ГБ памяти; отключите реконструкцию касанием.",
"imageSend_title": "Отправьте изображение",
"imageSend_cropNote": "Меняется размер на 512 × 512 · соотношение сторон не сохраняется",
"imageSend_originalSize": "Оригинал",
"imageSend_onAirSize": "В эфире",
"imageSend_quality": "Качество",
"imageSend_qualityStandard": "Стандарт",
"imageSend_qualityHigh": "Высота",
"imageSend_packetsLabel": "Пакеты",
"imageSend_airtimeLabel": "Время на эфире",
"imageSend_sizeLabel": "Пакет",
"imageSend_packetsCount": "{count} {count, plural, one{пакет} few{пакета} many{пакетов} other{пакета}}",
"imageSend_range": "{min}{max}",
"imageSend_unknownValue": "—",
"imageSend_radioUnknownTitle": "Настройки радио неизвестны",
"imageSend_radioUnknownBody": "Подключитесь к устройству, чтобы можно было рассчитать время эфирного времени.",
"imageSend_longSendTitle": "Долгая передача",
"imageSend_longSendBody": "Это будет поддерживать канал примерно {duration}.",
"imageSend_floodNote": "Руководство по маршрутизации потоков: каждый ретрансатор в зоне перераспределяет каждый пакет, поэтому канал остаётся занятным дольше, чем это.",
"imageSend_parityTitle": "Пакет восстановления",
"imageSend_paritySubtitle": "Один дополнительный пакет. Групповые сообщения не подтверждаются, поэтому это позволяет получателю восстановить изображение, если один пакет потерян.",
"imageSend_send": "Отправить",
"imageSend_cancel": "Отмена",
"imageSend_encodeFailed": "Это изображение не может быть закодировано.",
"imageSend_codecDownloading": "Модель изображения всё ещё загружается.",
"imageSend_codecUnavailable": "Отправка изображения на этом устройстве недоступна.",
"imageSend_codecDisabled": "В настройках отключены сообщения с изображениями.",
"imageSend_deviceUnsupported": "Это радио не может отправлять пакеты изображений. Подключите устройство, работающее на прошивке-компаньоне версии 13 или выше.",
"imageSend_directMessagesUnsupported": "Изображения передаются как групповые данные, поэтому их можно отправлять только в канал, а не в личное сообщение.",
"imageSend_tooLarge": "Изображение было закодировано в больше пакетов, чем позволяет формат сетки.",
"imageSend_sentConfirmation": "Изображение отправлено как {count} {count, plural, =1{пакет} other{пакетов}}.",
"imageSend_sendFailed": "Изображение не может быть отправлено: {error}",
"imageSend_sendingProgress": "Отправка изображения — пакет {sent} из {total}",
"receivedImage_senderPrefix": "Узел {prefix}",
"receivedImage_incoming": "{received} из {total} пакетов",
"receivedImage_queued": "Ожидание расшифровки",
"receivedImage_tapToDecode": "Нажмите для декодирования",
"receivedImage_decoding": "Реконструкция… примерно за 1 секунду",
"receivedImage_incomplete": "Отсутствует изображение — {received} из {total} пакетов пришло",
"receivedImage_corrupt": "Изображение не может быть восстановлено",
"receivedImage_decoderMissing": "Получен изображение — декодирование изображения неправильное",
"receivedImage_evicted": "Изображение больше не сохраняется",
"receivedImage_retry": "Попробуйте снова",
"receivedImage_decodeAgain": "Декодируйте снова",
"receivedImage_openSettings": "Настройте",
"receivedImage_tapToProcess": "Нажмите для обработки",
"receivedImage_awaiting": "{bytes} байт · {packets} {packets, plural, =1{пакет} other{пакетов}}",
"imageSend_secondsValue": "{seconds} секунд",
"imageSend_minutesSecondsValue": "{minutes} м {seconds} с"
"repeater_cliQuickClockSync": "Синхронизация часов"
}
+44 -810
View File
File diff suppressed because it is too large Load Diff
+38 -804
View File
File diff suppressed because it is too large Load Diff
+41 -807
View File
File diff suppressed because it is too large Load Diff
+205 -951
View File
File diff suppressed because it is too large Load Diff
+20 -786
View File
@@ -34,8 +34,6 @@
"common_remove": "移除",
"common_enable": "启用",
"common_disable": "禁用",
"common_autoRefresh": "自动刷新",
"common_interval": "间隔",
"common_reboot": "重启",
"common_loading": "正在加载...",
"common_notAvailable": "—",
@@ -81,7 +79,7 @@
"scanner_stop": "停止",
"scanner_scan": "扫描",
"device_quickSwitch": "快速切换",
"device_meshcore": "网格核心",
"device_meshcore": "MeshCore",
"settings_title": "设置",
"settings_deviceInfo": "设备信息",
"settings_appSettings": "应用设置",
@@ -105,16 +103,12 @@
"settings_locationIntervalInvalid": "间隔时间必须至少为 60 秒,但不超过 86400 秒。",
"settings_latitude": "纬度",
"settings_longitude": "经度",
"settings_autoZeroHopAdvertOnGpsUpdate": "GPS 更新时自动发送零跳广告",
"settings_autoZeroHopAdvertOnGpsUpdateSubtitle": "当 GPS 位置变化时,发送零跳广告(需要在广告中包含位置)。",
"settings_privacyMode": "隐私模式",
"settings_privacyModeSubtitle": "在广告中隐藏姓名/位置",
"settings_privacyModeToggle": "切换隐私模式以在广告中隐藏姓名和位置,保护个人信息。",
"settings_privacyModeEnabled": "隐私模式已启用",
"settings_privacyModeDisabled": "隐私模式已关闭",
"settings_actions": "操作",
"settings_deleteAllPaths": "Delete All Paths",
"settings_deleteAllPathsSubtitle": "Clear all path data from contacts.",
"settings_sendAdvertisement": "发送广播",
"settings_sendAdvertisementSubtitle": "立即发送广播",
"settings_advertisementSent": "已发送广播",
@@ -256,19 +250,6 @@
"appSettings_last6Hours": "过去6小时",
"appSettings_last24Hours": "过去24小时",
"appSettings_lastWeek": "上周",
"appSettings_rasterTileSource": "栅格瓦片源",
"appSettings_stadiaEndpoint": "Stadia 端点",
"appSettings_stadiaApiKey": "Stadia API 密钥",
"appSettings_stadiaApiKeyRequired": "使用 Stadia Maps 时必需",
"appSettings_stadiaApiKeyConfigured": "已配置:{maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "请输入你的 Stadia Maps API 密钥。该应用会使用它来请求栅格瓦片。",
"appSettings_offlineMapCache": "离线地图缓存",
"appSettings_noAreaSelected": "未选择任何区域",
"appSettings_areaSelectedZoom": "已选择区域(缩放 {minZoom} - {maxZoom}",
@@ -371,8 +352,11 @@
}
}
},
"channels_hashtagChannel": "标签频道",
"channels_public": "公共",
"channels_private": "私有",
"channels_publicChannel": "公共频道",
"channels_privateChannel": "私有频道",
"channels_editChannel": "编辑频道",
"channels_muteChannel": "静音频道",
"channels_unmuteChannel": "取消静音频道",
@@ -419,22 +403,6 @@
}
},
"channels_smazCompression": "SMAZ 压缩",
"channels_cyr2latCompression": "Cyr2Lat 压缩",
"channels_cyr2latCompressionDscr": "发送时将一些西里尔字符替换为拉丁字符。",
"channels_cyr2latSettingsHeading": "Cyr2Lat 設定",
"channels_cyr2latSettingsSubheading": "替換清單",
"channels_cyr2latSettingsDscr": "編輯 JSON 字元替換設定檔",
"channels_cyr2latSettingsDialogHint": "JSON 替換映射表",
"channels_cyr2latSettingsDialogWrongJSON": "JSON 格式錯誤:{error}",
"settings_cyr2latProfileAdd": "新增 Cyr2Lat 設定檔",
"settings_cyr2latProfileName": "設定檔名稱",
"settings_cyr2latProfileNameEmpty": "設定檔名稱不能為空",
"settings_cyr2latProfileAdded": "設定檔已成功新增",
"settings_cyr2latProfileUpdated": "設定檔已成功更新",
"settings_cyr2latProfileEdit": "編輯 Cyr2Lat 設定檔",
"settings_cyr2latProfileDelete": "刪除 Cyr2Lat 設定檔",
"settings_cyr2latProfileDeleted": "設定檔已成功刪除",
"settings_cyr2latProfileDeleteDscr": "您確定要刪除設定檔 \"{name}\" 嗎?",
"channels_channelUpdated": "频道 \"{name}\" 已更新",
"@channels_channelUpdated": {
"placeholders": {
@@ -446,7 +414,7 @@
"channels_publicChannelAdded": "已添加公共频道",
"channels_sortBy": "排序方式",
"channels_sortManual": "手动",
"channels_sortAZ": "AZ",
"channels_sortAZ": "A-Z",
"channels_sortLatestMessages": "最新消息",
"channels_sortUnread": "未读",
"channels_createPrivateChannel": "创建私有频道",
@@ -811,56 +779,6 @@
}
}
},
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "北 {north}, 南 {south}, 东 {east}, 西 {west}",
"@mapCache_boundsLabel": {
"placeholders": {
@@ -1104,7 +1022,7 @@
"repeater_guestPasswordHelper": "只读访问密码",
"repeater_radioSettings": "无线电设置",
"repeater_frequencyMhz": "频率 (MHz)",
"repeater_frequencyHelper": "300-2500 兆赫",
"repeater_frequencyHelper": "300-2500 MHz",
"repeater_txPower": "TX 功率",
"repeater_txPowerHelper": "1-30 dBm",
"repeater_bandwidth": "带宽",
@@ -1171,81 +1089,6 @@
},
"repeater_confirm": "确认",
"repeater_settingsSaved": "设置保存成功",
"repeater_rxGain": "增强的 RX 增益",
"repeater_rxGainHelper": "更高的灵敏度,更大的电流消耗(仅适用于 SX1262/SX1268",
"repeater_refreshRxGain": "重新启动增强型 RX 功能",
"repeater_multiAcks": "多重确认",
"repeater_multiAcksSubtitle": "通过多个路径确认消息,以提高传递效率。",
"repeater_refreshMultiAcks": "刷新多个确认",
"repeater_networkHealth": "网络健康",
"repeater_loopDetect": "循环检测",
"repeater_loopDetectHelper": "创建看起来像路由环的“洪水包”",
"repeater_loopDetectOff": "离开",
"repeater_loopDetectMinimal": "最少",
"repeater_loopDetectModerate": "适度的",
"repeater_loopDetectStrict": "严格",
"repeater_dutyCycle": "工作周期",
"repeater_dutyCycleHelper": "最大可使用的空闲时间百分比",
"repeater_dutyCyclePercent": "{percent}%",
"@repeater_dutyCyclePercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"repeater_ownerInfo": "运营商信息",
"repeater_ownerInfoHelper": "此复播器的公共元数据",
"repeater_refreshOwnerInfo": "刷新操作员信息",
"repeater_floodMax": "最大跳跃次数",
"repeater_floodMaxHelper": "一个洪水包中,最大可以传输的跳数 (0-64)",
"repeater_advancedSettings": "高级",
"repeater_advancedSettingsSubtitle": "高级操作员使用的调节旋钮",
"repeater_pathHashMode": "路径哈希模式",
"repeater_pathHashModeHelper": "用于在洪泛路径/环路检测标签中编码此中继器 ID 的字节数。0=1 字节(256 个 ID,最多 64 跳),1=2 字节(65K 个 ID,最多 32 跳),2=3 字节(16M 个 ID,最多 21 跳)。v1.14 之前的固件始终使用 1 字节路径;v1.14 及更新版本可配置为 2 或 3 字节路径。",
"repeater_txDelay": "洪水(德克萨斯州)延误",
"repeater_txDelayHelper": "对于洪水流量,重新传输间隔应设置为包的传输时间(0-2,默认值为0.5)的倍数。 较高的值意味着更少的冲突,但传输速度会变慢。",
"repeater_directTxDelay": "直接的 TX 延迟",
"repeater_directTxDelayHelper": "对于直接(非广播)流量,重新传输间隔应设置为包的传输时间(0-2,默认值为0.3)的倍数。",
"repeater_intThresh": "干扰阈值",
"repeater_intThreshHelper": "将阈值传递给射频信号的噪声水平校准,使其能够拒绝高于该水平的干扰。 0 表示禁用——只有在您在嘈杂频段中看到 RX 错误时才启用。",
"repeater_agcResetInterval": "AGC 恢复间隔",
"repeater_agcResetIntervalHelper": "为了从失控的增益状态中恢复,应该多久重置收音机的自动增益控制?设置为“秒”,每次重置间隔为4秒。将此选项设置为“0”将禁用周期性重置。",
"repeater_actionsTitle": "行动",
"repeater_sendAdvert": "发布防洪广告",
"repeater_sendAdvertSubtitle": "通过网络播放防洪广告",
"repeater_sendAdvertZeroHop": "发送零跳广告",
"repeater_sendAdvertZeroHopSubtitle": "进行单跳广告广播(不使用中继)",
"repeater_clockSync": "现在同步时钟",
"repeater_clockSyncSubtitle": "将手机的时间设置为与中继器同步",
"repeater_actionSucceeded": "{action} 成功",
"@repeater_actionSucceeded": {
"placeholders": {
"action": {
"type": "String"
}
}
},
"repeater_actionFailed": "{action} 失败:{error}",
"@repeater_actionFailed": {
"placeholders": {
"action": {
"type": "String"
},
"error": {
"type": "String"
}
}
},
"repeater_settingsSavedRebootNeeded": "设置已保存 — 重启发射器以应用",
"repeater_settingsPartialFailure": "部分设置失败:{failures}",
"@repeater_settingsPartialFailure": {
"placeholders": {
"failures": {
"type": "String"
}
}
},
"repeater_errorSavingSettings": "保存设置时出错:{error}",
"@repeater_errorSavingSettings": {
"placeholders": {
@@ -1257,9 +1100,11 @@
"repeater_refreshBasicSettings": "刷新基本设置",
"repeater_refreshRadioSettings": "刷新无线电设置",
"repeater_refreshTxPower": "刷新 TX 功率",
"repeater_refreshLocationSettings": "刷新位置设置",
"repeater_refreshPacketForwarding": "刷新包转发",
"repeater_refreshGuestAccess": "刷新访客权限",
"repeater_refreshPrivacyMode": "刷新隐私模式",
"repeater_refreshAdvertisementSettings": "刷新广播设置",
"repeater_refreshed": "{label} 已刷新",
"@repeater_refreshed": {
"placeholders": {
@@ -1377,43 +1222,6 @@
}
}
},
"telemetry_digitalInputLabel": "数字输入",
"telemetry_digitalOutputLabel": "数字输出",
"telemetry_analogInputLabel": "模拟输入",
"telemetry_analogOutputLabel": "模拟输出",
"telemetry_genericLabel": "通用传感器",
"telemetry_luminosityLabel": "照度",
"telemetry_presenceLabel": "存在检测",
"telemetry_humidityLabel": "湿度",
"telemetry_accelerometerLabel": "加速度计",
"telemetry_pressureLabel": "气压",
"telemetry_altitudeLabel": "高度",
"telemetry_frequencyLabel": "频率",
"telemetry_percentageLabel": "百分比",
"telemetry_concentrationLabel": "浓度",
"telemetry_powerLabel": "功率",
"telemetry_distanceLabel": "距离",
"telemetry_energyLabel": "能量",
"telemetry_directionLabel": "方向",
"telemetry_timeLabel": "时间",
"telemetry_gyrometerLabel": "陀螺仪",
"telemetry_colourLabel": "颜色",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "开关",
"telemetry_polylineLabel": "折线",
"telemetry_altitudeValue": "{meters} m",
"telemetry_frequencyValue": "{hertz} Hz",
"telemetry_pressureValue": "{hpa} hPa",
"telemetry_luminosityValue": "{lux} lx",
"telemetry_powerValue": "{watts} W",
"telemetry_distanceValue": "{meters} m",
"telemetry_energyValue": "{kilowattHours} kWh",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "请求次数",
"telemetry_error": "无法获取数据",
"telemetry_noData": "暂无遥测数据",
"telemetry_channelTitle": "频道 {channel}",
"@telemetry_channelTitle": {
@@ -1439,7 +1247,7 @@
}
}
},
"telemetry_voltageValue": "{volts}",
"telemetry_voltageValue": "{volts}V",
"@telemetry_voltageValue": {
"placeholders": {
"volts": {
@@ -1733,7 +1541,7 @@
"listFilter_sortBy": "排序方式",
"listFilter_latestMessages": "最新消息",
"listFilter_heardRecently": "最近听到",
"listFilter_az": "AZ",
"listFilter_az": "A-Z",
"listFilter_filters": "筛选",
"listFilter_all": "全部",
"listFilter_users": "用户",
@@ -1746,7 +1554,7 @@
"pathTrace_notAvailable": "无法获取路径信息。",
"pathTrace_refreshTooltip": "刷新路径追踪",
"contacts_pathTrace": "路径追踪",
"contacts_ping": "",
"contacts_ping": "Ping",
"contacts_repeaterPathTrace": "Trace 转发节点",
"contacts_repeaterPing": "Ping 转发节点",
"contacts_roomPathTrace": "Trace 房间服务器",
@@ -2119,6 +1927,13 @@
"contact_settings": "联系人设置",
"contact_teleLocSubtitle": "允许共享位置数据",
"contact_telemetry": "遥测数据",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeight": "最大路径重量",
"appSettings_initialRouteWeightSubtitle": "新发现路径的初始重量",
"appSettings_initialRouteWeight": "初始路线权重",
@@ -2130,7 +1945,7 @@
"appSettings_maxMessageRetries": "最大消息重试次数",
"appSettings_maxMessageRetriesSubtitle": "在将消息标记为失败之前,允许尝试的次数",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "多重ACK",
"settings_multiAck": "多重ACK{value}",
"settings_telemetryModeUpdated": "遥测模式已更新",
"map_showOverlaps": "重复键重叠",
"map_runTraceWithReturnPath": "沿着相同的路径返回",
@@ -2209,9 +2024,6 @@
"translation_composerTitle": "在发送之前进行翻译",
"translation_enableTitle": "启用翻译功能",
"translation_composerSubtitle": "控制作曲家翻译图标的默认状态。",
"translation_autoIncomingTitle": "自动翻译消息",
"translation_autoIncomingSubtitle": "自动为通知以及聊天或频道翻译消息。",
"translation_translateMessage": "翻译消息",
"translation_targetLanguage": "目标语言",
"translation_useAppLanguage": "使用应用程序语言",
"translation_downloadedModelLabel": "下载的模型",
@@ -2254,583 +2066,5 @@
"translation_translationOptions": "翻译选项",
"translation_systemLanguage": "系统语言",
"repeater_cliQuickDiscovery": "发现邻居",
"repeater_cliQuickClockSync": "同步时钟",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLogin": "登录后,自动同步时钟",
"repeater_clockSyncAfterLoginSubtitle": "在成功登录后,自动发送“时钟同步”指令。",
"repeater_guestTools": "访客工具",
"repeater_guest": "重复器信息",
"chat_sendMessage": "发送消息",
"room_guest": "服务器信息",
"repeater_getCategory": "获取值",
"repeater_powerMgmt": "电源管理",
"repeater_sensors": "传感器",
"repeater_cliHelpPowerOff": "关闭设备。(不应有任何响应)",
"repeater_cliHelpClkReboot": "将时钟重置为已知的时间点,并重启设备。",
"repeater_cliHelpAdvertZeroHop": "发送无中继广告(仅限于邻居)。",
"repeater_cliHelpStartOta": "在支持的板上启动通过空中进行固件更新。",
"repeater_cliHelpTime": "将设备时钟设置为给定的 Unix 纪元秒。时钟不能倒退。",
"repeater_cliHelpBoard": "显示制造商/硬件标识。",
"repeater_cliHelpDiscoverNeighbors": "向附近的邻居发送节点发现请求。(仅限中继器)",
"repeater_cliHelpPowersaving": "显示节能模式是否已开启或已关闭。",
"repeater_cliHelpPowersavingOnOff": "启用或禁用节能模式(如果支持)。",
"repeater_cliHelpErase": "(仅适用于序列模式)格式化设备的文件系统。清除所有设置和联系人。",
"repeater_cliHelpSetDutyCycle": "设定允许的最大传输时段百分比(1-100)。内部调整空闲时间因子。",
"repeater_cliHelpSetPrvKey": "(仅适用于序列号)替换设备身份私钥。需要重启才能应用。生成一个新的公钥。",
"repeater_cliHelpSetRadioRxGain": "(仅适用于 SX126x 芯片) 启用增强型 RX 增益,以在较高电流下提高灵敏度。",
"repeater_cliHelpSetOwnerInfo": "设置广告中包含的联系人信息字符串。使用 '|' 作为换行符。",
"repeater_cliHelpSetPathHashMode": "设置路径哈希模式。 0 = 传统模式,1 = 标准模式,2 = 严格模式。 影响路由路径的匹配方式。",
"repeater_cliHelpSetLoopDetect": "设置路由环检测的灵敏度:关闭、低、中、或高。",
"repeater_cliHelpSetFreq": "(仅限串行模式)快速设置频率。需要重启。 建议使用“设置收音机参数”功能,以便设置完整的收音机参数。",
"repeater_cliHelpSetBridgeChannel": "(仅适用于 ESPNow 桥)设置桥使用的 WiFi 频道(1-14)。",
"repeater_cliHelpGetName": "显示配置的节点名称。",
"repeater_cliHelpGetRole": "显示固件的功能(如:中继器、房间服务器等)。",
"repeater_cliHelpGetPublicKey": "显示设备的公钥。",
"repeater_cliHelpGetPrvKey": "(仅适用于序列号)显示设备的私钥。请将其视为机密信息。",
"repeater_cliHelpGetRepeat": "显示数据包转发(作为中继器)是否已启用或已禁用。",
"repeater_cliHelpGetTx": "显示当前的发射功率(以dBm为单位)。",
"repeater_cliHelpGetFreq": "显示配置的射频频率(以兆赫兹为单位)。",
"repeater_cliHelpGetRadio": "显示完整的无线电参数:频率、带宽、扩频因子、编码速率。",
"repeater_cliHelpGetRadioRxGain": "(仅适用于 SX126x 模块)显示 RX 放大器的状态。",
"repeater_cliHelpGetAf": "显示当前的空闲时间系数。",
"repeater_cliHelpGetDutyCycle": "显示当前允许的占空比(以百分比表示)。",
"repeater_cliHelpGetIntThresh": "显示信道干扰阈值(以dB为单位)。",
"repeater_cliHelpGetAgcResetInterval": "显示 AGC 重置的间隔时间(以秒为单位)。",
"repeater_cliHelpGetMultiAcks": "显示双重确认模式是否已启用(1)或已禁用(0)。",
"repeater_cliHelpGetAllowReadOnly": "显示是否允许访客仅限查看权限。",
"repeater_cliHelpGetAdvertInterval": "显示本地广告的时间间隔,单位为分钟。",
"repeater_cliHelpGetFloodAdvertInterval": "显示洪水广告的播放时间间隔,以小时为单位。",
"repeater_cliHelpGetGuestPassword": "显示已配置的访客密码。",
"repeater_cliHelpGetLat": "显示已配置的纬度。",
"repeater_cliHelpGetLon": "显示已配置的经度。",
"repeater_cliHelpGetRxDelay": "显示 rxdelay 的基本值。",
"repeater_cliHelpGetTxDelay": "显示洪水模式下的传输延迟系数。",
"repeater_cliHelpGetDirectTxDelay": "显示直接模式下的时延系数。",
"repeater_cliHelpGetFloodMax": "显示最大洪水传播次数。",
"repeater_cliHelpGetOwnerInfo": "显示所有者的联系信息。",
"repeater_cliHelpGetPathHashMode": "显示哈希模式(0/1/2)。",
"repeater_cliHelpGetLoopDetect": "显示循环检测的灵敏度。",
"repeater_cliHelpGetAcl": "(仅适用于序列号)列出复用器上的访问控制条目。",
"repeater_cliHelpGetBridgeEnabled": "显示桥是否已启用。",
"repeater_cliHelpGetBridgeDelay": "显示桥梁延迟的时间,单位为毫秒。",
"repeater_cliHelpGetBridgeSource": "显示桥接设备是否接收或发送 RX 或 TX 类型的数据包。",
"repeater_cliHelpGetBridgeBaud": "(仅限 RS232 桥)显示桥的波特率。",
"repeater_cliHelpGetBridgeChannel": "(仅适用于 ESPNow 桥)显示桥的 WiFi 通道。",
"repeater_cliHelpGetBridgeSecret": "(仅适用于 ESPNow 桥)显示桥的共享密钥。",
"repeater_cliHelpGetBootloaderVer": "(仅适用于NRF52)显示引导程序版本。",
"repeater_cliHelpGetAdcMultiplier": "显示 ADC 乘数(电池电压缩放)。",
"repeater_cliHelpGetPwrMgtSupport": "报告董事会是否支持电源管理功能。",
"repeater_cliHelpGetPwrMgtSource": "显示当前的电源:外部电源或电池。",
"repeater_cliHelpGetPwrMgtBootReason": "显示最近的重置和关闭原因。",
"repeater_cliHelpGetPwrMgtBootMv": "显示启动时的电池电压,单位为毫伏 (mV)。",
"repeater_cliHelpSensorGet": "通过按键读取自定义传感器设置。",
"repeater_cliHelpSensorSet": "编写自定义传感器设置。",
"repeater_cliHelpSensorList": "列出所有自定义传感器设置,并按可选的起始索引进行分页显示。",
"repeater_cliHelpRegionDefault": "显示当前默认的区域范围。",
"repeater_cliHelpRegionDefaultSet": "设置默认的区域范围。使用 \"<null>\" 可以清除。",
"repeater_cliHelpRegionListAllowed": "列出允许洪水交通的区域。",
"repeater_cliHelpRegionListDenied": "列出禁止洪水交通的区域。",
"repeater_cliHelpStatsPackets": "(仅显示序列信息)显示数据包级别的统计信息。",
"repeater_cliHelpStatsRadio": "(仅显示序列信息)显示收音机相关统计数据。",
"repeater_cliHelpStatsCore": "(仅显示序列号)显示核心固件统计信息。",
"common_done": "Done",
"background_serviceTitle": "MeshCore running",
"background_serviceText": "Keeping BLE connected",
"appSettings_translationModelDeleted": "Deleted {name}",
"@appSettings_translationModelDeleted": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"appSettings_translationModelDeleteFailed": "Failed to delete: {error}",
"@appSettings_translationModelDeleteFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"channels_channelUpdateFailed": "Failed to update channel: {error}",
"@channels_channelUpdateFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"map_type": "Type",
"map_path": "Path",
"map_location": "Location",
"map_estLocation": "Est. Location",
"map_publicKey": "Public Key",
"map_publicKeyPrefixHint": "e.g. ab12",
"contact_typeChat": "Chat",
"contact_typeRepeater": "Repeater",
"contact_typeRoom": "Room",
"contact_typeSensor": "Sensor",
"contact_typeUnknown": "Unknown",
"channels_via": "via {path}",
"chat_score": "Score",
"map_sharedAt": "已分享",
"@losBlockedSpotChip": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"@losSelectedObstructionDetails": {
"placeholders": {
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
},
"distanceFromA": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"distanceFromB": {
"type": "String"
}
}
},
"losBlockedSpotsTitle": "被占用区域",
"losBlockedSpotsHint": "点击地图上的某个被遮盖的区域,以突出显示该区域。",
"losSelectedObstructionTitle": "选择性阻碍",
"losBlockedSpotChip": "{distance} {distanceUnit} • {obstruction} {heightUnit}",
"losSelectedObstructionDetails": "Blocked by {obstruction} {heightUnit}, {distanceFromA} from A and {distanceFromB} from B ({distanceUnit}).",
"chat_markAsUnread": "标记为未读",
"settings_companionDebugLog": "调试日志",
"chat_newMessages": "新的消息",
"settings_companionDebugLogSubtitle": "BLE/TCP/USB 协议、响应和原始数据",
"repeater_chanUtil": "频道利用率",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"messageStatus_sent": "发送",
"common_undo": "撤销",
"messageStatus_delivered": "已送达",
"messageStatus_pending": "发送",
"messageStatus_failed": "发送失败",
"messageStatus_repeated": "多次听到",
"contacts_moreOptions": "更多选择",
"contacts_searchOpen": "搜索联系人",
"contacts_searchClose": "高级搜索",
"routing_title": "路由",
"routing_modeAuto": "汽车",
"routing_modeFlood": "洪水",
"routing_modeManual": "手册",
"routing_modeAutoHint": "自动选择已知最佳路径,当没有已知路径时,则进行“洪水”搜索。",
"routing_modeFloodHint": "通过所有中继站进行广播。 这种方式最可靠,但占用更多的时间。",
"routing_modeManualHint": "总是按照您设置的路径进行导航。",
"routing_currentRoute": "当前路线",
"routing_directNoHops": "直接连接— 无中继跳",
"routing_noPathYet": "目前还没有找到路径。直到找到路径,才会收到后续消息。",
"routing_floodBroadcast": "通过所有中继器进行广播",
"routing_editPath": "编辑路径",
"routing_forgetPath": "忘记原路",
"routing_knownPaths": "已知的路径",
"routing_knownPathsHint": "点击该路径以切换到它。",
"routing_inUse": "使用中",
"routing_qualityStrong": "强劲的初始阶段",
"routing_qualityGood": "不错的开端",
"routing_qualityFair": "第一次尝试,结果良好",
"routing_qualityWorked": "已完成",
"routing_qualityFlood": "通过新闻报道",
"routing_qualityUntested": "未经测试",
"routing_lastWorked": "工作于 {when}",
"routing_neverWorked": "从未得到证实",
"routing_floodDelivery": "洪水配送",
"pathEditor_title": "构建路径",
"pathEditor_noHops": "目前还没有添加任何啤酒花。点击下面的“添加”按钮,按顺序添加,或者直接保存,不添加任何啤酒花。",
"pathEditor_addHops": "按照顺序添加啤酒花",
"pathEditor_searchRepeaters": "重复搜索",
"pathEditor_advancedHex": "高级:原始十六进制路径",
"pathEditor_hexLabel": "十六进制前缀",
"pathEditor_hexHelper": "每次跳跃,使用两个十六进制字符,用逗号分隔。",
"pathEditor_invalidTokens": "无效:{tokens}",
"pathEditor_tooManyHops": "最多 64 个跳跃",
"pathEditor_usePath": "请使用此路径",
"pathEditor_removeHop": "去除啤酒花",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"pathEditor_hopCounter": "{count} of 64 hops",
"pathEditor_unknownHop": "未知的重复器",
"map_zoomIn": "放大",
"map_zoomOut": "放大",
"map_centerMap": "中心地图",
"chrome_bluetoothRequiresChromium": "Web Bluetooth 需要 Chromium 浏览器",
"channels_communityShortId": "ID{id}...",
"pathTrace_legendGpsConfirmed": "通过GPS确认",
"pathTrace_legendInferred": "推测的位置",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"map_online": "在线",
"scanner_bluetoothWebUnsupported": "浏览器不支持蓝牙,请改用 USB 连接。",
"map_activity": "活动",
"map_searchHint": "搜索节点名称或ID",
"map_recent": "最近",
"map_visible": "可见",
"map_stale": "过时",
"map_hidden": "已隐藏",
"map_centerOnNode": "以节点为中心",
"map_details": "详细信息",
"map_noGps": "无 GPS",
"map_noResults": "未找到匹配的节点",
"pathMap_viewSingle": "单条",
"pathMap_viewCombined": "综合",
"pathMap_play": "播放",
"pathMap_pause": "暂停",
"pathMap_replay": "重播",
"pathMap_stepBack": "上一跳",
"pathMap_stepForward": "下一跳",
"pathMap_animationOn": "显示数据包动画",
"pathMap_animationOff": "隐藏数据包动画",
"pathMap_hopOf": "第 {current} 跳,共 {total} 跳",
"pathMap_observedPaths": "观测到的路径:{count}",
"pathMap_primary": "主路径",
"pathMap_alternate": "备用 {index}",
"pathMap_hopCount": "{count, plural, =1{1 跳} other{{count} 跳}}",
"pathMap_legendShared": "共享路段",
"pathMap_legendEstimated": "估算路段",
"pathMap_sharedNodeCount": "已被 {count} 条路径使用",
"pathMap_showAllPaths": "显示全部",
"pathMap_hidePath": "隐藏路径",
"pathMap_showPath": "显示路径",
"pathMap_collapsePanel": "收起面板",
"pathMap_expandPanel": "展开面板",
"pathMap_noLocation": "无位置",
"pathMap_followPacket": "锁定视图跟随数据包",
"pathMap_unfollowPacket": "解锁视图跟随",
"pathMap_gpsCount": "{confirmed}/{total} GPS",
"pathMap_partialAnimation": "{count, plural, =1{1 跳无位置信息 — 显示的路径不完整} other{{count} 跳无位置信息 — 显示的路径不完整}}",
"@settings_deleteRegionConfirm": {
"placeholders": {
"region": {
"type": "String"
}
}
},
"@channels_regionSetTo": {
"placeholders": {
"region": {
"type": "String",
"example": "de-mitte"
}
}
},
"@chat_imagePickFailed": {
"description": "Shown when picking or reading a photo to send fails"
},
"@repeater_pubKeyPrefixHelper": {
"placeholders": {
"tries": {
"type": "int"
}
}
},
"@imageSend_packetsCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@imageSend_range": {
"placeholders": {
"min": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"@imageSend_longSendBody": {
"placeholders": {
"duration": {
"type": "String"
}
}
},
"@imageSend_sentConfirmation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@imageSend_sendFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"@imageSend_sendingProgress": {
"placeholders": {
"sent": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@receivedImage_senderPrefix": {
"placeholders": {
"prefix": {
"type": "String"
}
}
},
"@receivedImage_incoming": {
"placeholders": {
"received": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@receivedImage_incomplete": {
"placeholders": {
"received": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@receivedImage_awaiting": {
"placeholders": {
"bytes": {
"type": "int"
},
"packets": {
"type": "int"
}
}
},
"@imageSend_secondsValue": {
"placeholders": {
"seconds": {
"type": "String"
}
}
},
"@imageSend_minutesSecondsValue": {
"placeholders": {
"minutes": {
"type": "String"
},
"seconds": {
"type": "String"
}
}
},
"settings_regionSettings": "地区",
"settings_regionSettingsSubtitle": "管理已存储的区域",
"settings_regionManagement_screenTitle": "区域管理",
"settings_regionNameHint": "区域名称",
"settings_regionAddRegion": "增加地区",
"settings_regionFetchRegions": "从中继器获取区域",
"settings_regionFetchRegionsFail": "未发现任何地区",
"settings_regionFetchRegionsAlreadyExists": "该地区已被添加",
"settings_regionName": "地区名称",
"settings_regionDeleted": "区域已删除",
"settings_deleteRegion": "删除区域",
"settings_deleteRegionConfirm": "从 {region} 列表中删除?",
"settings_infoHardware": "硬件",
"settings_infoFirmware": "固件",
"repeater_pathHashModeOption0": "0 - 1 字节",
"repeater_pathHashModeOption1": "1 - 2 字节",
"repeater_pathHashModeOption2": "2 - 3 字节",
"repeater_pathHashModeOption3": "3 - 4 字节",
"appSettings_batteryLipoHv": "锂多压电源(3.0-4.35伏)",
"channels_regionSetTo": "地区:{region}",
"channels_regionNotSet": "地区:无",
"channels_regionSelect_Title": "选择一个地区",
"channels_clearRegion": "无碍区域",
"chat_sendImage": "发送图片",
"chat_imagePickFailed": "无法打开该图片",
"chat_receivedGif": "收到一个GIF",
"repeater_keySettings": "更改身份密钥",
"repeater_keySettingsSubtitle": "更换公钥/私钥对",
"repeater_prvKey": "私钥",
"repeater_prvKeyHelper": "中继器的新私钥,为一个128位的十六进制字符串。",
"repeater_generatePrvKey": "生成一个随机的密钥对",
"repeater_stopGeneratingPrvKey": "中断密钥对搜索",
"repeater_pubKey": "公钥",
"repeater_pubKeyHelper": "这是与生成的私钥配对的公钥。您不能直接设置此内容。",
"repeater_pubKeyPrefix": "期望的前缀",
"repeater_pubKeyPrefixHelper": "找到一组以这些十六进制数字开头的公钥。预计尝试次数:{tries}",
"imageMessages_enableTitle": "启用图片消息",
"imageMessages_enableSubtitle": "通过网格发送图像。需要一次性下载图像模型。",
"imageMessages_modelSectionTitle": "图像模型",
"imageMessages_downloadModel": "下载",
"imageMessages_cancelDownload": "取消",
"imageMessages_removeModel": "移除模型",
"imageMessages_modelReady": "准备就绪",
"imageMessages_modelNotPublished": "尚未发布——此版本无法下载该内容。",
"imageMessages_downloadFailed": "图像模型无法下载。",
"imageMessages_autoProcessTitle": "自动处理图像",
"imageMessages_autoProcessSubtitle": "每次图像到达后立即重建,每次需要约2GB的内存;若想关闭重建功能,可通过点击取消开启。",
"imageSend_title": "发送图片",
"imageSend_cropNote": "调整大小至512 × 512 · 宽高比未保留",
"imageSend_originalSize": "原文",
"imageSend_onAirSize": "直播中",
"imageSend_quality": "质量",
"imageSend_qualityStandard": "标准",
"imageSend_qualityHigh": "高",
"imageSend_packetsLabel": "数据包",
"imageSend_airtimeLabel": "播出时间",
"imageSend_sizeLabel": "有效载荷",
"imageSend_packetsCount": "{count} {count, plural, =1{个数据包} other{个数据包}}",
"imageSend_range": "{min}{max}",
"imageSend_unknownValue": "—",
"imageSend_radioUnknownTitle": "无线电设置未知",
"imageSend_radioUnknownBody": "连接设备以计算播出时间。",
"imageSend_longSendTitle": "长距离传输",
"imageSend_longSendBody": "这将维持频道大约 {duration} 时间。",
"imageSend_floodNote": "洪水路由:每个中继站都会转发每个数据包,因此信道比这段时间更长地处于繁忙状态。",
"imageSend_parityTitle": "恢复包",
"imageSend_paritySubtitle": "多一个数据包。群发消息不被确认,因此如果某个数据包丢失,接收方可以重新构建图像。",
"imageSend_send": "发送",
"imageSend_cancel": "取消",
"imageSend_encodeFailed": "此图像无法编码。",
"imageSend_codecDownloading": "图像模型仍在下载中。",
"imageSend_codecUnavailable": "此设备无法发送图片。",
"imageSend_codecDisabled": "图片消息在设置中已关闭。",
"imageSend_deviceUnsupported": "此无线电无法发送图像数据包。请连接运行伴侣固件13或更高版本的设备。",
"imageSend_directMessagesUnsupported": "图片作为群组数据传输,因此只能发送到频道,而不能直接发送给个人。",
"imageSend_tooLarge": "该图像编码的数据包数量超过了网格格式所允许的范围。",
"imageSend_sentConfirmation": "图像已作为 {count} {count, plural, =1{个数据包} other{个数据包}} 发送。",
"imageSend_sendFailed": "无法发送图片:{error}",
"imageSend_sendingProgress": "发送图像 — 已发送 {sent} 包含 {total}",
"receivedImage_senderPrefix": "节点 {prefix}",
"receivedImage_incoming": "已收到的 {received} 个包含在 {total} 个包裹中",
"receivedImage_queued": "等待解码",
"receivedImage_tapToDecode": "点击解锁",
"receivedImage_decoding": "重建……大约1秒",
"receivedImage_incomplete": "图片不完整 — {received} 已收到 {total} 个数据包",
"receivedImage_corrupt": "图像无法重建",
"receivedImage_decoderMissing": "收到图片 — 图像解码失败",
"receivedImage_evicted": "图片已不再存储",
"receivedImage_retry": "再试一次",
"receivedImage_decodeAgain": "重新解码",
"receivedImage_openSettings": "建立",
"receivedImage_tapToProcess": "点击处理",
"receivedImage_awaiting": "{bytes} 字节 · {packets} {packets, plural, =1{个数据包} other{个数据包}}",
"imageSend_secondsValue": "{seconds} 秒",
"imageSend_minutesSecondsValue": "{minutes} 分 {seconds} 秒"
"repeater_cliQuickClockSync": "同步时钟"
}
-53
View File
@@ -1,53 +0,0 @@
import '../connector/meshcore_protocol.dart';
import '../helpers/path_helper.dart';
import '../models/contact.dart';
import 'app_localizations.dart';
/// UI-level localization helpers for [Contact].
///
/// Kept out of the model layer so `Contact` does not depend on
/// `AppLocalizations`. Use these from widgets/screens; for logs and
/// non-UI export use `Contact.typeLabelRaw`.
extension ContactLocalization on Contact {
String typeLabel(AppLocalizations l10n) {
switch (type) {
case advTypeChat:
return l10n.contact_typeChat;
case advTypeRepeater:
return l10n.contact_typeRepeater;
case advTypeRoom:
return l10n.contact_typeRoom;
case advTypeSensor:
return l10n.contact_typeSensor;
default:
return l10n.contact_typeUnknown;
}
}
String pathLabel(
AppLocalizations l10n, {
int pathHashByteWidth = pathHashSize,
}) {
if (pathOverride != null) {
if (pathOverride! < 0) return l10n.chat_floodForced;
if (pathOverride == 0) return l10n.chat_directForced;
return l10n.chat_hopsForced(
_displayHopCount(pathOverrideBytes, pathOverride!, pathHashByteWidth),
);
}
if (pathLength < 0) return l10n.channelPath_floodPath;
if (pathLength == 0) return l10n.chat_direct;
return l10n.chat_hopsCount(
_displayHopCount(path, pathLength, pathHashByteWidth),
);
}
int _displayHopCount(
List<int>? pathBytes,
int storedHopCount,
int hashWidth,
) {
if (pathBytes == null || pathBytes.isEmpty) return storedHopCount;
return PathHelper.splitPathBytes(pathBytes, hashWidth).length;
}
}
+101 -267
View File
@@ -3,21 +3,18 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'screens/channel_chat_screen.dart';
import 'screens/chat_screen.dart';
import 'screens/chrome_required_screen.dart';
import 'screens/discovery_screen.dart';
import 'utils/platform_info.dart';
import 'connector/meshcore_connector.dart';
import 'models/image_codec_support.dart';
import 'screens/scanner_screen.dart';
import 'services/image_chunk_transport.dart';
import 'services/image_codec_service.dart';
import 'services/image_codec_settings_store.dart';
import 'services/received_image_blob_store_factory.dart';
import 'services/received_image_store.dart';
import 'services/storage_service.dart';
import 'services/message_retry_service.dart';
import 'services/path_history_service.dart';
@@ -32,22 +29,11 @@ import 'services/translation_service.dart';
import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart';
import 'storage/prefs_manager.dart';
import 'theme/mesh_theme.dart';
import 'utils/app_logger.dart';
import 'widgets/image_send_codec_binding.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// On desktop, debugPrint is not suppressed in release builds and every
// call is a synchronous stdout write. The connector logs heavily on hot
// paths (frame handling, queue/channel sync), which shows up as syscall
// overhead on low-end Linux machines (issue #202). The in-app debug log
// screens are unaffected they store entries themselves.
if (kReleaseMode) {
debugPrint = (String? message, {int? wrapWidth}) {};
}
// Initialize SharedPreferences cache
await PrefsManager.initialize();
@@ -60,60 +46,15 @@ void main() async {
final bleDebugLogService = BleDebugLogService();
final appDebugLogService = AppDebugLogService();
final backgroundService = BackgroundService();
final mapTileCacheService = MapTileCacheService(
appSettingsService: appSettingsService,
);
final mapTileCacheService = MapTileCacheService();
final chatTextScaleService = ChatTextScaleService();
final translationService = TranslationService(appSettingsService);
final uiViewStateService = UiViewStateService();
final timeoutPredictionService = TimeoutPredictionService(storage);
// Load settings before anything reads them. The image stack below takes its
// model registry and its "process automatically" default straight off
// `appSettingsService.settings`, so this cannot stay where it used to be
// (after the constructions) without those two starting out wrong.
// Load settings
await appSettingsService.loadSettings();
// ---- image messages (AEIC over GRP_DATA) --------------------------------
// The codec owns the ONNX decoder; the store owns received-image state and
// the decode queue; the reassembler/transport pair is the receive path the
// connector feeds raw frames into.
final imageCodecService = ImageCodecService(
appSettingsService,
settingsStore: AppSettingsImageCodecStore(appSettingsService),
);
final receivedImageStore = ReceivedImageStore(
// Without a file-backed store this silently falls back to memory and every
// received image sidecar included is gone at the next launch.
blobs: createReceivedImageBlobStore(),
decoder: ImageCodecServiceDecoder(imageCodecService),
processAutomatically: appSettingsService.settings.imageProcessAutomatically,
);
// A decode peaks around 2.16 GiB, so the setting is the user's consent to
// spend it. Mirrored on every settings change; the store applies it to
// future arrivals only, so turning it on does not decode a backlog.
appSettingsService.addListener(() {
receivedImageStore.processAutomatically =
appSettingsService.settings.imageProcessAutomatically;
});
// Availability/isBusy changes are the only thing that un-parks a decode
// queue that stopped because the codec was unusable or busy.
imageCodecService.addListener(receivedImageStore.notifyDecoderChanged);
final imageReassembler = ImageStreamReassembler(store: receivedImageStore);
final imageTransport = ImageChunkTransport(
reassembler: imageReassembler,
// The UI sends whole chunk sets through connector.sendImageChunks so it
// gets real inter-chunk pacing and per-chunk progress; this closure only
// keeps ImageChunkTransport.sendImage() usable on its own.
send: (blob, channelIndex) => connector.sendImageChunks(
<Uint8List>[blob],
channelIndex: channelIndex,
interChunkDelay: Duration.zero,
),
// Overwritten by onImageSenderPrefix as soon as SELF_INFO lands.
senderPrefix: 0,
);
// Initialize app logger
appLogger.initialize(
appDebugLogService,
@@ -124,15 +65,10 @@ void main() async {
final notificationService = NotificationService();
await notificationService.initialize();
await backgroundService.initialize();
backgroundService.setLanguageOverrideProvider(
() => appSettingsService.settings.languageOverride,
);
_registerThirdPartyLicenses();
await chatTextScaleService.initialize();
await translationService.refreshDownloadedModels();
await imageCodecService.refreshDownloadedModels();
await receivedImageStore.load();
await uiViewStateService.initialize();
await timeoutPredictionService.initialize();
@@ -146,12 +82,6 @@ void main() async {
appDebugLogService: appDebugLogService,
backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService,
imageCodecService: imageCodecService,
imageTransport: imageTransport,
// ImageStreamReassembler.addChunk already forwards every outcome to the
// store together with the channel index (which ImageChunkOutcome itself
// does not carry), so onImageChunk would be a second, channel-blind path.
onImageSenderPrefix: (prefix) => imageReassembler.selfPrefix = prefix,
);
await connector.loadContactCache();
@@ -176,107 +106,10 @@ void main() async {
translationService: translationService,
uiViewStateService: uiViewStateService,
timeoutPredictionService: timeoutPredictionService,
imageCodecService: imageCodecService,
receivedImageStore: receivedImageStore,
imageReassembler: imageReassembler,
),
);
}
/// [ReceivedImageDecoder] over [ImageCodecService].
///
/// Pure delegation. It exists so `received_image_store.dart` never imports
/// `image_codec_service.dart` (and so the store stays testable without an
/// 872 MB ONNX graph).
class ImageCodecServiceDecoder implements ReceivedImageDecoder {
final ImageCodecService service;
const ImageCodecServiceDecoder(this.service);
@override
ImageCodecAvailability get availability => service.availability;
@override
bool get isBusy => service.isBusy;
@override
Future<ImageCodecResult?> decodeBitstream({
required Uint8List bitstream,
required AeicRatePoint ratePoint,
required int resolution,
}) => service.decodeBitstream(
bitstream: bitstream,
ratePoint: ratePoint,
resolution: resolution,
);
@override
void cancelCodecJob() => service.cancelCodecJob();
}
/// [ImageCodecSettingsStore] backed by [AppSettingsService].
///
/// Replaces `PrefsImageCodecSettingsStore` now that the five `imageCodec*`
/// fields live on [AppSettings]; the JSON keys were already the same, so a
/// user upgrading keeps their model registry only if it was written through
/// this adapter the old standalone `image_codec_settings` blob is not
/// migrated, because a placeholder-URL build cannot have produced one.
class AppSettingsImageCodecStore implements ImageCodecSettingsStore {
final AppSettingsService _service;
const AppSettingsImageCodecStore(this._service);
@override
ImageCodecPreferences get preferences => _service.settings.imageCodec;
@override
Future<void> load() async {
// AppSettingsService.loadSettings() already ran in main().
}
@override
Future<void> save(ImageCodecPreferences preferences) =>
_service.setImageCodecPreferences(preferences);
}
/// An [ImageReassembler] that (a) learns the local sender prefix after
/// SELF_INFO and (b) forwards every outcome to [ReceivedImageStore] together
/// with the channel index.
///
/// Both exist because of upstream shapes this file cannot change:
/// * `ImageReassembler.selfPrefix` is `final`, but the local public key does
/// not exist until `RESP_CODE_SELF_INFO`, so a reassembler built here would
/// start with a null prefix and never be able to drop our own flood echo.
/// Overriding the getter is the smallest fix that does not touch the
/// transport; see the report's "needs from others".
/// * `ImageChunkOutcome` carries no channel index, so the connector's
/// `onImageChunk` callback cannot call `handleOutcome`, which requires one.
/// Intercepting `addChunk` the only place the index is in scope can.
class ImageStreamReassembler extends ImageReassembler {
final ReceivedImageStore store;
int? _selfPrefix;
ImageStreamReassembler({required this.store})
: super(onFailed: ((failure) => unawaited(store.handleFailure(failure))));
@override
int? get selfPrefix => _selfPrefix;
set selfPrefix(int? value) => _selfPrefix = value;
@override
ImageChunkOutcome addChunk(
Uint8List blob, {
int channelIndex = 0,
DateTime? now,
}) {
final outcome = super.addChunk(blob, channelIndex: channelIndex, now: now);
unawaited(store.handleOutcome(outcome, channelIndex: channelIndex));
return outcome;
}
}
void _registerThirdPartyLicenses() {
LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
@@ -311,9 +144,6 @@ class MeshCoreApp extends StatefulWidget {
final TranslationService translationService;
final UiViewStateService uiViewStateService;
final TimeoutPredictionService timeoutPredictionService;
final ImageCodecService imageCodecService;
final ReceivedImageStore receivedImageStore;
final ImageStreamReassembler imageReassembler;
const MeshCoreApp({
super.key,
@@ -329,70 +159,79 @@ class MeshCoreApp extends StatefulWidget {
required this.translationService,
required this.uiViewStateService,
required this.timeoutPredictionService,
required this.imageCodecService,
required this.receivedImageStore,
required this.imageReassembler,
});
@override
State<MeshCoreApp> createState() => _MeshCoreAppState();
}
/// How often abandoned image streams are swept.
///
/// `ImageReassembler.evictExpired` otherwise only runs from `addChunk`, so a
/// sender that stops mid-image would leave its bubble stuck on "2 of 3
/// packets" until some unrelated image arrived.
const Duration _kImageSweepInterval = Duration(seconds: 5);
class _MeshCoreAppState extends State<MeshCoreApp> with WidgetsBindingObserver {
Timer? _imageSweepTimer;
class _MeshCoreAppState extends State<MeshCoreApp> {
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
StreamSubscription<NotificationTapEvent>? _notificationTapSubscription;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_imageSweepTimer = Timer.periodic(
_kImageSweepInterval,
(_) => widget.imageReassembler.evictExpired(),
);
_notificationTapSubscription = NotificationService().onNotificationTapped
.listen(_handleNotificationTap);
}
@override
void dispose() {
_imageSweepTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
_notificationTapSubscription?.cancel();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
widget.receivedImageStore.setForeground(state == AppLifecycleState.resumed);
// ~2.7 GiB resident in a backgrounded app is a low-memory-killer kill, so
// unlike the translation stack the graph is dropped on background rather
// than held until the radio disconnects.
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden) {
unawaited(widget.imageCodecService.handleMemoryPressure());
unawaited(widget.receivedImageStore.handleMemoryPressure());
void _handleNotificationTap(NotificationTapEvent event) {
final navigator = _navigatorKey.currentState;
if (navigator == null) return;
switch (event.type) {
case NotificationTapEventType.message:
if (event.id == null) return;
final contact = widget.connector.findContactByKeyHex(event.id!);
if (contact == null) return;
widget.connector.markContactRead(contact.publicKeyHex);
navigator.push(
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
break;
case NotificationTapEventType.channel:
if (event.id == null) return;
final channelIndex = int.tryParse(event.id!);
if (channelIndex == null) return;
final channel = widget.connector.findChannelByIndex(channelIndex);
if (channel == null) return;
widget.connector.markChannelRead(channelIndex);
navigator.push(
MaterialPageRoute(
builder: (_) => ChannelChatScreen(channel: channel),
),
);
break;
case NotificationTapEventType.advert:
// Clear every advert notification the discovery
// list the user is about to see contains them all.
NotificationService().clearAllAdvertNotifications();
final ids = widget.connector.allContacts
.map((c) => c.publicKeyHex)
.toList();
NotificationService().clearAdvertNotifications(ids);
navigator.push(
MaterialPageRoute(builder: (_) => const DiscoveryScreen()),
);
break;
case NotificationTapEventType.batch:
// Batch summaries have no single target; no-op.
break;
}
}
@override
void didHaveMemoryPressure() {
super.didHaveMemoryPressure();
unawaited(widget.imageCodecService.handleMemoryPressure());
unawaited(widget.receivedImageStore.handleMemoryPressure());
}
@override
Widget build(BuildContext context) {
final connector = widget.connector;
final storage = widget.storage;
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connector),
ChangeNotifierProvider.value(value: widget.connector),
ChangeNotifierProvider.value(value: widget.retryService),
ChangeNotifierProvider.value(value: widget.pathHistoryService),
ChangeNotifierProvider.value(value: widget.appSettingsService),
@@ -401,44 +240,57 @@ class _MeshCoreAppState extends State<MeshCoreApp> with WidgetsBindingObserver {
ChangeNotifierProvider.value(value: widget.chatTextScaleService),
ChangeNotifierProvider.value(value: widget.translationService),
ChangeNotifierProvider.value(value: widget.uiViewStateService),
Provider.value(value: storage),
ChangeNotifierProvider.value(value: widget.mapTileCacheService),
Provider.value(value: widget.storage),
Provider.value(value: widget.mapTileCacheService),
ChangeNotifierProvider.value(value: widget.timeoutPredictionService),
ChangeNotifierProvider.value(value: widget.imageCodecService),
ChangeNotifierProvider.value(value: widget.receivedImageStore),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {
return MaterialApp(
title: 'MeshCore Open',
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: _localeFromSetting(
settingsService.settings.languageOverride,
return WithForegroundTask(
child: MaterialApp(
navigatorKey: _navigatorKey,
title: 'MeshCore Open',
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: _localeFromSetting(
settingsService.settings.languageOverride,
),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
),
themeMode: _themeModeFromSetting(
settingsService.settings.themeMode,
),
builder: (context, child) {
// Update notification service with resolved locale
final locale = Localizations.localeOf(context);
NotificationService().setLocale(locale);
return child ?? const SizedBox.shrink();
},
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
? const ChromeRequiredScreen()
: const ScannerScreen(),
),
theme: MeshTheme.light(),
darkTheme: MeshTheme.dark(),
themeMode: _themeModeFromSetting(
settingsService.settings.themeMode,
),
builder: (context, child) {
// Update notification service with resolved locale
final locale = Localizations.localeOf(context);
NotificationService().setLocale(locale);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: _systemUiOverlayStyle(context),
child: child ?? const SizedBox.shrink(),
);
},
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
? const ChromeRequiredScreen()
: const ScannerScreen(),
);
},
),
@@ -456,24 +308,6 @@ class _MeshCoreAppState extends State<MeshCoreApp> with WidgetsBindingObserver {
}
}
SystemUiOverlayStyle _systemUiOverlayStyle(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final iconBrightness = isDark ? Brightness.light : Brightness.dark;
// Keep Android system bars aligned with the resolved Flutter theme.
return SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: iconBrightness,
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
systemNavigationBarColor: colorScheme.surface,
systemNavigationBarIconBrightness: iconBrightness,
systemNavigationBarDividerColor: colorScheme.surface,
systemNavigationBarContrastEnforced: false,
);
}
Locale? _localeFromSetting(String? languageCode) {
if (languageCode == null) return null;
return Locale(languageCode);
+5 -273
View File
@@ -1,4 +1,3 @@
import 'image_codec_support.dart';
import 'translation_support.dart';
enum UnitSystem { metric, imperial }
@@ -14,70 +13,8 @@ extension UnitSystemValue on UnitSystem {
}
}
const Map<String, String> defaultCyr2LatCharMap = {
'А': 'A',
'В': 'B',
'Е': 'E',
'Ё': 'E',
'З': '3',
'К': 'K',
'М': 'M',
'Н': 'H',
'О': 'O',
'Р': 'P',
'С': 'C',
'Т': 'T',
'Х': 'X',
'Ь': 'b',
'а': 'a',
'е': 'e',
'ё': 'e',
'о': 'o',
'р': 'p',
'с': 'c',
'у': 'y',
'х': 'x',
};
class Cyr2LatProfile {
final String id;
final String name;
final Map<String, String> charMap;
Cyr2LatProfile({required this.id, required this.name, required this.charMap});
Map<String, dynamic> toJson() {
return {'id': id, 'name': name, 'char_map': charMap};
}
factory Cyr2LatProfile.fromJson(Map<String, dynamic> json) {
return Cyr2LatProfile(
id: json['id'] as String,
name: json['name'] as String,
charMap:
(json['char_map'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()),
) ??
{},
);
}
Cyr2LatProfile copyWith({
String? id,
String? name,
Map<String, String>? charMap,
}) {
return Cyr2LatProfile(
id: id ?? this.id,
name: name ?? this.name,
charMap: charMap ?? this.charMap,
);
}
}
class AppSettings {
static const Object _unset = Object();
static const String stadiaDemo = '51bd0381-4685-4666-bae8-48940f6d77c0';
final bool clearPathOnMaxRetry;
final bool mapShowRepeaters;
@@ -93,15 +30,10 @@ class AppSettings {
final Map<String, double>? mapCacheBounds;
final int mapCacheMinZoom;
final int mapCacheMaxZoom;
final String mapRasterSourceId;
final String mapTileEndpointId;
final String? mapTileApiKey;
final bool notificationsEnabled;
final bool notifyOnNewMessage;
final bool notifyOnNewChannelMessage;
final bool notifyOnNewAdvert;
final bool autoSendZeroHopAdvertOnGpsUpdate;
final int gpsIntervalSeconds;
final bool autoRouteRotationEnabled;
final double maxRouteWeight;
final double initialRouteWeight;
@@ -119,66 +51,12 @@ class AppSettings {
final String tcpServerAddress;
final int tcpServerPort;
final bool jumpToOldestUnread;
final bool imageMessagesEnabled;
/// Whether a received image is decoded as soon as it is reassembled.
///
/// Off by default and deliberately so: a decode peaks around 2.16 GiB
/// resident and takes about a second, so an unattended chat must not be able
/// to trigger one per arriving image. When false, `ReceivedImageStore` parks
/// the arrival as a "Tap to process" placeholder instead of queueing it.
final bool imageProcessAutomatically;
// ---- neural image codec (AEIC-SE) ---------------------------------------
// Structural twins of the translation block above; the JSON keys match
// ImageCodecPreferences.toJson so ImageCodecService reads them unchanged.
final bool imageCodecEnabled;
final String? imageCodecSelectedModelId;
final String? imageCodecModelSourceUrl;
/// [AeicRatePoint.wireValue] of the composer's default rate point.
/// 4 == ft32, the only rate point this build ships.
final int imageCodecRatePoint;
final List<ImageCodecModelRecord> imageCodecDownloadedModels;
final bool translationEnabled;
final bool autoTranslateIncomingMessages;
final String? translationTargetLanguageCode;
final bool composerTranslationEnabled;
final String? translationModelSourceUrl;
final String? translationSelectedModelId;
final List<TranslationModelRecord> translationDownloadedModels;
final List<Cyr2LatProfile> cyr2latProfiles;
final String selectedCyr2latProfileId;
/// The five `imageCodec*` fields as the value object `ImageCodecService`
/// consumes. Assembled rather than stored so the settings blob stays flat.
ImageCodecPreferences get imageCodec => ImageCodecPreferences(
enabled: imageCodecEnabled,
selectedModelId: imageCodecSelectedModelId,
modelSourceUrl: imageCodecModelSourceUrl,
ratePoint: imageCodecRatePoint,
downloadedModels: imageCodecDownloadedModels,
);
String get effectiveMapTileApiKey {
final apiKey = mapTileApiKey?.trim();
if (apiKey == null || apiKey.isEmpty) {
return stadiaDemo;
}
return apiKey;
}
bool get usesstadiaDemo => effectiveMapTileApiKey == stadiaDemo;
Map<String, String> get cyr2latCharMap {
final profile = cyr2latProfiles.firstWhere(
(p) => p.id == selectedCyr2latProfileId,
orElse: () => cyr2latProfiles.first,
);
return profile.charMap;
}
AppSettings({
this.clearPathOnMaxRetry = false,
@@ -191,20 +69,15 @@ class AppSettings {
this.mapKeyPrefix = '',
this.mapShowMarkers = true,
this.mapShowGuessedLocations = true,
this.enableMessageTracing = true,
this.enableMessageTracing = false,
this.mapCacheBounds,
this.mapCacheMinZoom = 10,
this.mapCacheMaxZoom = 15,
this.mapRasterSourceId = 'osm_auto',
this.mapTileEndpointId = 'standard_2x',
this.mapTileApiKey,
this.notificationsEnabled = true,
this.notifyOnNewMessage = true,
this.notifyOnNewChannelMessage = true,
this.notifyOnNewAdvert = true,
this.autoSendZeroHopAdvertOnGpsUpdate = false,
this.gpsIntervalSeconds = 900,
this.autoRouteRotationEnabled = true,
this.autoRouteRotationEnabled = false,
this.maxRouteWeight = 5.0,
this.initialRouteWeight = 3.0,
this.routeWeightSuccessIncrement = 0.5,
@@ -221,37 +94,16 @@ class AppSettings {
this.tcpServerAddress = '',
this.tcpServerPort = 0,
this.jumpToOldestUnread = false,
this.imageMessagesEnabled = false,
this.imageProcessAutomatically = false,
this.imageCodecEnabled = false,
this.imageCodecSelectedModelId,
this.imageCodecModelSourceUrl,
this.imageCodecRatePoint = 4,
List<ImageCodecModelRecord>? imageCodecDownloadedModels,
this.translationEnabled = false,
this.autoTranslateIncomingMessages = true,
this.translationTargetLanguageCode,
this.composerTranslationEnabled = false,
this.translationModelSourceUrl,
this.translationSelectedModelId,
List<TranslationModelRecord>? translationDownloadedModels,
List<Cyr2LatProfile>? cyr2latProfiles,
String? selectedCyr2latProfileId,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {},
imageCodecDownloadedModels = imageCodecDownloadedModels ?? const [],
translationDownloadedModels = translationDownloadedModels ?? const [],
cyr2latProfiles =
cyr2latProfiles ??
[
Cyr2LatProfile(
id: 'default',
name: 'Default',
charMap: defaultCyr2LatCharMap,
),
],
selectedCyr2latProfileId = selectedCyr2latProfileId ?? 'default';
translationDownloadedModels = translationDownloadedModels ?? const [];
Map<String, dynamic> toJson() {
return {
@@ -269,16 +121,10 @@ class AppSettings {
'map_cache_bounds': mapCacheBounds,
'map_cache_min_zoom': mapCacheMinZoom,
'map_cache_max_zoom': mapCacheMaxZoom,
'map_raster_source_id': mapRasterSourceId,
'map_tile_endpoint_id': mapTileEndpointId,
'map_tile_api_key': mapTileApiKey,
'notifications_enabled': notificationsEnabled,
'notify_on_new_message': notifyOnNewMessage,
'notify_on_new_channel_message': notifyOnNewChannelMessage,
'notify_on_new_advert': notifyOnNewAdvert,
'auto_send_zero_hop_advert_on_gps_update':
autoSendZeroHopAdvertOnGpsUpdate,
'gps_interval_seconds': gpsIntervalSeconds,
'auto_route_rotation_enabled': autoRouteRotationEnabled,
'max_route_weight': maxRouteWeight,
'initial_route_weight': initialRouteWeight,
@@ -296,17 +142,7 @@ class AppSettings {
'tcp_server_address': tcpServerAddress,
'tcp_server_port': tcpServerPort,
'jump_to_oldest_unread': jumpToOldestUnread,
'image_messages_enabled': imageMessagesEnabled,
'image_process_automatically': imageProcessAutomatically,
'image_codec_enabled': imageCodecEnabled,
'image_codec_selected_model_id': imageCodecSelectedModelId,
'image_codec_model_source_url': imageCodecModelSourceUrl,
'image_codec_rate_point': imageCodecRatePoint,
'image_codec_downloaded_models': imageCodecDownloadedModels
.map((model) => model.toJson())
.toList(),
'translation_enabled': translationEnabled,
'auto_translate_incoming_messages': autoTranslateIncomingMessages,
'translation_target_language_code': translationTargetLanguageCode,
'composer_translation_enabled': composerTranslationEnabled,
'translation_model_source_url': translationModelSourceUrl,
@@ -314,10 +150,6 @@ class AppSettings {
'translation_downloaded_models': translationDownloadedModels
.map((model) => model.toJson())
.toList(),
'cyr2lat_profiles': cyr2latProfiles
.map((profile) => profile.toJson())
.toList(),
'selected_cyr2lat_profile_id': selectedCyr2latProfileId,
};
}
@@ -342,26 +174,19 @@ class AppSettings {
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
mapShowGuessedLocations:
json['map_show_guessed_locations'] as bool? ?? true,
enableMessageTracing: json['enable_message_tracing'] as bool? ?? true,
enableMessageTracing: json['enable_message_tracing'] as bool? ?? false,
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
),
mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10,
mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15,
mapRasterSourceId: json['map_raster_source_id'] as String? ?? 'osm_auto',
mapTileEndpointId: json['map_tile_endpoint_id'] as String? ?? 'standard',
mapTileApiKey: json['map_tile_api_key'] as String?,
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
notifyOnNewMessage: json['notify_on_new_message'] as bool? ?? true,
notifyOnNewChannelMessage:
json['notify_on_new_channel_message'] as bool? ?? true,
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoSendZeroHopAdvertOnGpsUpdate:
json['auto_send_zero_hop_advert_on_gps_update'] as bool? ?? false,
gpsIntervalSeconds:
(json['gps_interval_seconds'] as num?)?.toInt() ?? 900,
autoRouteRotationEnabled:
json['auto_route_rotation_enabled'] as bool? ?? true,
json['auto_route_rotation_enabled'] as bool? ?? false,
maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0,
initialRouteWeight:
(json['initial_route_weight'] as num?)?.toDouble() ?? 3.0,
@@ -394,26 +219,7 @@ class AppSettings {
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false,
imageMessagesEnabled: json['image_messages_enabled'] as bool? ?? false,
imageProcessAutomatically:
json['image_process_automatically'] as bool? ?? false,
imageCodecEnabled: json['image_codec_enabled'] as bool? ?? false,
imageCodecSelectedModelId:
json['image_codec_selected_model_id'] as String?,
imageCodecModelSourceUrl: json['image_codec_model_source_url'] as String?,
imageCodecRatePoint: json['image_codec_rate_point'] as int? ?? 4,
imageCodecDownloadedModels:
(json['image_codec_downloaded_models'] as List<dynamic>?)
?.map(
(entry) => ImageCodecModelRecord.fromJson(
Map<String, dynamic>.from(entry as Map),
),
)
.toList() ??
const [],
translationEnabled: json['translation_enabled'] as bool? ?? false,
autoTranslateIncomingMessages:
json['auto_translate_incoming_messages'] as bool? ?? true,
translationTargetLanguageCode:
json['translation_target_language_code'] as String?,
composerTranslationEnabled:
@@ -431,38 +237,6 @@ class AppSettings {
)
.toList() ??
const [],
cyr2latProfiles:
(json['cyr2lat_profiles'] as List<dynamic>?)
?.map(
(entry) => Cyr2LatProfile.fromJson(
Map<String, dynamic>.from(entry as Map),
),
)
.toList() ??
// Backward compatibility: if old cyr2lat_char_map exists, create a profile from it
(json['cyr2lat_char_map'] != null
? [
Cyr2LatProfile(
id: 'migrated',
name: 'Migrated Profile',
charMap:
(json['cyr2lat_char_map'] as Map?)?.map(
(key, value) =>
MapEntry(key.toString(), value.toString()),
) ??
defaultCyr2LatCharMap,
),
]
: [
Cyr2LatProfile(
id: 'default',
name: 'Default',
charMap: defaultCyr2LatCharMap,
),
]),
selectedCyr2latProfileId:
json['selected_cyr2lat_profile_id'] as String? ??
(json['cyr2lat_char_map'] != null ? 'migrated' : 'default'),
);
}
@@ -481,15 +255,10 @@ class AppSettings {
Object? mapCacheBounds = _unset,
int? mapCacheMinZoom,
int? mapCacheMaxZoom,
String? mapRasterSourceId,
String? mapTileEndpointId,
Object? mapTileApiKey = _unset,
bool? notificationsEnabled,
bool? notifyOnNewMessage,
bool? notifyOnNewChannelMessage,
bool? notifyOnNewAdvert,
bool? autoSendZeroHopAdvertOnGpsUpdate,
int? gpsIntervalSeconds,
bool? autoRouteRotationEnabled,
double? maxRouteWeight,
double? initialRouteWeight,
@@ -507,22 +276,12 @@ class AppSettings {
String? tcpServerAddress,
int? tcpServerPort,
bool? jumpToOldestUnread,
bool? imageMessagesEnabled,
bool? imageProcessAutomatically,
bool? imageCodecEnabled,
Object? imageCodecSelectedModelId = _unset,
Object? imageCodecModelSourceUrl = _unset,
int? imageCodecRatePoint,
List<ImageCodecModelRecord>? imageCodecDownloadedModels,
bool? translationEnabled,
bool? autoTranslateIncomingMessages,
Object? translationTargetLanguageCode = _unset,
bool? composerTranslationEnabled,
Object? translationModelSourceUrl = _unset,
Object? translationSelectedModelId = _unset,
List<TranslationModelRecord>? translationDownloadedModels,
List<Cyr2LatProfile>? cyr2latProfiles,
String? selectedCyr2latProfileId,
}) {
return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@@ -542,20 +301,11 @@ class AppSettings {
: mapCacheBounds as Map<String, double>?,
mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom,
mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom,
mapRasterSourceId: mapRasterSourceId ?? this.mapRasterSourceId,
mapTileEndpointId: mapTileEndpointId ?? this.mapTileEndpointId,
mapTileApiKey: mapTileApiKey == _unset
? this.mapTileApiKey
: mapTileApiKey as String?,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
notifyOnNewMessage: notifyOnNewMessage ?? this.notifyOnNewMessage,
notifyOnNewChannelMessage:
notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage,
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoSendZeroHopAdvertOnGpsUpdate:
autoSendZeroHopAdvertOnGpsUpdate ??
this.autoSendZeroHopAdvertOnGpsUpdate,
gpsIntervalSeconds: gpsIntervalSeconds ?? this.gpsIntervalSeconds,
autoRouteRotationEnabled:
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
maxRouteWeight: maxRouteWeight ?? this.maxRouteWeight,
@@ -581,22 +331,7 @@ class AppSettings {
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread,
imageMessagesEnabled: imageMessagesEnabled ?? this.imageMessagesEnabled,
imageProcessAutomatically:
imageProcessAutomatically ?? this.imageProcessAutomatically,
imageCodecEnabled: imageCodecEnabled ?? this.imageCodecEnabled,
imageCodecSelectedModelId: imageCodecSelectedModelId == _unset
? this.imageCodecSelectedModelId
: imageCodecSelectedModelId as String?,
imageCodecModelSourceUrl: imageCodecModelSourceUrl == _unset
? this.imageCodecModelSourceUrl
: imageCodecModelSourceUrl as String?,
imageCodecRatePoint: imageCodecRatePoint ?? this.imageCodecRatePoint,
imageCodecDownloadedModels:
imageCodecDownloadedModels ?? this.imageCodecDownloadedModels,
translationEnabled: translationEnabled ?? this.translationEnabled,
autoTranslateIncomingMessages:
autoTranslateIncomingMessages ?? this.autoTranslateIncomingMessages,
translationTargetLanguageCode: translationTargetLanguageCode == _unset
? this.translationTargetLanguageCode
: translationTargetLanguageCode as String?,
@@ -610,9 +345,6 @@ class AppSettings {
: translationSelectedModelId as String?,
translationDownloadedModels:
translationDownloadedModels ?? this.translationDownloadedModels,
cyr2latProfiles: cyr2latProfiles ?? this.cyr2latProfiles,
selectedCyr2latProfileId:
selectedCyr2latProfileId ?? this.selectedCyr2latProfileId,
);
}
}
-38
View File
@@ -4,9 +4,6 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import '../connector/meshcore_protocol.dart';
import 'community.dart';
enum ChannelType { public, private, hashtag, communityPublic, communityHashtag }
class Channel {
final int index;
@@ -27,10 +24,6 @@ class Channel {
bool get isPublicChannel => pskHex == publicChannelPsk;
bool get isHashtagChannel => name.startsWith('#');
bool get isPrivateChannel => !isPublicChannel && !isHashtagChannel;
static Channel? fromFrame(Uint8List frame) {
// CHANNEL_INFO format:
// [0] = RESP_CODE_CHANNEL_INFO (18)
@@ -118,36 +111,5 @@ class Channel {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
static bool isCommunityChannel(ChannelType channelType) {
switch (channelType) {
case ChannelType.communityPublic:
case ChannelType.communityHashtag:
return true;
case ChannelType.public:
case ChannelType.private:
case ChannelType.hashtag:
return false;
}
}
static ChannelType getChannelType(
Channel channel,
CommunityPskIndex communityIndex,
) {
Community? community = communityIndex.getCommunityForChannel(channel);
if (community != null) {
if (Community.isCommunityPublicChannel(channel, community)) {
return ChannelType.communityPublic;
}
return ChannelType.communityHashtag;
}
if (channel.isPublicChannel) {
return ChannelType.public;
} else if (channel.name.startsWith('#')) {
return ChannelType.hashtag;
}
return ChannelType.private;
}
static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72';
}
+5 -17
View File
@@ -41,7 +41,6 @@ class ChannelMessage {
final List<Repeat> repeats;
final int repeatCount;
final int? pathLength;
final int? pathHashWidth;
final Uint8List pathBytes;
final List<Uint8List> pathVariants;
final int? channelIndex;
@@ -67,7 +66,6 @@ class ChannelMessage {
this.repeats = const [],
this.repeatCount = 0,
this.pathLength,
this.pathHashWidth,
Uint8List? pathBytes,
List<Uint8List>? pathVariants,
this.channelIndex,
@@ -95,7 +93,6 @@ class ChannelMessage {
List<Repeat>? repeats,
int? repeatCount,
int? pathLength,
int? pathHashWidth,
Uint8List? pathBytes,
List<Uint8List>? pathVariants,
String? packetHash,
@@ -132,7 +129,6 @@ class ChannelMessage {
repeats: repeats ?? this.repeats,
repeatCount: repeatCount ?? this.repeatCount,
pathLength: pathLength ?? this.pathLength,
pathHashWidth: pathHashWidth ?? this.pathHashWidth,
pathBytes: pathBytes ?? this.pathBytes,
pathVariants: pathVariants ?? this.pathVariants,
channelIndex: channelIndex,
@@ -159,7 +155,6 @@ class ChannelMessage {
int pathLen;
int txtType;
int? packetPathHashWidth;
Uint8List pathBytes = Uint8List(0);
int channelIdx;
if (code == respCodeChannelMsgRecvV3) {
@@ -168,18 +163,12 @@ class ChannelMessage {
final hasPath = (flags & 0x01) != 0;
reader.skipBytes(1); // Skip reserved byte
channelIdx = reader.readByte();
final pathByte = reader.readUInt8();
// pathByte packs: top 2 bits = hash width mode, low 6 bits = hop count
packetPathHashWidth = ((pathByte & 0xC0) >> 6) + 1;
final hopCount = pathByte & 0x3F;
pathLen = hopCount;
// If a path is present, read hopCount * width bytes
if (hasPath && hopCount > 0) {
final totalPathBytes = hopCount * packetPathHashWidth;
pathBytes = reader.readBytes(totalPathBytes);
}
// After consuming optional path bytes, read the text type byte.
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();
@@ -220,7 +209,6 @@ class ChannelMessage {
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: pathLen,
pathHashWidth: packetPathHashWidth,
pathBytes: pathBytes,
channelIndex: channelIdx,
);

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