From abe57fdb0298e2161f442413ad0d265fa8820177 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 01:20:22 +0300 Subject: [PATCH 01/33] Add project refactoring audit and ignore local tmp directory --- .gitignore | 3 + docs/README.md | 1 + docs/development/refactoring-audit.md | 776 ++++++++++++++++++++++++++ 3 files changed, 780 insertions(+) create mode 100644 docs/development/refactoring-audit.md diff --git a/.gitignore b/.gitignore index 70b0c2e..9acd299 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,9 @@ app.*.map.json # Local AI tooling .aider* +# Local scratch scripts and data exports +/tmp/ + # Environment files may contain secrets .env .env.* diff --git a/docs/README.md b/docs/README.md index f2c406a..b4cb5c1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ ## Development - [Map screen refactoring plan](development/map-screen-refactoring.md) +- [Refactoring audit (2026-09-02)](development/refactoring-audit.md) - [Impossible GPS zones](superpowers/specs/2026-08-20-impossible-zones-design.md) - [In-app localization (en / ru)](superpowers/specs/2026-08-20-localization-design.md) - [Localization implementation plan](superpowers/plans/2026-08-20-localization.md) diff --git a/docs/development/refactoring-audit.md b/docs/development/refactoring-audit.md new file mode 100644 index 0000000..22492d1 --- /dev/null +++ b/docs/development/refactoring-audit.md @@ -0,0 +1,776 @@ +# Аудит рефакторинга проекта (2026-09-02) + +Подробный обход репозитория с целью найти точки рефакторинга: кандидатов на +разбиение больших файлов, проблемы тестового набора, вопросы структуры +каталогов и общей гигиены. Каждая находка отнесена к одной из категорий: +**стоит поправить** (с планом и оценкой риска), **мелкий мусор** (можно +поправить в любой момент, низкой ценности), **оставить как есть**. + +## 1. Методика и базовое состояние + +- Тулчейн: репозиторный `.toolchain/`, Flutter 3.47.1 stable, Dart SDK ^3.13. +- Точка отсчёта: коммит `8d9f32e`, рабочее дерево чистое (кроме локального + `tmp/`). +- Базовая верификация перед аудитом: + - `dart format --output=none --set-exit-if-changed lib test` — 0 изменений; + - `flutter analyze` — 0 ошибок и предупреждений, 12 существующих + info-замечаний (список в §4.3); + - `flutter test` — 382 теста, все проходят. +- Объём: 112 dart-файлов в `lib/`, 65 в `test/`. +- Метод: ручной обход плюс пять параллельных субагентов — god-file карты, + крупные сервисы, UI-слой и каталоги, тестовый набор, гигиена репозитория. + +### Крупнейшие файлы (физические строки, без сгенерированных `lib/l10n/generated/*`) + +| Файл | Строк | +| --- | --- | +| `lib/screens/map_screen.dart` | 3261 | +| `lib/services/lora_companion_service.dart` | 2011 | +| `lib/screens/analytics_screen.dart` | 1735 | +| `lib/services/location_service.dart` | 1708 | +| `lib/services/meshcore_protocol.dart` | 1282 | +| `lib/services/settings_service.dart` | 1054 | +| `lib/screens/repeater_health_screen.dart` | 969 | +| `lib/services/database_service.dart` | 970 | +| `lib/screens/settings/settings_page.dart` (part) | 947 | + +Примечание: `map_screen.dart` — уже результат рефакторинга: по +`docs/development/map-screen-refactoring.md` он был сокращён с ~5750 строк +(этапы 1–4 плана закрыты). Оставшиеся точки роста описаны ниже. + +## 2. Резюме: главное + +Кодовая база в хорошем рабочем состоянии (формат чистый, 382 теста зелёные, +0 ошибок анализатора), архитектурные границы AGENTS.md в основном соблюдаются, +и большой рефакторинг map screen уже наполовину сделан по существующему +плану. Главные находки аудита: + +1. **Реальные дефекты, а не только стиль** (§3.6): утечка BLE-подписки + батареи, залипший набор ожидающих contact-запросов, гонка таймера скана, + dispose-гонки в трёх сервисах, неатомарный построчный импорт в БД. Все + чинится точечно с низким риском. +2. **Безопасность** (§3.7): пароль Carpeater хранится в plaintext и уходит в + JSON-экспорт настроек — нарушение собственной конвенции AGENTS.md; плюс + дыры в составе экспорта. +3. **God-file'ы** (§5.1, §5.2): `map_screen.dart` (3261 + part-файл настроек + на 947 строк — незакрытый этап 5 плана), `LoRaCompanionService` (2011), + `LocationService` (1708) — для каждого есть поэтапный план разбиения с + оценкой риска; `analytics_screen`/`repeater_health` дублируют друг друга. +4. **Тесты** (§5.4): 22 из 65 тестов лежат в корне `test/` в обход + конвенции (переносятся почти бесплатно — правки в 5 файлах); совсем не + покрыты `location_service`, `database_service`, большая часть + `lora_companion_service`; в крупных тестах — дублируемые setUp и каркасы, + просящиеся в хелперы. +5. **Гигиена** (§3.8–§3.10, §5.5): три неиспользуемые зависимости, + устаревший гайд про удалённый MQTT, легаси-разрешения манифеста, + `tmp/` вне `.gitignore` (закрыто в этом аудите). +6. **Реструктуризация каталогов**: глобально **не нужна** — раскладка lib/ + соответствует AGENTS.md, плоские экраны переносить в подкаталоги не стоит; + реальные нарушения точечны (§3.5: два UI-виджета в `lib/utils/`). + +Порядок рекомендуемых работ по соотношению выигрыш/риск: §3.6 → §3.7 → +переносы тестов (§5.4) → §3.5/§3.8 → этапы плана из §5.1 (1–9, затем 12) → +фазное разбиение сервисов (§5.2) → доки/манифест (§3.9, §3.10). Каждый шаг — +отдельный коммит с полной верификацией. + +## 3. Стоит поправить + +### 3.1. Завершить этап 5 плана map screen: убрать `part`-связку настроек + +- `lib/screens/settings/settings_page.dart` (947 строк) объявлен как + `part of '../map_screen.dart'` (`lib/screens/map_screen.dart:115`), то есть + страницы настроек — это методы-расширения (`extension _SettingsPageNavigation + on _MapScreenState`, `settings_page.dart:39`) приватного State-класса. + Это единственная оставшаяся `part`-связка в `lib/` и прямо названный + незакрытый пункт этапа 5 в `docs/development/map-screen-refactoring.md` + (строки 129–135). +- Extension напрямую читает/пишет ~40 приватных полей State + (`settings_page.dart:195–631`), поэтому шаг «высокого» риска — делать его + последним, после дешёвых извлечений из плана §5.1. +- Следствие: настройки нельзя тестировать и переиспользовать отдельно от + экрана карты; плюс ~10 полей State живут только ради этого part-файла. +- План: шаг 12 из §5.1 — неизменяемый `MapUiSnapshot` (расширение + `MapSettingsSnapshot`) + `MapUiActions`, страница настроек становится + обычным виджетом; тесты `test/settings/` обновляются вместе с ним. +- Риск: высокий (широкая поверхность State-класса), выигрыш: −947 строк из + связки и развязка фичи настроек; сам `map_screen.dart` худеет ещё на ~120 + строк импортов/`_loadSettings`. + +### 3.2. Тесты в корне `test/` нарушают конвенцию каталогов + +AGENTS.md требует размещать тесты в подкаталоге по префиксу имени +(например `test/bluetooth/`), общие хелперы — в `test/helpers/`. В корне +`test/` лежит 22 файла из 65 тестов, при том что 17 тестов уже разложены по +подкаталогам. Полная таблица переносов «файл → целевой подкаталог» — в §5.4. + +Проверено: у большинства переносимых тестов package-импорты, поэтому реальные +правки при переносе нужны только в 5 файлах — 4 пути к +`helpers/l10n_harness.dart` (`appearance_dialogs_test.dart:6`, +`connection_dialogs_test.dart:5`, `marker_dialogs_test.dart:6`, +`tracking_play_button_test.dart:6`) и 1 путь к `../tool/version.dart` +(`version_tool_test.dart:3`). Отдельно: `snr_quarter_db_test.dart` +целенаправленно переносится в новый `test/snr/`, а **не** в защищённый +`test/meshcore/` (добавление файла туда требует отдельного одобрения). + +### 3.3. `tmp/` не покрыт `.gitignore` + +В корне репозитория есть незакоммиченный `tmp/` (`analyze_export.ps1`, +`analyze_export2.ps1`, `meshcore_export_20260825_203017.csv`) — локальные +скрипты анализа и выгрузка данных. `.gitignore` их не игнорирует, их можно +случайно закоммитить. Правка добавлена в этом аудите (строка `/tmp/` в +`.gitignore`, проверено `git check-ignore`); удаление содержимого — на +усмотрение владельца (см. §7). + +### 3.4. Зависимости: отмеченные ограничения и discontinued-пакет + +- Пины версий в `pubspec.yaml` (flutter_map 7, geolocator 13, + permission_handler 11 и др.) снабжены комментариями-причинами — это + осознанные ограничения, не долг (хорошо). +- `dio_cache_interceptor_file_store` официально discontinued (заменён на + `http_cache_file_store`) — миграция возможна только вместе с обновлением + стека flutter_map/dio; отдельно не трогать, зафиксировать как задачу + «на потом» в связке с переездом на flutter_map 8. + +### 3.5. Виджеты в `lib/utils/` — нарушение собственной конвенции + +AGENTS.md: «utils — reusable helpers without UI responsibilities». Из 15 +файлов два содержат полноценные виджеты: + +- `lib/utils/ping_distance_options.dart:33-59` — `PingDistanceDropdown`; +- `lib/utils/discovery_timeout_options.dart:23-59` — `DiscoveryTimeoutDropdown`. + +Используются только из UI (`map_quick_settings_panel.dart:4-5`, +`discovery_section.dart:6`). Решение: перенести только классы-дропдауны в +`lib/widgets/`, опции-классы (`presets`, `labelFor`, `menuItems`) оставить в +utils; обновить 2 импорта в `lib/` и 2 в `test/`. Риск: минимальный. + +### 3.6. Реальные дефекты в сервисах: утечки, гонки, teardown + +Найдены при чтении кода (детали и точные строки — §5.2); каждый пункт — +небольшая точечная правка с низким риском: + +- **Утечка BLE-подписки батареи**: `lora_companion_service.dart:700` — + `listen()` не сохраняется и не отменяется; каждое переподключение BLE + добавляет «вечную» подписку и дубли событий. +- **Залипший `_pendingContactRequests`**: при неответе устройства ключ + остаётся навсегда (`:339`, `:1329`, удаление только в `:1448`) — повторные + запросы контакта блокируются до перезапуска приложения; на disconnect + не чистится. +- **Гонка таймера `scanForRepeaters`** (`lora_companion_service.dart:897-906`): + старый таймер может завершить новый completer частичными данными. +- **dispose-гонки**: `lora_companion_service.dart:2004-2009` (add в закрытые + контроллеры, `_failPendingPings` не вызывается), `location_service.dart:1688-1700` + (9 контроллеров закрываются после незawaitленного `stopTracking`), + `carpeater_service.dart:505-510` (цикл обнаружения может писать в закрытые + контроллеры). +- **Медленный неатомарный импорт БД**: `database_service.dart:563-594` — + SELECT+INSERT на каждую строку без транзакции, хотя batch-механизм уже + есть (`insertSamples`, `:323-334`). + +### 3.7. Пароль Carpeater хранится в открытом виде и уезжает в экспорт + +- `settings_service.dart:66, 681-693` — пароль ретранслятора лежит в + SharedPreferences (plaintext) и включён в `_exportKeys` (`:965`) — то есть + попадает в JSON-файл экспорта настроек. Это прямое нарушение конвенции + AGENTS.md («Store runtime credentials with the existing secure-storage + abstraction»). +- Решение: перенести в secure storage с миграцией существующего значения + (read-old → write-secure → remove-old, иначе пользователи потеряют пароль + при обновлении); из экспорта убрать, при импорте старых файлов — + игнорировать ключ. +- Заодно: в `_exportKeys` (`:921-986`) отсутствуют ключи + `_deadZoneAlertsKey` (`:78`) и `_newRepeaterAlertsKey` (`:79`) — + переключатели теряются при export/import; чужие ключи + `'upload_api_url'` и др. (`:978-981`) продублированы строковыми + литералами вместо констант из `upload_service.dart:26-32`. +- Риск: средний (миграция значения), выигрыш: безопасность + целостность + экспорта. + +### 3.8. Неиспользуемые зависимости и устаревшая формулировка AGENTS.md + +Проверено grep-ом dart-импортов по всему репозиторию — три зависимости без +единого использования: + +- `flutter_secure_storage` (`pubspec.yaml:67`); +- `pointycastle` (`pubspec.yaml:69`); +- `cupertino_icons` (`pubspec.yaml:32`; ни одного `CupertinoIcons`). + +При этом `AGENTS.md:100-101` ссылается на «existing secure-storage +abstraction», которой в коде нет (креды хранятся в `shared_preferences` +через `SettingsService`, см. §3.7). Решение: удалить три зависимости +(с прогоном `flutter pub get` / `analyze` / `test`), поправить формулировку +в AGENTS.md — а по §3.7 появление secure-storage станет реальностью. + +### 3.9. Гигиена AndroidManifest.xml (требует ручной проверки на устройстве) + +- `AndroidManifest.xml:25-26` — легаси `WRITE/READ_EXTERNAL_STORAGE` без + `maxSdkVersion`: на современных targetSdk это no-op (SAF/MediaStore через + file_picker/saver_gallery). Кандидат на удаление. +- `AndroidManifest.xml:29-30` — старые `BLUETOOTH`/`BLUETOOTH_ADMIN` без + `android:maxSdkVersion="30"` (рекомендация flutter_blue_plus). +- `AndroidManifest.xml:35` — `uses-feature android.hardware.usb.host` без + `required="false"`: устройства без USB-хоста (но рабочие по BLE) + отфильтровываются при установке — для приложения с dual-транспортом это + реальное сужение аудитории. +- Вопрос для ручной проверки: на Android 13+ для Wi-Fi-скана формально нужен + `NEARBY_WIFI_DEVICES`; в манифесте его нет, `WifiLocationService`/beaconDB + (`wifi_location_service.dart:176`, `MainActivity.kt:147-189`) может + получать пустые результаты на API 33+. По AGENTS.md это случай «describe + manual device testing» — проверить с устройством до любых правок. + +### 3.10. Устаревший гайд `docs/guides/lora-companion.md` + +Разделы «Customization» (строки 115-153) описывают удалённую +MQTT-функциональность: `defaultMqttBroker` (`:119-126`), топик-паттерн и +`ping $pingId` (`:130-140`), «`location_service.dart:71`: +`distanceFilter: 5`» (`:148-151`) — ничего из этого в коде нет (MQTT удалён: +`lora_companion_service.dart:870`, no-op `disconnectMqtt` `:1907-1908`). +Переписать раздел под реальный код (discovery/ping через `MeshCoreProtocol`) +или явно пометить документ как описание апстрима с удалением неверных +номеров строк. + +## 4. Мелкий мусор + +### 4.1. Одинаковые базовые имена файлов + +`lib/utils/compass_calibration.dart` (чистая политика калибровки, 112 строк) +и `lib/widgets/compass_calibration.dart` (баннер/шит, 323 строки) — +размещение обоих корректно, но одинаковые базовые имена провоцируют неверные +импорты (виджет уже импортирует utils: `widgets/compass_calibration.dart:8`). +Предложение: переименовать виджет-файл в `compass_calibration_sheet.dart` +(тест уже называется `compass_calibration_sheet_test.dart`). Косметика. + +### 4.2. Каталог `lib/constants/` ради одного файла + +`lib/constants/app_version.dart` — 2 строки, константа синхронизируется +`tool/version.dart`. Допустимо, но каталог на один файл — пограничный случай; +можно оставить как есть. + +### 4.3. Существующие info-замечания анализатора (12 штук) + +Все легко устранимы, поведение не меняют: + +| Локация | Линт | +| --- | --- | +| `lib/main.dart:29` | `library_private_types_in_public_api` | +| `lib/screens/map/map_screen_controller.dart:59` | `prefer_initializing_formals` | +| `lib/screens/map/map_settings_controller.dart:118` | `prefer_initializing_formals` | +| `lib/screens/map/map_settings_controller.dart:153` | `prefer_initializing_formals` | +| `lib/screens/map/map_settings_controller.dart:154` | `prefer_initializing_formals` | +| `lib/services/location_quality_filter.dart:14` | `prefer_initializing_formals` | +| `lib/screens/repeater_health_screen.dart:748` | `unnecessary_underscores` | +| `lib/services/sound_service.dart:8` | `constant_identifier_names` (`TONE_PROP_BEEP`) | +| `lib/services/sound_service.dart:9` | `constant_identifier_names` (`TONE_PROP_ACK`) | +| `lib/services/sound_service.dart:10` | `constant_identifier_names` (`TONE_PROP_NACK`) | +| `lib/services/sound_service.dart:11` | `constant_identifier_names` (`TONE_CDMA_ABBR_ALERT`) | +| `lib/services/sound_service.dart:12` | `constant_identifier_names` (`TONE_CDMA_MED_L`) | + +Проверено дополнительно: + +- Звуковые константы используются только внутри + `lib/services/sound_service.dart` и один раз в + `test/sound/sound_service_test.dart:47`; платформенный + `android/.../MainActivity.kt:57` берёт `ToneGenerator.TONE_PROP_BEEP` + из Android SDK и от имён в Dart не зависит. Переименование в + lowerCamelCase (`tonePropBeep`, …) затрагивает 2 файла и безопасно; + имена сейчас зеркалируют константы `ToneGenerator` — при переименовании + стоит сохранить эту связь в doc-комментарии. +- `lib/main.dart:29` — `static _MyAppState? of(...)` в публичном `MyApp` + возвращает приватный State (4 вызова из `lib/screens/map_screen.dart`). + Классическое лечение — сделать State-класс публичным + (`class MyAppState extends State`), поведение не меняется. + +### 4.4. Сервисный мелкий мусор (сводка; детали в §5.2) + +- Мёртвый код: `parseRawLogFrame` (~110 строк, + `meshcore_protocol.dart:751-860`) без единого вызова; `getMostRecentSample` + (`database_service.dart:450-460`) без вызывающих; deprecated-обёртка + `uploadNewSamples` (`upload_service.dart:239-243`); no-op `disconnectMqtt` + (`lora_companion_service.dart:1907-1908`). +- Копипаста-комментарий «Standard baud rate for **Meshtastic**» + (`lora_companion_service.dart:815`) — проект про MeshCore. +- Режим пинга строковыми литералами `'distance'/'time'/'both'` + (`location_service.dart:112-114`, те же литералы в + `settings_service.dart:613-621`) — просится enum. +- Несоответствие имени и логики: достижение `smolensk_legend` + (`achievement_service.dart:52`) проверяет префиксы `'ya_', 'yakut', 'якут'` + (`:57`). +- Зашитый URL устаревшего домена в миграции v5 + (`database_service.dart:210-216`) — не трогать, пометить как исторический. +- Прочее: дубли `_startingPing = false` в try и finally + (`lora_companion_service.dart:1098`, `:1171`); магия 3000/1200 mV батареи + без профиля (`:1521-1524`); `{s}` тайл-субдомен всегда `'a'` + (`tile_download_service.dart:72`); `flush: true` на каждую строку лога + (`persistent_debug_logger.dart:93-97`) — осознанный трейдофф; + `dispose()` синглтона `debug_log_service.dart:61-63` навсегда закрывает + контроллер (никем не вызывается). + +## 5. Детальные находки субагентов + +### 5.1. `lib/screens/map_screen.dart` — god-file (3261 строка) + +Файл — уже результат рефакторинга (5750 → 3261 строк по плану +`docs/development/map-screen-refactoring.md`, этапы 1–4 закрыты). Весь файл +после строки 115 — один State-класс `_MapScreenState` +(`map_screen.dart:124`); вместе с part-файлом настроек (947 строк) god-модуль +составляет ~4200 строк. Публичная поверхность минимальна: `MapScreen` +импортируется только из `lib/main.dart:7`, тесты сам экран не импортируют — +это делает дальнейшую резку безопасной по контрактам. + +Ключевые блоки внутри State (диапазоны строк `map_screen.dart`): + +| Строки | Блок | +| --- | --- | +| 124–319 | Поля: 9 сервисов, контроллеры, ~45 display-флагов настроек | +| 334–637 | `_initialize`: 16 stream-подписок, загрузки, алерты (304 строки) | +| 639–692 | `_loadSettings`: ручная раскладка 44 полей снапшота по State | +| 694–807 | Компас: подписка, калибровка, сглаживание heading (таймер 80 мс) | +| 1025–1135 | Разрешения Android (location/precise/always/battery/wifi-throttling) | +| 1137–1501 | Ввод-вывод: экспорт/импорт данных, настроек, БД (365 строк) | +| 1503–1701 | Маркеры, privacy/impossible зоны, delete mode | +| 1770–1824 | Проверка обновлений (HTTP к GitHub API) | +| 1873–1942 | Скриншоты (дублируется с `_shareCoverageMap` 3044–3113) | +| 1955–2295 | `build` + `_buildMap`: панели по 13–16 параметров, стек ~14 слоёв | +| 2313–2417 | `_manualPing`: пинг, звуки, конструирование и вставка Sample в БД | +| 2419–2624 | Подключение USB/BLE, контакты, сканирование ретрансляторов | +| 2664–2756 | Тема/язык: 7 методов через `MyApp.of(context)` | +| 2789–2897 | Инфо-диалоги sample/cluster/repeater/coverage | +| 2899–3240 | Upload, offline-тайлы, share coverage, фильтры, community coverage | + +Оценка уже сделанного разбиения: `map_screen_controller.dart` (365 строк, +`MapDataStore` + fingerprint-кэш + LOD, покрыт тестом) — образец; +`map_settings_controller.dart` (221 строка) хорош, но снапшот всё равно +вручную раскладывается по 45 полям State (`_loadSettings`, 639–692); +`map_runtime_bindings.dart` владеет подписками/таймерами, но сами подписки +описаны инлайн в `_initialize`; layers/dialogs/widgets вынесены чисто. +Итог: UI-«листья» вынесены (~30% по строкам), но подготовлено ~70% +архитектурных швов — оставшиеся извлечения дешёвые. + +План дальнейшего разбиения (каждый шаг = отдельный коммит + полная +верификация; новые файлы — в существующие подкаталоги `lib/screens/map/`, +без новых архитектурных слоёв — по AGENTS.md): + +| # | Шаг | Новый файл | Диапазон | Выигрыш | Риск | +| --- | --- | --- | --- | --- | --- | +| 1 | Проверка обновлений (`_checkForUpdates`, `_openGitHub`) | `map/dialogs/update_flow.dart` | 1770–1824 | ~55 | низкий | +| 2 | Экспорт/импорт данных, настроек, БД (`MapDataIo`) | `map/data_io.dart` | 1137–1501 | ~365 | низкий-средний | +| 3 | Маркеры, зоны, delete mode (`MapAnnotationsController`) | `map/map_annotations_controller.dart` | 1503–1701 | ~165 | низкий | +| 4 | Permission-преамбула трекинга | `map/tracking_permissions.dart` | 1025–1135 | ~110 | низкий | +| 5 | Подключение USB/BLE, контакты (`ConnectionFlow`) | `map/connection_flow.dart` | 2419–2624 | ~180 | средний | +| 6 | Скриншоты + дедупликация двух потоков | `map/screenshot_flow.dart` | 1873–1942, 3044–3113 | ~140 | средний | +| 7 | Upload / community-coverage потоки | `map/dialogs/upload_flows.dart` | 2899–2983, 3181–3240 | ~145 | низкий | +| 8 | Тема/язык + ducting-хелперы | `map/map_theme_helpers.dart` + utils | 2558–2582, 2664–2756 | ~120 | низкий | +| 9 | `_initialize` → биндеры `bindRadio/bindLocation/bindAlerts/bindTelemetry` | дополнить `map_runtime_bindings.dart` | 334–637 | ~250 из State | средний | +| 10 | Вставку Sample из `_manualPing` — в сервис | `services/manual_ping_service.dart` | 2313–2417 | ~70 | средний | +| 11 | `MapLayerStack` + viewState вместо 13–16 параметров панелей | `map/widgets/map_layer_stack.dart` | 1955–2295 | −200 котла | средний | +| 12 | Отклеить `settings_page.dart` от State (см. §3.1) | `MapUiSnapshot` + `MapUiActions` | part целиком | −947 | высокий | + +Эффект: шаги 1–9 сокращают `map_screen.dart` до ~1800–1900 строк, после +шага 12 — до ~1300–1500 строк (композитор, в пределах цели существующего +плана «600–900» + запас на специфику экрана). Общие правила для всех шагов: +не менять поведение `if (!mounted) return`; таймеры/подписки — только через +`MapRuntimeBindings`; сохранять точные l10n-строки и тайминги (300 мс +скриншот, 80 мс heading, 2 с pingPulse). + +Мелкий мусор в `map_screen.dart` (path — `lib/screens/map_screen.dart`): + +- Поля State, живущие только ради part-файла настроек: `_ignoredRepeaterPrefix` + (183, запись 657; в сервис применяется независимо через + `map_settings_controller.dart:142–144`), `_fuelUnit` (215/664), + `_pingTimeInterval` (283/674), `_keepScreenOn` (232/678), + `_batterySaverEnabled` (300/686), alert-флаги (306–308/683–685), + `_soundEnabled`/`_vibrationEnabled` (278–279/675–676) — единственный + читатель `settings_page.dart:195–631`. +- Бессмысленный `setState` вокруг мутации контроллера: + `setState(() => _mapDataController.replaceRepeaters(...))` — 2612. +- `void ... async` (async без Future, глушит unawaited-ошибки): + 1554, 1672, 1686, 2419, 3115, 3161. +- Дубли URL тайлов OSM/Carto: 2136–2137 и 3003–3005 — вынести в константу + (рядом с `lib/utils/initial_map_camera.dart`). +- Дубли скриншот-последовательности (скрытие UI + задержка 300 мс + + `capture(pixelRatio: 2.0)`): 1876–1886 и 3047–3054. +- Дубли расчёта success-rate: 851–855 и 3069–3079 — один хелпер. +- Магические числа: zoom 15.0 — 2837, 2893, 3178; 300 мс — 1881, 3050; + порог heading 0.25° — 798; `Duration(minutes: 2)` протухания радио-позиции + в двух независимых местах — 384 и 2302–2303. +- Сиротский комментарий от удалённого поля — 125; пустой колбэк прогресса + с комментарием-заглушкой — 3205–3207. +- Не относящийся к карте код в `_initialize`: проверка обновлений и + achievement-check (562–577, 1770). + +### 5.2. Сервисы (25 файлов, ~10,6 тыс. строк) + +Границы из AGENTS.md в целом соблюдены: протокол не зависит от виджетов, +экраны не занимаются фреймингом. Главные проблемы — god-объекты +`LoRaCompanionService` и `LocationService`, несколько реальных +утечек/гонок (см. §3.6), plaintext-пароль (см. §3.7) и медленный импорт в БД. + +**`lora_companion_service.dart` (2011 строк).** Ответственностей 7+: +BLE-транспорт (637-768), USB-транспорт (797-867), BLE-скан для UI (498-591), +авто-reconnect с backoff (1772-1905), диспетчер кадров (1176-1287), +ping/discovery-сессии, книга повторителей-контактов + алерты (342-366, +1290-1514), батарея, мост Carpeater (1916-2000). План разбиения фазами: +- Фаза A (низкий риск): `RepeaterDirectory` (6 коллекций + обработчики + контактов), `BatteryMonitor` (таймер 1642-1669 + BLE-char), carpeater-команды + → в `CarpeaterService` (у него уже есть свой `_protocol`, + `carpeater_service.dart:30`). +- Фаза B (средний): транспорт — интерфейс `RadioTransport` + BLE/USB + реализации; осторожно: порядок MTU→notify→battery и `_serializeConnect` + (:600-622) хрупкий. Не совмещать с другими правками. +- Фаза C (средний): `PingSessionManager` (tracker + `_pendingPings`/таймеры). +- Итог: 2011 → 4 файла по 400-600 строк, тестируемость без железа. +- Длинные методы: `ping` :1034-1173 (~140), `_connectBluetoothDevice` + :637-768 (~132), `watchBluetoothScan` :498-591 (~94). Дубли + connect/disconnect-хвостов BLE (:741-757) и USB (:846-858) → общий + `_finishConnection`/`_handleLinkLost` (−70 строк). Незафиксированная + зависимость: `SettingsService()` создаётся ad-hoc в `:993` и `:999` — + принимать в конструктор. + +**`location_service.dart` (1708 строк).** Ответственностей 12+ (GPS-стрим + +watchdog, Wi-Fi позиционирование, auto-ping time/distance, исполнение пинга, +battery-saver, dead-zone, ducting, Carpeater-оркестрация, foreground-сервис, +сессии, wakelock/sound/widget, CRUD pass-through). Ключевое: +- `_handleNewPosition` (:980-1201) — 222 строки и `void … async`: события + позиций обрабатываются fire-and-forget, порядок не гарантирован. Разбить на + qualityGate → fusedSourceSwitch → pingDecision → recordSample (риск + средний — сначала покрыть тестами). +- Async-void по файлу: `:922`, `:1279`, `:1303`, `:1575`. +- 2-4 запроса БД/prefs на каждый GPS-фикс (~5 с): зоны `:1008`, precision + `:1282`, dead-zone `:1290`, ducting `:1174` → кэшировать в памяти с + инвалидацией. +- Дубли триггеров пинга (:949-967 и :1118-1143) → `_maybeTriggerPing`. +- `startTracking` 159 строк (:664-822) с частичным откатом при ошибке. +- Владение сервисами: `map_screen.dart:127` создаёт `LocationService()`, + который сам создаёт `LoRaCompanionService()` (:37) — единственность держится + на соглашении; минимальное лечение — factory с кэшем инстанса или создание + в `main` и проброс. +- План разбиения: `PositionStreamManager` (477-634, целиком — паттерн + поколений корректен), `AutoPingScheduler`, carpeater-оркестрация → + `CarpeaterService`, `NotificationPresenter`. + +**`meshcore_protocol.dart` (1282 строки) — особые риски.** Это контракт +радио-обмена: layout-версия задаёт формат ответов прошивки (:12-18), форма +`Map` де-факто API и зафиксирована в защищённых тестах. +- Разрешено и безопасно (механика без поведения): 11 повторов + `if (x > 127) x -= 256` → `_readInt8` (:760, :765, :815, :870, :872, :891, + :924, :980, :985, :1035, :1262); 5 инлайновых LE-чтений → существующий + `_readUint32LE` (:616). −40 строк; обязательно прогонять защищённые тесты + (запуск разрешён) и проверять с реальным радио. +- Не делать сейчас: типизация результатов (правки API + защищённых тестов), + смена `codeUnits` на utf8 для пароля/команд (:1104, :1127) — изменит байты + логина «в эфире» (если чинить не-ASCII — только валидация с явной ошибкой). +- Не трогать: resync-логику `parseIncomingData` (:253-311, корректная), BLE + допущение «1 notification = 1 кадр» (:240-251, известное ограничение), + канальные методы без вызывающих в lib/ — они покрыты защищёнными + контракт-тестами, это сознательная «библиотека протокола». + +**`settings_service.dart` (1054 строки).** Границы соблюдает; +`await SharedPreferences.getInstance()` ~120 раз → кэширующий геттер +(−~100 строк, механически). Безопасность и экспорт — см. §3.7. Деление на +доменные классы не рекомендуется (AGENTS.md: слой ради одного use case). + +**`database_service.dart` (970 строк).** Импорт — см. §3.6. Плюс: дубли +гаверсинуса приват-зон (:895-906 и :909-925) → один хелпер; `database`-геттер +без мемоизации Future (:31-35); `getSessionSampleCounts` (:627-653) — три +COUNT → один с `SUM(CASE…)`. `_onUpgrade` (:172-310) длинный, но это нормальная +хронология миграций. + +**`carpeater_service.dart` (511 строк).** Три одинаковых retry-скелета +(:375-462) → хелпер `_awaitAck`; общий `_sentCompleter` (:57) для разных +команд — запоздалый ack может confirm-нуть не ту команду (маловероятно, +цикл последовательный — как минимум комментарий); dispose (:505-510) может +закрыть контроллеры, пока летит `_runDiscoveryCycle`. + +**`upload_service.dart` (638 строк).** Вербальный дубликат ~60 строк +(батч+retry: `:151-216` ↔ `:531-596`) → делегирование; +`uploadNewSamples` (:239-243) — deprecated-обёртка без вызывающих → удалить. + +**Сквозное дублирование между сервисами:** ignored-prefix матчинг +(`location_service.dart:1596-1610` ≈ `lora_companion_service.dart:435-445`); +ducting-risk «получить+unknown→null» ×3; префикс pubkey +`substring(0,8).toUpperCase()` вручную дублируют lora (:1297, :1326, :1381) и +location (:1589-1592, :1639-1641), хотя канонизирован в +`AggregationService.repeaterLookupKey` (`aggregation_service.dart:16-21`); +`achievement_service.dart:127` хардкодит geohash-прецизию 6 мимо +`GeohashUtils.coverageKey` — при смене дефолта (:260) достижения разъедутся +с картой. + +**Оставить как есть:** `aggregation_service.dart` (чистые статические +функции; `buildIndexes` ~195 строк — связный алгоритм весов, не дробить); +`map_lod_service`, `radio_position_estimator`, `location_quality_filter`, +`bad_fix_monitor`, `wifi_location_service`, `database_backup_service` +(образцовый restore с откатом :164-203), `sound_service`, `screen_wake_service` +и остальные мелкие — чистые, с DI/фейками; `ReconnectBackoff` + +`_serializeConnect` — тонкий, но работающий механизм; генерации стрима +позиций (`location :539-604`) — корректный паттерн против гонок. + +Итоговый порядок работ по сервисам (выигрыш/риск): 1) утечка battery-подписки ++ dispose-guard'ы; 2) `_pendingContactRequests` и гонка скана; 3) пароль → +secure storage + дыры экспорта; 4) импорт БД в транзакции; 5) дедупликация +upload и connect-хвостов; 6) фазное разбиение lora/location — только после +пунктов 1-5, каждая фаза отдельным коммитом. + +### 5.3. UI-слой и структура каталогов + +Раскладка: `screens/` — 60 файлов (`map/` — 28, `settings/` — 20, плоско — +10 + `map_screen.dart`), `services/` — 30, `utils/` — 15, `widgets/` — 4, +`models/` — 3 (см. также §3.5 про виджеты в utils). + +**Вердикты по реструктуризации каталогов:** + +- Плоские экраны → подкаталоги: **не стоит**. У каждого плоского экрана + 1–2 сайта импорта (`analytics/achievements/device_comparison/debug_*` — + только `map_screen.dart:80-94`; `session_history` — ещё и + `test/session/session_history_hint_test.dart:3`; `repeater_health`/ + `signal_trend` — `diagnostics_section.dart:5-6`; `ducting_forecast` — + `location_section.dart:6`). Перенос создаст 9 подкаталогов по одному + файлу — против духа AGENTS.md. Исключение: `lib/screens/analytics/` + возникнет естественно, если резать `analytics_screen.dart` (ниже). +- `lib/models/models.dart` (баррел на 6 типов: `Sample`, `Coverage`, + `Repeater`, `Edge`, `WSession`, `NodeData`): делить **можно, но низкий + приоритет** — 31 импорт через баррел не сломается, типы маленькие и + связные. +- `lib/widgets/`: все 4 файла на своих местах; экранно-локальные виджеты + корректно лежат в `screens/*/widgets/`. + +**Крупные экраны:** + +- `lib/screens/analytics_screen.dart` (1735 строк) = shell (33-89) + 5 + самодостаточных табов: `_CoverageScoreTab` (95-304), `_TimeOfDayTab` + (310-551), `_CoverageGoalTab` (557-990), `_CoverageComparisonTab` + (996-1408), `_RepeaterReliabilityTab` (1414-1735). Чистые расчёты + (score 106-183, goal 777-874, session diff 1028-1122, repeater stats + 1644-1712) можно вынести в тестируемый модуль; полный вариант — + `lib/screens/analytics/` с файлом на таб. +- `lib/screens/repeater_health_screen.dart` (969 строк): почти полностью + дублирует repeater-статистику analytics с уже разъехавшимися порогами + тренда (analytics 7d/prev-7d ±0.1, `analytics_screen.dart:1690-1694` vs + health 7d/30d −0.15, `repeater_health_screen.dart:249-254` — вероятно, + намеренно; сохранить как параметры). `_RepeaterDetailScreen` (448-931, + ~484 строки) — кандидат на собственный файл. +- `lib/screens/settings/settings_page.dart`: `sections/` использован полно + (13 секций вынесены), но внутри остались `_buildSettingsCategories` + (103-591, ~489 строк, 13 категорий в одном методе — режется на 5 + групп-методов по `_SettingsOverviewGroupId`, enum на строке 8), + `_setMapDisplaySetting` (649-728, два параллельных switch на 16 значений — + сворачивается в таблицу) и близнецы-подтверждения 846-918. part-связка — + см. §3.1. + +**Главные дубли UI-кода:** + +| Что | Где | +| --- | --- | +| `_miniStat` байт-в-байт ×3 | `analytics_screen.dart:1630-1642`, `repeater_health_screen.dart:403-419`, `device_comparison_screen.dart:445-457` | +| Группировка+статистика повторов ×2 (и два класса `_RepeaterStats`) | `analytics_screen.dart:1430-1712` ↔ `repeater_health_screen.dart:197-285`, 937-969 | +| Sort-бар с одинаковыми строковыми литералами | `analytics_screen.dart:1471-1514` ↔ `repeater_health_screen.dart:115-168` | +| Порог цвета 0.7/0.3 — 8 мест | `analytics_screen.dart:453-459, 507-513, 698-704, 1533-1540`; `repeater_health_screen.dart:306-310, 520-524, 805-809, 850-856` | +| displayId `substring(0,8).toUpperCase()` ×3 | `analytics_screen.dart:1529-1531`, `repeater_health_screen.dart:302-304, 456-458` | +| Периоды суток (0-6-12-18) ×2 | `analytics_screen.dart:357-362, 541-550` ↔ `repeater_health_screen.dart:824-837` | +| Confirm-диалог «Cancel + красная» — 8+ копий | `settings_page.dart:851-872, 888-909`, `session_history_screen.dart:82-101`, `statistics_section.dart:67-83`, `map_screen.dart:1673, 1687, 2537`, …; правильный приём уже есть в `map_workflow_dialogs.dart:20-48` | +| Константа 1609.34 (метры→мили) — 11 мест | `analytics_screen.dart:878, 892, 1204, 1207`; `session_history_screen.dart:75`; `settings_dialogs.dart:20`; `statistics_section.dart:47, 201`; `location_service.dart:243, 974`; приватная `_metersPerMile` уже есть в `achievement_service.dart:60` | + +Решения: общий чистый модуль repeater-статистики (например +`lib/utils/repeater_stats.dart`) — −150…−200 строк и тестируемость без +виджетов (риск средний: сверить пороги тренда); общий +`lib/widgets/confirm_dialog.dart` (title, message, confirmLabel, +destructive) — −120…−150 строк, единый UX (риск низкий); одна публичная +константа метров в миле. + +Длинные build-методы (кроме map_screen, см. §5.1): +`_buildSettingsCategories` (~489), `_TimeOfDayTab.build` (~205), +`ducting_forecast_screen.dart` build 190-374 (~185), +`_CoverageScoreTab.build` (~188), `debug_diagnostics_screen.dart` build +166-334 (~169), `session_history_screen.dart` `_buildSessionCard` (~162), +`repeater_health` State.build (~162). Дополнительно: analytics и +ducting_forecast считают агрегаты прямо в build (freshness-цикл 132-144, +группировка 326-348, goal-grid 794-807) — работа на каждом кадре таба; +вынос в чистые классы снимает и это. + +Мелкий мусор UI-слоя: устаревшее дерево проекта в `README.md:90-115` +(нет `screens/map/`, `screens/settings/`, `widgets/`, `l10n/`; см. §5.5); +чужой l10n-ключ `l10n.analyticsPingsCount` в health-экране +(`repeater_health_screen.dart:756, 847`); `this.context` в +extension-методах (`settings_page.dart:603, 619` — всплывёт при съёме +part-связки); строковые литералы сортировки вместо enum +(`analytics_screen.dart:1424`, `repeater_health_screen.dart:21`); +`dynamic` в `_compRow`/`fmt` (`device_comparison_screen.dart:366-386`); +хардкод precision 6 (`repeater_health_screen.dart:262`); inline-диалог +выбора радиуса цели ~88 строк (`analytics_screen.dart:883-970`). + +### 5.4. Тестовый набор + +Объём: 66 dart-файлов = 65 тестов + 1 хелпер (`test/helpers/l10n_harness.dart`). +22 теста лежат в корне `test/`, 43 разложены по 17 подкаталогам. + +**Приоритет 1 — переносы по конвенции** (package-импорты не меняются; +реальные правки — 5 файлов): + +| Файл в корне `test/` | Целевой каталог | Правки | +| --- | --- | --- | +| `geohash_utils_test.dart` | `test/geohash/` (новый) | — | +| `internet_connectivity_service_test.dart` | `test/internet_connectivity/` (новый) | — | +| `screen_wake_service_test.dart` | `test/screen_wake/` (новый) | — | +| `heading_utils_test.dart` | `test/heading/` (новый) | — | +| `wifi_location_service_test.dart` | `test/wifi_location/` (новый) | — | +| `location_quality_filter_test.dart` | `test/location_quality/` (новый) | — | +| `discovery_timeout_options_test.dart` | `test/discovery/` (новый) | — | +| `initial_map_camera_test.dart` | `test/map/` (существующий) | — | +| `impossible_zone_test.dart` | `test/impossible_zone/` (новый) | — | +| `app_locale_test.dart` | `test/l10n/` (новый) | — | +| `bad_fix_monitor_test.dart` | `test/location_quality/` (рядом с фильтром) | — | +| `lora_reconnect_test.dart` | `test/lora/` (новый) | — | +| `sample_export_test.dart` | `test/export/` (новый) | — | +| `snr_quarter_db_test.dart` | `test/snr/` (новый); **не** в защищённый `test/meshcore/` | — | +| `repeater_contacts_test.dart` | `test/repeater/` (новый) | — | +| `android_tracking_settings_service_test.dart` | `test/tracking/` (новый) | — | +| `aggregation_service_test.dart` | `test/aggregation/` (новый) | — | +| `version_tool_test.dart` | `test/tool/` (новый) | путь `../tool/` → `../../tool/` | +| `tracking_play_button_test.dart` | `test/tracking/` | путь helpers → `../helpers/` | +| `appearance_dialogs_test.dart` | `test/map/` | путь helpers → `../helpers/` | +| `connection_dialogs_test.dart` | `test/map/` | путь helpers → `../helpers/` | +| `marker_dialogs_test.dart` | `test/map/` | путь helpers → `../helpers/` | + +После переносов в корне `test/` останется только `helpers/`. + +**Приоритет 2 — покрытие.** Сервисы без собственных тестов +(сопоставление по grep импортов): + +| Модуль | Статус | Покрывать в первую очередь | +| --- | --- | --- | +| `location_service.dart` (1517) | 0 тестов | start/stopTracking (:664, :1432); пауза пингов по bad-fix (:343); режимы пинга time/distance (:883, :922); battery saver (:1258-1269); Wi-Fi fallback (:372, :1203); dead-zone алерты (:1279). Границы geolocator/foreground-task не изолированы фейками — требование AGENTS.md не выполняется | +| `lora_companion_service.dart` (1779) | частично | `lora_reconnect_test.dart` покрывает только чистые хелперы (:26-118). Не покрыто: state machine `ping()` (:1034), маршрутизация кадров `_handleFrame` (:1193), `_failPendingPings` (:1566), auto-reconnect (:1805), слияние контактов (:924). Ответы радио скармливаются байтами — протокол уже отделён | +| `database_service.dart` (873) | 0 тестов | миграция `_onUpgrade` (:172) — самый рискованный метод; `insertSamples` (:323); отметки выгрузки по эндпоинтам (:404, :423); round-trip export/import (:493, :513); приватные зоны (:895, :909). Фейабельно через `sqflite_common_ffi` | +| `carpeater_service.dart` (446) | 0 unit-тестов | цикл обнаружения (:313), состояние при сбоях, логин (:206) | +| `ducting_service.dart` (240) | 0 тестов | `fetchAndCache` (:61), risk-геттеры — http-граница фейабельна | +| `tile_download_service.dart`, `widget_service.dart`, `debug_log_service.dart`, `persistent_debug_logger.dart` | 0 тестов | download/cancel; сериализация данных виджета; кольцевой буфер; запись в файл | + +Тонкое покрытие: `upload_service_test.dart` — 44 строки на сервис в 546 +строк. Экраны без тестов: `map_screen.dart` (включая part-файл настроек), +`analytics_screen.dart`, `repeater_health_screen.dart` и ещё 6. Стратегия — +не «покрыть экраны целиком», а сначала вынести их бизнес-логику в +сервисы/контроллеры (см. §5.1, §5.3) и покрыть логику. Топ-3 по ценности: +`location_service`, `database_service`, маршрутизация ответов +`lora_companion_service`. + +**Приоритет 3 — качество крупных тестов.** + +- `settings_service_test.dart` (434): 16 групп, в каждой дублируется + `setUp(() { SharedPreferences.setMockInitialValues({}); })` — 16 копий + (например :10-12, :49-51, …, :534-536) → достаточно одного общего setUp. + Паттерн «default → persist → входит в exportSettings» повторён для ~8 фич — + кандидат на параметризованный хелпер в `test/helpers/`. Разбиение на файлы + сейчас не обязательно; швы, если делить: location quality (:392-494), + map-настройки (:91-247). +- `marker_dialogs_test.dart` (278): каркас + Scaffold→Builder→TextButton('Open')→showDialog повторён 8 раз (:13-28 … + :277-295) → хелпер `pumpDialog(tester, …)` в `test/helpers/`, он же + сократит appearance/connection dialogs тесты. +- `settings_sections_test.dart` (385): локальные хелперы уже хорошие; мелкий + риск — мутабельная глобальная `currentSettings` (:372). +- `map_screen_controller_test.dart` (367): эталон — не трогать; если + `FakeMapDataStore`/фабрика `_sample` понадобятся другим map-тестам, + поднять в `test/helpers/`. + +**Мелкий мусор:** `wifi_location_service_test.dart` не мёртвый (сервис +существует и используется `location_service.dart:28, :45`); +`version_tool_test.dart` живой и единственный с импортом вне `test/` — не +удалять; `test/helpers/` содержит единственный файл — после переносов +корень `test/` станет чистым. + +**Защищённая зона `test/meshcore/`** (наблюдения; правки не предлагались): +contract-файл — образцовый спецификационный набор (документированные +векторы :7-13, полные реестры команд/ответов :17-280, golden-фреймы +:286-382, тест ресинхронизации на каждом разрезе потока :400-432). +Между двумя файлами есть перекрытие сценариев и продублированный +`_writeUint32LE` (contract:626-631 ↔ protocol_test:465-470) — по комментарию +contract:7-8 пересечение намеренное («specification tests»), но любая смена +раскладки байт потребует синхронной правки обоих файлов. SNR-квартование +покрыто трижды (contract:550-575, protocol_test:194-205 и внешний +`snr_quarter_db_test.dart`). + +### 5.5. Гигиена репозитория + +**Стоит поправить:** + +- `docs/guides/lora-companion.md:115-153` — разделы «Customization» + описывают удалённую MQTT-функциональность (`defaultMqttBroker`, + `ping $pingId`, «`location_service.dart:71`: `distanceFilter: 5`») — + ничего из этого в коде нет; в `lora_companion_service.dart:870` — + `// MQTT CONNECTION - REMOVED`, `:1907-1908` — no-op `disconnectMqtt()`. + Переписать под реальный код или пометить как описание апстрима. +- Неиспользуемые зависимости (проверено grep-ом импортов по всему репо): + `flutter_secure_storage` (`pubspec.yaml:67`), `pointycastle` (`:69`), + `cupertino_icons` (`:32`) — ноль dart-импортов. Детали — §3.8. +- `AndroidManifest.xml`: легаси `WRITE/READ_EXTERNAL_STORAGE` без + `maxSdkVersion` (:25-26), старые `BLUETOOTH(_ADMIN)` без + `maxSdkVersion="30"` (:29-30), `usb.host` без `required="false"` (:35) — + устройства без USB-хоста (но с BLE) отфильтровываются при установке. + Детали — §3.9. +- `docs/README.md:28` — «screenshot source files … `assets/screenshots/`», + реально они в `docs/assets/screenshots/`. +- `android/.gitignore` игнорирует `gradle-wrapper.jar`, `/gradlew`, + `/gradlew.bat`, поэтому совет AGENTS.md:70 (`gradlew.bat --stop`) не + сработает на свежем клоне до первой сборки — уточнить формулировку. + +**Мелкий мусор:** + +- `analysis_options.yaml:10-14` — excludes для ios/web/windows/macos/linux, + которых в репо нет и не будет (игнорируются `.gitignore:41-45`). +- `AGENTS.md:100-101` — «existing secure-storage abstraction» не существует + в коде (креды в `shared_preferences` через `SettingsService`); + формулировку поправить вместе с §3.7/§3.8. +- `docs/getting-started.md:29` — «grant Storage permissions» устарело: + приложение не запрашивает storage в рантайме. +- Остатки MQTT: no-op `disconnectMqtt()` (`lora_companion_service.dart:1907-1908`), + упоминание MQTT в докстринге `debug_log_service.dart:6`. +- Остальное чисто: TODO/FIXME/HACK/XXX в `lib/` и `test/` — ноль; граф docs + без битых ссылок (все 16 md линкуются из `docs/README.md`); CHANGELOG + `v1.0.44-x` ↔ pubspec `1.0.44-x+47` ↔ `app_version.dart` согласованы; + все остальные зависимости используются; `tool/version.dart` и + `tool/build_release.ps1` актуальны и документированы; `.gitignore` полный + (единственная дыра `tmp/` закрыта в этом аудите); `analysis_options.yaml` + без per-file игноров. + +**Позитив:** манифест-разрешения в целом обоснованы (location, wifi-state, +vibrate, battery-optimizations, wakelock/foreground — каждое имеет живого +потребителя в коде); widget-receiver соответствует `WardriveWidgetProvider.kt`; +`pubspec.lock` и `.metadata` затреканы правильно для приложения. + +## 6. Оставить как есть + +- Сгенерированные `lib/l10n/generated/app_localizations*.dart` отслеживаются в + git намеренно (см. AGENTS.md) — не «огромные файлы» в смысле рефакторинга. +- `.toolchain/` — репозиторный тулчейн, корректно игнорируется git'ом. +- Пины зависимостей с комментариями-причинами в `pubspec.yaml`. +- Байтовая логика `meshcore_protocol.dart` (resync, фрейминг, канальные + методы) — менять только при реальной необходимости, побайтово и под + защищёнными тестами + железом (детали в §5.2). +- Плоская раскладка одиночных экранов в `lib/screens/` и баррел + `lib/models/models.dart` — см. вердикты §5.3. +- Мелкие чистые сервисы (`map_lod_service`, `radio_position_estimator`, + `location_quality_filter`, `bad_fix_monitor`, `wifi_location_service`, + `database_backup_service`, `ducting_service`, `sound_service`, + `screen_wake_service`, `screenshot_service`, `android_tracking_settings_service`, + `internet_connectivity_service`, `widget_service`) — не трогать. +- `ReconnectBackoff` + `_serializeConnect`, генерации стрима позиций, + `aggregation_service.buildIndexes` — тонкая, но корректная механика + (детали в §5.2). +- `companionNodeName` в prefs и per-line flush персистентного логгера — + задокументированные осознанные компромиссы. + +## 7. Вопросы к владельцу репозитория + +1. `tmp/`: удалить содержимое (разовый анализ экспорта от 2026-08-25) или + оставить локально / перенести скрипты в `tool/`? Правка `.gitignore` + (`/tmp/`) уже сделана в этом аудите и безопасна в любом случае. +2. Защищённый набор `test/meshcore/`: правки не предлагались и не выполнялись + (только наблюдения); любые изменения файлов в нём потребуют отдельного + явного одобрения. +3. Чистка манифеста (§3.9) и переезд пароля в secure storage (§3.7) затрагивают + устройство/данные пользователей — делать ли их отдельной задачей с ручной + проверкой на железе (USB + BLE + Wi-Fi-скан на Android 13+)? +4. Удалять ли три неиспользуемые зависимости (§3.8) с попутной правкой + формулировки AGENTS.md про secure-storage? +5. Разбиение `map_screen.dart` и сервисов — больших механических переносов + без запроса не производилось; отчёт содержит готовые поэтапные планы + (§5.1, §5.2) — какие этапы запускать. From fe53ec560b4a141c3704dbd14d9e4555463311a3 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 01:38:31 +0300 Subject: [PATCH 02/33] Fix LoRa service leaks, races, and teardown determinism --- lib/services/lora_companion_service.dart | 215 +++++++++++++++-------- 1 file changed, 139 insertions(+), 76 deletions(-) diff --git a/lib/services/lora_companion_service.dart b/lib/services/lora_companion_service.dart index 18fa31f..28d4165 100644 --- a/lib/services/lora_companion_service.dart +++ b/lib/services/lora_companion_service.dart @@ -344,6 +344,9 @@ class LoRaCompanionService { final Map _repeaterContactCache = {}; // All known repeater contacts (from scan) Completer>? _scanCompleter; + // Timeout of the active repeater scan; cancelled when a newer scan starts + // or on dispose so a stale timer cannot complete a newer scan. + Timer? _scanTimeoutTimer; final Map _knownRepeaters = {}; // Map of repeater ID -> location from internet map // Advertised names of every repeater/room-server contact ever parsed @@ -694,10 +697,16 @@ class LoRaCompanionService { debugPrint('Battery level: $_batteryPercent%'); } - // Subscribe to battery updates if supported + // Subscribe to battery updates if supported. The + // subscription is stored in a field and cancelled by + // _stopBatteryMonitoring so reconnects cannot stack + // long-lived listeners. if (char.properties.notify) { await char.setNotifyValue(true); - char.lastValueStream.listen((value) { + _batteryNotifySubscription?.cancel(); + _batteryNotifySubscription = char.lastValueStream.listen(( + value, + ) { if (value.isNotEmpty) { _batteryPercent = value[0]; _batteryController.add(_batteryPercent); @@ -738,23 +747,7 @@ class LoRaCompanionService { } }); - // Enable BLE mode in protocol parser (unwrapped frames) - _protocol.setBLEMode(true); - _debugLog.logInfo('Protocol set to BLE mode (unwrapped frames)'); - - // Start periodic battery check if not already getting updates - _startBatteryMonitoring(); - - // Negotiate the protocol and identify the app. - await Future.delayed(const Duration(milliseconds: 500)); - await _sendProtocolHandshake(); - - // Load full contact list so repeaters appear on the map - await Future.delayed(const Duration(milliseconds: 150)); - await _requestAllContacts(); - - _notifyConnectionEstablished(); - return true; + return await _finishConnection(ble: true); } return false; @@ -812,7 +805,7 @@ class LoRaCompanionService { await _usbPort!.setDTR(true); await _usbPort!.setRTS(true); await _usbPort!.setPortParameters( - 115200, // Standard baud rate for Meshtastic + 115200, // Standard baud rate for MeshCore UsbPort.DATABITS_8, UsbPort.STOPBITS_1, UsbPort.PARITY_NONE, @@ -843,20 +836,7 @@ class LoRaCompanionService { 'Connected to LoRa device via USB (ID: $_connectedDeviceId)', ); - // Ensure USB mode in protocol parser (wrapped frames with '>') - _protocol.setBLEMode(false); - _debugLog.logInfo('Protocol set to USB mode (wrapped frames)'); - - // Negotiate the protocol and identify the app. - await Future.delayed(const Duration(milliseconds: 500)); - await _sendProtocolHandshake(); - - // Load full contact list so repeaters appear on the map - await Future.delayed(const Duration(milliseconds: 150)); - await _requestAllContacts(); - - _notifyConnectionEstablished(); - return true; + return await _finishConnection(ble: false); } catch (e) { debugPrint('USB connection error: $e'); return false; @@ -866,9 +846,33 @@ class LoRaCompanionService { } } - // ============================================================================ - // MQTT CONNECTION - REMOVED - // ============================================================================ + /// Post-connect sequence shared by both transports: select the frame + /// format, negotiate the protocol and identify the app, load the contact + /// list so repeaters appear on the map, then notify listeners. + Future _finishConnection({required bool ble}) async { + // BLE uses unwrapped frames; USB wraps frames with '>'. + _protocol.setBLEMode(ble); + _debugLog.logInfo( + ble + ? 'Protocol set to BLE mode (unwrapped frames)' + : 'Protocol set to USB mode (wrapped frames)', + ); + if (ble) { + // Start periodic battery check if not already getting updates + _startBatteryMonitoring(); + } + + // Negotiate the protocol and identify the app. + await Future.delayed(const Duration(milliseconds: 500)); + await _sendProtocolHandshake(); + + // Load full contact list so repeaters appear on the map + await Future.delayed(const Duration(milliseconds: 150)); + await _requestAllContacts(); + + _notifyConnectionEstablished(); + return true; + } // ============================================================================ // REPEATER SCANNING @@ -884,8 +888,16 @@ class LoRaCompanionService { try { _debugLog.logInfo('🔍 Loading repeater contacts from device...'); + // A newer scan supersedes an in-flight one: finish it with the data + // collected so far so its awaiter is not left hanging. + final previous = _scanCompleter; + if (previous != null) { + _finishRepeaterScan(previous); + } + _scanTimeoutTimer?.cancel(); _repeaterContactCache.clear(); - _scanCompleter = Completer>(); + final completer = Completer>(); + _scanCompleter = completer; // Request all contacts from device await _requestAllContacts(); @@ -893,27 +905,37 @@ class LoRaCompanionService { _debugLog.logInfo('Requested contact list'); debugPrint('📡 Loading repeater contacts...'); - // Wait for contacts to be loaded - Timer(Duration(seconds: timeoutSeconds), () { - if (_scanCompleter != null && !_scanCompleter!.isCompleted) { - _debugLog.logInfo( - '✅ Scan complete: Cached ${_repeaterContactCache.length} contact(s)', - ); - debugPrint( - '✅ Cached ${_repeaterContactCache.length} repeater contact(s)', - ); - _scanCompleter!.complete(List.from(_repeaterContactCache.values)); - _scanCompleter = null; - } + // Wait for contacts to be loaded. The timeout completes the local + // [completer] it captured, so a stale timer can never complete a newer + // scan with partial data. + _scanTimeoutTimer = Timer(Duration(seconds: timeoutSeconds), () { + _debugLog.logInfo( + '✅ Scan complete: Cached ${_repeaterContactCache.length} contact(s)', + ); + debugPrint( + '✅ Cached ${_repeaterContactCache.length} repeater contact(s)', + ); + _finishRepeaterScan(completer); }); - return await _scanCompleter!.future; + return await completer.future; } catch (e) { _debugLog.logError('Repeater scan error: $e'); return []; } } + /// Completes [completer] with the contacts collected so far and forgets it + /// when it is still the active scan. + void _finishRepeaterScan(Completer> completer) { + if (!completer.isCompleted) { + completer.complete(List.from(_repeaterContactCache.values)); + } + if (identical(_scanCompleter, completer)) { + _scanCompleter = null; + } + } + List get discoveredRepeaters => List.unmodifiable(_discoveredRepeaters); @@ -1095,7 +1117,6 @@ class LoRaCompanionService { _pingResultController.add(result); } }); - _startingPing = false; // Send zero-hop advertisement to get immediate contact updates final zeroHopPayload = Uint8List.fromList([0]); // 0 = zero-hop @@ -1638,6 +1659,9 @@ class LoRaCompanionService { Timer? _batteryMonitorTimer; BluetoothCharacteristic? _batteryCharacteristic; + // BLE battery notify listener, kept in a field so it can be cancelled when + // battery monitoring stops instead of leaking across reconnects. + StreamSubscription>? _batteryNotifySubscription; void _startBatteryMonitoring() { // Poll battery every 30 seconds if we have a battery characteristic @@ -1663,6 +1687,8 @@ class LoRaCompanionService { void _stopBatteryMonitoring() { _batteryMonitorTimer?.cancel(); _batteryMonitorTimer = null; + _batteryNotifySubscription?.cancel(); + _batteryNotifySubscription = null; _batteryCharacteristic = null; _batteryPercent = null; _batteryController.add(null); @@ -1681,23 +1707,7 @@ class LoRaCompanionService { if (_connectionType != ConnectionType.usb) return; debugPrint('⚠️ USB device disconnected'); _debugLog.logError('USB disconnected'); - - _stopBatteryMonitoring(); - _deviceSubscription?.cancel(); - _deviceSubscription = null; - - _usbPort = null; - _connectionType = ConnectionType.none; - _deviceName = null; - _forgetNodeAdvertName(); - - _failPendingPings('USB connection lost'); - - // Notify listeners of disconnect - _disconnectController.add(null); - - // The user did not ask for this: try to restore the connection. - _beginAutoReconnect(); + _handleLinkLost('USB connection lost'); } /// Handle unexpected Bluetooth disconnection @@ -1705,7 +1715,14 @@ class LoRaCompanionService { if (_connectionType != ConnectionType.bluetooth) return; debugPrint('⚠️ Bluetooth device disconnected unexpectedly'); _debugLog.logError('Bluetooth disconnected'); + _handleLinkLost('Bluetooth connection lost'); + } + /// Shared teardown after an unexpected USB/BLE link loss: stops battery + /// monitoring, cancels transport subscriptions, forgets device state and + /// in-flight contact requests, fails pending pings with [reason], and + /// engages the automatic reconnection loop. + void _handleLinkLost(String reason) { _stopBatteryMonitoring(); _connectionStateSubscription?.cancel(); _deviceSubscription?.cancel(); @@ -1715,11 +1732,13 @@ class LoRaCompanionService { _bluetoothDevice = null; _txCharacteristic = null; _rxCharacteristic = null; + _usbPort = null; _connectionType = ConnectionType.none; _deviceName = null; _forgetNodeAdvertName(); + _pendingContactRequests.clear(); - _failPendingPings('Bluetooth connection lost'); + _failPendingPings(reason); // Notify listeners of disconnect _disconnectController.add(null); @@ -1755,10 +1774,13 @@ class LoRaCompanionService { _forgetNodeAdvertName(); _connectionStateSubscription = null; _deviceSubscription = null; + _pendingContactRequests.clear(); debugPrint('LoRa device disconnected'); // Notify listeners of disconnect - _disconnectController.add(null); + if (!_disconnectController.isClosed) { + _disconnectController.add(null); + } } catch (e) { debugPrint('Error disconnecting device: $e'); } @@ -1904,10 +1926,6 @@ class LoRaCompanionService { ); } - Future disconnectMqtt() async { - // MQTT removed - no-op - } - // ============================================================================ // CARPEATER MODE - PUBLIC METHODS FOR REPEATER CONTROL // ============================================================================ @@ -1999,13 +2017,58 @@ class LoRaCompanionService { _carpeaterPayloadCallback = callback; } + /// Deterministic synchronous teardown. + /// + /// Subscriptions and timers are cancelled first so no callback can fire + /// into a closed controller, pending pings are failed with a 'disposed' + /// reason, and only then are the controllers closed. The device link is + /// dropped best-effort in the background. After [dispose] returns the + /// service is inert and must not throw. void dispose() { _stopAutoReconnect(); - disconnectDevice(); + _stopBatteryMonitoring(); + _scanTimeoutTimer?.cancel(); + _scanTimeoutTimer = null; + final pendingScan = _scanCompleter; + if (pendingScan != null) { + _finishRepeaterScan(pendingScan); + } + unawaited(_connectionStateSubscription?.cancel()); + unawaited(_deviceSubscription?.cancel()); + _connectionStateSubscription = null; + _deviceSubscription = null; + _failPendingPings('disposed'); + unawaited(_closeTransportLink()); + + _bluetoothDevice = null; + _txCharacteristic = null; + _rxCharacteristic = null; + _usbPort = null; + _connectionType = ConnectionType.none; + _deviceName = null; + _forgetNodeAdvertName(); + _pingResultController.close(); _batteryController.close(); _disconnectController.close(); _reconnectStateController.close(); _connectedController.close(); } + + /// Best-effort transport drop used by [dispose]. It only talks to the + /// plugin; every service subscription is already cancelled by the time the + /// futures settle, so no callback can reach a closed controller. + Future _closeTransportLink() async { + try { + final bluetoothDevice = _bluetoothDevice; + final usbPort = _usbPort; + if (bluetoothDevice != null) { + await bluetoothDevice.disconnect(); + } else if (usbPort != null) { + await usbPort.close(); + } + } catch (e) { + debugPrint('Error closing device link on dispose: $e'); + } + } } From 64d362de06f4993fcd708c92c39833f367c4cead Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 01:38:55 +0300 Subject: [PATCH 03/33] Make sample import atomic, dedupe queries, fix settings export gaps --- lib/services/database_service.dart | 218 +++++++++++++---------- lib/services/settings_service.dart | 267 ++++++++++++++++------------- lib/services/upload_service.dart | 32 ++-- 3 files changed, 285 insertions(+), 232 deletions(-) diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index 668f06a..0506dca 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -12,7 +12,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; class DatabaseService { - static Database? _database; + static Future? _databaseFuture; static const String _databaseName = 'meshcore_wardrive.db'; static const int _databaseVersion = 13; @@ -28,10 +28,19 @@ class DatabaseService { static const String tableImpossibleZones = 'impossible_zones'; static const String tableDevices = 'devices'; - Future get database async { - if (_database != null) return _database!; - _database = await _initDatabase(); - return _database!; + /// Lazily opened database. The opening future itself is memoized so that + /// concurrent first callers share a single [openDatabase] call instead of + /// racing to open the same file twice. + Future get database => _databaseFuture ??= _openDatabase(); + + Future _openDatabase() async { + try { + return await _initDatabase(); + } catch (_) { + // Do not cache a failed open so a later call can retry. + _databaseFuture = null; + rethrow; + } } Future _initDatabase() async { @@ -559,38 +568,48 @@ class DatabaseService { return {'samples': samplesImported, 'sessions': sessionsImported}; } - /// Import samples from JSON (skips duplicates by ID) + /// Import samples from JSON atomically (skips duplicates by ID). + /// + /// Rows are normalized up front, then applied inside a single transaction + /// via a batch: either the whole import lands or, on failure, nothing does. + /// [ConflictAlgorithm.ignore] drops rows whose primary key already exists. + /// The returned count is the row delta measured inside the transaction, + /// which is more reliable than the batch result. Future importSamples(List> jsonData) async { final db = await database; - int importedCount = 0; + // Validate/normalize before opening the transaction so a malformed row is + // skipped instead of aborting the whole import. + final samples = []; for (final json in jsonData) { try { - final sample = Sample.fromJson(json); - - // Check if sample with this ID already exists - final existing = await db.query( - tableSamples, - where: 'id = ?', - whereArgs: [sample.id], - limit: 1, - ); - - if (existing.isEmpty) { - await db.insert( - tableSamples, - sample.toMap(), - conflictAlgorithm: ConflictAlgorithm.ignore, - ); - importedCount++; - } + samples.add(Sample.fromJson(json)); } catch (e) { debugPrint('Error importing sample: $e'); // Skip invalid samples } } + if (samples.isEmpty) return 0; + + return db.transaction((txn) async { + Future countOf(String query) async => + Sqflite.firstIntValue(await txn.rawQuery(query)) ?? 0; - return importedCount; + final countBefore = await countOf('SELECT COUNT(*) FROM $tableSamples'); + + final batch = txn.batch(); + for (final sample in samples) { + batch.insert( + tableSamples, + sample.toMap(), + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + await batch.commit(noResult: true); + + final countAfter = await countOf('SELECT COUNT(*) FROM $tableSamples'); + return countAfter - countBefore; + }); } /// Create a new session, returns the session ID @@ -623,6 +642,9 @@ class DatabaseService { await db.delete(tableSessions, where: 'id = ?', whereArgs: [id]); } + /// Coerces a SQLite aggregate value (int, double, or null) to a non-null int. + static int _sqlInt(Object? value) => (value as num?)?.toInt() ?? 0; + /// Get sample counts for a session's time range Future> getSessionSampleCounts( DateTime start, @@ -632,23 +654,22 @@ class DatabaseService { final startMs = start.millisecondsSinceEpoch; final endMs = end.millisecondsSinceEpoch; - final totalResult = await db.rawQuery( - 'SELECT COUNT(*) FROM $tableSamples WHERE timestamp >= ? AND timestamp <= ?', - [startMs, endMs], - ); - final pingResult = await db.rawQuery( - 'SELECT COUNT(*) FROM $tableSamples WHERE timestamp >= ? AND timestamp <= ? AND pingSuccess IS NOT NULL', + // One aggregate scan instead of three COUNT queries. SUM over an empty + // set yields NULL, which [_sqlInt] maps to 0 exactly like the old COUNTs. + final row = (await db.rawQuery( + 'SELECT ' + 'COUNT(*) AS total, ' + 'SUM(CASE WHEN pingSuccess IS NOT NULL THEN 1 ELSE 0 END) AS pings, ' + 'SUM(CASE WHEN pingSuccess = 1 THEN 1 ELSE 0 END) AS successes ' + 'FROM $tableSamples ' + 'WHERE timestamp >= ? AND timestamp <= ?', [startMs, endMs], - ); - final successResult = await db.rawQuery( - 'SELECT COUNT(*) FROM $tableSamples WHERE timestamp >= ? AND timestamp <= ? AND pingSuccess = 1', - [startMs, endMs], - ); + )).first; return { - 'total': Sqflite.firstIntValue(totalResult) ?? 0, - 'pings': Sqflite.firstIntValue(pingResult) ?? 0, - 'successes': Sqflite.firstIntValue(successResult) ?? 0, + 'total': _sqlInt(row['total']), + 'pings': _sqlInt(row['pings']), + 'successes': _sqlInt(row['successes']), }; } @@ -748,54 +769,37 @@ class DatabaseService { /// Get per-device stats from samples tagged with device_id Future> getDeviceStats(String publicKey) async { final db = await database; - final total = - Sqflite.firstIntValue( - await db.rawQuery( - 'SELECT COUNT(*) FROM $tableSamples WHERE device_id = ? AND pingSuccess IS NOT NULL', - [publicKey], - ), - ) ?? - 0; - final successes = - Sqflite.firstIntValue( - await db.rawQuery( - 'SELECT COUNT(*) FROM $tableSamples WHERE device_id = ? AND pingSuccess = 1', - [publicKey], - ), - ) ?? - 0; - final failures = total - successes; - final cells = - Sqflite.firstIntValue( - await db.rawQuery( - 'SELECT COUNT(DISTINCT substr(geohash, 1, 6)) FROM $tableSamples WHERE device_id = ? AND pingSuccess IS NOT NULL', - [publicKey], - ), - ) ?? - 0; - - final avgResp = await db.rawQuery( - 'SELECT AVG(response_time_ms) as avg_resp FROM $tableSamples WHERE device_id = ? AND response_time_ms IS NOT NULL', - [publicKey], - ); - final avgResponseMs = (avgResp.first['avg_resp'] as num?)?.toDouble(); - final avgSignal = await db.rawQuery( - 'SELECT AVG(snr) as avg_snr, AVG(rssi) as avg_rssi FROM $tableSamples WHERE device_id = ? AND pingSuccess = 1', + // Single aggregate scan instead of five queries. Conditional CASE + // aggregates keep each metric's original filter, and AVG ignores NULL, + // so CASE branches without ELSE exclude rows exactly like the old WHEREs. + final row = (await db.rawQuery( + ''' + SELECT + SUM(CASE WHEN pingSuccess IS NOT NULL THEN 1 ELSE 0 END) AS total_pings, + SUM(CASE WHEN pingSuccess = 1 THEN 1 ELSE 0 END) AS successes, + COUNT(DISTINCT CASE WHEN pingSuccess IS NOT NULL THEN substr(geohash, 1, 6) END) AS unique_cells, + AVG(CASE WHEN response_time_ms IS NOT NULL THEN response_time_ms END) AS avg_resp, + AVG(CASE WHEN pingSuccess = 1 THEN snr END) AS avg_snr, + AVG(CASE WHEN pingSuccess = 1 THEN rssi END) AS avg_rssi + FROM $tableSamples + WHERE device_id = ? + ''', [publicKey], - ); - final avgSnr = (avgSignal.first['avg_snr'] as num?)?.toDouble(); - final avgRssi = (avgSignal.first['avg_rssi'] as num?)?.toDouble(); + )).first; + + final total = _sqlInt(row['total_pings']); + final successes = _sqlInt(row['successes']); return { 'totalPings': total, 'successes': successes, - 'failures': failures, + 'failures': total - successes, 'successRate': total > 0 ? successes / total : 0.0, - 'uniqueCells': cells, - 'avgResponseMs': avgResponseMs, - 'avgSnr': avgSnr, - 'avgRssi': avgRssi, + 'uniqueCells': _sqlInt(row['unique_cells']), + 'avgResponseMs': (row['avg_resp'] as num?)?.toDouble(), + 'avgSnr': (row['avg_snr'] as num?)?.toDouble(), + 'avgRssi': (row['avg_rssi'] as num?)?.toDouble(), }; } @@ -890,17 +894,41 @@ class DatabaseService { await db.delete(tablePrivacyZones, where: 'id = ?', whereArgs: [id]); } + /// Planar distance approximation in meters between a point and a zone + /// center (111320 meters per degree of latitude, longitude scaled by the + /// point's latitude). Good enough for the small radii of privacy zones. + static double _distanceToZoneMeters( + double lat, + double lon, + double zoneLat, + double zoneLon, + ) { + final dlat = (lat - zoneLat) * 111320; // meters per degree lat + final dlon = (lon - zoneLon) * 111320 * cos(lat * 3.14159 / 180); + return sqrt(dlat * dlat + dlon * dlon); + } + + /// Whether the point falls inside the given privacy-zone row. + static bool _isInsidePrivacyZone( + Map zone, + double lat, + double lon, + ) { + return _distanceToZoneMeters( + lat, + lon, + zone['lat'] as double, + zone['lon'] as double, + ) <= + (zone['radius_meters'] as double); + } + /// Check if a lat/lon point falls inside any privacy zone /// Uses haversine approximation (good enough for small radii) Future isInPrivacyZone(double lat, double lon) async { final zones = await getAllPrivacyZones(); for (final zone in zones) { - final dlat = - (lat - (zone['lat'] as double)) * 111320; // meters per degree lat - final dlon = - (lon - (zone['lon'] as double)) * 111320 * cos(lat * 3.14159 / 180); - final dist = sqrt(dlat * dlat + dlon * dlon); - if (dist <= (zone['radius_meters'] as double)) return true; + if (_isInsidePrivacyZone(zone, lat, lon)) return true; } return false; } @@ -912,13 +940,13 @@ class DatabaseService { return samples.where((s) { for (final zone in zones) { - final dlat = (s.position.latitude - (zone['lat'] as double)) * 111320; - final dlon = - (s.position.longitude - (zone['lon'] as double)) * - 111320 * - cos(s.position.latitude * 3.14159 / 180); - final dist = sqrt(dlat * dlat + dlon * dlon); - if (dist <= (zone['radius_meters'] as double)) return false; + if (_isInsidePrivacyZone( + zone, + s.position.latitude, + s.position.longitude, + )) { + return false; + } } return true; }).toList(); @@ -965,6 +993,6 @@ class DatabaseService { Future close() async { final db = await database; await db.close(); - _database = null; + _databaseFuture = null; } } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 5f7836a..77fae5a 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/app_locale.dart'; import '../models/location_quality_settings.dart'; import '../utils/bluetooth_scan.dart'; +import 'upload_service.dart'; enum CurrentLocationMarkerStyle { circle, arrow } @@ -90,101 +91,119 @@ class SettingsService { static const double maxSampleMarkerRadius = 16; static const double defaultSampleMarkerRadius = 10; + /// Resolved [SharedPreferences], kept after the first load. The loading + /// future itself is memoized so concurrent first callers do not each hit + /// the plugin. Instance-scoped (not static) on purpose: a fresh + /// [SettingsService] re-resolves whatever [SharedPreferences.getInstance] + /// currently returns, which keeps tests that swap mock initial values + /// between cases isolated. + SharedPreferences? _prefsInstance; + Future? _prefsFuture; + + Future get _prefs { + final instance = _prefsInstance; + if (instance != null) return Future.value(instance); + return _prefsFuture ??= SharedPreferences.getInstance().then((prefs) { + _prefsInstance = prefs; + return prefs; + }); + } + // Alert toggles Future getDeadZoneAlertsEnabled() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_deadZoneAlertsKey) ?? true; } Future setDeadZoneAlertsEnabled(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_deadZoneAlertsKey, value); } Future getNewRepeaterAlertsEnabled() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_newRepeaterAlertsKey) ?? true; } Future setNewRepeaterAlertsEnabled(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_newRepeaterAlertsKey, value); } Future getLinkLossAlertsEnabled() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_linkLossAlertsKey) ?? true; } Future setLinkLossAlertsEnabled(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_linkLossAlertsKey, value); } Future getBatterySaverEnabled() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_batterySaverEnabledKey) ?? true; } Future setBatterySaverEnabled(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_batterySaverEnabledKey, value); } Future getShowSamples() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showSamplesKey) ?? false; } Future setShowSamples(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showSamplesKey, value); } Future getShowGpsSamples() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showGpsSamplesKey) ?? true; } Future setShowGpsSamples(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showGpsSamplesKey, value); } Future getShowPrivacyZones() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showPrivacyZonesKey) ?? true; } Future setShowPrivacyZones(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showPrivacyZonesKey, value); } Future getShowGpsExclusionZones() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showGpsExclusionZonesKey) ?? false; } Future setShowGpsExclusionZones(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showGpsExclusionZonesKey, value); } /// Whether sample points use [getSampleMarkerRadius] instead of their /// automatic size, which varies with the number of grouped measurements. Future getFixedSampleMarkerSizeEnabled() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_fixedSampleMarkerSizeEnabledKey) ?? false; } Future setFixedSampleMarkerSizeEnabled(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_fixedSampleMarkerSizeEnabledKey, value); } Future getSampleMarkerRadius() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final value = prefs.getDouble(_sampleMarkerRadiusKey); if (value == null || !value.isFinite || @@ -201,77 +220,77 @@ class SettingsService { value > maxSampleMarkerRadius) { throw ArgumentError.value(value, 'value', 'Unsupported marker radius'); } - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setDouble(_sampleMarkerRadiusKey, value); } Future getShowCoverage() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showCoverageKey) ?? true; } Future setShowCoverage(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showCoverageKey, value); } Future getShowEdges() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showEdgesKey) ?? true; } Future setShowEdges(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showEdgesKey, value); } Future getShowRepeaters() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showRepeatersKey) ?? true; } Future setShowRepeaters(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showRepeatersKey, value); } Future getColorMode() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_colorModeKey) ?? 'quality'; } Future setColorMode(String value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(_colorModeKey, value); } Future getPingInterval() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getDouble(_pingIntervalKey) ?? 805.0; // Default 0.5 miles } Future setPingInterval(double value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setDouble(_pingIntervalKey, value); } Future getCoveragePrecision() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getInt(_coveragePrecisionKey) ?? 7; // ~150m coverage cells } Future setCoveragePrecision(int value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setInt(_coveragePrecisionKey, value); } Future getIgnoredRepeaterPrefix() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_ignoredRepeaterPrefixKey); } Future setIgnoredRepeaterPrefix(String? value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; if (value == null || value.isEmpty) { await prefs.remove(_ignoredRepeaterPrefixKey); } else { @@ -282,12 +301,12 @@ class SettingsService { /// Get comma-separated list of repeater prefixes to ONLY hear from (whitelist) /// Empty or null = hear from all repeaters Future getIncludeOnlyRepeaters() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_includeOnlyRepeatersKey); } Future setIncludeOnlyRepeaters(String? value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; if (value == null || value.isEmpty) { await prefs.remove(_includeOnlyRepeatersKey); } else { @@ -297,45 +316,45 @@ class SettingsService { /// Whether to filter edges (purple lines) by the Include Only Repeaters whitelist Future getFilterEdgesByWhitelist() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_filterEdgesByWhitelistKey) ?? false; } Future setFilterEdgesByWhitelist(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_filterEdgesByWhitelistKey, value); } /// Get distance unit ('miles' or 'km') Future getDistanceUnit() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_distanceUnitKey) ?? 'km'; } Future setDistanceUnit(String value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(_distanceUnitKey, value); } /// Get color blind mode ('normal', 'deuteranopia', 'protanopia', 'tritanopia') Future getColorBlindMode() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_colorBlindModeKey) ?? 'normal'; } Future setColorBlindMode(String value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(_colorBlindModeKey, value); } /// Get discovery timeout in seconds (5-30 seconds, default 10) Future getDiscoveryTimeout() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getInt(_discoveryTimeoutKey) ?? 10; } Future setDiscoveryTimeout(int value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setInt(_discoveryTimeoutKey, value); } @@ -344,43 +363,43 @@ class SettingsService { /// Disabled by default to preserve the fast mode, which completes the /// collection three seconds after the first response. Future getThoroughResponseCollection() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_thoroughResponseCollectionKey) ?? false; } Future setThoroughResponseCollection(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_thoroughResponseCollectionKey, value); } /// Get total distance driven across all sessions (in meters) Future getTotalDistanceDriven() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getDouble(_totalDistanceDrivenKey) ?? 0.0; } /// Add distance from a session to the persistent total Future addToTotalDistanceDriven(double meters) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final current = prefs.getDouble(_totalDistanceDrivenKey) ?? 0.0; await prefs.setDouble(_totalDistanceDrivenKey, current + meters); } /// Reset total distance driven Future resetTotalDistanceDriven() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setDouble(_totalDistanceDrivenKey, 0.0); } /// Get vehicle MPG (miles per gallon), null if not set Future getVehicleMpg() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getDouble(_vehicleMpgKey); } /// Set vehicle MPG Future setVehicleMpg(double? value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; if (value == null) { await prefs.remove(_vehicleMpgKey); } else { @@ -390,72 +409,72 @@ class SettingsService { /// Get gas price per gallon (default 3.50) Future getGasPrice() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getDouble(_gasPriceKey) ?? 3.50; } /// Set gas price per gallon Future setGasPrice(double value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setDouble(_gasPriceKey, value); } /// Get fuel unit ('imperial' or 'metric') Future getFuelUnit() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_fuelUnitKey) ?? 'metric'; } /// Set fuel unit ('imperial' or 'metric') Future setFuelUnit(String value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(_fuelUnitKey, value); } /// Get show route trail setting Future getShowRouteTrail() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showRouteTrailKey) ?? false; } /// Set show route trail setting Future setShowRouteTrail(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showRouteTrailKey, value); } /// Get show heatmap setting Future getShowHeatmap() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showHeatmapKey) ?? false; } /// Set show heatmap setting Future setShowHeatmap(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showHeatmapKey, value); } /// Get show prediction rings setting Future getShowPredictionRings() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showPredictionRingsKey) ?? false; } /// Set show prediction rings setting Future setShowPredictionRings(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showPredictionRingsKey, value); } /// Whether the already-computed radio position estimate is drawn on the map. Future getShowRadioPosition() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showRadioPositionKey) ?? true; } Future setShowRadioPosition(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showRadioPositionKey, value); } @@ -463,17 +482,17 @@ class SettingsService { /// Disabled by default because this sends radio observations to a third /// party and requires network access. Future getBeaconDbWifiPositioning() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_beaconDbWifiPositioningKey) ?? false; } Future setBeaconDbWifiPositioning(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_beaconDbWifiPositioningKey, value); } Future getLocationQualitySettings() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return LocationQualitySettings( maxHorizontalAccuracyMeters: _positiveOrDefault( prefs.getDouble(_maxHorizontalAccuracyMetersKey), @@ -531,7 +550,7 @@ class SettingsService { ); } - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setDouble( _maxHorizontalAccuracyMetersKey, settings.maxHorizontalAccuracyMeters, @@ -567,42 +586,42 @@ class SettingsService { /// Get show ducting monitor setting Future getShowDucting() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showDuctingKey) ?? false; } /// Set show ducting monitor setting Future setShowDucting(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showDuctingKey, value); } // Coverage goal settings Future getGoalCenterLat() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getDouble(_goalCenterLatKey); } Future getGoalCenterLon() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getDouble(_goalCenterLonKey); } Future getGoalRadiusMeters() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getDouble(_goalRadiusMetersKey) ?? 8047.0; // Default 5 miles } Future setGoal(double lat, double lon, double radiusMeters) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setDouble(_goalCenterLatKey, lat); await prefs.setDouble(_goalCenterLonKey, lon); await prefs.setDouble(_goalRadiusMetersKey, radiusMeters); } Future clearGoal() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.remove(_goalCenterLatKey); await prefs.remove(_goalCenterLonKey); await prefs.remove(_goalRadiusMetersKey); @@ -611,66 +630,66 @@ class SettingsService { // Ping mode: 'distance', 'time', or 'both' Future getPingMode() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_pingModeKey) ?? 'time'; } Future setPingMode(String value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(_pingModeKey, value); } Future getPingTimeInterval() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getInt(_pingTimeIntervalKey) ?? 30; } Future setPingTimeInterval(int value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setInt(_pingTimeIntervalKey, value); } // Sound feedback Future getSoundEnabled() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_soundEnabledKey) ?? true; } Future setSoundEnabled(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_soundEnabledKey, value); } Future getVibrationEnabled() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_vibrationEnabledKey) ?? true; } Future setVibrationEnabled(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_vibrationEnabledKey, value); } // Ping mode: Future getCarpeaterEnabled() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_carpeaterEnabledKey) ?? false; } Future setCarpeaterEnabled(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_carpeaterEnabledKey, value); } Future getCarpeaterRepeaterId() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_carpeaterRepeaterIdKey); } Future setCarpeaterRepeaterId(String? value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; if (value == null || value.isEmpty) { await prefs.remove(_carpeaterRepeaterIdKey); } else { @@ -679,12 +698,12 @@ class SettingsService { } Future getCarpeaterPassword() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_carpeaterPasswordKey); } Future setCarpeaterPassword(String? value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; if (value == null || value.isEmpty) { await prefs.remove(_carpeaterPasswordKey); } else { @@ -693,24 +712,24 @@ class SettingsService { } Future getCarpeaterInterval() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getInt(_carpeaterIntervalKey) ?? 30; } Future setCarpeaterInterval(int value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setInt(_carpeaterIntervalKey, value); } /// Get device/operator name for multi-device wardrive Future getDeviceName() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_deviceNameKey); } /// Set device/operator name Future setDeviceName(String? value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; if (value == null || value.isEmpty) { await prefs.remove(_deviceNameKey); } else { @@ -723,13 +742,13 @@ class SettingsService { /// it is refreshed by [LoRaCompanionService] on every self-info frame and is /// intentionally excluded from settings export/import. Future getCompanionNodeName() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getString(_companionNodeNameKey); } /// Set or clear the connected companion radio's MeshCore advert name. Future setCompanionNodeName(String? value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; if (value == null || value.isEmpty) { await prefs.remove(_companionNodeNameKey); } else { @@ -738,7 +757,7 @@ class SettingsService { } Future> getRecentBluetoothDevices() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final raw = prefs.getString(_recentBluetoothDevicesKey); if (raw == null || raw.isEmpty) return const []; try { @@ -768,7 +787,7 @@ class SettingsService { ...await getRecentBluetoothDevices(), ], ); - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString( _recentBluetoothDevicesKey, jsonEncode([ @@ -779,12 +798,12 @@ class SettingsService { } Future getShowSuccessfulOnly() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_showSuccessfulOnlyKey) ?? false; } Future setShowSuccessfulOnly(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_showSuccessfulOnlyKey, value); } @@ -792,62 +811,62 @@ class SettingsService { /// ignoring failed pings unless the success went stale (see /// [AggregationService.optimisticStalenessDays]). Future getOptimisticDisplay() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_optimisticDisplayKey) ?? false; } Future setOptimisticDisplay(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_optimisticDisplayKey, value); } Future getMapLodEnabled() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_mapLodEnabledKey) ?? true; } Future setMapLodEnabled(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_mapLodEnabledKey, value); } /// Whether every measurement inside one geohash cell collapses into a /// single sample marker regardless of zoom. Future getSampleGeohashGrouping() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_sampleGeohashGroupingKey) ?? false; } Future setSampleGeohashGrouping(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_sampleGeohashGroupingKey, value); } /// Get lock rotation north setting Future getLockRotationNorth() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_lockRotationKey) ?? false; } /// Set lock rotation north setting Future setLockRotationNorth(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_lockRotationKey, value); } /// Whether the display should stay awake while the app is open. Future getKeepScreenOn() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return prefs.getBool(_keepScreenOnKey) ?? false; } Future setKeepScreenOn(bool value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setBool(_keepScreenOnKey, value); } Future getCurrentLocationMarkerStyle() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final storedValue = prefs.getString(_currentLocationMarkerStyleKey); return CurrentLocationMarkerStyle.values.firstWhere( (style) => style.name == storedValue, @@ -858,20 +877,20 @@ class SettingsService { Future setCurrentLocationMarkerStyle( CurrentLocationMarkerStyle value, ) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(_currentLocationMarkerStyleKey, value.name); } /// Local quiet period for the compass calibration banner. Not exported. Future getCompassCalibrationQuietUntil() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final millis = prefs.getInt(_compassCalibrationQuietUntilKey); if (millis == null) return null; return DateTime.fromMillisecondsSinceEpoch(millis); } Future setCompassCalibrationQuietUntil(DateTime? value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; if (value == null) { await prefs.remove(_compassCalibrationQuietUntilKey); return; @@ -887,7 +906,7 @@ class SettingsService { /// Existing installations inherit their previous app-wide theme once. This /// keeps the map appearance stable while decoupling future interface changes. Future getMapThemeMode() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final storedValue = prefs.getString(_mapThemeModeKey); final legacyThemeValue = prefs.getString('theme_mode'); final value = storedValue ?? legacyThemeValue ?? MapThemeMode.system.name; @@ -903,17 +922,17 @@ class SettingsService { } Future setMapThemeMode(MapThemeMode value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(_mapThemeModeKey, value.name); } Future getAppLocalePreference() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; return AppLocale.parse(prefs.getString(_appLocaleKey)); } Future setAppLocalePreference(AppLocalePreference value) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(_appLocaleKey, AppLocale.persist(value)); } @@ -974,11 +993,13 @@ class SettingsService { _mapLodEnabledKey, _sampleGeohashGroupingKey, _linkLossAlertsKey, - // Upload service keys - 'upload_api_url', - 'auto_upload_enabled', - 'upload_endpoints', - 'selected_endpoints', + _deadZoneAlertsKey, + _newRepeaterAlertsKey, + // Upload service keys (constants owned by UploadService) + UploadService.apiUrlKey, + UploadService.autoUploadKey, + UploadService.uploadEndpointsKey, + UploadService.selectedEndpointsKey, // Theme 'theme_mode', _mapThemeModeKey, @@ -987,7 +1008,7 @@ class SettingsService { /// Export all settings to a JSON-encodable map Future> exportSettings() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final Map data = { '_format': 'meshcore_wardrive_settings', '_version': 1, @@ -1011,7 +1032,7 @@ class SettingsService { throw FormatException('Not a valid MeshCore Wardrive settings file'); } - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; int applied = 0; for (final key in _exportKeys) { diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart index b84eb98..da9e238 100644 --- a/lib/services/upload_service.dart +++ b/lib/services/upload_service.dart @@ -23,14 +23,18 @@ class UploadService { norm(url) == norm(defaultGlobalApiUrl); } - static const String _apiUrlKey = 'upload_api_url'; - static const String _autoUploadKey = 'auto_upload_enabled'; - static const String _lastUploadKey = 'last_upload_timestamp'; - static const String _uploadEndpointsKey = + /// Preference keys shared with [SettingsService] for settings export and + /// import. Keep the string values stable: existing installs persist them + /// verbatim, and the settings backup references these same constants. + static const String apiUrlKey = 'upload_api_url'; + static const String autoUploadKey = 'auto_upload_enabled'; + static const String uploadEndpointsKey = 'upload_endpoints'; // JSON list of endpoints - static const String _selectedEndpointsKey = + static const String selectedEndpointsKey = 'selected_endpoints'; // JSON list of selected endpoint names + static const String _lastUploadKey = 'last_upload_timestamp'; + static const String defaultEndpointName = 'Meshcoretel'; static const String defaultRuApiUrl = 'https://meshcoretel.ru/wardrive/samples'; @@ -86,22 +90,22 @@ class UploadService { Future getApiUrl() async { final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_apiUrlKey) ?? defaultApiUrl; + return prefs.getString(apiUrlKey) ?? defaultApiUrl; } Future setApiUrl(String url) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_apiUrlKey, url); + await prefs.setString(apiUrlKey, url); } Future isAutoUploadEnabled() async { final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_autoUploadKey) ?? false; + return prefs.getBool(autoUploadKey) ?? false; } Future setAutoUploadEnabled(bool enabled) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_autoUploadKey, enabled); + await prefs.setBool(autoUploadKey, enabled); } Future getLastUploadTime() async { @@ -371,7 +375,7 @@ class UploadService { /// Get list of configured upload endpoints Future> getUploadEndpoints() async { final prefs = await SharedPreferences.getInstance(); - final json = prefs.getString(_uploadEndpointsKey); + final json = prefs.getString(uploadEndpointsKey); if (json == null || json.isEmpty) { // Return default endpoint @@ -388,13 +392,13 @@ class UploadService { Future setUploadEndpoints(List endpoints) async { final prefs = await SharedPreferences.getInstance(); final json = jsonEncode(endpoints.map((e) => e.toJson()).toList()); - await prefs.setString(_uploadEndpointsKey, json); + await prefs.setString(uploadEndpointsKey, json); } /// Get list of selected endpoint names (for multi-upload) Future> getSelectedEndpoints() async { final prefs = await SharedPreferences.getInstance(); - final json = prefs.getString(_selectedEndpointsKey); + final json = prefs.getString(selectedEndpointsKey); if (json == null || json.isEmpty) { return [defaultEndpointName]; @@ -405,7 +409,7 @@ class UploadService { // Preserve selection for installs that stored the old implicit name but // never saved a custom endpoint list. - if (!prefs.containsKey(_uploadEndpointsKey) && names.contains('Default')) { + if (!prefs.containsKey(uploadEndpointsKey) && names.contains('Default')) { return names .map((name) => name == 'Default' ? defaultEndpointName : name) .toList(); @@ -417,7 +421,7 @@ class UploadService { Future setSelectedEndpoints(List names) async { final prefs = await SharedPreferences.getInstance(); final json = jsonEncode(names); - await prefs.setString(_selectedEndpointsKey, json); + await prefs.setString(selectedEndpointsKey, json); } /// Upload to all selected endpoints From 591140421cdb9bd531c412fca457198cda759e42 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 01:40:34 +0300 Subject: [PATCH 04/33] Allow picking any file for database import on Android --- lib/screens/map_screen.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index aebd3fe..90ac5f8 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1455,10 +1455,11 @@ class _MapScreenState extends State { } try { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['db'], - ); + // FileType.custom with ['db'] is unusable on Android: '.db' has no MIME + // mapping there, so the picker greys the backup out and it cannot be + // selected. Accept any file instead and rely on validateBackupFile + // below to reject non-backup contents with a clear error. + final result = await FilePicker.platform.pickFiles(type: FileType.any); if (result == null || result.files.isEmpty) return; final backupPath = result.files.single.path; From 0f8f49d8c06cb75c88164aff399252a79519b4d0 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 01:44:52 +0300 Subject: [PATCH 05/33] Harden location and carpeater services against dispose races --- lib/services/carpeater_service.dart | 141 ++++++++---- lib/services/location_service.dart | 338 +++++++++++++++++----------- 2 files changed, 301 insertions(+), 178 deletions(-) diff --git a/lib/services/carpeater_service.dart b/lib/services/carpeater_service.dart index a978ae7..480a8e5 100644 --- a/lib/services/carpeater_service.dart +++ b/lib/services/carpeater_service.dart @@ -54,6 +54,17 @@ class CarpeaterService { // Response completers Completer?>? _loginCompleter; + + /// Ack completer for the CLI command currently in flight + /// ('discover.neighbors' / 'neighbor.remove '). + /// + /// WARNING: both commands share this field, and a RESP_CODE_SENT push + /// carries no command identity — a late ack from the previous command + /// could therefore ack the wrong wait. This does not fire today only + /// because the discovery cycle is strictly sequential and every attempt + /// clears the field as soon as it stops waiting (see [_sendAndAwaitPush], + /// which owns that clearing). Never await two commands on this field + /// concurrently — give each command its own completer field instead. Completer? _sentCompleter; Completer?>? _neighboursCompleter; @@ -271,6 +282,10 @@ class CarpeaterService { bool get _isStopped => _stopSignal == null || _stopSignal!.isCompleted; + /// A discovery cycle must stop publishing once [stop] was requested or + /// [dispose] already closed the stream controllers. + bool get _cycleAborted => _isStopped || _neighboursController.isClosed; + Future _runDiscoveryLoop() async { while (!_isStopped) { await _runDiscoveryCycle(); @@ -320,9 +335,11 @@ class CarpeaterService { // Without this, the repeater returns cached neighbours from wherever // it was last located, not what it can hear RIGHT NOW await _clearPreviousNeighbours(); + if (_cycleAborted) return; // Step 1: Trigger discovery — tell repeater to send zero-hop advert final advertOk = await _triggerRepeaterAdvert(); + if (_cycleAborted) return; if (!advertOk) { _debugLog.logError( 'Carpeater: Could not trigger advert — skipping cycle', @@ -333,7 +350,9 @@ class CarpeaterService { } // Notify listeners to snapshot GPS position - _discoveryStartedController.add(null); + if (!_discoveryStartedController.isClosed) { + _discoveryStartedController.add(null); + } // Step 2: Wait for responses — v1.14+ repeaters respond via zero-hop // adverts within 1-2s of LoRa airtime. 3s is plenty. @@ -345,22 +364,27 @@ class CarpeaterService { Future.delayed(const Duration(seconds: discoveryWaitSeconds)), if (_stopSignal != null) _stopSignal!.future, ]); - if (_stopSignal == null || _stopSignal!.isCompleted) return; + if (_cycleAborted) return; // Step 3: Fetch neighbours _setState(CarpeaterState.fetchingNeighbours); final neighbours = await _fetchNeighbours(); + if (_cycleAborted) return; if (neighbours != null && neighbours.isNotEmpty) { _lastNeighbours = neighbours; _lastDiscoveryTime = DateTime.now(); - _neighboursController.add(neighbours); + if (!_neighboursController.isClosed) { + _neighboursController.add(neighbours); + } _totalNeighboursFound += neighbours.length; _debugLog.logInfo('Carpeater: Found ${neighbours.length} neighbours'); _consecutiveFailures = 0; } else { _debugLog.logInfo('Carpeater: No neighbours found this cycle'); - _neighboursController.add([]); + if (!_neighboursController.isClosed) { + _neighboursController.add([]); + } } _cyclesCompleted++; @@ -372,6 +396,34 @@ class CarpeaterService { } } + /// Run one attempt of a repeater command: register [completer] as the + /// pending response via [setPending], send the command via [send], and + /// wait up to [timeout] for the radio push to complete it. + /// + /// Returns null when the command was not enqueued or no push arrived in + /// time. The pending-completer field is cleared on every exit path so a + /// late response can never complete a wait that has already been + /// abandoned. + Future _sendAndAwaitPush( + Completer completer, + void Function(Completer?) setPending, + Future Function() send, + Duration timeout, + ) async { + setPending(completer); + try { + final sent = await send(); + if (!sent) return null; + return await completer.future.timeout(timeout); + } on TimeoutException { + // No push arrived in time — same as the previous per-call + // `onTimeout: () => null/false` behaviour. + return null; + } finally { + setPending(null); + } + } + Future _triggerRepeaterAdvert() async { if (_targetRepeaterPubKeyBytes == null) return false; @@ -379,23 +431,18 @@ class CarpeaterService { for (int attempt = 1; attempt <= maxAttempts; attempt++) { if (attempt > 1) await Future.delayed(const Duration(seconds: 2)); try { - _sentCompleter = Completer(); - final enqueued = await _loraService.sendRepeaterCliCommand( - targetPubKey: _targetRepeaterPubKeyBytes!, - command: 'discover.neighbors', - ); - if (!enqueued) { - _sentCompleter = null; - continue; - } - final acked = await _sentCompleter!.future.timeout( + final acked = await _sendAndAwaitPush( + Completer(), + (completer) => _sentCompleter = completer, + () => _loraService.sendRepeaterCliCommand( + targetPubKey: _targetRepeaterPubKeyBytes!, + command: 'discover.neighbors', + ), const Duration(seconds: 5), - onTimeout: () => false, ); - _sentCompleter = null; - if (acked) return true; - } catch (e) { - _sentCompleter = null; + if (acked ?? false) return true; + } catch (_) { + // Drop the attempt and retry. } } return false; @@ -408,25 +455,20 @@ class CarpeaterService { for (int attempt = 1; attempt <= maxAttempts; attempt++) { if (attempt > 1) await Future.delayed(const Duration(seconds: 2)); try { - _neighboursCompleter = Completer?>(); - final sent = await _loraService.sendRepeaterGetNeighbours( - targetPubKey: _targetRepeaterPubKeyBytes!, - ); - if (!sent) { - _neighboursCompleter = null; - continue; - } - final response = await _neighboursCompleter!.future.timeout( + final response = await _sendAndAwaitPush?>( + Completer?>(), + (completer) => _neighboursCompleter = completer, + () => _loraService.sendRepeaterGetNeighbours( + targetPubKey: _targetRepeaterPubKeyBytes!, + ), const Duration(seconds: 8), - onTimeout: () => null, ); - _neighboursCompleter = null; if (response == null) continue; return (response['neighbours'] as List?) ?.cast>() ?? []; - } catch (e) { - _neighboursCompleter = null; + } catch (_) { + // Drop the attempt and retry. } } return null; @@ -439,23 +481,18 @@ class CarpeaterService { for (int attempt = 1; attempt <= maxAttempts; attempt++) { if (attempt > 1) await Future.delayed(const Duration(seconds: 3)); try { - _sentCompleter = Completer(); - final enqueued = await _loraService.sendRepeaterCliCommand( - targetPubKey: _targetRepeaterPubKeyBytes!, - command: 'neighbor.remove ', - ); - if (!enqueued) { - _sentCompleter = null; - continue; - } - final acked = await _sentCompleter!.future.timeout( + final acked = await _sendAndAwaitPush( + Completer(), + (completer) => _sentCompleter = completer, + () => _loraService.sendRepeaterCliCommand( + targetPubKey: _targetRepeaterPubKeyBytes!, + command: 'neighbor.remove ', + ), const Duration(seconds: 10), - onTimeout: () => false, ); - _sentCompleter = null; - if (acked) return true; - } catch (e) { - _sentCompleter = null; + if (acked ?? false) return true; + } catch (_) { + // Drop the attempt and retry. } } return false; @@ -498,11 +535,19 @@ class CarpeaterService { void _setState(CarpeaterState newState) { if (_state != newState) { _state = newState; - _stateController.add(newState); + // dispose() closes this controller right after stop(); late state + // transitions from an in-flight cycle must not add to it. + if (!_stateController.isClosed) { + _stateController.add(newState); + } } } void dispose() { + // stop() first: pending completers are resolved and the stop signal is + // raised so in-flight cycle code unwinds before the controllers close. + // Every controller add in this class checks isClosed immediately before + // adding, with no await in between — race-free on the single isolate. stop(); _neighboursController.close(); _stateController.close(); diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index 53f5d73..6e7bce4 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -96,6 +96,9 @@ class LocationService { Position? _lastFusedPosition; LocationPositionSource _activePositionSource = LocationPositionSource.fused; bool _isTracking = false; + // Set as the very first line of [dispose]; the guarded emit helpers below + // check it so in-flight async work never adds to a closed controller. + bool _disposed = false; bool _autoPingEnabled = false; bool _autoPingResumeOnReconnect = false; @@ -191,6 +194,60 @@ class LocationService { Stream get batterySaverStream => _batterySaverController.stream; bool get isBatterySaverActive => _batterySaverActive; + // Emit helpers: asynchronous work that is still in flight when [dispose] + // runs must never touch a closed controller, so every controller add in + // this class goes through one of these guarded wrappers. + + void _emitCurrentPosition(LatLng latLng) { + if (_disposed) return; + _currentPositionController.add(latLng); + } + + void _emitPositionSource(LocationPositionSource source) { + if (_disposed) return; + _positionSourceController.add(source); + } + + void _emitCourse(double headingDegrees) { + if (_disposed) return; + _courseController.add(headingDegrees); + } + + void _emitSampleSaved() { + if (_disposed) return; + _sampleSavedController.add(null); + } + + void _emitPingEvent(String event) { + if (_disposed) return; + _pingEventController.add(event); + } + + void _emitPingPause(bool paused) { + if (_disposed) return; + _pingPauseController.add(paused); + } + + void _emitTotalDistance(double meters) { + if (_disposed) return; + _totalDistanceController.add(meters); + } + + void _emitSpeed(double speedMps) { + if (_disposed) return; + _speedController.add(speedMps); + } + + void _emitDeadZone(String cellHash) { + if (_disposed) return; + _deadZoneController.add(cellHash); + } + + void _emitBatterySaver(bool active) { + if (_disposed) return; + _batterySaverController.add(active); + } + // Ducting monitoring bool _ductingEnabled = false; Timer? _ductingFetchTimer; @@ -344,7 +401,7 @@ class LocationService { final paused = _pingPauseEnabled && _badFixMonitor.isPaused; if (paused == _pingPauseActive) return; _pingPauseActive = paused; - _pingPauseController.add(paused); + _emitPingPause(paused); if (paused) { unawaited( _logger.logPingEvent( @@ -738,7 +795,7 @@ class LocationService { _lastRecordedPosition = null; _lastPingPosition = null; _lastPingTimestamp = null; - _totalDistanceController.add(_totalDistanceMeters); + _emitTotalDistance(_totalDistanceMeters); _sessionStartTime = DateTime.now(); _sessionPingCount = 0; _sessionSuccessCount = 0; @@ -911,7 +968,9 @@ class LocationService { _timePingTimer = Timer.periodic( Duration(seconds: _pingTimeIntervalSeconds), - (_) => _handleTimePing(), + (_) { + unawaited(_handleTimePing()); + }, ); _logger.logPingEvent( 'Time-based ping timer started (${_pingTimeIntervalSeconds}s)', @@ -919,7 +978,7 @@ class LocationService { } /// Handle a time-triggered ping - void _handleTimePing() async { + Future _handleTimePing() async { if (!_autoPingEnabled || _carpeaterModeEnabled || _pingInProgress || @@ -946,6 +1005,22 @@ class LocationService { } } + try { + await _triggerPing(position, 'Time-based'); + } catch (e) { + // A failed trigger must not leave the single-ping guard stuck on. + _pingInProgress = false; + await _logger.logError('Auto Ping', e.toString()); + debugPrint('Error triggering time-based ping: $e'); + } + } + + /// Shared tail of the time- and distance-triggered ping paths: claim the + /// single in-flight ping slot, announce the trigger via event, sound and + /// foreground notification, then hand off to the background ping without + /// waiting for it. + Future _triggerPing(LatLng position, String trigger) async { + // Keep a single radio ping active so its response window has one owner. _pingInProgress = true; _lastPingPosition = position; _lastRecordedPosition = position; @@ -956,15 +1031,18 @@ class LocationService { position.longitude, ); await _logger.logPingEvent( - 'Time-based ping triggered at ${position.latitude}, ${position.longitude}', + '$trigger ping triggered at ${position.latitude}, ${position.longitude}', ); - _pingEventController.add('pinging'); + // Notify UI that ping is starting + _emitPingEvent('pinging'); _soundService.playPingSent(); + // Update foreground notification await _setNotificationText((l10n) => l10n.notificationPinging); - _performPingInBackground(position, geohash); + // Start ping in background - don't wait for it + unawaited(_performPingInBackground(position, geohash)); } /// Get total distance traveled in meters @@ -1041,11 +1119,11 @@ class LocationService { // Update speed (filter out invalid negative values) _currentSpeedMps = (position.speed >= 0) ? position.speed : 0.0; - _speedController.add(_currentSpeedMps); + _emitSpeed(_currentSpeedMps); if (_currentSpeedMps >= 0.5 && position.heading.isFinite && position.heading >= 0) { - _courseController.add(position.heading % 360); + _emitCourse(position.heading % 360); } // Calculate distance at the same five-metre granularity previously @@ -1061,7 +1139,7 @@ class LocationService { if (distanceMeters >= _minimumRecordedMovementMeters) { _totalDistanceMeters += distanceMeters; _lastDistancePosition = latLng; - _totalDistanceController.add(_totalDistanceMeters); + _emitTotalDistance(_totalDistanceMeters); } } else if (_isTracking) { _lastDistancePosition = latLng; @@ -1069,7 +1147,7 @@ class LocationService { _lastPosition = latLng; // Broadcast current position to listeners - _currentPositionController.add(latLng); + _emitCurrentPosition(latLng); // Outside an active wardrive session we still keep the current-position // marker fresh, but do not calculate trip distance or persist samples. @@ -1116,31 +1194,11 @@ class LocationService { } if (shouldPing) { - // Keep a single radio ping active so its response window has one owner. - _pingInProgress = true; - _lastPingPosition = latLng; - _lastRecordedPosition = latLng; - _lastPingTimestamp = DateTime.now(); - await _logger.logPingEvent( - 'Distance-based ping triggered at ${latLng.latitude}, ${latLng.longitude}', - ); - - // Notify UI that ping is starting - _pingEventController.add('pinging'); - _soundService.playPingSent(); - - // Update foreground notification - await _setNotificationText((l10n) => l10n.notificationPinging); - // Start ping in background - don't wait for it debugPrint( 'Triggering auto-ping via LoRa at ${latLng.latitude}, ${latLng.longitude}', ); - final geohash = GeohashUtils.sampleKey( - position.latitude, - position.longitude, - ); - _performPingInBackground(latLng, geohash); + unawaited(_triggerPing(latLng, 'Distance-based')); return; // Don't save GPS sample when auto-pinging - wait for ping result } } @@ -1159,7 +1217,7 @@ class LocationService { _lastRecordedPosition = latLng; // Dead zone alert: check if current coverage cell is a known dead zone - _checkDeadZone(latLng); + unawaited(_checkDeadZone(latLng)); // Create sample final geohash = GeohashUtils.sampleKey( @@ -1194,7 +1252,7 @@ class LocationService { 'Saved GPS sample: ${sample.id} at ${latLng.latitude}, ${latLng.longitude}', ); // Notify listeners that a sample was saved - _sampleSavedController.add(null); + _emitSampleSaved(); } catch (e) { debugPrint('Error saving sample: $e'); } @@ -1214,7 +1272,7 @@ class LocationService { _lastDistancePosition = null; _lastRecordedPosition = null; _lastPingPosition = null; - _positionSourceController.add(source); + _emitPositionSource(source); _logger.logLocationEvent('Active position source: ${source.name}'); } @@ -1260,7 +1318,7 @@ class LocationService { _batterySaverActive = true; _normalPingInterval = _pingIntervalMeters; _pingIntervalMeters = _normalPingInterval * 2; - _batterySaverController.add(true); + _emitBatterySaver(true); _logger.logPowerEvent( 'Battery saver ON — ping interval doubled to ${_pingIntervalMeters.toStringAsFixed(0)}m', ); @@ -1269,14 +1327,14 @@ class LocationService { void _deactivateBatterySaver() { _batterySaverActive = false; _pingIntervalMeters = _normalPingInterval; - _batterySaverController.add(false); + _emitBatterySaver(false); _logger.logPowerEvent( 'Battery saver OFF — ping interval restored to ${_pingIntervalMeters.toStringAsFixed(0)}m', ); } /// Check if the current position is in a known dead zone and alert once per cell - void _checkDeadZone(LatLng latLng) async { + Future _checkDeadZone(LatLng latLng) async { if (!_deadZoneAlertsEnabled) return; try { final precision = await _settings.getCoveragePrecision(); @@ -1290,7 +1348,7 @@ class LocationService { final isDead = await _dbService.isDeadZoneCell(cellHash); if (isDead) { _deadZoneAlertedCells.add(cellHash); - _deadZoneController.add(cellHash); + _emitDeadZone(cellHash); _soundService.playPingFailed(); await _logger.logPingEvent('Dead zone alert: cell $cellHash'); } @@ -1300,7 +1358,7 @@ class LocationService { } /// Perform ping in background and update sample when complete - void _performPingInBackground(LatLng latLng, String geohash) async { + Future _performPingInBackground(LatLng latLng, String geohash) async { try { // Get user-configured discovery timeout final timeoutSeconds = await _settings.getDiscoveryTimeout(); @@ -1358,7 +1416,7 @@ class LocationService { if (pingSuccess) _sessionSuccessCount++; // Notify UI - _pingEventController.add(pingSuccess ? 'success' : 'failed'); + _emitPingEvent(pingSuccess ? 'success' : 'failed'); // Update notification with live stats Future.delayed(const Duration(seconds: 3), () { @@ -1404,7 +1462,7 @@ class LocationService { await _dbService.insertSample(sample); } // Notify listeners - _sampleSavedController.add(null); + _emitSampleSaved(); } catch (e) { await _logger.logError('Background Ping', e.toString()); debugPrint('Error during background ping: $e'); @@ -1422,7 +1480,7 @@ class LocationService { ); await _dbService.insertSample(sample); // Notify listeners - _sampleSavedController.add(null); + _emitSampleSaved(); } finally { _pingInProgress = false; } @@ -1541,13 +1599,15 @@ class LocationService { .discoveryStartedStream .listen((_) { _carpeaterDiscoveryPosition = _lastPosition; - _pingEventController.add('pinging'); + _emitPingEvent('pinging'); _soundService.playPingSent(); }); // Subscribe to neighbour results _carpeaterNeighboursSubscription = _carpeaterService.neighboursStream - .listen(_onCarpeaterNeighbours); + .listen((neighbours) { + unawaited(_onCarpeaterNeighbours(neighbours)); + }); final started = await _carpeaterService.start(); if (!started) { @@ -1572,120 +1632,138 @@ class LocationService { } /// Handle Carpeater neighbour results — save as samples - void _onCarpeaterNeighbours(List> neighbours) async { - final position = _carpeaterDiscoveryPosition ?? _lastPosition; - if (position == null) return; - - final geohash = GeohashUtils.sampleKey( - position.latitude, - position.longitude, - ); - - // Filter out the target repeater itself — it always shows up as its own neighbour - final targetId = _carpeaterService.targetRepeaterId?.toUpperCase(); - final filtered = neighbours.where((n) { - final pubkey = n['pubkey'] as String?; - if (pubkey == null || targetId == null) return true; - final nId = pubkey.length >= 8 - ? pubkey.substring(0, 8).toUpperCase() - : pubkey.toUpperCase(); - return !nId.startsWith(targetId); - }).toList(); - - // Also filter ignored repeater prefixes if set (comma-separated) - final ignoredPrefixStr = _loraCompanion.ignoredRepeaterPrefix; - final results = ignoredPrefixStr != null && ignoredPrefixStr.isNotEmpty - ? filtered.where((n) { - final pubkey = n['pubkey'] as String?; - if (pubkey == null) return true; - final nId = pubkey.length >= 8 - ? pubkey.substring(0, 8).toUpperCase() - : pubkey.toUpperCase(); - final prefixes = ignoredPrefixStr - .split(',') - .map((s) => s.trim().toUpperCase()) - .where((s) => s.isNotEmpty); - return !prefixes.any((prefix) => nId.startsWith(prefix)); - }).toList() - : filtered; - - // Get ducting risk if enabled - String? ductingRisk; - if (_ductingEnabled) { - ductingRisk = await _ductingService.getCurrentRisk(DateTime.now()); - if (ductingRisk == DuctingRisk.unknown) ductingRisk = null; - } + Future _onCarpeaterNeighbours( + List> neighbours, + ) async { + try { + final position = _carpeaterDiscoveryPosition ?? _lastPosition; + if (position == null) return; - if (results.isEmpty) { - // Dead zone — repeater heard nobody - final sample = Sample( - id: _generateUniqueId(), - position: position, - timestamp: DateTime.now(), - path: _carpeaterService.targetRepeaterId, - geohash: geohash, - pingSuccess: false, - ductingRisk: ductingRisk, - deviceId: _loraCompanion.connectedDeviceId, + final geohash = GeohashUtils.sampleKey( + position.latitude, + position.longitude, ); - await _dbService.insertSample(sample); - _pingEventController.add('failed'); - _soundService.playPingFailed(); - } else { - // Save one sample per neighbour - for (final n in results) { + + // Filter out the target repeater itself — it always shows up as its own neighbour + final targetId = _carpeaterService.targetRepeaterId?.toUpperCase(); + final filtered = neighbours.where((n) { final pubkey = n['pubkey'] as String?; - final snr = snrQuarterDbToWholeDb(n['snr']); - final repeaterId = pubkey != null && pubkey.length >= 8 - ? pubkey.substring(0, 8) - : pubkey; + if (pubkey == null || targetId == null) return true; + final nId = pubkey.length >= 8 + ? pubkey.substring(0, 8).toUpperCase() + : pubkey.toUpperCase(); + return !nId.startsWith(targetId); + }).toList(); + + // Also filter ignored repeater prefixes if set (comma-separated) + final ignoredPrefixStr = _loraCompanion.ignoredRepeaterPrefix; + final results = ignoredPrefixStr != null && ignoredPrefixStr.isNotEmpty + ? filtered.where((n) { + final pubkey = n['pubkey'] as String?; + if (pubkey == null) return true; + final nId = pubkey.length >= 8 + ? pubkey.substring(0, 8).toUpperCase() + : pubkey.toUpperCase(); + final prefixes = ignoredPrefixStr + .split(',') + .map((s) => s.trim().toUpperCase()) + .where((s) => s.isNotEmpty); + return !prefixes.any((prefix) => nId.startsWith(prefix)); + }).toList() + : filtered; + + // Get ducting risk if enabled + String? ductingRisk; + if (_ductingEnabled) { + ductingRisk = await _ductingService.getCurrentRisk(DateTime.now()); + if (ductingRisk == DuctingRisk.unknown) ductingRisk = null; + } + if (results.isEmpty) { + // Dead zone — repeater heard nobody final sample = Sample( id: _generateUniqueId(), position: position, timestamp: DateTime.now(), - path: repeaterId, + path: _carpeaterService.targetRepeaterId, geohash: geohash, - snr: snr, - pingSuccess: true, + pingSuccess: false, ductingRisk: ductingRisk, deviceId: _loraCompanion.connectedDeviceId, ); await _dbService.insertSample(sample); + _emitPingEvent('failed'); + _soundService.playPingFailed(); + } else { + // Save one sample per neighbour + for (final n in results) { + final pubkey = n['pubkey'] as String?; + final snr = snrQuarterDbToWholeDb(n['snr']); + final repeaterId = pubkey != null && pubkey.length >= 8 + ? pubkey.substring(0, 8) + : pubkey; + + final sample = Sample( + id: _generateUniqueId(), + position: position, + timestamp: DateTime.now(), + path: repeaterId, + geohash: geohash, + snr: snr, + pingSuccess: true, + ductingRisk: ductingRisk, + deviceId: _loraCompanion.connectedDeviceId, + ); + await _dbService.insertSample(sample); + } + _emitPingEvent('success'); + // Use best SNR from filtered results for sound quality + final bestSnr = results + .map((n) => snrQuarterDbToWholeDb(n['snr'])) + .where((s) => s != null) + .fold( + null, + (best, s) => best == null || s! > best ? s : best, + ); + _soundService.playForPingResult(success: true, snr: bestSnr); } - _pingEventController.add('success'); - // Use best SNR from filtered results for sound quality - final bestSnr = results - .map((n) => snrQuarterDbToWholeDb(n['snr'])) - .where((s) => s != null) - .fold(null, (best, s) => best == null || s! > best ? s : best); - _soundService.playForPingResult(success: true, snr: bestSnr); - } - _sampleSavedController.add(null); + _emitSampleSaved(); - await _setNotificationText( - (l10n) => results.isEmpty - ? l10n.notificationCarpeaterNoNeighbours - : l10n.notificationCarpeaterNeighboursFound(results.length), - ); - Future.delayed(const Duration(seconds: 3), () { - _updateCarpeaterNotification(); - }); + await _setNotificationText( + (l10n) => results.isEmpty + ? l10n.notificationCarpeaterNoNeighbours + : l10n.notificationCarpeaterNeighboursFound(results.length), + ); + Future.delayed(const Duration(seconds: 3), () { + _updateCarpeaterNotification(); + }); + } catch (e) { + await _logger.logError('Carpeater Neighbours', e.toString()); + debugPrint('Error handling Carpeater neighbours: $e'); + } } /// Dispose resources void dispose() { + // Must be the first line: the guarded emit helpers above stop feeding the + // controllers that are closed below, so in-flight async work (background + // pings, Carpeater results, dead-zone checks) can never add to them. + _disposed = true; _positionSearchRequested = false; _wifiPositioningEnabled = false; _positionWatchdogTimer?.cancel(); _positionRestartTimer?.cancel(); _wifiLocationTimer?.cancel(); + // Cancel here as well: stopTracking() below is fire-and-forget, so the + // ducting timer must not be left running on the dispose path. + _ductingFetchTimer?.cancel(); + _ductingFetchTimer = null; _locationServiceStatusSubscription?.cancel(); _positionStreamGeneration++; _positionStreamSubscription?.cancel(); _positionStreamSubscription = null; - stopTracking(); + unawaited(stopTracking()); _disconnectSubscription?.cancel(); _connectedSubscription?.cancel(); _logger.close(); From 606e94f67cc7abd8bb9fb58cadf7c8d52aad21dd Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 01:44:52 +0300 Subject: [PATCH 06/33] Update agent conventions and mark audit items fixed --- AGENTS.md | 15 ++++++++++++--- docs/development/refactoring-audit.md | 7 +++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ad33719..bef6d42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,11 @@ stores observations in SQLite, and renders aggregated coverage on a map. - `lib/services/`: location, radio, protocol, storage, settings, logging, and upload integrations. - `lib/utils/`: reusable helpers without UI responsibilities. +- `lib/widgets/`: reusable widgets shared across screens. +- `lib/l10n/`: localization inputs; generated outputs under `lib/l10n/generated/`. +- `lib/constants/`: generated version constant synced by `tool/version.dart`. +- `tool/`: developer scripts for versioning and release builds (`version.dart`, + `build_release.ps1`). - `android/`: the only maintained Flutter host platform. - `test/`: unit and widget tests, grouped into subdirectories by name prefix such as `test/bluetooth/` and `test/map/`; shared helpers stay in @@ -68,7 +73,9 @@ not launch concurrent release builds. If a build runner was interrupted and a subsequent build reports that an intermediate file such as `base.jar` is in use, first confirm that no build is still active. A stale Gradle daemon for this project can then be stopped from `android/` with `gradlew.bat --stop` before -retrying once. +retrying once. On a fresh clone the Gradle wrapper (`gradlew.bat` and its JAR) +is absent until the first `flutter run` or `flutter build` regenerates it, +because `android/.gitignore` excludes those files. The full analyzer may report existing diagnostics outside the current change. Still run it, and also use targeted analysis for edited files when needed to @@ -97,8 +104,10 @@ or a physical LoRa device, describe any manual device testing that remains. `lowerCamelCase` members. - Keep asynchronous lifecycle cleanup explicit: cancel subscriptions, timers, and device connections when their owner is disposed. -- Keep secrets and device credentials out of source control. Store runtime - credentials with the existing secure-storage abstraction. +- Keep secrets and device credentials out of source control and out of + exported settings files. A credential that must persist on the device is + stored with platform secure storage, never in plaintext + `SharedPreferences`, and never included in settings export/import. - Add or update tests for behavior that can be exercised without physical hardware. Isolate device and network boundaries so they can be faked. - Prefer placing new tests in the `test/` subdirectory matching the test name diff --git a/docs/development/refactoring-audit.md b/docs/development/refactoring-audit.md index 22492d1..1d28a49 100644 --- a/docs/development/refactoring-audit.md +++ b/docs/development/refactoring-audit.md @@ -710,7 +710,8 @@ contract:7-8 пересечение намеренное («specification tests реально они в `docs/assets/screenshots/`. - `android/.gitignore` игнорирует `gradle-wrapper.jar`, `/gradlew`, `/gradlew.bat`, поэтому совет AGENTS.md:70 (`gradlew.bat --stop`) не - сработает на свежем клоне до первой сборки — уточнить формулировку. + сработает на свежем клоне до первой сборки — уточнить формулировку + (исправлено в этой ветке: примечание добавлено в AGENTS.md). **Мелкий мусор:** @@ -718,7 +719,9 @@ contract:7-8 пересечение намеренное («specification tests которых в репо нет и не будет (игнорируются `.gitignore:41-45`). - `AGENTS.md:100-101` — «existing secure-storage abstraction» не существует в коде (креды в `shared_preferences` через `SettingsService`); - формулировку поправить вместе с §3.7/§3.8. + формулировка исправлена в этой ветке: AGENTS.md теперь требует платформенный + secure storage и запрещает plaintext-prefs и экспорт для креденшелов — + фикс §3.7 (пароль Carpeater) стал обязательным по конвенции. - `docs/getting-started.md:29` — «grant Storage permissions» устарело: приложение не запрашивает storage в рантайме. - Остатки MQTT: no-op `disconnectMqtt()` (`lora_companion_service.dart:1907-1908`), From 2bfadf529758d542bb37883d3cd6f3d3d1b2da9b Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 02:15:02 +0300 Subject: [PATCH 07/33] Group root tests into prefix subdirectories per convention --- test/{ => aggregation}/aggregation_service_test.dart | 0 test/{ => discovery}/discovery_timeout_options_test.dart | 0 test/{ => export}/sample_export_test.dart | 0 test/{ => geohash}/geohash_utils_test.dart | 0 test/{ => heading}/heading_utils_test.dart | 0 test/{ => impossible_zone}/impossible_zone_test.dart | 0 .../internet_connectivity_service_test.dart | 0 test/{ => l10n}/app_locale_test.dart | 0 test/{ => location_quality}/bad_fix_monitor_test.dart | 0 test/{ => location_quality}/location_quality_filter_test.dart | 0 test/{ => lora}/lora_reconnect_test.dart | 0 test/{ => map}/appearance_dialogs_test.dart | 2 +- test/{ => map}/connection_dialogs_test.dart | 2 +- test/{ => map}/initial_map_camera_test.dart | 0 test/{ => map}/marker_dialogs_test.dart | 2 +- test/{ => repeater}/repeater_contacts_test.dart | 0 test/{ => screen_wake}/screen_wake_service_test.dart | 0 test/{ => snr}/snr_quarter_db_test.dart | 0 test/{ => tool}/version_tool_test.dart | 2 +- test/{ => tracking}/android_tracking_settings_service_test.dart | 0 test/{ => tracking}/tracking_play_button_test.dart | 2 +- test/{ => wifi_location}/wifi_location_service_test.dart | 0 22 files changed, 5 insertions(+), 5 deletions(-) rename test/{ => aggregation}/aggregation_service_test.dart (100%) rename test/{ => discovery}/discovery_timeout_options_test.dart (100%) rename test/{ => export}/sample_export_test.dart (100%) rename test/{ => geohash}/geohash_utils_test.dart (100%) rename test/{ => heading}/heading_utils_test.dart (100%) rename test/{ => impossible_zone}/impossible_zone_test.dart (100%) rename test/{ => internet_connectivity}/internet_connectivity_service_test.dart (100%) rename test/{ => l10n}/app_locale_test.dart (100%) rename test/{ => location_quality}/bad_fix_monitor_test.dart (100%) rename test/{ => location_quality}/location_quality_filter_test.dart (100%) rename test/{ => lora}/lora_reconnect_test.dart (100%) rename test/{ => map}/appearance_dialogs_test.dart (96%) rename test/{ => map}/connection_dialogs_test.dart (96%) rename test/{ => map}/initial_map_camera_test.dart (100%) rename test/{ => map}/marker_dialogs_test.dart (99%) rename test/{ => repeater}/repeater_contacts_test.dart (100%) rename test/{ => screen_wake}/screen_wake_service_test.dart (100%) rename test/{ => snr}/snr_quarter_db_test.dart (100%) rename test/{ => tool}/version_tool_test.dart (98%) rename test/{ => tracking}/android_tracking_settings_service_test.dart (100%) rename test/{ => tracking}/tracking_play_button_test.dart (97%) rename test/{ => wifi_location}/wifi_location_service_test.dart (100%) diff --git a/test/aggregation_service_test.dart b/test/aggregation/aggregation_service_test.dart similarity index 100% rename from test/aggregation_service_test.dart rename to test/aggregation/aggregation_service_test.dart diff --git a/test/discovery_timeout_options_test.dart b/test/discovery/discovery_timeout_options_test.dart similarity index 100% rename from test/discovery_timeout_options_test.dart rename to test/discovery/discovery_timeout_options_test.dart diff --git a/test/sample_export_test.dart b/test/export/sample_export_test.dart similarity index 100% rename from test/sample_export_test.dart rename to test/export/sample_export_test.dart diff --git a/test/geohash_utils_test.dart b/test/geohash/geohash_utils_test.dart similarity index 100% rename from test/geohash_utils_test.dart rename to test/geohash/geohash_utils_test.dart diff --git a/test/heading_utils_test.dart b/test/heading/heading_utils_test.dart similarity index 100% rename from test/heading_utils_test.dart rename to test/heading/heading_utils_test.dart diff --git a/test/impossible_zone_test.dart b/test/impossible_zone/impossible_zone_test.dart similarity index 100% rename from test/impossible_zone_test.dart rename to test/impossible_zone/impossible_zone_test.dart diff --git a/test/internet_connectivity_service_test.dart b/test/internet_connectivity/internet_connectivity_service_test.dart similarity index 100% rename from test/internet_connectivity_service_test.dart rename to test/internet_connectivity/internet_connectivity_service_test.dart diff --git a/test/app_locale_test.dart b/test/l10n/app_locale_test.dart similarity index 100% rename from test/app_locale_test.dart rename to test/l10n/app_locale_test.dart diff --git a/test/bad_fix_monitor_test.dart b/test/location_quality/bad_fix_monitor_test.dart similarity index 100% rename from test/bad_fix_monitor_test.dart rename to test/location_quality/bad_fix_monitor_test.dart diff --git a/test/location_quality_filter_test.dart b/test/location_quality/location_quality_filter_test.dart similarity index 100% rename from test/location_quality_filter_test.dart rename to test/location_quality/location_quality_filter_test.dart diff --git a/test/lora_reconnect_test.dart b/test/lora/lora_reconnect_test.dart similarity index 100% rename from test/lora_reconnect_test.dart rename to test/lora/lora_reconnect_test.dart diff --git a/test/appearance_dialogs_test.dart b/test/map/appearance_dialogs_test.dart similarity index 96% rename from test/appearance_dialogs_test.dart rename to test/map/appearance_dialogs_test.dart index bc736c3..3eff30d 100644 --- a/test/appearance_dialogs_test.dart +++ b/test/map/appearance_dialogs_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_wardrive/screens/map/dialogs/appearance_dialogs.dart'; import 'package:meshcore_wardrive/services/settings_service.dart'; -import 'helpers/l10n_harness.dart'; +import '../helpers/l10n_harness.dart'; void main() { testWidgets('map theme dialog returns a typed theme', (tester) async { diff --git a/test/connection_dialogs_test.dart b/test/map/connection_dialogs_test.dart similarity index 96% rename from test/connection_dialogs_test.dart rename to test/map/connection_dialogs_test.dart index 8e753ef..da62a94 100644 --- a/test/connection_dialogs_test.dart +++ b/test/map/connection_dialogs_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_wardrive/screens/map/dialogs/connection_dialogs.dart'; -import 'helpers/l10n_harness.dart'; +import '../helpers/l10n_harness.dart'; void main() { testWidgets('connection dialog returns a typed method', (tester) async { diff --git a/test/initial_map_camera_test.dart b/test/map/initial_map_camera_test.dart similarity index 100% rename from test/initial_map_camera_test.dart rename to test/map/initial_map_camera_test.dart diff --git a/test/marker_dialogs_test.dart b/test/map/marker_dialogs_test.dart similarity index 99% rename from test/marker_dialogs_test.dart rename to test/map/marker_dialogs_test.dart index 9f55e35..a69554e 100644 --- a/test/marker_dialogs_test.dart +++ b/test/map/marker_dialogs_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_wardrive/screens/map/dialogs/marker_dialogs.dart'; -import 'helpers/l10n_harness.dart'; +import '../helpers/l10n_harness.dart'; void main() { testWidgets('map long press sheet returns the selected typed action', ( diff --git a/test/repeater_contacts_test.dart b/test/repeater/repeater_contacts_test.dart similarity index 100% rename from test/repeater_contacts_test.dart rename to test/repeater/repeater_contacts_test.dart diff --git a/test/screen_wake_service_test.dart b/test/screen_wake/screen_wake_service_test.dart similarity index 100% rename from test/screen_wake_service_test.dart rename to test/screen_wake/screen_wake_service_test.dart diff --git a/test/snr_quarter_db_test.dart b/test/snr/snr_quarter_db_test.dart similarity index 100% rename from test/snr_quarter_db_test.dart rename to test/snr/snr_quarter_db_test.dart diff --git a/test/version_tool_test.dart b/test/tool/version_tool_test.dart similarity index 98% rename from test/version_tool_test.dart rename to test/tool/version_tool_test.dart index 0ca3745..3ba9f37 100644 --- a/test/version_tool_test.dart +++ b/test/tool/version_tool_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import '../tool/version.dart'; +import '../../tool/version.dart'; void main() { group('version tool', () { diff --git a/test/android_tracking_settings_service_test.dart b/test/tracking/android_tracking_settings_service_test.dart similarity index 100% rename from test/android_tracking_settings_service_test.dart rename to test/tracking/android_tracking_settings_service_test.dart diff --git a/test/tracking_play_button_test.dart b/test/tracking/tracking_play_button_test.dart similarity index 97% rename from test/tracking_play_button_test.dart rename to test/tracking/tracking_play_button_test.dart index 746915a..ae47ece 100644 --- a/test/tracking_play_button_test.dart +++ b/test/tracking/tracking_play_button_test.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_wardrive/widgets/tracking_play_button.dart'; -import 'helpers/l10n_harness.dart'; +import '../helpers/l10n_harness.dart'; void main() { testWidgets( diff --git a/test/wifi_location_service_test.dart b/test/wifi_location/wifi_location_service_test.dart similarity index 100% rename from test/wifi_location_service_test.dart rename to test/wifi_location/wifi_location_service_test.dart From ea81281ca3d60bbd8750496259b0f9a523d40bcc Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 02:18:43 +0300 Subject: [PATCH 08/33] Deduplicate batch upload loop and drop deprecated wrapper --- lib/services/upload_service.dart | 100 ++++--------------------------- 1 file changed, 11 insertions(+), 89 deletions(-) diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart index da9e238..c20f599 100644 --- a/lib/services/upload_service.dart +++ b/lib/services/upload_service.dart @@ -148,104 +148,26 @@ class UploadService { return UploadResult(success: true, message: 'No new samples to upload'); } - final samplesJson = _samplesToJson(samples, repeaterNames: repeaterNames); - - debugPrint('Uploading ${samplesJson.length} samples in batches...'); - - // Split into batches of 100 samples each - const batchSize = 100; - final totalBatches = (samplesJson.length / batchSize).ceil(); - int totalCells = 0; - - for (int i = 0; i < totalBatches; i++) { - final start = i * batchSize; - final end = (start + batchSize < samplesJson.length) - ? start + batchSize - : samplesJson.length; - final batch = samplesJson.sublist(start, end); - - // Report progress - if (onProgress != null) { - onProgress(i + 1, totalBatches); - } - - debugPrint( - 'Uploading batch ${i + 1}/$totalBatches (${batch.length} samples)', - ); - - // Try up to 2 times (original + 1 retry) - bool success = false; - http.Response? response; - String? error; - - for (int attempt = 0; attempt < 2; attempt++) { - try { - response = await http - .post( - Uri.parse(apiUrl), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({'samples': batch}), - ) - .timeout(const Duration(seconds: 60)); - - if (response.statusCode == 200) { - success = true; - final responseData = jsonDecode(response.body); - totalCells = responseData['totalCells'] ?? totalCells; - break; // Success, exit retry loop - } else { - error = 'Server error: ${response.statusCode}'; - if (attempt == 0) { - debugPrint( - 'Batch ${i + 1} failed with ${response.statusCode}, retrying...', - ); - await Future.delayed(const Duration(seconds: 2)); - } - } - } catch (e) { - error = e.toString(); - if (attempt == 0) { - debugPrint('Batch ${i + 1} failed: $e, retrying...'); - await Future.delayed(const Duration(seconds: 2)); - } - } - } - - if (!success) { - return UploadResult( - success: false, - message: 'Failed at batch ${i + 1}/$totalBatches: $error', - ); - } - } - - // All batches successful - await _setLastUploadTime(DateTime.now()); + final result = await _uploadSamplesToEndpoint( + apiUrl, + samples, + repeaterNames: repeaterNames, + onProgress: onProgress, + ); - // Mark ALL samples (including GPS-only) as uploaded so they don't get re-queried - final allSampleIds = allSamples.map((s) => s.id).toList(); - if (isDefault) { + // Mark ALL samples (including GPS-only) as uploaded so they don't get + // re-queried. + if (result.success && isDefault) { + final allSampleIds = allSamples.map((s) => s.id).toList(); await _db.markSamplesAsUploaded(allSampleIds); } - return UploadResult( - success: true, - message: 'Upload Complete', - uploadedCount: samples.length, - totalCount: totalCells, - ); + return result; } catch (e) { return UploadResult(success: false, message: 'Upload failed: $e'); } } - /// Upload only samples since last upload (deprecated - use uploadAllSamples instead) - Future uploadNewSamples({ - Map? repeaterNames, - }) async { - return uploadAllSamples(repeaterNames: repeaterNames); - } - /// Download community coverage data from a map endpoint. /// Returns the parsed coverage map, or null on failure. /// Also caches to a local file for offline viewing. From 7f7edb4dfb518a1816cc7170485503895ea4ecd3 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 02:22:48 +0300 Subject: [PATCH 09/33] Deduplicate protocol byte readers and drop dead raw log parser --- lib/services/meshcore_protocol.dart | 195 ++++------------------------ 1 file changed, 26 insertions(+), 169 deletions(-) diff --git a/lib/services/meshcore_protocol.dart b/lib/services/meshcore_protocol.dart index 75c0955..b1ea7ab 100644 --- a/lib/services/meshcore_protocol.dart +++ b/lib/services/meshcore_protocol.dart @@ -421,31 +421,17 @@ class MeshCoreProtocol { if (data.length >= offset + 4) { // Last advert timestamp (4 bytes, uint32 LE) - lastAdvert = - data[offset] | - (data[offset + 1] << 8) | - (data[offset + 2] << 16) | - (data[offset + 3] << 24); + lastAdvert = _readUint32LE(data, offset); offset += 4; } if (data.length >= offset + 8) { // Latitude (4 bytes, int32 LE, * 1E6) - final latInt = - data[offset] | - (data[offset + 1] << 8) | - (data[offset + 2] << 16) | - (data[offset + 3] << 24); - advLat = _int32ToSigned(latInt) / 1000000.0; + advLat = _readInt32LE(data, offset) / 1000000.0; offset += 4; // Longitude (4 bytes, int32 LE, * 1E6) - final lonInt = - data[offset] | - (data[offset + 1] << 8) | - (data[offset + 2] << 16) | - (data[offset + 3] << 24); - advLon = _int32ToSigned(lonInt) / 1000000.0; + advLon = _readInt32LE(data, offset) / 1000000.0; offset += 4; } @@ -622,6 +608,12 @@ class MeshCoreProtocol { int _readInt32LE(Uint8List data, int offset) => _int32ToSigned(_readUint32LE(data, offset)); + /// Read one byte as a signed (two's complement) value. + int _readInt8(Uint8List data, int offset) { + final value = data[offset]; + return value > 127 ? value - 256 : value; + } + /// Create CMD_GET_CHANNEL command to query channel at specific index Uint8List createGetChannelPayload(int channelIdx) { _checkChannelIndex(channelIdx); @@ -743,122 +735,6 @@ class MeshCoreProtocol { return payload.toBytes(); } - /// Parse PUSH_CODE_LOG_RX_DATA (0x88) - raw radio log frame - /// Format: [SNR] [RSSI] [raw_packet_bytes...] - /// Raw packet format: [header(1)] [transport_codes(4)-optional] [path_len(1)] [path(path_len)] [payload...] - /// SNR is multiplied by 4 in firmware, RSSI is raw value - /// Returns map with 'snr', 'rssi', and parsed packet data if available - Map? parseRawLogFrame(Uint8List data) { - try { - if (data.length < 2) { - debugPrint('⚠️ Raw log frame too short: ${data.length} bytes'); - return null; - } - - // SNR at byte 0 (scaled by 4x in firmware) - var snrRaw = data[0]; - if (snrRaw > 127) snrRaw -= 256; - final snr = snrRaw / 4.0; - - // RSSI at byte 1 (raw value) - int rssi = data[1]; - if (rssi > 127) rssi -= 256; // Convert to signed byte - - debugPrint( - '📻 Raw log frame: SNR=$snr (raw=$snrRaw), RSSI=$rssi, total=${data.length} bytes', - ); - - // Parse raw MeshCore packet structure - // Frame is: [SNR][RSSI][raw_packet...] - // Raw packet is: [header][transport_codes?][pathLen][path...][payload...] - String? repeater; - Uint8List? repeaterKey; - - if (data.length > 4) { - // Need at least SNR+RSSI+header+pathLen - int offset = 2; // Skip SNR/RSSI - - // Parse packet header - final header = data[offset++]; - final routeType = header & 0x03; - final hasTransportCodes = routeType == 0x00 || routeType == 0x03; - - // Skip transport codes if present (4 bytes) - if (hasTransportCodes) { - if (data.length < offset + 4) { - debugPrint(' Not enough data for transport codes'); - return { - 'snr': snr, - 'rssi': rssi, - 'sender': null, - 'repeater': null, - 'repeaterKey': null, - }; - } - offset += 4; - } - - // Read path_len (signed byte) - if (data.length <= offset) { - debugPrint(' No pathLen byte'); - return { - 'snr': snr, - 'rssi': rssi, - 'sender': null, - 'repeater': null, - 'repeaterKey': null, - }; - } - - int pathLen = data[offset++]; - // Convert unsigned byte to signed - if (pathLen > 127) pathLen -= 256; - debugPrint( - ' header=0x${header.toRadixString(16)}, routeType=$routeType, hasTransport=$hasTransportCodes, pathLen=$pathLen', - ); - - // IMPORTANT: Flood packets with built-up paths store 1-byte prefixes per hop! - // Direct packets (routeType=0x02) have full 32-byte keys per hop - // Get LAST hop in path (most recent repeater) - if (pathLen > 0 && routeType == 0x01) { - // ROUTE_TYPE_FLOOD - if (data.length >= offset + pathLen) { - // Extract last byte from path (last repeater's 1-byte prefix) - final lastHopByte = data[offset + pathLen - 1]; - repeater = lastHopByte - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); - debugPrint( - ' 🎯 FLOOD packet with path! Last hop ($pathLen hops): $repeater', - ); - } else { - debugPrint( - ' Path exists but data too short: pathLen=$pathLen, available=${data.length - offset}', - ); - } - } else if (pathLen > 0 && routeType == 0x02) { - // ROUTE_TYPE_DIRECT - // Direct routes have full 32-byte keys (not used in wardrive typically) - debugPrint(' Direct route with full keys (pathLen=$pathLen)'); - } else if (pathLen == 0 || pathLen < 0) { - debugPrint(' Zero-hop packet (direct/flood with no path built)'); - } - } - - return { - 'snr': snr, - 'rssi': rssi, - 'sender': null, // Not extracting sender from encrypted payload, use repeater instead - 'repeater': repeater, - 'repeaterKey': repeaterKey, - }; - } catch (e) { - debugPrint('Error parsing raw log frame: $e'); - return null; - } - } - /// Parse PUSH_CODE_RAW_DATA (0x84). /// /// This is an arbitrary radio payload, not a delivery ACK. The companion @@ -866,10 +742,8 @@ class MeshCoreProtocol { Map? parseRawDataPush(Uint8List data) { if (data.length < 3) return null; - var snrRaw = data[0]; - if (snrRaw > 127) snrRaw -= 256; - var rssi = data[1]; - if (rssi > 127) rssi -= 256; + final snrRaw = _readInt8(data, 0); + final rssi = _readInt8(data, 1); return { 'snr': snrRaw / 4.0, 'rssi': rssi, @@ -887,8 +761,8 @@ class MeshCoreProtocol { double? snr; if (version3) { if (data.length < 10) return null; - var snrRaw = data[offset++]; - if (snrRaw > 127) snrRaw -= 256; + final snrRaw = _readInt8(data, offset); + offset++; snr = snrRaw / 4.0; offset += 2; // reserved } else if (data.length < 7) { @@ -920,8 +794,7 @@ class MeshCoreProtocol { Map? parseChannelDataFrame(Uint8List data) { if (data.length < 8) return null; - var snrRaw = data[0]; - if (snrRaw > 127) snrRaw -= 256; + final snrRaw = _readInt8(data, 0); final dataLength = data[7]; if (data.length < 8 + dataLength) return null; @@ -976,13 +849,13 @@ class MeshCoreProtocol { int offset = 0; // SNR at byte 0 (scaled by 4x) - int snrRaw = data[offset++]; - if (snrRaw > 127) snrRaw -= 256; // Convert to signed + final snrRaw = _readInt8(data, offset); + offset++; final snr = snrRaw / 4.0; // RSSI at byte 1 (signed) - int rssi = data[offset++]; - if (rssi > 127) rssi -= 256; + final rssi = _readInt8(data, offset); + offset++; // Path length at byte 2 final pathLen = data[offset++]; @@ -1031,8 +904,8 @@ class MeshCoreProtocol { } // SNR at byte 1 (already scaled by 4, signed) - int snrRaw = payload[offset++]; - if (snrRaw > 127) snrRaw -= 256; + final snrRaw = _readInt8(payload, offset); + offset++; final snr = snrRaw / 4.0; // Tag: 4 bytes (little-endian uint32) @@ -1040,11 +913,7 @@ class MeshCoreProtocol { debugPrint('⚠️ Discovery response: not enough data for tag'); return null; } - final tag = - payload[offset] | - (payload[offset + 1] << 8) | - (payload[offset + 2] << 16) | - (payload[offset + 3] << 24); + final tag = _readUint32LE(payload, offset); offset += 4; // Public key: exactly 8 or 32 bytes (depends on prefix_only in request). @@ -1177,11 +1046,7 @@ class MeshCoreProtocol { int permissions = 0; int? firmwareVersion; if (data.length >= 13) { - serverTimestamp = - data[offset] | - (data[offset + 1] << 8) | - (data[offset + 2] << 16) | - (data[offset + 3] << 24); + serverTimestamp = _readUint32LE(data, offset); offset += 4; permissions = data[offset++]; firmwareVersion = data[offset++]; @@ -1229,11 +1094,7 @@ class MeshCoreProtocol { if (data.length < 9) return null; int offset = 0; offset++; // reserved - final tag = - data[offset] | - (data[offset + 1] << 8) | - (data[offset + 2] << 16) | - (data[offset + 3] << 24); + final tag = _readUint32LE(data, offset); offset += 4; final totalCount = data[offset] | (data[offset + 1] << 8); offset += 2; @@ -1252,14 +1113,10 @@ class MeshCoreProtocol { .join('') .toUpperCase(); offset += pubkeyPrefixLength; - final heardSecondsAgo = - data[offset] | - (data[offset + 1] << 8) | - (data[offset + 2] << 16) | - (data[offset + 3] << 24); + final heardSecondsAgo = _readUint32LE(data, offset); offset += 4; - int snrRaw = data[offset++]; - if (snrRaw > 127) snrRaw -= 256; + final snrRaw = _readInt8(data, offset); + offset++; final snr = snrRaw / 4.0; neighbours.add({ 'pubkey': pubkey, From b1fc77818fd1522440813bb8151abb7771a85695 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 02:24:03 +0300 Subject: [PATCH 10/33] Move Carpeater password to secure storage and drop unused deps --- lib/services/settings_service.dart | 84 +++++++++++++- pubspec.lock | 24 ---- pubspec.yaml | 3 - test/settings/settings_service_test.dart | 140 +++++++++++++++++++++++ 4 files changed, 219 insertions(+), 32 deletions(-) diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 77fae5a..514e266 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/app_locale.dart'; @@ -11,7 +12,49 @@ enum CurrentLocationMarkerStyle { circle, arrow } enum MapThemeMode { system, light, dark } +/// Minimal read/write/delete boundary over platform secure storage. +/// +/// Isolates the device storage boundary so tests can substitute an in-memory +/// fake instead of touching the real keychain/keystore (AGENTS.md: "Isolate +/// device and network boundaries so they can be faked"). +abstract class SecureCredentialsStore { + Future read(String key); + + Future write(String key, String value); + + Future delete(String key); +} + +/// Default [SecureCredentialsStore] backed by [FlutterSecureStorage]. +/// +/// Android keeps the plugin v10 defaults (KeyStore-wrapped AES-GCM with +/// `resetOnError` and automatic cipher migration). The legacy +/// `encryptedSharedPreferences` flag is deprecated and ignored by the plugin +/// since v10, so it is intentionally left unset. +class FlutterSecureCredentialsStore implements SecureCredentialsStore { + const FlutterSecureCredentialsStore(); + + static const FlutterSecureStorage _storage = FlutterSecureStorage( + aOptions: AndroidOptions(), + ); + + @override + Future read(String key) => _storage.read(key: key); + + @override + Future write(String key, String value) => + _storage.write(key: key, value: value); + + @override + Future delete(String key) => _storage.delete(key: key); +} + class SettingsService { + SettingsService({SecureCredentialsStore? credentialsStore}) + : _credentials = credentialsStore ?? const FlutterSecureCredentialsStore(); + + final SecureCredentialsStore _credentials; + static const String _showSamplesKey = 'show_samples'; static const String _showGpsSamplesKey = 'show_gps_samples'; static const String _fixedSampleMarkerSizeEnabledKey = @@ -64,6 +107,9 @@ class SettingsService { static const String _pingTimeIntervalKey = 'ping_time_interval_seconds'; static const String _carpeaterEnabledKey = 'carpeater_enabled'; static const String _carpeaterRepeaterIdKey = 'carpeater_repeater_id'; + // Carpeater password: the value lives in secure storage. This key doubles + // as the secure-storage key and as the legacy plaintext prefs key that the + // one-time migration moves out of SharedPreferences. static const String _carpeaterPasswordKey = 'carpeater_password'; static const String _carpeaterIntervalKey = 'carpeater_interval_seconds'; static const String _deviceNameKey = 'device_name'; @@ -697,18 +743,43 @@ class SettingsService { } } + /// Carpeater admin password, stored only in platform secure storage — + /// never in plaintext prefs, never in settings export/import. + /// + /// Reads transparently migrate a legacy plaintext value from + /// SharedPreferences (read-old → write-secure → remove-old), so an update + /// over an existing installation keeps the saved password. If the secure + /// write fails the legacy value stays in place and is still returned; the + /// next read retries the migration, which makes it idempotent. Future getCarpeaterPassword() async { + final secureValue = await _credentials.read(_carpeaterPasswordKey); + if (secureValue != null) { + return secureValue; + } final prefs = await _prefs; - return prefs.getString(_carpeaterPasswordKey); + final legacyValue = prefs.getString(_carpeaterPasswordKey); + if (legacyValue == null || legacyValue.isEmpty) { + return null; + } + try { + await _credentials.write(_carpeaterPasswordKey, legacyValue); + await prefs.remove(_carpeaterPasswordKey); + } catch (_) { + // Migration failed: keep the legacy copy so the credential is not lost. + } + return legacyValue; } Future setCarpeaterPassword(String? value) async { - final prefs = await _prefs; if (value == null || value.isEmpty) { - await prefs.remove(_carpeaterPasswordKey); + await _credentials.delete(_carpeaterPasswordKey); } else { - await prefs.setString(_carpeaterPasswordKey, value); + await _credentials.write(_carpeaterPasswordKey, value); } + // Drop any pre-migration plaintext copy once the secure value is in + // place; a failure above leaves it untouched so nothing is lost. + final prefs = await _prefs; + await prefs.remove(_carpeaterPasswordKey); } Future getCarpeaterInterval() async { @@ -981,7 +1052,10 @@ class SettingsService { _pingTimeIntervalKey, _carpeaterEnabledKey, _carpeaterRepeaterIdKey, - _carpeaterPasswordKey, + // _carpeaterPasswordKey is intentionally absent: the Carpeater password + // lives in secure storage only and must never enter settings exports. + // importSettings iterates this same list, so legacy export files that + // still carry the key are skipped automatically. _carpeaterIntervalKey, _deviceNameKey, _lockRotationKey, diff --git a/pubspec.lock b/pubspec.lock index e4ed0a7..3af944b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,14 +97,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" cross_file: dependency: transitive description: @@ -121,14 +113,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" - source: hosted - version: "1.0.9" dart_earcut: dependency: transitive description: @@ -765,14 +749,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: "direct main" - description: - name: pointycastle - sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" - url: "https://pub.dev" - source: hosted - version: "4.0.0" polylabel: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 320fc21..b5e0cb0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,8 +29,6 @@ dependencies: flutter_localizations: sdk: flutter - cupertino_icons: ^1.0.9 - # Map and location # flutter_map 8 and flutter_map_cache 2 need a heatmap plugin that still # only supports flutter_map 7. @@ -66,7 +64,6 @@ dependencies: # 11 needs API 37. flutter_secure_storage: ^10.0.0 shared_preferences: ^2.5.5 - pointycastle: ^4.0.0 # Utilities and export intl: any diff --git a/test/settings/settings_service_test.dart b/test/settings/settings_service_test.dart index aaaac82..9eb6d17 100644 --- a/test/settings/settings_service_test.dart +++ b/test/settings/settings_service_test.dart @@ -552,4 +552,144 @@ void main() { expect(await settings.getCompanionNodeName(), isNull); }); }); + + group('carpeater password secure storage', () { + const passwordKey = 'carpeater_password'; + + test('defaults to unset', () async { + SharedPreferences.setMockInitialValues({}); + final settings = SettingsService( + credentialsStore: _FakeSecureCredentialsStore(), + ); + + expect(await settings.getCarpeaterPassword(), isNull); + }); + + test('persists to secure storage and keeps prefs clean', () async { + SharedPreferences.setMockInitialValues({}); + final secure = _FakeSecureCredentialsStore(); + final settings = SettingsService(credentialsStore: secure); + + await settings.setCarpeaterPassword('s3cret'); + + expect(await settings.getCarpeaterPassword(), 's3cret'); + expect(secure.values[passwordKey], 's3cret'); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(passwordKey), isNull); + }); + + test('migrates a legacy plaintext password on first read', () async { + SharedPreferences.setMockInitialValues({passwordKey: 'legacy'}); + final secure = _FakeSecureCredentialsStore(); + final settings = SettingsService(credentialsStore: secure); + + expect(await settings.getCarpeaterPassword(), 'legacy'); + expect(secure.values[passwordKey], 'legacy'); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(passwordKey), isNull); + }); + + test( + 'migration is idempotent and secure storage wins afterwards', + () async { + SharedPreferences.setMockInitialValues({passwordKey: 'legacy'}); + final secure = _FakeSecureCredentialsStore(); + final settings = SettingsService(credentialsStore: secure); + + expect(await settings.getCarpeaterPassword(), 'legacy'); + + // A stale plaintext value reappearing in prefs must not win over the + // already migrated secure value. + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(passwordKey, 'stale-reinserted'); + expect(await settings.getCarpeaterPassword(), 'legacy'); + }, + ); + + test('keeps the legacy value when secure storage is unavailable', () async { + SharedPreferences.setMockInitialValues({passwordKey: 'legacy'}); + final secure = _FakeSecureCredentialsStore()..failWrites = true; + final settings = SettingsService(credentialsStore: secure); + + expect(await settings.getCarpeaterPassword(), 'legacy'); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(passwordKey), 'legacy'); + }); + + test('clearing removes the value from secure and legacy storage', () async { + SharedPreferences.setMockInitialValues({}); + final secure = _FakeSecureCredentialsStore(); + final settings = SettingsService(credentialsStore: secure); + + await settings.setCarpeaterPassword('s3cret'); + await settings.setCarpeaterPassword(null); + expect(await settings.getCarpeaterPassword(), isNull); + expect(secure.values, isEmpty); + + await settings.setCarpeaterPassword(''); + expect(await settings.getCarpeaterPassword(), isNull); + expect(secure.values, isEmpty); + }); + + test('export never contains the password', () async { + SharedPreferences.setMockInitialValues({}); + final settings = SettingsService( + credentialsStore: _FakeSecureCredentialsStore(), + ); + + await settings.setCarpeaterPassword('s3cret'); + final exported = await settings.exportSettings(); + + expect(exported, isNot(contains('carpeater_password'))); + }); + + test('legacy export file with a password imports without it', () async { + SharedPreferences.setMockInitialValues({}); + final secure = _FakeSecureCredentialsStore(); + final settings = SettingsService(credentialsStore: secure); + + final legacyExport = { + '_format': 'meshcore_wardrive_settings', + '_version': 1, + 'carpeater_enabled': true, + 'carpeater_password': 'leaked-from-old-export', + }; + final applied = await settings.importSettings(legacyExport); + + expect(applied, 1); + expect(await settings.getCarpeaterEnabled(), isTrue); + expect(await settings.getCarpeaterPassword(), isNull); + expect(secure.values, isEmpty); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(passwordKey), isNull); + }); + }); +} + +/// In-memory [SecureCredentialsStore] fake standing in for the platform +/// secure storage in tests. +class _FakeSecureCredentialsStore implements SecureCredentialsStore { + _FakeSecureCredentialsStore([Map? initial]) + : values = initial == null ? {} : Map.of(initial); + + final Map values; + + /// When true, writes throw to simulate an unavailable secure backend. + bool failWrites = false; + + @override + Future read(String key) async => values[key]; + + @override + Future write(String key, String value) async { + if (failWrites) { + throw StateError('secure storage unavailable'); + } + values[key] = value; + } + + @override + Future delete(String key) async { + values.remove(key); + } } From be615449286896c5d5dd2ca580a4da08c028e252 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 02:26:31 +0300 Subject: [PATCH 11/33] Fix all analyzer infos and move dropdown widgets out of utils --- lib/main.dart | 8 +- lib/screens/map/map_screen_controller.dart | 16 +-- lib/screens/map/map_settings_controller.dart | 136 +++++++++--------- .../map/widgets/map_quick_settings_panel.dart | 4 +- lib/screens/repeater_health_screen.dart | 2 +- .../settings/sections/discovery_section.dart | 2 +- lib/services/location_quality_filter.dart | 20 ++- lib/services/sound_service.dart | 32 +++-- lib/utils/discovery_timeout_options.dart | 39 ----- lib/utils/ping_distance_options.dart | 29 ---- lib/widgets/discovery_timeout_options.dart | 42 ++++++ lib/widgets/ping_distance_options.dart | 32 +++++ .../discovery_timeout_options_test.dart | 1 + test/map/map_settings_controller_test.dart | 27 +++- test/ping/ping_distance_options_test.dart | 1 + test/sound/sound_service_test.dart | 4 +- 16 files changed, 211 insertions(+), 184 deletions(-) create mode 100644 lib/widgets/discovery_timeout_options.dart create mode 100644 lib/widgets/ping_distance_options.dart diff --git a/lib/main.dart b/lib/main.dart index 103e6c5..02747de 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,14 +24,14 @@ class MyApp extends StatefulWidget { const MyApp({super.key}); @override - State createState() => _MyAppState(); + MyAppState createState() => MyAppState(); - static _MyAppState? of(BuildContext context) { - return context.findAncestorStateOfType<_MyAppState>(); + static MyAppState? of(BuildContext context) { + return context.findAncestorStateOfType(); } } -class _MyAppState extends State { +class MyAppState extends State { ThemeMode _themeMode = ThemeMode.system; AppLocalePreference _localePreference = AppLocalePreference.system; late final InternetConnectivityService _connectivityService; diff --git a/lib/screens/map/map_screen_controller.dart b/lib/screens/map/map_screen_controller.dart index af7a29a..182bfc5 100644 --- a/lib/screens/map/map_screen_controller.dart +++ b/lib/screens/map/map_screen_controller.dart @@ -56,9 +56,9 @@ class MapCoverageLod { } class MapScreenController { - MapScreenController({required MapDataStore store}) : _store = store; + MapScreenController({required this.store}); - final MapDataStore _store; + final MapDataStore store; int _sampleCount = 0; List _samples = const []; @@ -125,7 +125,7 @@ class MapScreenController { final generation = ++_refreshGeneration; final repeaters = List.unmodifiable(discoveredRepeaters); final repeaterFingerprint = _repeaterFingerprint(repeaters); - final count = await _store.getSampleCount(); + final count = await store.getSampleCount(); if (generation != _refreshGeneration) return false; final needsAggregation = @@ -138,7 +138,7 @@ class MapScreenController { _sampleCount = count; if (!needsAggregation) return false; - var samples = _sessionView.visibleSamples(await _store.getAllSamples()); + var samples = _sessionView.visibleSamples(await store.getAllSamples()); if (generation != _refreshGeneration) return false; final sourceFilter = _sourceFilter; if (sourceFilter != null) { @@ -191,20 +191,20 @@ class MapScreenController { } Future deleteSample(String sampleId) async { - await _store.deleteSample(sampleId); + await store.deleteSample(sampleId); invalidate(); } Future deleteCoverage(String geohashPrefix) async { - final deleted = await _store.deleteSamplesByGeohash(geohashPrefix); + final deleted = await store.deleteSamplesByGeohash(geohashPrefix); invalidate(); return deleted; } - Future> getSessions() => _store.getAllSessions(); + Future> getSessions() => store.getAllSessions(); Future deleteSession(int id) async { - await _store.deleteSession(id); + await store.deleteSession(id); invalidate(); } diff --git a/lib/screens/map/map_settings_controller.dart b/lib/screens/map/map_settings_controller.dart index 44369ec..a6a9f2d 100644 --- a/lib/screens/map/map_settings_controller.dart +++ b/lib/screens/map/map_settings_controller.dart @@ -112,14 +112,13 @@ abstract interface class MapSettingsRuntime { class DefaultMapSettingsRuntime implements MapSettingsRuntime { DefaultMapSettingsRuntime({ - required LocationService locationService, + required this.locationService, SoundService? soundService, ScreenWakeService? screenWakeService, - }) : _locationService = locationService, - _soundService = soundService ?? SoundService(), + }) : _soundService = soundService ?? SoundService(), _screenWakeService = screenWakeService ?? ScreenWakeService.instance; - final LocationService _locationService; + final LocationService locationService; final SoundService _soundService; final ScreenWakeService _screenWakeService; @@ -127,19 +126,17 @@ class DefaultMapSettingsRuntime implements MapSettingsRuntime { Future apply(MapSettingsSnapshot settings) async { _soundService.setEnabled(settings.soundEnabled); _soundService.setVibrationEnabled(settings.vibrationEnabled); - _locationService.setLinkLossAlertsEnabled(settings.linkLossAlertsEnabled); - _locationService.setPingMode(settings.pingMode); - _locationService.setPingTimeInterval(settings.pingTimeInterval); - _locationService.setBatterySaverEnabled(settings.batterySaverEnabled); - _locationService.setCarpeaterMode(settings.carpeaterEnabled); - _locationService.setPingInterval(settings.pingIntervalMeters); - _locationService.setWifiPositioningEnabled( - settings.beaconDbWifiPositioning, - ); - _locationService.setLocationQualitySettings( + locationService.setLinkLossAlertsEnabled(settings.linkLossAlertsEnabled); + locationService.setPingMode(settings.pingMode); + locationService.setPingTimeInterval(settings.pingTimeInterval); + locationService.setBatterySaverEnabled(settings.batterySaverEnabled); + locationService.setCarpeaterMode(settings.carpeaterEnabled); + locationService.setPingInterval(settings.pingIntervalMeters); + locationService.setWifiPositioningEnabled(settings.beaconDbWifiPositioning); + locationService.setLocationQualitySettings( settings.locationQualitySettings, ); - _locationService.loraCompanion.setIgnoredRepeaterPrefix( + locationService.loraCompanion.setIgnoredRepeaterPrefix( settings.ignoredRepeaterPrefix, ); await _screenWakeService.setAlwaysOn(settings.keepScreenOn); @@ -147,75 +144,70 @@ class DefaultMapSettingsRuntime implements MapSettingsRuntime { } class MapSettingsController { - MapSettingsController({ - required SettingsService settingsService, - required MapSettingsRuntime runtime, - }) : _settingsService = settingsService, - _runtime = runtime; + MapSettingsController({required this.settingsService, required this.runtime}); - final SettingsService _settingsService; - final MapSettingsRuntime _runtime; + final SettingsService settingsService; + final MapSettingsRuntime runtime; Future loadAndApply() async { final settings = MapSettingsSnapshot( - showSamples: await _settingsService.getShowSamples(), - showGpsSamples: await _settingsService.getShowGpsSamples(), - fixedSampleMarkerSizeEnabled: await _settingsService + showSamples: await settingsService.getShowSamples(), + showGpsSamples: await settingsService.getShowGpsSamples(), + fixedSampleMarkerSizeEnabled: await settingsService .getFixedSampleMarkerSizeEnabled(), - sampleMarkerRadius: await _settingsService.getSampleMarkerRadius(), - showCoverage: await _settingsService.getShowCoverage(), - mapLodEnabled: await _settingsService.getMapLodEnabled(), - sampleGeohashGrouping: await _settingsService.getSampleGeohashGrouping(), - showEdges: await _settingsService.getShowEdges(), - showRepeaters: await _settingsService.getShowRepeaters(), - showPrivacyZones: await _settingsService.getShowPrivacyZones(), - showGpsExclusionZones: await _settingsService.getShowGpsExclusionZones(), - colorMode: await _settingsService.getColorMode(), - pingIntervalMeters: await _settingsService.getPingInterval(), - coveragePrecision: await _settingsService.getCoveragePrecision(), - ignoredRepeaterPrefix: await _settingsService.getIgnoredRepeaterPrefix(), - includeOnlyRepeaters: await _settingsService.getIncludeOnlyRepeaters(), - filterEdgesByWhitelist: await _settingsService - .getFilterEdgesByWhitelist(), - distanceUnit: await _settingsService.getDistanceUnit(), - colorBlindMode: await _settingsService.getColorBlindMode(), - discoveryTimeoutSeconds: await _settingsService.getDiscoveryTimeout(), - thoroughResponseCollection: await _settingsService + sampleMarkerRadius: await settingsService.getSampleMarkerRadius(), + showCoverage: await settingsService.getShowCoverage(), + mapLodEnabled: await settingsService.getMapLodEnabled(), + sampleGeohashGrouping: await settingsService.getSampleGeohashGrouping(), + showEdges: await settingsService.getShowEdges(), + showRepeaters: await settingsService.getShowRepeaters(), + showPrivacyZones: await settingsService.getShowPrivacyZones(), + showGpsExclusionZones: await settingsService.getShowGpsExclusionZones(), + colorMode: await settingsService.getColorMode(), + pingIntervalMeters: await settingsService.getPingInterval(), + coveragePrecision: await settingsService.getCoveragePrecision(), + ignoredRepeaterPrefix: await settingsService.getIgnoredRepeaterPrefix(), + includeOnlyRepeaters: await settingsService.getIncludeOnlyRepeaters(), + filterEdgesByWhitelist: await settingsService.getFilterEdgesByWhitelist(), + distanceUnit: await settingsService.getDistanceUnit(), + colorBlindMode: await settingsService.getColorBlindMode(), + discoveryTimeoutSeconds: await settingsService.getDiscoveryTimeout(), + thoroughResponseCollection: await settingsService .getThoroughResponseCollection(), - fuelUnit: await _settingsService.getFuelUnit(), - showRouteTrail: await _settingsService.getShowRouteTrail(), - showHeatmap: await _settingsService.getShowHeatmap(), - showPredictionRings: await _settingsService.getShowPredictionRings(), - showRadioPosition: await _settingsService.getShowRadioPosition(), - beaconDbWifiPositioning: await _settingsService + fuelUnit: await settingsService.getFuelUnit(), + showRouteTrail: await settingsService.getShowRouteTrail(), + showHeatmap: await settingsService.getShowHeatmap(), + showPredictionRings: await settingsService.getShowPredictionRings(), + showRadioPosition: await settingsService.getShowRadioPosition(), + beaconDbWifiPositioning: await settingsService .getBeaconDbWifiPositioning(), - locationQualitySettings: await _settingsService + locationQualitySettings: await settingsService .getLocationQualitySettings(), - showDucting: await _settingsService.getShowDucting(), - mapThemeMode: await _settingsService.getMapThemeMode(), - pingMode: await _settingsService.getPingMode(), - pingTimeInterval: await _settingsService.getPingTimeInterval(), - soundEnabled: await _settingsService.getSoundEnabled(), - vibrationEnabled: await _settingsService.getVibrationEnabled(), - lockRotationNorth: await _settingsService.getLockRotationNorth(), - keepScreenOn: await _settingsService.getKeepScreenOn(), - currentLocationMarkerStyle: await _settingsService + showDucting: await settingsService.getShowDucting(), + mapThemeMode: await settingsService.getMapThemeMode(), + pingMode: await settingsService.getPingMode(), + pingTimeInterval: await settingsService.getPingTimeInterval(), + soundEnabled: await settingsService.getSoundEnabled(), + vibrationEnabled: await settingsService.getVibrationEnabled(), + lockRotationNorth: await settingsService.getLockRotationNorth(), + keepScreenOn: await settingsService.getKeepScreenOn(), + currentLocationMarkerStyle: await settingsService .getCurrentLocationMarkerStyle(), - showSuccessfulOnly: await _settingsService.getShowSuccessfulOnly(), - optimisticDisplay: await _settingsService.getOptimisticDisplay(), - compassCalibrationQuietUntil: await _settingsService + showSuccessfulOnly: await settingsService.getShowSuccessfulOnly(), + optimisticDisplay: await settingsService.getOptimisticDisplay(), + compassCalibrationQuietUntil: await settingsService .getCompassCalibrationQuietUntil(), - deadZoneAlertsEnabled: await _settingsService.getDeadZoneAlertsEnabled(), - newRepeaterAlertsEnabled: await _settingsService + deadZoneAlertsEnabled: await settingsService.getDeadZoneAlertsEnabled(), + newRepeaterAlertsEnabled: await settingsService .getNewRepeaterAlertsEnabled(), - linkLossAlertsEnabled: await _settingsService.getLinkLossAlertsEnabled(), - batterySaverEnabled: await _settingsService.getBatterySaverEnabled(), - carpeaterEnabled: await _settingsService.getCarpeaterEnabled(), - carpeaterRepeaterId: await _settingsService.getCarpeaterRepeaterId(), - carpeaterPassword: await _settingsService.getCarpeaterPassword(), - carpeaterInterval: await _settingsService.getCarpeaterInterval(), + linkLossAlertsEnabled: await settingsService.getLinkLossAlertsEnabled(), + batterySaverEnabled: await settingsService.getBatterySaverEnabled(), + carpeaterEnabled: await settingsService.getCarpeaterEnabled(), + carpeaterRepeaterId: await settingsService.getCarpeaterRepeaterId(), + carpeaterPassword: await settingsService.getCarpeaterPassword(), + carpeaterInterval: await settingsService.getCarpeaterInterval(), ); - await _runtime.apply(settings); + await runtime.apply(settings); return settings; } } diff --git a/lib/screens/map/widgets/map_quick_settings_panel.dart b/lib/screens/map/widgets/map_quick_settings_panel.dart index 56d5d59..336787e 100644 --- a/lib/screens/map/widgets/map_quick_settings_panel.dart +++ b/lib/screens/map/widgets/map_quick_settings_panel.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import '../../../l10n/generated/app_localizations.dart'; -import '../../../utils/discovery_timeout_options.dart'; -import '../../../utils/ping_distance_options.dart'; +import '../../../widgets/discovery_timeout_options.dart'; +import '../../../widgets/ping_distance_options.dart'; class MapQuickSettingsPanel extends StatelessWidget { const MapQuickSettingsPanel({ diff --git a/lib/screens/repeater_health_screen.dart b/lib/screens/repeater_health_screen.dart index b5ae4ad..c227425 100644 --- a/lib/screens/repeater_health_screen.dart +++ b/lib/screens/repeater_health_screen.dart @@ -745,7 +745,7 @@ class _RepeaterDetailScreen extends StatelessWidget { maxY: 1.0, barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( - getTooltipItem: (group, _, rod, __) { + getTooltipItem: (group, _, rod, _) { if (group.x >= weeks.length) return null; final week = weeks[group.x]; final count = week.value.length; diff --git a/lib/screens/settings/sections/discovery_section.dart b/lib/screens/settings/sections/discovery_section.dart index 890a609..91bdbed 100644 --- a/lib/screens/settings/sections/discovery_section.dart +++ b/lib/screens/settings/sections/discovery_section.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../../l10n/generated/app_localizations.dart'; -import '../../../utils/discovery_timeout_options.dart'; +import '../../../widgets/discovery_timeout_options.dart'; import '../widgets/settings_section_header.dart'; class DiscoverySettingsValues { diff --git a/lib/services/location_quality_filter.dart b/lib/services/location_quality_filter.dart index ca5eaa8..42fc1ac 100644 --- a/lib/services/location_quality_filter.dart +++ b/lib/services/location_quality_filter.dart @@ -9,15 +9,13 @@ import '../models/location_quality_settings.dart'; /// The altitude check is deliberately combined with speed. A blanket altitude /// limit would discard legitimate drives on mountain roads. class LocationQualityFilter { - LocationQualityFilter({ - LocationQualitySettings settings = const LocationQualitySettings(), - }) : _settings = settings; + LocationQualityFilter({this.settings = const LocationQualitySettings()}); - LocationQualitySettings _settings; + LocationQualitySettings settings; Position? _lastAcceptedPosition; - void updateSettings(LocationQualitySettings settings) { - _settings = settings; + void updateSettings(LocationQualitySettings newSettings) { + settings = newSettings; reset(); } @@ -37,10 +35,10 @@ class LocationQualityFilter { } if (position.accuracy.isFinite && - position.accuracy > _settings.maxHorizontalAccuracyMeters) { + position.accuracy > settings.maxHorizontalAccuracyMeters) { return 'horizontal accuracy ${position.accuracy.toStringAsFixed(1)}m ' 'is worse than ' - '${_settings.maxHorizontalAccuracyMeters.toStringAsFixed(0)}m'; + '${settings.maxHorizontalAccuracyMeters.toStringAsFixed(0)}m'; } final reportedSpeed = position.speed.isFinite && position.speed > 0 @@ -50,14 +48,14 @@ class LocationQualityFilter { final effectiveSpeed = math.max(reportedSpeed, derivedSpeed); if (position.altitude.isFinite && - position.altitude >= _settings.airborneAltitudeMeters && - effectiveSpeed >= _settings.airborneSpeedMetersPerSecond) { + position.altitude >= settings.airborneAltitudeMeters && + effectiveSpeed >= settings.airborneSpeedMetersPerSecond) { return 'probable flight: altitude ' '${position.altitude.toStringAsFixed(0)}m, speed ' '${(effectiveSpeed * 3.6).toStringAsFixed(0)}km/h'; } - if (effectiveSpeed >= _settings.maxWardriveSpeedMetersPerSecond) { + if (effectiveSpeed >= settings.maxWardriveSpeedMetersPerSecond) { return 'speed ${(effectiveSpeed * 3.6).toStringAsFixed(0)}km/h ' 'is too high for wardriving'; } diff --git a/lib/services/sound_service.dart b/lib/services/sound_service.dart index af7c344..2aae5cc 100644 --- a/lib/services/sound_service.dart +++ b/lib/services/sound_service.dart @@ -3,13 +3,21 @@ import 'package:flutter/services.dart'; import 'settings_service.dart'; -/// Android ToneGenerator tone constants +/// Android ToneGenerator tone constants. +/// +/// Names mirror the `ToneGenerator.TONE_*` constants from the Android SDK +/// (lowerCamelCase per Dart style): [tonePropBeep] is +/// `ToneGenerator.TONE_PROP_BEEP`, [tonePropAck] is +/// `ToneGenerator.TONE_PROP_ACK`, [tonePropNack] is +/// `ToneGenerator.TONE_PROP_NACK`, [toneCdmaAbbrAlert] is +/// `ToneGenerator.TONE_CDMA_ABBR_ALERT`, and [toneCdmaMedL] is +/// `ToneGenerator.TONE_CDMA_MED_L`. class AndroidTones { - static const int TONE_PROP_BEEP = 36; - static const int TONE_PROP_ACK = 37; - static const int TONE_PROP_NACK = 38; - static const int TONE_CDMA_ABBR_ALERT = 97; - static const int TONE_CDMA_MED_L = 76; + static const int tonePropBeep = 36; + static const int tonePropAck = 37; + static const int tonePropNack = 38; + static const int toneCdmaAbbrAlert = 97; + static const int toneCdmaMedL = 76; } /// Sound and vibration feedback service for wardrive events. @@ -78,7 +86,7 @@ class SoundService { await _vibrate(durationMs: 150, amplitude: 180); } if (_enabled) { - await _playTone(AndroidTones.TONE_PROP_BEEP, durationMs: 100); + await _playTone(AndroidTones.tonePropBeep, durationMs: 100); } } @@ -88,7 +96,7 @@ class SoundService { await _vibrate(durationMs: 200, amplitude: 255); } if (_enabled) { - await _playTone(AndroidTones.TONE_PROP_ACK, durationMs: 200); + await _playTone(AndroidTones.tonePropAck, durationMs: 200); } } @@ -98,7 +106,7 @@ class SoundService { await _vibrate(durationMs: 150, amplitude: 200); } if (_enabled) { - await _playTone(AndroidTones.TONE_CDMA_MED_L, durationMs: 150); + await _playTone(AndroidTones.toneCdmaMedL, durationMs: 150); } } @@ -108,7 +116,7 @@ class SoundService { await _vibrate(durationMs: 400, amplitude: 255); } if (_enabled) { - await _playTone(AndroidTones.TONE_PROP_NACK, durationMs: 300); + await _playTone(AndroidTones.tonePropNack, durationMs: 300); } } @@ -121,9 +129,9 @@ class SoundService { await _vibrate(durationMs: 200, amplitude: 255); } if (_enabled) { - await _playTone(AndroidTones.TONE_CDMA_ABBR_ALERT, durationMs: 250); + await _playTone(AndroidTones.toneCdmaAbbrAlert, durationMs: 250); await Future.delayed(const Duration(milliseconds: 180)); - await _playTone(AndroidTones.TONE_CDMA_ABBR_ALERT, durationMs: 250); + await _playTone(AndroidTones.toneCdmaAbbrAlert, durationMs: 250); } if (_vibrationEnabled) { await Future.delayed(const Duration(milliseconds: 120)); diff --git a/lib/utils/discovery_timeout_options.dart b/lib/utils/discovery_timeout_options.dart index 895039a..30265c6 100644 --- a/lib/utils/discovery_timeout_options.dart +++ b/lib/utils/discovery_timeout_options.dart @@ -19,42 +19,3 @@ class DiscoveryTimeoutOptions { ]; } } - -class DiscoveryTimeoutDropdown extends StatelessWidget { - final int value; - final ValueChanged onChanged; - final bool isDense; - final TextStyle? itemStyle; - - const DiscoveryTimeoutDropdown({ - super.key, - required this.value, - required this.onChanged, - this.isDense = true, - this.itemStyle = const TextStyle(fontSize: 12), - }); - - @override - Widget build(BuildContext context) { - final items = [ - for (final item in DiscoveryTimeoutOptions.valuesFor(value)) - DropdownMenuItem( - value: item, - child: Text(DiscoveryTimeoutOptions.labelFor(item), style: itemStyle), - ), - ]; - final matching = items.where((item) => item.value == value).toList(); - final dropdownValue = matching.length == 1 - ? matching.single.value - : items.first.value; - - return DropdownButton( - value: dropdownValue, - isDense: isDense, - items: items, - onChanged: (selected) { - if (selected != null) onChanged(selected); - }, - ); - } -} diff --git a/lib/utils/ping_distance_options.dart b/lib/utils/ping_distance_options.dart index f394b40..c4c7592 100644 --- a/lib/utils/ping_distance_options.dart +++ b/lib/utils/ping_distance_options.dart @@ -29,32 +29,3 @@ class PingDistanceOptions { ]; } } - -class PingDistanceDropdown extends StatelessWidget { - final double value; - final ValueChanged onChanged; - - const PingDistanceDropdown({ - super.key, - required this.value, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - final items = PingDistanceOptions.menuItems(value); - final matching = items.where((item) => item.value == value).toList(); - final dropdownValue = matching.length == 1 - ? matching.single.value - : items.first.value; - - return DropdownButton( - value: dropdownValue, - isDense: true, - items: items, - onChanged: (selected) { - if (selected != null) onChanged(selected); - }, - ); - } -} diff --git a/lib/widgets/discovery_timeout_options.dart b/lib/widgets/discovery_timeout_options.dart new file mode 100644 index 0000000..0d667c8 --- /dev/null +++ b/lib/widgets/discovery_timeout_options.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import '../utils/discovery_timeout_options.dart'; + +class DiscoveryTimeoutDropdown extends StatelessWidget { + final int value; + final ValueChanged onChanged; + final bool isDense; + final TextStyle? itemStyle; + + const DiscoveryTimeoutDropdown({ + super.key, + required this.value, + required this.onChanged, + this.isDense = true, + this.itemStyle = const TextStyle(fontSize: 12), + }); + + @override + Widget build(BuildContext context) { + final items = [ + for (final item in DiscoveryTimeoutOptions.valuesFor(value)) + DropdownMenuItem( + value: item, + child: Text(DiscoveryTimeoutOptions.labelFor(item), style: itemStyle), + ), + ]; + final matching = items.where((item) => item.value == value).toList(); + final dropdownValue = matching.length == 1 + ? matching.single.value + : items.first.value; + + return DropdownButton( + value: dropdownValue, + isDense: isDense, + items: items, + onChanged: (selected) { + if (selected != null) onChanged(selected); + }, + ); + } +} diff --git a/lib/widgets/ping_distance_options.dart b/lib/widgets/ping_distance_options.dart new file mode 100644 index 0000000..b73e8d6 --- /dev/null +++ b/lib/widgets/ping_distance_options.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import '../utils/ping_distance_options.dart'; + +class PingDistanceDropdown extends StatelessWidget { + final double value; + final ValueChanged onChanged; + + const PingDistanceDropdown({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final items = PingDistanceOptions.menuItems(value); + final matching = items.where((item) => item.value == value).toList(); + final dropdownValue = matching.length == 1 + ? matching.single.value + : items.first.value; + + return DropdownButton( + value: dropdownValue, + isDense: true, + items: items, + onChanged: (selected) { + if (selected != null) onChanged(selected); + }, + ); + } +} diff --git a/test/discovery/discovery_timeout_options_test.dart b/test/discovery/discovery_timeout_options_test.dart index 114b561..07d6570 100644 --- a/test/discovery/discovery_timeout_options_test.dart +++ b/test/discovery/discovery_timeout_options_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_wardrive/utils/discovery_timeout_options.dart'; +import 'package:meshcore_wardrive/widgets/discovery_timeout_options.dart'; void main() { test('includes the 5s timeout used in Settings', () { diff --git a/test/map/map_settings_controller_test.dart b/test/map/map_settings_controller_test.dart index ff5ed6f..f729878 100644 --- a/test/map/map_settings_controller_test.dart +++ b/test/map/map_settings_controller_test.dart @@ -31,7 +31,9 @@ void main() { }); final runtime = _FakeMapSettingsRuntime(); final controller = MapSettingsController( - settingsService: SettingsService(), + settingsService: SettingsService( + credentialsStore: _FakeCredentialsStore(), + ), runtime: runtime, ); @@ -66,7 +68,9 @@ void main() { SharedPreferences.setMockInitialValues({}); final runtime = _FakeMapSettingsRuntime(); final controller = MapSettingsController( - settingsService: SettingsService(), + settingsService: SettingsService( + credentialsStore: _FakeCredentialsStore(), + ), runtime: runtime, ); @@ -102,3 +106,22 @@ class _FakeMapSettingsRuntime implements MapSettingsRuntime { applied = settings; } } + +/// In-memory credentials store so the controller tests never touch the real +/// secure-storage plugin channel. +class _FakeCredentialsStore implements SecureCredentialsStore { + final _values = {}; + + @override + Future read(String key) async => _values[key]; + + @override + Future write(String key, String value) async { + _values[key] = value; + } + + @override + Future delete(String key) async { + _values.remove(key); + } +} diff --git a/test/ping/ping_distance_options_test.dart b/test/ping/ping_distance_options_test.dart index d0d046a..e827ddf 100644 --- a/test/ping/ping_distance_options_test.dart +++ b/test/ping/ping_distance_options_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_wardrive/utils/ping_distance_options.dart'; +import 'package:meshcore_wardrive/widgets/ping_distance_options.dart'; void main() { test('includes the Frequent 50m interval used in Settings', () { diff --git a/test/sound/sound_service_test.dart b/test/sound/sound_service_test.dart index 33d98d5..4143029 100644 --- a/test/sound/sound_service_test.dart +++ b/test/sound/sound_service_test.dart @@ -43,9 +43,7 @@ void main() { expect(toneCalls.length, 2); expect( - toneCalls.every( - (call) => call['tone'] == AndroidTones.TONE_CDMA_ABBR_ALERT, - ), + toneCalls.every((call) => call['tone'] == AndroidTones.toneCdmaAbbrAlert), isTrue, ); expect(vibrateCalls.length, 2); From 4ca67ae8f7844df7b1e9a5a282a4fef1593d0c7e Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 02:29:55 +0300 Subject: [PATCH 12/33] Extract shared pumpDialog harness for dialog widget tests --- test/helpers/pump_dialog.dart | 37 ++++ test/map/appearance_dialogs_test.dart | 27 +-- test/map/connection_dialogs_test.dart | 27 +-- test/map/marker_dialogs_test.dart | 248 +++++++++----------------- 4 files changed, 135 insertions(+), 204 deletions(-) create mode 100644 test/helpers/pump_dialog.dart diff --git a/test/helpers/pump_dialog.dart b/test/helpers/pump_dialog.dart new file mode 100644 index 0000000..92d08e8 --- /dev/null +++ b/test/helpers/pump_dialog.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_wardrive/l10n/generated/app_localizations.dart'; + +import 'l10n_harness.dart'; + +/// Pumps a localized scaffold hosting a single [buttonLabel] button whose +/// handler runs [open] — the shared harness of the dialog widget tests — +/// and returns the localized strings for asserting dialog labels. +Future pumpDialog( + WidgetTester tester, + FutureOr Function(BuildContext context) open, { + String buttonLabel = 'Open', +}) { + return pumpWithL10n( + tester, + Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => open(context), + child: Text(buttonLabel), + ), + ), + ), + ); +} + +/// Taps the [buttonLabel] button and settles the opened dialog route. +Future openDialog( + WidgetTester tester, { + String buttonLabel = 'Open', +}) async { + await tester.tap(find.text(buttonLabel)); + await tester.pumpAndSettle(); +} diff --git a/test/map/appearance_dialogs_test.dart b/test/map/appearance_dialogs_test.dart index 3eff30d..b103464 100644 --- a/test/map/appearance_dialogs_test.dart +++ b/test/map/appearance_dialogs_test.dart @@ -3,30 +3,19 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_wardrive/screens/map/dialogs/appearance_dialogs.dart'; import 'package:meshcore_wardrive/services/settings_service.dart'; -import '../helpers/l10n_harness.dart'; +import '../helpers/pump_dialog.dart'; void main() { testWidgets('map theme dialog returns a typed theme', (tester) async { MapThemeMode? result; - final l10n = await pumpWithL10n( - tester, - Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () async { - result = await showDialog( - context: context, - builder: (context) => const MapThemeDialog(), - ); - }, - child: const Text('Open'), - ), - ), - ), - ); + final l10n = await pumpDialog(tester, (context) async { + result = await showDialog( + context: context, + builder: (context) => const MapThemeDialog(), + ); + }); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + await openDialog(tester); await tester.tap(find.text(l10n.settingsThemeDark)); await tester.pumpAndSettle(); diff --git a/test/map/connection_dialogs_test.dart b/test/map/connection_dialogs_test.dart index da62a94..51f5442 100644 --- a/test/map/connection_dialogs_test.dart +++ b/test/map/connection_dialogs_test.dart @@ -2,30 +2,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_wardrive/screens/map/dialogs/connection_dialogs.dart'; -import '../helpers/l10n_harness.dart'; +import '../helpers/pump_dialog.dart'; void main() { testWidgets('connection dialog returns a typed method', (tester) async { ConnectionMethod? result; - final l10n = await pumpWithL10n( - tester, - Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () async { - result = await showDialog( - context: context, - builder: (context) => const ConnectionMethodDialog(), - ); - }, - child: const Text('Open'), - ), - ), - ), - ); + final l10n = await pumpDialog(tester, (context) async { + result = await showDialog( + context: context, + builder: (context) => const ConnectionMethodDialog(), + ); + }); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + await openDialog(tester); await tester.tap(find.text(l10n.mapScanBluetooth)); await tester.pumpAndSettle(); diff --git a/test/map/marker_dialogs_test.dart b/test/map/marker_dialogs_test.dart index a69554e..f0c1af0 100644 --- a/test/map/marker_dialogs_test.dart +++ b/test/map/marker_dialogs_test.dart @@ -3,32 +3,21 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_wardrive/screens/map/dialogs/marker_dialogs.dart'; -import '../helpers/l10n_harness.dart'; +import '../helpers/pump_dialog.dart'; void main() { testWidgets('map long press sheet returns the selected typed action', ( tester, ) async { MapLongPressAction? result; - final l10n = await pumpWithL10n( - tester, - Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () async { - result = await showModalBottomSheet( - context: context, - builder: (context) => const MapLongPressActionSheet(), - ); - }, - child: const Text('Open'), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + final l10n = await pumpDialog(tester, (context) async { + result = await showModalBottomSheet( + context: context, + builder: (context) => const MapLongPressActionSheet(), + ); + }); + + await openDialog(tester); await tester.tap(find.text(l10n.settingsAddImpossibleZone)); await tester.pumpAndSettle(); @@ -39,30 +28,19 @@ void main() { tester, ) async { PlannedMarkerAction? result; - final l10n = await pumpWithL10n( - tester, - Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () async { - result = await showDialog( - context: context, - builder: (context) => PlannedMarkerInfoDialog( - latitude: 55.75, - longitude: 37.62, - createdAt: DateTime(2026, 8, 20), - label: 'Future repeater', - ), - ); - }, - child: const Text('Open'), - ), + final l10n = await pumpDialog(tester, (context) async { + result = await showDialog( + context: context, + builder: (context) => PlannedMarkerInfoDialog( + latitude: 55.75, + longitude: 37.62, + createdAt: DateTime(2026, 8, 20), + label: 'Future repeater', ), - ), - ); + ); + }); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + await openDialog(tester); await tester.tap(find.text(l10n.mapDelete)); await tester.pumpAndSettle(); @@ -73,26 +51,15 @@ void main() { tester, ) async { PrivacyZoneDraft? result; - final l10n = await pumpWithL10n( - tester, - Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () async { - result = await showDialog( - context: context, - builder: (context) => - const AddPrivacyZoneDialog(center: LatLng(55.75, 37.62)), - ); - }, - child: const Text('Open'), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + final l10n = await pumpDialog(tester, (context) async { + result = await showDialog( + context: context, + builder: (context) => + const AddPrivacyZoneDialog(center: LatLng(55.75, 37.62)), + ); + }); + + await openDialog(tester); await tester.enterText(find.byKey(const Key('zone_dialog_label')), 'Home'); await tester.enterText(find.byKey(const Key('zone_dialog_radius')), '2000'); await tester.tap(find.text(l10n.settingsAddZone)); @@ -107,25 +74,14 @@ void main() { ) async { ImpossibleZoneDraft? result; final center = LatLng(55.75, 37.62); - final l10n = await pumpWithL10n( - tester, - Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () async { - result = await showDialog( - context: context, - builder: (context) => AddImpossibleZoneDialog(center: center), - ); - }, - child: const Text('Open'), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + final l10n = await pumpDialog(tester, (context) async { + result = await showDialog( + context: context, + builder: (context) => AddImpossibleZoneDialog(center: center), + ); + }); + + await openDialog(tester); await tester.enterText( find.byKey(const Key('zone_dialog_label')), 'Airport', @@ -143,26 +99,15 @@ void main() { tester, ) async { PrivacyZoneDraft? result; - final l10n = await pumpWithL10n( - tester, - Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () async { - result = await showDialog( - context: context, - builder: (context) => - const AddPrivacyZoneDialog(center: LatLng(55.75, 37.62)), - ); - }, - child: const Text('Open'), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + final l10n = await pumpDialog(tester, (context) async { + result = await showDialog( + context: context, + builder: (context) => + const AddPrivacyZoneDialog(center: LatLng(55.75, 37.62)), + ); + }); + + await openDialog(tester); await tester.enterText( find.byKey(const Key('zone_dialog_radius')), '99999', @@ -181,24 +126,15 @@ void main() { testWidgets('zone dialog slider updates the radius text field', ( tester, ) async { - await pumpWithL10n( - tester, - Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () => showDialog( - context: context, - builder: (context) => - const AddPrivacyZoneDialog(center: LatLng(55.75, 37.62)), - ), - child: const Text('Open'), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + await pumpDialog(tester, (context) { + return showDialog( + context: context, + builder: (context) => + const AddPrivacyZoneDialog(center: LatLng(55.75, 37.62)), + ); + }); + + await openDialog(tester); expect( tester .widget(find.byKey(const Key('zone_dialog_radius'))) @@ -222,31 +158,20 @@ void main() { ) async { PrivacyZoneDraft? result; final previewed = []; - await pumpWithL10n( - tester, - Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () async { - result = await showAddZoneDialog( - context: context, - center: const LatLng(55.75, 37.62), - title: 'Zone', - blurb: 'Blurb', - labelHint: 'Hint', - onPreviewRadius: previewed.add, - createDraft: (radiusMeters, label) => - PrivacyZoneDraft(radiusMeters: radiusMeters, label: label), - ); - }, - child: const Text('Open'), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + await pumpDialog(tester, (context) async { + result = await showAddZoneDialog( + context: context, + center: const LatLng(55.75, 37.62), + title: 'Zone', + blurb: 'Blurb', + labelHint: 'Hint', + onPreviewRadius: previewed.add, + createDraft: (radiusMeters, label) => + PrivacyZoneDraft(radiusMeters: radiusMeters, label: label), + ); + }); + + await openDialog(tester); // Collapse: the form hides and the preview bar shows the current radius. await tester.tap(find.byKey(const Key('zone_dialog_preview'))); @@ -274,28 +199,19 @@ void main() { }); testWidgets('collapsible zone dialog stays content-sized', (tester) async { - await pumpWithL10n( - tester, - Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () => showAddZoneDialog( - context: context, - center: const LatLng(55.75, 37.62), - title: 'Zone', - blurb: 'Blurb', - labelHint: 'Hint', - createDraft: (radiusMeters, label) => - PrivacyZoneDraft(radiusMeters: radiusMeters, label: label), - ), - child: const Text('Open'), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + await pumpDialog(tester, (context) { + return showAddZoneDialog( + context: context, + center: const LatLng(55.75, 37.62), + title: 'Zone', + blurb: 'Blurb', + labelHint: 'Hint', + createDraft: (radiusMeters, label) => + PrivacyZoneDraft(radiusMeters: radiusMeters, label: label), + ); + }); + + await openDialog(tester); // Exactly one dialog card, and it must keep its intrinsic height instead // of filling the whole inset area (default 800x600 surface minus the From f33a782896ab18522f9ad1b4a85cde6260e93d6b Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 07:53:01 +0300 Subject: [PATCH 13/33] Refresh project docs and mark upstream MQTT sections --- README.md | 61 +++++++---- docs/README.md | 2 +- docs/getting-started.md | 17 +-- docs/guides/lora-companion.md | 196 +++++++++++++++++++--------------- 4 files changed, 161 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index ccfbdb6..e6e4e6d 100644 --- a/README.md +++ b/README.md @@ -89,30 +89,51 @@ setup, public version changes, and per-architecture builds. ``` lib/ -├── main.dart # App entry point +├── main.dart # App entry point and initialization ├── constants/ -│ └── app_version.dart # Version constant +│ └── app_version.dart # Version constant (synced by tool/version.dart) +├── l10n/ # Localization inputs; generated outputs in l10n/generated/ ├── models/ -│ └── models.dart # Data models (Sample, Coverage, Repeater, WSession) +│ ├── models.dart # Core models (Sample, Coverage, Repeater, Edge, WSession, NodeData) +│ ├── impossible_zone.dart # Impossible Zones +│ └── location_quality_settings.dart # GPS quality filter thresholds ├── screens/ │ ├── map_screen.dart # Main map interface +│ ├── map/ # Map screen building blocks +│ │ ├── map_screen_controller.dart # Map data store, caching, level of detail +│ │ ├── map_settings_controller.dart # Settings snapshot handling +│ │ ├── map_runtime_bindings.dart # Stream/timer wiring +│ │ ├── dialogs/ # Marker, coverage, connection, upload, and tile dialogs +│ │ ├── layers/ # Coverage, sample, repeater, route, and overlay layers +│ │ └── widgets/ # Control panels, action buttons, banners +│ ├── settings/ # Settings UI +│ │ ├── settings_screen.dart # Settings screen shell +│ │ ├── settings_page.dart # Settings page content +│ │ ├── settings_dialogs.dart # Shared settings dialogs +│ │ ├── sections/ # Grouped settings sections (discovery, map display, ...) +│ │ └── widgets/ # Settings dialogs and section headers +│ ├── analytics_screen.dart # Coverage analytics +│ ├── session_history_screen.dart # Session history viewer +│ ├── repeater_health_screen.dart # Repeater health statistics +│ ├── signal_trend_screen.dart # Signal trend charts +│ ├── achievements_screen.dart # Achievements +│ ├── ducting_forecast_screen.dart # Ducting forecast +│ ├── device_comparison_screen.dart # Device comparison │ ├── debug_log_screen.dart # Debug terminal -│ ├── debug_diagnostics_screen.dart # Advanced diagnostics -│ ├── session_history_screen.dart # Session history viewer -│ └── signal_trend_screen.dart # Signal trend charts +│ └── debug_diagnostics_screen.dart # Advanced diagnostics ├── services/ -│ ├── location_service.dart # GPS tracking & auto-ping -│ ├── lora_companion_service.dart # LoRa device communication -│ ├── database_service.dart # SQLite database -│ ├── aggregation_service.dart # Coverage calculation -│ ├── upload_service.dart # Web map upload -│ ├── settings_service.dart # User preferences -│ ├── meshcore_protocol.dart # Protocol implementation -│ ├── debug_log_service.dart # Debug logging -│ └── persistent_debug_logger.dart # Persistent log storage -└── utils/ - ├── geohash_utils.dart # Geohash utilities - └── color_blind_palette.dart # Accessible color schemes +│ ├── location_service.dart # GPS tracking & auto-ping +│ ├── lora_companion_service.dart # Companion radio communication (USB/BLE) +│ ├── meshcore_protocol.dart # Companion radio protocol implementation +│ ├── database_service.dart # SQLite database +│ ├── aggregation_service.dart # Coverage calculation +│ ├── settings_service.dart # User preferences & export/import +│ ├── upload_service.dart # Web map upload +│ └── … # Backup, tiles, sound, Wi-Fi positioning, achievements, ... +├── widgets/ # Shared widgets (play button, device picker, banners) +└── utils/ # Helpers: geohash, sample export, color palettes, + # ping distance/time options, compass calibration, ... +``` ## Requirements @@ -151,10 +172,12 @@ assets/ Source assets used during development and packaging docs/ User, setup, protocol, and troubleshooting documentation lib/ Flutter application source constants/ Application-wide constants + l10n/ Localization inputs and generated outputs models/ Domain and persistence models - screens/ UI screens + screens/ UI screens (map, settings, analytics, and more) services/ Device, location, storage, and network services utils/ Reusable helpers + widgets/ Reusable widgets shared across screens test/ Automated tests ``` diff --git a/docs/README.md b/docs/README.md index b4cb5c1..67de71d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,4 +25,4 @@ - [Fork changelog draft (RU)](changelog-fork-ru.md) - [Version history](../CHANGELOG.md) -Screenshot source files are stored in `assets/screenshots/`. +Screenshot source files are stored in `docs/assets/screenshots/`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1ca3b92..5e43dcf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -24,9 +24,11 @@ that file to your Android device and install it. ## First Launch -1. **Grant Permissions**: When you first open the app, grant: - - Location permissions (choose "Allow all the time" for best results) - - Storage permissions (for exporting data) +1. **Grant Permissions**: When you first open the app, grant the location + permissions it asks for (choose "Allow all the time" for best results). + No storage permissions are required: exports and imports go through the + system file picker / share sheet, and the app never asks for storage + access at runtime. 2. **Start Tracking**: Tap the green play button (bottom right) - The button will turn red when tracking is active @@ -84,8 +86,9 @@ The app language (System / English / Русский) is under Settings → App & ### Data Management - **Export**: Saves all collected samples as JSON file - - Files saved to app's external storage - - Named with timestamp: `meshcore_export_YYYYMMDD_HHMMSS.json` + - Save location is chosen in the system file picker (or shared via the + Android share sheet) + - Suggested file name: `meshcore_export_YYYYMMDD_HHMMSS.json` - **Clear**: Deletes all collected data (with confirmation) @@ -138,8 +141,8 @@ Exported JSON contains an array of samples: - Verify INTERNET permission is granted ### Export Fails -- Grant storage permissions -- Check available storage space +- Check available storage space on the device +- Pick a different folder in the system file picker, or use the share option ## Technical Details diff --git a/docs/guides/lora-companion.md b/docs/guides/lora-companion.md index b407e9d..c3650c7 100644 --- a/docs/guides/lora-companion.md +++ b/docs/guides/lora-companion.md @@ -1,23 +1,37 @@ ## MeshCore Wardrive - LoRa Companion Guide -This app now works **exactly like the mesh-map.pages.dev website** - using your LoRa companion device to send actual radio pings and MQTT to listen for observer responses. +> **Fork note:** the upstream app used an MQTT broker to listen for observer +> responses. **MQTT was removed in this fork.** The app now talks only to the +> companion radio over USB/Bluetooth, and repeater responses arrive as frames +> on the same link: `LoRaCompanionService` dispatches them, and +> `lib/services/meshcore_protocol.dart` encodes and parses the frames. +> +> Sections that describe upstream-only MQTT functionality and do **not** apply +> to this fork: **Connect to MQTT** (Setup step 2), **MQTT Configuration**, +> **MQTT Won't Connect** and the MQTT items of **No Responses** +> (Troubleshooting), and **Test MQTT Connection** / **Simulate Observer +> Response** (Testing Without Real Network). + +This app works like the mesh-map.pages.dev website - using your LoRa companion +device to send actual radio pings and collect repeater responses over the same +radio link. ## How It Works ``` 1. Phone (GPS) → USB/Bluetooth → LoRa Companion -2. LoRa Companion → LoRa Radio → MeshCore Observers -3. Observers → MQTT Broker → App listens -4. Green square = Observer heard you -5. Red square = Dead zone (no observer response) +2. LoRa Companion → LoRa Radio → MeshCore mesh +3. Repeaters respond over the LoRa mesh; the companion radio relays the responses back +4. Green square = A repeater heard you +5. Red square = Dead zone (no response) ``` ### Key Points -- **LoRa device transmits** the actual radio ping -- **MQTT listens** for responses from observers +- **LoRa device transmits** the actual radio ping (zero-hop advert + discovery request) +- **Responses arrive over the radio link** - no MQTT broker is involved - Tests **real mesh coverage**, not just internet connectivity -- Pings every ~0.5 miles (adjustable) +- Auto-ping by time interval (default 30 s), by distance (default ~0.5 miles), or both - Can ignore your mobile repeater to avoid false positives ## Setup Steps @@ -34,11 +48,11 @@ This app now works **exactly like the mesh-map.pages.dev website** - using your **Option B: Bluetooth** 1. Pair LoRa device in Android Bluetooth settings 2. In app: Tap "Scan Bluetooth Devices" -3. Select your device from the live list (e.g., "Meshtastic_xxxx"). Previously - used devices appear immediately. +3. Select your device from the live list. Previously used devices appear + immediately. 4. Wait for "Connected via Bluetooth" -### 2. Connect to MQTT +### 2. Connect to MQTT *(upstream only - removed in this fork)* 1. Tap "Connect to MQTT" 2. Enter broker details (default: `mqtt.meshcore.io`) @@ -47,43 +61,51 @@ This app now works **exactly like the mesh-map.pages.dev website** - using your ### 3. Configure Settings (Optional) +Ping and discovery options live in Settings → Discovery and in the quick +settings panel on the map screen: + +**Ping Mode:** +- **Distance** - ping after moving a set distance (default ~0.5 miles) +- **Time** - ping on a fixed interval (default 30 s) +- **Both** - ping when either trigger fires first + +**Ping Interval:** +- Distance presets: 50 m, 200 m, 400 m, 0.5 mi (805 m), 1 mi (1609 m) +- Time presets: 5 s to 5 minutes + +**Discovery Timeout:** +- How long the app waits for repeater responses after each ping +- Adjustable from 5 to 30 seconds (default 10 s) + **Ignore Mobile Repeater:** - If you carry a portable repeater, set its prefix - Example: If your repeater ID is `MOB-123`, enter `MOB-` - This prevents false positive pings -**Ping Interval:** -- Default: Every ~0.5 miles -- Adjust distance filter in settings - ### 4. Start Wardriving 1. Enable "Auto-Ping" toggle 2. Tap green play button to start GPS tracking 3. As you move: - - Every 0.5 miles → LoRa device sends ping - - Wait 30 seconds for observers to respond via MQTT - - Green = heard by observer + - A ping fires when the distance or time trigger is reached + - The app waits up to the discovery timeout for repeater responses + - Green = heard by a repeater - Red = no response (dead zone) ## Supported LoRa Devices -The app should work with: -- **Meshtastic** devices (T-Beam, Heltec, LILYGO, etc.) -- **Custom LoRa** boards with serial interface -- Any device that accepts ping commands via UART - -### Command Format +This fork works with radios running the **MeshCore companion firmware**, +connected over USB serial or Bluetooth LE (boards such as T-Beam, Heltec, +LILYGO, and other LoRa boards supported by that firmware). -The app sends: `ping {8-char-id}\n` +### Protocol -Example: `ping abc12345\n` +The app speaks the MeshCore companion radio binary protocol. Frame layout, +command codes (`CMD_*`), and response codes (`RESP_CODE_*`) are defined in +`lib/services/meshcore_protocol.dart` and must stay in sync with the +companion firmware (see `companion_protocol.md` in the MeshCore repository). -Your LoRa device should: -1. Transmit this as a broadcast LoRa message -2. Include the ping ID in the transmission - -## MQTT Configuration +## MQTT Configuration *(upstream only - removed in this fork)* ### Default Settings @@ -114,43 +136,42 @@ When an observer hears your ping, it should publish to MQTT: ## Customization -### Change MQTT Broker +Customization happens through app settings - no code edits required: -Edit `lib/services/lora_companion_service.dart`: +### Ping Trigger and Interval -```dart -// Line 69-71 -static const String defaultMqttBroker = 'mqtt.meshcore.io'; -static const int defaultMqttPort = 1883; -static const String baseTopic = 'meshcore'; -``` +Settings → Discovery → **Ping mode**: -### Change MQTT Topic Pattern +- **Distance**: ping after moving a set distance. Presets: 50 m, 200 m, + 400 m, 0.5 mi (805 m), 1 mi (1609 m). Default: 805 m (~0.5 miles). +- **Time**: ping on a fixed interval. Presets: 5 s … 5 min. Default: 30 s. +- **Both**: whichever trigger fires first. -Edit line 270: -```dart -final topic = '$baseTopic/observer/+/pong'; -``` +The same ping mode, interval, and timeout controls are also available in the +quick settings panel on the map screen. -### Adjust Ping Command +### Discovery Timeout -Edit line 314: -```dart -await _sendToDevice('ping $pingId\n'); -``` +Settings → Discovery → **Discovery timeout**: how long the app collects +repeater responses after a ping, from 5 to 30 seconds (default 10 s). The +**Thorough response collection** toggle next to it keeps collecting until the +timeout instead of finishing early. -For custom LoRa devices, change this to match your command format. +### Ignore / Include Repeaters -### Change Ping Interval +Settings → Discovery: -The app pings based on distance moved. To change: +- **Ignore repeaters**: prefixes of repeaters to exclude from results (for + example, your own mobile repeater) to avoid false positives. +- **Include only repeaters**: whitelist of prefixes; when set, only matching + repeaters are shown. -Edit `lib/services/location_service.dart` line 71: -```dart -distanceFilter: 5, // meters - reduce for more frequent pings -``` +### Protocol Constants -For ~0.5 miles: `distanceFilter: 805` (805 meters = 0.5 miles) +Protocol-level constants (response layout version, maximum frame size, `CMD_*` +command codes, `RESP_CODE_*` response codes) are defined in +`lib/services/meshcore_protocol.dart`. They mirror the MeshCore companion +firmware - change them only together with the firmware side of the link. ## Data Export @@ -169,8 +190,8 @@ Exported samples include all ping data: } ``` -- `pingSuccess: true` = Observer heard your ping (green) -- `pingSuccess: false` = No observer response (red) +- `pingSuccess: true` = A repeater heard your ping (green) +- `pingSuccess: false` = No response (red) - `pingSuccess: null` = Auto-ping was disabled ## Troubleshooting @@ -187,45 +208,39 @@ Exported samples include all ping data: - Ensure device is in discoverable mode - Check device battery -### MQTT Won't Connect +### MQTT Won't Connect *(upstream only - removed in this fork)* - Verify broker address and port - Check internet connection (cellular/WiFi) - Confirm credentials if required - Test broker with MQTT client (MQTT Explorer, mosquitto_sub) -### No Observer Responses +### No Responses (Dead Zones) -- Verify observers are online and publishing to MQTT -- Check MQTT topic pattern matches -- Ensure LoRa device is actually transmitting -- Confirm ping command format is correct -- Check if you're in range of any observers +- Ensure the LoRa device is actually transmitting (check the debug terminal) +- Check that you are in range of any repeaters +- Increase the discovery timeout in Settings → Discovery +- If you carry a repeater, review the ignored-prefix filter ### Ping Timeout Too Long -Default timeout is 30 seconds. To reduce: - -Edit `lib/services/location_service.dart` line 146: -```dart -timeoutSeconds: 30, // Reduce this value -``` +Lower the **Discovery timeout** in Settings → Discovery (5–30 s presets, +default 10 s). No code changes required. ### False Positives from Mobile Repeater -Set ignored repeater prefix in app settings: -- Settings → Ignore Repeater Prefix -- Enter your repeater's ID prefix (e.g., `MOB-`) +Set the ignored repeater prefix in Settings → Discovery → Ignore repeaters +(e.g., `MOB-`). ## Testing Without Real Network ### Test LoRa Connection 1. Connect device via USB/Bluetooth -2. Check device response in logs +2. Check device response in the debug terminal 3. Send test ping manually -### Test MQTT Connection +### Test MQTT Connection *(upstream only - removed in this fork)* Use a public MQTT broker for testing: ```dart @@ -234,7 +249,7 @@ port: 1883 // No authentication required ``` -### Simulate Observer Response +### Simulate Observer Response *(upstream only - removed in this fork)* Use MQTT client to publish test response: @@ -251,11 +266,16 @@ mosquitto_pub -h mqtt.meshcore.io -t meshcore/observer/TEST/pong -m '{ ### Custom Ping Logic -For non-Meshtastic devices, modify `_sendToDevice()` in `lora_companion_service.dart`. +Ping/discovery is implemented in `LoRaCompanionService.ping()` +(`lib/services/lora_companion_service.dart`): it sends a zero-hop +advertisement and a discovery request through the companion protocol, then +matches repeater responses by tag until the timeout. ### Custom Response Parsing -Modify `_handleObserverResponse()` (line 383) to match your MQTT response format. +Frame dispatch lives in `LoRaCompanionService` (`_handleFrame`); frame +encoding/parsing helpers and all protocol constants live in +`lib/services/meshcore_protocol.dart`. ### Add Manual Ping Button @@ -263,19 +283,19 @@ Access `locationService.loraCompanion.ping()` directly for single pings. ## Performance Tips -1. **Ping Interval**: 0.5 miles is good balance - closer intervals may slow you down waiting for responses -2. **Timeout**: 30 seconds is reasonable for mesh networks +1. **Ping Interval**: ~0.5 miles is good balance - closer intervals may slow you down waiting for responses +2. **Timeout**: 10–30 seconds covers most mesh response times 3. **Battery**: USB connection drains less battery than Bluetooth -4. **Range**: Stay within observer range for best results +4. **Range**: Stay within repeater range for best results ## Security & Privacy -- Your device ID is randomly generated -- GPS coordinates are sent to MQTT broker -- Ping IDs are random 8-character strings +- GPS coordinates stay on the device unless you explicitly export or upload data +- Ping/discovery requests are transmitted over the LoRa mesh by your radio - No personal information transmitted -- All collected data stays local unless exported +- All collected data stays local unless exported or uploaded ## Credits -This implementation replicates the exact workflow from mesh-map.pages.dev for MeshCore coverage mapping with LoRa companions. +This implementation replicates the workflow from mesh-map.pages.dev for +MeshCore coverage mapping with LoRa companions. From 095227ac99ccebe51df3f3fe0187d7a3fc96bfc5 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 08:02:01 +0300 Subject: [PATCH 14/33] Extract update check flow from map screen state --- lib/screens/map/dialogs/update_flow.dart | 88 ++++++++++++++++++++++++ lib/screens/map_screen.dart | 64 ++--------------- 2 files changed, 95 insertions(+), 57 deletions(-) create mode 100644 lib/screens/map/dialogs/update_flow.dart diff --git a/lib/screens/map/dialogs/update_flow.dart b/lib/screens/map/dialogs/update_flow.dart new file mode 100644 index 0000000..578ad25 --- /dev/null +++ b/lib/screens/map/dialogs/update_flow.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../constants/app_version.dart'; +import '../../../l10n/generated/app_localizations.dart'; +import '../../../utils/update_check.dart'; +import 'map_workflow_dialogs.dart'; + +/// In-app update check and releases-page navigation. +/// +/// The flow owns no state: it performs the GitHub releases request itself and +/// reports user-facing outcomes through [onShowSnackBar]. The caller passes +/// the owning screen's [context] so mounted checks after asynchronous gaps +/// match the screen's lifetime, and localization and dialogs resolve against +/// the same context. +class UpdateFlow { + const UpdateFlow({required this.context, required this.onShowSnackBar}); + + /// Screen context used for mounted checks, localization, and dialogs. + final BuildContext context; + + /// Shows a transient message; the owner guards this callback with its own + /// mounted check. + final void Function(String message) onShowSnackBar; + + /// Queries GitHub releases and offers to download a newer version. + Future checkForUpdates() async { + try { + final response = await http + .get(Uri.parse(updateCheckApiUrl)) + .timeout(const Duration(seconds: 5)); + + if (response.statusCode == 200) { + final releases = jsonDecode(response.body) as List; + final latestVersion = latestVersionFromReleaseTags( + releases + .whereType>() + .map((release) => release['tag_name']) + .whereType(), + ); + + if (!context.mounted) return; + if (latestVersion == null) { + onShowSnackBar(AppLocalizations.of(context).mapCouldNotCheckUpdates); + } else if (!isNewerAppVersion(latestVersion, appVersion)) { + onShowSnackBar(AppLocalizations.of(context).mapOnLatestVersion); + } else { + final shouldDownload = await showDialog( + context: context, + builder: (dialogContext) => UpdateAvailableDialog( + latestVersion: latestVersion, + currentVersion: appVersion, + ), + ); + if (shouldDownload == true) await openGitHub(); + } + } else { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapCouldNotCheckUpdates); + } + } on SocketException { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapNoInternetTryAgain); + } on TimeoutException { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapUpdateCheckTimedOut); + } catch (_) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapCouldNotCheckUpdates); + } + } + + /// Opens the releases page in an external browser. + Future openGitHub() async { + final url = Uri.parse(updateCheckReleasesUrl); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } else { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapCouldNotOpenGitHub); + } + } +} diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 90ac5f8..20900b4 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -30,7 +30,6 @@ import '../utils/community_coverage.dart'; import '../utils/bluetooth_scan.dart'; import '../utils/sample_export.dart'; import '../utils/ping_burst.dart'; -import '../utils/update_check.dart'; import '../widgets/compass_calibration.dart'; import '../widgets/bluetooth_device_picker_dialog.dart'; import 'map/layers/coverage_prediction_layer.dart'; @@ -52,6 +51,7 @@ import 'map/dialogs/map_entity_dialogs.dart'; import 'map/dialogs/map_workflow_dialogs.dart'; import 'map/dialogs/marker_dialogs.dart'; import 'map/dialogs/offline_tile_dialogs.dart'; +import 'map/dialogs/update_flow.dart'; import 'map/dialogs/upload_endpoint_dialog.dart'; import 'map/map_runtime_bindings.dart'; import 'map/map_screen_controller.dart'; @@ -64,8 +64,6 @@ import '../services/widget_service.dart'; import 'package:usb_serial/usb_serial.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:http/http.dart' as http; -import 'package:url_launcher/url_launcher.dart'; import 'package:file_picker/file_picker.dart'; import 'package:share_plus/share_plus.dart'; import 'package:screenshot/screenshot.dart'; @@ -1768,61 +1766,13 @@ class _MapScreenState extends State { ); } - Future _checkForUpdates() async { - try { - final response = await http - .get(Uri.parse(updateCheckApiUrl)) - .timeout(const Duration(seconds: 5)); - - if (response.statusCode == 200) { - final releases = jsonDecode(response.body) as List; - final latestVersion = latestVersionFromReleaseTags( - releases - .whereType>() - .map((release) => release['tag_name']) - .whereType(), - ); - - if (!mounted) return; - if (latestVersion == null) { - _showSnackBar(AppLocalizations.of(context).mapCouldNotCheckUpdates); - } else if (!isNewerAppVersion(latestVersion, appVersion)) { - _showSnackBar(AppLocalizations.of(context).mapOnLatestVersion); - } else { - final shouldDownload = await showDialog( - context: context, - builder: (context) => UpdateAvailableDialog( - latestVersion: latestVersion, - currentVersion: appVersion, - ), - ); - if (shouldDownload == true) await _openGitHub(); - } - } else { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapCouldNotCheckUpdates); - } - } on SocketException { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapNoInternetTryAgain); - } on TimeoutException { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapUpdateCheckTimedOut); - } catch (_) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapCouldNotCheckUpdates); - } - } + Future _checkForUpdates() => UpdateFlow( + context: context, + onShowSnackBar: _showSnackBar, + ).checkForUpdates(); - Future _openGitHub() async { - final url = Uri.parse(updateCheckReleasesUrl); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } else { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapCouldNotOpenGitHub); - } - } + Future _openGitHub() => + UpdateFlow(context: context, onShowSnackBar: _showSnackBar).openGitHub(); void _toggleFollowLocation() { setState(() { From 190b40f190afeb4dec30a15a082c734b2e3887c5 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 08:03:31 +0300 Subject: [PATCH 15/33] Avoid void_checks lint in dialog test closures --- test/map/marker_dialogs_test.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/map/marker_dialogs_test.dart b/test/map/marker_dialogs_test.dart index f0c1af0..becca90 100644 --- a/test/map/marker_dialogs_test.dart +++ b/test/map/marker_dialogs_test.dart @@ -126,8 +126,8 @@ void main() { testWidgets('zone dialog slider updates the radius text field', ( tester, ) async { - await pumpDialog(tester, (context) { - return showDialog( + await pumpDialog(tester, (context) async { + await showDialog( context: context, builder: (context) => const AddPrivacyZoneDialog(center: LatLng(55.75, 37.62)), @@ -199,8 +199,8 @@ void main() { }); testWidgets('collapsible zone dialog stays content-sized', (tester) async { - await pumpDialog(tester, (context) { - return showAddZoneDialog( + await pumpDialog(tester, (context) async { + await showAddZoneDialog( context: context, center: const LatLng(55.75, 37.62), title: 'Zone', From 7b7eebb10d5ca9c58877cda85a64f7dc9ac5dca6 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 08:20:02 +0300 Subject: [PATCH 16/33] Extract data import and export flows from map screen --- lib/screens/map/data_io.dart | 461 +++++++++++++++++++++++++++++++++++ lib/screens/map_screen.dart | 390 +++-------------------------- 2 files changed, 489 insertions(+), 362 deletions(-) create mode 100644 lib/screens/map/data_io.dart diff --git a/lib/screens/map/data_io.dart b/lib/screens/map/data_io.dart new file mode 100644 index 0000000..1e38e4a --- /dev/null +++ b/lib/screens/map/data_io.dart @@ -0,0 +1,461 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../l10n/generated/app_localizations.dart'; +import '../../models/models.dart'; +import '../../services/database_backup_service.dart'; +import '../../services/database_service.dart'; +import '../../services/location_service.dart'; +import '../../services/settings_service.dart'; +import '../../utils/sample_export.dart'; +import 'dialogs/map_workflow_dialogs.dart'; + +/// Data and settings import/export orchestration for the map screen. +/// +/// The class owns no state: the caller passes the owning screen's [context] +/// for localization, dialogs, and mounted checks, services for persistence, +/// and callbacks for everything that belongs to the screen (snackbars, +/// cache invalidation, and reloads of screen-owned data). Serialization of +/// sample exports stays in `SampleExport`; this class only orchestrates +/// pickers, sharing, dialogs, and the database. +class MapDataIo { + const MapDataIo({ + required this.context, + required this.onShowSnackBar, + required this.locationService, + required this.databaseService, + required this.databaseBackupService, + required this.settingsService, + required this.isTracking, + required this.sampleCount, + required this.repeaters, + required this.invalidateCaches, + required this.loadSamples, + required this.loadSettings, + required this.onDatabaseRestored, + required this.loadMarkers, + required this.loadPrivacyZones, + required this.loadImpossibleZones, + }); + + /// Screen context used for mounted checks, localization, and dialogs. + final BuildContext context; + + /// Shows a transient message; the owner guards this callback with its own + /// mounted check. + final void Function(String message) onShowSnackBar; + + final LocationService locationService; + final DatabaseService databaseService; + final DatabaseBackupService databaseBackupService; + final SettingsService settingsService; + + /// Current tracking state; import of a database backup is refused while + /// tracking is active. + final bool Function() isTracking; + + /// Current sample count, shown in the clear-history confirmation and in its + /// completion snackbar. + final int Function() sampleCount; + + /// Current repeater contacts included in JSON data exports. + final List Function() repeaters; + + /// Drops screen-owned derived caches after data changes. + final VoidCallback invalidateCaches; + + /// Reloads samples after data or settings changes. + final Future Function() loadSamples; + + /// Reloads applied settings after a settings import. + final Future Function() loadSettings; + + /// Resets the session map view after a database restore. + final VoidCallback onDatabaseRestored; + + final Future Function() loadMarkers; + final Future Function() loadPrivacyZones; + final Future Function() loadImpossibleZones; + + /// Clears all samples after a confirmation dialog. + Future clearData() async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => + ClearMapHistoryDialog(sampleCount: sampleCount()), + ); + + if (confirmed == true) { + await locationService.clearAllSamples(); + await loadSamples(); + onShowSnackBar(l10n.mapDeletedSamples(sampleCount())); + } + } + + /// Exports all samples in the selected format, saving to a file or sharing. + Future exportData() async { + // Ask user for export format + final format = await showDialog( + context: context, + builder: (dialogContext) => const SampleExportFormatDialog(), + ); + + if (format == null) return; + + // Ask save or share + if (!context.mounted) return; + final choice = await showDialog( + context: context, + builder: (dialogContext) => ExportDestinationDialog( + title: AppLocalizations.of(context).mapExportAs(format.displayName), + ), + ); + + if (choice == null) return; + + try { + final samples = await locationService.getAllSamples(); + final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); + String content; + String fileName; + String extension; + + switch (format) { + case SampleExportFormat.csv: + content = SampleExport.buildCsv(samples); + extension = 'csv'; + fileName = 'meshcore_export_$timestamp.csv'; + break; + case SampleExportFormat.gpx: + content = SampleExport.buildGpx(samples); + extension = 'gpx'; + fileName = 'meshcore_export_$timestamp.gpx'; + break; + case SampleExportFormat.kml: + content = SampleExport.buildKml(samples); + extension = 'kml'; + fileName = 'meshcore_export_$timestamp.kml'; + break; + case SampleExportFormat.json: + // Include discovered repeater contacts in the export + final repeaterJsonList = repeaters() + .where( + (r) => + r.position.latitude != 0.0 || r.position.longitude != 0.0, + ) + .map((r) => r.toJson()) + .toList(); + final data = await databaseService.exportAllData( + repeaters: repeaterJsonList, + ); + content = jsonEncode(data); + extension = 'json'; + fileName = 'meshcore_export_$timestamp.json'; + } + + if (choice == ExportDestination.save) { + if (!context.mounted) return; + await FilePicker.platform.saveFile( + dialogTitle: AppLocalizations.of(context).mapSaveExport, + fileName: fileName, + type: FileType.custom, + allowedExtensions: [extension], + bytes: utf8.encode(content), + ); + if (!context.mounted) return; + onShowSnackBar( + AppLocalizations.of(context) + .mapExportedSamples(samples.length, format.displayName), + ); + } else if (choice == ExportDestination.share) { + final directory = await getExternalStorageDirectory(); + final file = File('${directory!.path}/$fileName'); + await file.writeAsString(content); + + if (!context.mounted) return; + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: AppLocalizations.of(context).mapExportShareSubject, + text: AppLocalizations.of(context) + .mapExportShareText(samples.length), + ), + ); + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapExportShared); + } + } catch (e) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapExportFailed('$e')); + } + } + + /// Imports sample/session JSON files, merging multiple picks. + Future importData() async { + try { + // Pick JSON file(s) — allow multiple for community merge + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + allowMultiple: true, + ); + + if (result == null || result.files.isEmpty) return; + + int totalSamplesImported = 0; + int totalSessionsImported = 0; + final Set sources = {}; + + for (final pickedFile in result.files) { + if (pickedFile.path == null) continue; + final file = File(pickedFile.path!); + final jsonString = await file.readAsString(); + final dynamic jsonData = jsonDecode(jsonString); + + // Use unified import that handles both old (array) and new (object) formats + final counts = await databaseService.importAllData(jsonData); + totalSamplesImported += counts['samples'] ?? 0; + totalSessionsImported += counts['sessions'] ?? 0; + + // Extract sources for display + if (jsonData is Map && + jsonData.containsKey('samples')) { + for (final s in (jsonData['samples'] as List)) { + final map = s as Map; + if (map['source'] != null) sources.add(map['source'] as String); + } + } else if (jsonData is List) { + for (final s in jsonData) { + final map = s as Map; + if (map['source'] != null) sources.add(map['source'] as String); + } + } + } + + // Reload map + invalidateCaches(); + await loadSamples(); + + if (!context.mounted) return; + final l10n = AppLocalizations.of(context); + final sessionLabel = totalSessionsImported > 0 + ? l10n.mapImportedSessionsSuffix(totalSessionsImported) + : ''; + final sourceLabel = sources.isNotEmpty + ? l10n.mapImportedFromSources(sources.join(', ')) + : ''; + onShowSnackBar( + '${l10n.mapImportedSamples(totalSamplesImported)}$sessionLabel$sourceLabel', + ); + } catch (e) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapImportFailed('$e')); + } + } + + /// Exports settings JSON to a file or via the share sheet. + Future exportSettings() async { + try { + final jsonString = await settingsService.exportSettingsJson(); + final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); + final fileName = 'meshcore_settings_$timestamp.json'; + + // Ask save or share + if (!context.mounted) return; + final choice = await showDialog( + context: context, + builder: (dialogContext) => ExportDestinationDialog( + title: AppLocalizations.of(context).settingsExportSettings, + ), + ); + + if (choice == null) return; + + if (choice == ExportDestination.save) { + if (!context.mounted) return; + await FilePicker.platform.saveFile( + dialogTitle: AppLocalizations.of(context).mapSaveSettings, + fileName: fileName, + type: FileType.custom, + allowedExtensions: ['json'], + bytes: utf8.encode(jsonString), + ); + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapSettingsExported); + } else if (choice == ExportDestination.share) { + final dir = await getApplicationDocumentsDirectory(); + final file = File('${dir.path}/$fileName'); + await file.writeAsString(jsonString); + if (!context.mounted) return; + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + text: AppLocalizations.of(context).mapSettingsShareText, + ), + ); + } + } catch (e) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapExportFailed('$e')); + } + } + + /// Imports settings JSON after a confirmation dialog and applies it. + Future importSettings() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); + + if (result == null || result.files.isEmpty) return; + final pickedFile = result.files.single; + + final file = File(pickedFile.path!); + final jsonString = await file.readAsString(); + + // Show confirmation dialog + if (!context.mounted) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => const ImportSettingsConfirmationDialog(), + ); + + if (confirmed != true) return; + + final applied = await settingsService.importSettingsJson(jsonString); + + // Reload settings to apply changes + await loadSettings(); + invalidateCaches(); + await loadSamples(); + + if (!context.mounted) return; + onShowSnackBar( + AppLocalizations.of(context).mapImportedSettingsCount(applied), + ); + } on FormatException catch (e) { + if (!context.mounted) return; + onShowSnackBar( + AppLocalizations.of(context).mapInvalidSettingsFile(e.message), + ); + } catch (e) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapImportFailed('$e')); + } + } + + /// Exports a database backup snapshot to a file or via the share sheet. + Future exportDatabase() async { + try { + final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); + final fileName = 'meshcore_backup_$timestamp.db'; + + if (!context.mounted) return; + final choice = await showDialog( + context: context, + builder: (dialogContext) => ExportDestinationDialog( + title: AppLocalizations.of(context).settingsExportDatabase, + ), + ); + + if (choice == null) return; + + if (choice == ExportDestination.save) { + final bytes = await databaseBackupService.exportSnapshotBytes(); + if (!context.mounted) return; + await FilePicker.platform.saveFile( + dialogTitle: AppLocalizations.of(context).mapSaveExport, + fileName: fileName, + type: FileType.custom, + allowedExtensions: ['db'], + bytes: bytes, + ); + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).settingsDatabaseExported); + } else if (choice == ExportDestination.share) { + final dir = await getApplicationDocumentsDirectory(); + final file = await databaseBackupService.exportToShareFile( + dir, + fileName, + ); + if (!context.mounted) return; + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: AppLocalizations.of(context).settingsExportDatabase, + text: AppLocalizations.of(context).settingsDatabaseShareText, + ), + ); + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapExportShared); + } + } catch (e) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapExportFailed('$e')); + } + } + + /// Restores a database backup after validation and confirmation, then + /// reloads everything derived from the database. + Future importDatabase() async { + final l10n = AppLocalizations.of(context); + if (isTracking()) { + onShowSnackBar(l10n.settingsImportDatabaseStopTracking); + return; + } + + try { + // FileType.custom with ['db'] is unusable on Android: '.db' has no MIME + // mapping there, so the picker greys the backup out and it cannot be + // selected. Accept any file instead and rely on validateBackupFile + // below to reject non-backup contents with a clear error. + final result = await FilePicker.platform.pickFiles(type: FileType.any); + + if (result == null || result.files.isEmpty) return; + final backupPath = result.files.single.path; + if (backupPath == null) return; + + // Validate before destroying anything. + await databaseBackupService.validateBackupFile(backupPath); + + if (!context.mounted) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => const ImportDatabaseConfirmationDialog(), + ); + if (confirmed != true) return; + + await databaseBackupService.restoreFromFile(backupPath); + + // Reload everything that is derived from the database. + onDatabaseRestored(); + invalidateCaches(); + await loadSamples(); + await loadMarkers(); + await loadPrivacyZones(); + await loadImpossibleZones(); + + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).settingsDatabaseImported); + } on DatabaseBackupException catch (e) { + if (!context.mounted) return; + onShowSnackBar(switch (e.error) { + DatabaseBackupValidationError.newerVersion => AppLocalizations.of( + context, + ).settingsDatabaseNewerVersion, + _ => AppLocalizations.of(context).settingsDatabaseInvalidFile, + }); + } catch (e) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapImportFailed('$e')); + } + } +} diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 20900b4..da27624 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -28,7 +27,6 @@ import '../utils/heading_utils.dart'; import '../utils/session_map_view.dart'; import '../utils/community_coverage.dart'; import '../utils/bluetooth_scan.dart'; -import '../utils/sample_export.dart'; import '../utils/ping_burst.dart'; import '../widgets/compass_calibration.dart'; import '../widgets/bluetooth_device_picker_dialog.dart'; @@ -53,6 +51,7 @@ import 'map/dialogs/marker_dialogs.dart'; import 'map/dialogs/offline_tile_dialogs.dart'; import 'map/dialogs/update_flow.dart'; import 'map/dialogs/upload_endpoint_dialog.dart'; +import 'map/data_io.dart'; import 'map/map_runtime_bindings.dart'; import 'map/map_screen_controller.dart'; import 'map/map_settings_controller.dart'; @@ -64,7 +63,6 @@ import '../services/widget_service.dart'; import 'package:usb_serial/usb_serial.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:share_plus/share_plus.dart'; import 'package:screenshot/screenshot.dart'; import 'package:flutter_map_cache/flutter_map_cache.dart'; @@ -1132,372 +1130,40 @@ class _MapScreenState extends State { return onOpen(); } - Future _clearData() async { - final l10n = AppLocalizations.of(context); - final confirmed = await showDialog( - context: context, - builder: (context) => ClearMapHistoryDialog(sampleCount: _sampleCount), - ); - - if (confirmed == true) { - await _locationService.clearAllSamples(); - await _loadSamples(); - _showSnackBar(l10n.mapDeletedSamples(_sampleCount)); - } - } - - Future _exportData() async { - // Ask user for export format - final format = await showDialog( - context: context, - builder: (context) => const SampleExportFormatDialog(), - ); - - if (format == null) return; - - // Ask save or share - if (!mounted) return; - final choice = await showDialog( - context: context, - builder: (context) => ExportDestinationDialog( - title: AppLocalizations.of(context).mapExportAs(format.displayName), - ), - ); - - if (choice == null) return; - - try { - final samples = await _locationService.getAllSamples(); - final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); - String content; - String fileName; - String extension; - - switch (format) { - case SampleExportFormat.csv: - content = SampleExport.buildCsv(samples); - extension = 'csv'; - fileName = 'meshcore_export_$timestamp.csv'; - break; - case SampleExportFormat.gpx: - content = SampleExport.buildGpx(samples); - extension = 'gpx'; - fileName = 'meshcore_export_$timestamp.gpx'; - break; - case SampleExportFormat.kml: - content = SampleExport.buildKml(samples); - extension = 'kml'; - fileName = 'meshcore_export_$timestamp.kml'; - break; - case SampleExportFormat.json: - // Include discovered repeater contacts in the export - final repeaterJsonList = _repeaters - .where( - (r) => - r.position.latitude != 0.0 || r.position.longitude != 0.0, - ) - .map((r) => r.toJson()) - .toList(); - final data = await _databaseService.exportAllData( - repeaters: repeaterJsonList, - ); - content = jsonEncode(data); - extension = 'json'; - fileName = 'meshcore_export_$timestamp.json'; - } - - if (choice == ExportDestination.save) { - if (!mounted) return; - await FilePicker.platform.saveFile( - dialogTitle: AppLocalizations.of(context).mapSaveExport, - fileName: fileName, - type: FileType.custom, - allowedExtensions: [extension], - bytes: utf8.encode(content), - ); - if (!mounted) return; - _showSnackBar( - AppLocalizations.of(context) - .mapExportedSamples(samples.length, format.displayName), - ); - } else if (choice == ExportDestination.share) { - final directory = await getExternalStorageDirectory(); - final file = File('${directory!.path}/$fileName'); - await file.writeAsString(content); - - if (!mounted) return; - await SharePlus.instance.share( - ShareParams( - files: [XFile(file.path)], - subject: AppLocalizations.of(context).mapExportShareSubject, - text: AppLocalizations.of(context) - .mapExportShareText(samples.length), - ), - ); - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapExportShared); - } - } catch (e) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapExportFailed('$e')); - } - } - - Future _importData() async { - try { - // Pick JSON file(s) — allow multiple for community merge - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['json'], - allowMultiple: true, - ); - - if (result == null || result.files.isEmpty) return; - - int totalSamplesImported = 0; - int totalSessionsImported = 0; - final Set sources = {}; - - for (final pickedFile in result.files) { - if (pickedFile.path == null) continue; - final file = File(pickedFile.path!); - final jsonString = await file.readAsString(); - final dynamic jsonData = jsonDecode(jsonString); - - // Use unified import that handles both old (array) and new (object) formats - final counts = await _databaseService.importAllData(jsonData); - totalSamplesImported += counts['samples'] ?? 0; - totalSessionsImported += counts['sessions'] ?? 0; - - // Extract sources for display - if (jsonData is Map && - jsonData.containsKey('samples')) { - for (final s in (jsonData['samples'] as List)) { - final map = s as Map; - if (map['source'] != null) sources.add(map['source'] as String); - } - } else if (jsonData is List) { - for (final s in jsonData) { - final map = s as Map; - if (map['source'] != null) sources.add(map['source'] as String); - } - } - } - - // Reload map - _mapDataController.invalidate(); - await _loadSamples(); - - if (!mounted) return; - final l10n = AppLocalizations.of(context); - final sessionLabel = totalSessionsImported > 0 - ? l10n.mapImportedSessionsSuffix(totalSessionsImported) - : ''; - final sourceLabel = sources.isNotEmpty - ? l10n.mapImportedFromSources(sources.join(', ')) - : ''; - _showSnackBar( - '${l10n.mapImportedSamples(totalSamplesImported)}$sessionLabel$sourceLabel', - ); - } catch (e) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapImportFailed('$e')); - } - } - - Future _exportSettings() async { - try { - final jsonString = await _settingsService.exportSettingsJson(); - final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); - final fileName = 'meshcore_settings_$timestamp.json'; - - // Ask save or share - if (!mounted) return; - final choice = await showDialog( - context: context, - builder: (context) => ExportDestinationDialog( - title: AppLocalizations.of(context).settingsExportSettings, - ), - ); - - if (choice == null) return; - - if (choice == ExportDestination.save) { - if (!mounted) return; - await FilePicker.platform.saveFile( - dialogTitle: AppLocalizations.of(context).mapSaveSettings, - fileName: fileName, - type: FileType.custom, - allowedExtensions: ['json'], - bytes: utf8.encode(jsonString), - ); - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapSettingsExported); - } else if (choice == ExportDestination.share) { - final dir = await getApplicationDocumentsDirectory(); - final file = File('${dir.path}/$fileName'); - await file.writeAsString(jsonString); - if (!mounted) return; - await SharePlus.instance.share( - ShareParams( - files: [XFile(file.path)], - text: AppLocalizations.of(context).mapSettingsShareText, - ), - ); - } - } catch (e) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapExportFailed('$e')); - } - } - - Future _importSettings() async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['json'], - ); - - if (result == null || result.files.isEmpty) return; - final pickedFile = result.files.single; - - final file = File(pickedFile.path!); - final jsonString = await file.readAsString(); - - // Show confirmation dialog - if (!mounted) return; - final confirmed = await showDialog( - context: context, - builder: (context) => const ImportSettingsConfirmationDialog(), - ); - - if (confirmed != true) return; - - final applied = await _settingsService.importSettingsJson(jsonString); - - // Reload settings to apply changes - await _loadSettings(); - _mapDataController.invalidate(); - await _loadSamples(); - - if (!mounted) return; - _showSnackBar( - AppLocalizations.of(context).mapImportedSettingsCount(applied), - ); - } on FormatException catch (e) { - if (!mounted) return; - _showSnackBar( - AppLocalizations.of(context).mapInvalidSettingsFile(e.message), - ); - } catch (e) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapImportFailed('$e')); - } - } - - Future _exportDatabase() async { - try { - final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); - final fileName = 'meshcore_backup_$timestamp.db'; - - if (!mounted) return; - final choice = await showDialog( - context: context, - builder: (context) => ExportDestinationDialog( - title: AppLocalizations.of(context).settingsExportDatabase, - ), - ); - - if (choice == null) return; - - if (choice == ExportDestination.save) { - final bytes = await _databaseBackupService.exportSnapshotBytes(); - if (!mounted) return; - await FilePicker.platform.saveFile( - dialogTitle: AppLocalizations.of(context).mapSaveExport, - fileName: fileName, - type: FileType.custom, - allowedExtensions: ['db'], - bytes: bytes, - ); - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).settingsDatabaseExported); - } else if (choice == ExportDestination.share) { - final dir = await getApplicationDocumentsDirectory(); - final file = await _databaseBackupService.exportToShareFile( - dir, - fileName, - ); - if (!mounted) return; - await SharePlus.instance.share( - ShareParams( - files: [XFile(file.path)], - subject: AppLocalizations.of(context).settingsExportDatabase, - text: AppLocalizations.of(context).settingsDatabaseShareText, - ), - ); - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapExportShared); - } - } catch (e) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapExportFailed('$e')); - } - } - - Future _importDatabase() async { - final l10n = AppLocalizations.of(context); - if (_isTracking) { - _showSnackBar(l10n.settingsImportDatabaseStopTracking); - return; - } + /// Data I/O facade with the screen's services and reload hooks injected. + MapDataIo get _dataIo => MapDataIo( + context: context, + onShowSnackBar: _showSnackBar, + locationService: _locationService, + databaseService: _databaseService, + databaseBackupService: _databaseBackupService, + settingsService: _settingsService, + isTracking: () => _isTracking, + sampleCount: () => _sampleCount, + repeaters: () => _repeaters, + invalidateCaches: _mapDataController.invalidate, + loadSamples: _loadSamples, + loadSettings: _loadSettings, + onDatabaseRestored: () => + _updateMapState(() => _sessionMapView = const SessionMapView.all()), + loadMarkers: _loadMarkers, + loadPrivacyZones: _loadPrivacyZones, + loadImpossibleZones: _loadImpossibleZones, + ); - try { - // FileType.custom with ['db'] is unusable on Android: '.db' has no MIME - // mapping there, so the picker greys the backup out and it cannot be - // selected. Accept any file instead and rely on validateBackupFile - // below to reject non-backup contents with a clear error. - final result = await FilePicker.platform.pickFiles(type: FileType.any); + Future _clearData() => _dataIo.clearData(); - if (result == null || result.files.isEmpty) return; - final backupPath = result.files.single.path; - if (backupPath == null) return; + Future _exportData() => _dataIo.exportData(); - // Validate before destroying anything. - await _databaseBackupService.validateBackupFile(backupPath); + Future _importData() => _dataIo.importData(); - if (!mounted) return; - final confirmed = await showDialog( - context: context, - builder: (context) => const ImportDatabaseConfirmationDialog(), - ); - if (confirmed != true) return; + Future _exportSettings() => _dataIo.exportSettings(); - await _databaseBackupService.restoreFromFile(backupPath); + Future _importSettings() => _dataIo.importSettings(); - // Reload everything that is derived from the database. - _updateMapState(() => _sessionMapView = const SessionMapView.all()); - _mapDataController.invalidate(); - await _loadSamples(); - await _loadMarkers(); - await _loadPrivacyZones(); - await _loadImpossibleZones(); + Future _exportDatabase() => _dataIo.exportDatabase(); - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).settingsDatabaseImported); - } on DatabaseBackupException catch (e) { - if (!mounted) return; - _showSnackBar(switch (e.error) { - DatabaseBackupValidationError.newerVersion => AppLocalizations.of( - context, - ).settingsDatabaseNewerVersion, - _ => AppLocalizations.of(context).settingsDatabaseInvalidFile, - }); - } catch (e) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapImportFailed('$e')); - } - } + Future _importDatabase() => _dataIo.importDatabase(); // ============================================================================ // PLANNED MARKERS From 5f1e67bb18bfe51c82ddb22f9ad3d249efd733fa Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 08:27:54 +0300 Subject: [PATCH 17/33] Extract map annotations persistence into controller --- .../map/map_annotations_controller.dart | 110 ++++++++++++++++++ lib/screens/map_screen.dart | 91 ++++++++------- 2 files changed, 157 insertions(+), 44 deletions(-) create mode 100644 lib/screens/map/map_annotations_controller.dart diff --git a/lib/screens/map/map_annotations_controller.dart b/lib/screens/map/map_annotations_controller.dart new file mode 100644 index 0000000..7c32ea8 --- /dev/null +++ b/lib/screens/map/map_annotations_controller.dart @@ -0,0 +1,110 @@ +import '../../models/impossible_zone.dart'; +import '../../services/database_service.dart'; + +/// Annotation CRUD for the map screen: planned markers, privacy and +/// impossible zones, and delete-mode removals. +/// +/// The controller is not a widget and holds no BuildContext: it coordinates +/// database access and delegates list updates, sample reloads, and the +/// sample/coverage removal mechanics to callbacks owned by the screen. +class MapAnnotationsController { + const MapAnnotationsController({ + required this.databaseService, + required this.onMarkersLoaded, + required this.onPrivacyZonesLoaded, + required this.onImpossibleZonesLoaded, + required this.loadSamples, + required this.deleteSampleById, + required this.deleteCoverageById, + }); + + final DatabaseService databaseService; + + /// Applies freshly loaded markers and zones to the screen state; the owner + /// guards these callbacks with its own mounted check. + final Future Function(List> markers) + onMarkersLoaded; + final Future Function(List> zones) + onPrivacyZonesLoaded; + final Future Function(List zones) + onImpossibleZonesLoaded; + + /// Reloads samples after a delete-mode removal. + final Future Function() loadSamples; + + /// Removes a sample or a coverage cell through the screen data controller. + final Future Function(String sampleId) deleteSampleById; + final Future Function(String geohashPrefix) deleteCoverageById; + + Future loadMarkers() async { + final markers = await databaseService.getAllMarkers(); + await onMarkersLoaded(markers); + } + + Future loadPrivacyZones() async { + final zones = await databaseService.getAllPrivacyZones(); + await onPrivacyZonesLoaded(zones); + } + + Future loadImpossibleZones() async { + final zones = await databaseService.getAllImpossibleZones(); + await onImpossibleZonesLoaded(zones); + } + + Future addPlannedMarker({ + required double latitude, + required double longitude, + required String? label, + }) async { + await databaseService.addMarker(latitude, longitude, label); + await loadMarkers(); + } + + Future deleteMarker(int id) async { + await databaseService.deleteMarker(id); + await loadMarkers(); + } + + Future addPrivacyZone({ + required double latitude, + required double longitude, + required double radiusMeters, + required String? label, + }) async { + await databaseService.addPrivacyZone( + latitude, + longitude, + radiusMeters, + label, + ); + await loadPrivacyZones(); + } + + Future addImpossibleZone({ + required double latitude, + required double longitude, + required double radiusMeters, + required String? label, + }) async { + await databaseService.addImpossibleZone( + latitude, + longitude, + radiusMeters, + label, + ); + await loadImpossibleZones(); + } + + /// Removes a sample and reloads the screen samples. + Future deleteSample(String sampleId) async { + await deleteSampleById(sampleId); + await loadSamples(); + } + + /// Removes a coverage cell and reloads the screen samples. + Future deleteCoverageCell(String geohashPrefix) async { + final deleted = await deleteCoverageById(geohashPrefix); + await loadSamples(); + return deleted; + } +} diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index da27624..f82d113 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -52,6 +52,7 @@ import 'map/dialogs/offline_tile_dialogs.dart'; import 'map/dialogs/update_flow.dart'; import 'map/dialogs/upload_endpoint_dialog.dart'; import 'map/data_io.dart'; +import 'map/map_annotations_controller.dart'; import 'map/map_runtime_bindings.dart'; import 'map/map_screen_controller.dart'; import 'map/map_settings_controller.dart'; @@ -1169,13 +1170,33 @@ class _MapScreenState extends State { // PLANNED MARKERS // ============================================================================ - Future _loadMarkers() async { - final markers = await _databaseService.getAllMarkers(); - if (!mounted) return; - setState(() { - _plannedMarkers = markers; - }); - } + /// Annotation CRUD facade with screen-owned update callbacks injected. + MapAnnotationsController get _annotations => MapAnnotationsController( + databaseService: _databaseService, + onMarkersLoaded: (markers) async { + if (!mounted) return; + setState(() { + _plannedMarkers = markers; + }); + }, + onPrivacyZonesLoaded: (zones) async { + if (!mounted) return; + setState(() { + _privacyZones = zones; + }); + }, + onImpossibleZonesLoaded: (zones) async { + if (!mounted) return; + setState(() { + _impossibleZones = zones; + }); + }, + loadSamples: _loadSamples, + deleteSampleById: _mapDataController.deleteSample, + deleteCoverageById: _mapDataController.deleteCoverage, + ); + + Future _loadMarkers() => _annotations.loadMarkers(); Future _handleMapLongPress(LatLng point) async { final action = await showModalBottomSheet( @@ -1205,12 +1226,11 @@ class _MapScreenState extends State { ); if (label != null) { - await _databaseService.addMarker( - point.latitude, - point.longitude, - label.isEmpty ? null : label, + await _annotations.addPlannedMarker( + latitude: point.latitude, + longitude: point.longitude, + label: label.isEmpty ? null : label, ); - await _loadMarkers(); if (!mounted) return; _showSnackBar(AppLocalizations.of(context).mapPlannedRepeaterMarkerAdded); } @@ -1236,8 +1256,7 @@ class _MapScreenState extends State { ); if (action != PlannedMarkerAction.delete) return; - await _databaseService.deleteMarker(id); - await _loadMarkers(); + await _annotations.deleteMarker(id); if (!mounted) return; _showSnackBar(AppLocalizations.of(context).mapMarkerDeleted); } @@ -1246,21 +1265,9 @@ class _MapScreenState extends State { // PRIVACY ZONES // ============================================================================ - Future _loadPrivacyZones() async { - final zones = await _databaseService.getAllPrivacyZones(); - if (!mounted) return; - setState(() { - _privacyZones = zones; - }); - } + Future _loadPrivacyZones() => _annotations.loadPrivacyZones(); - Future _loadImpossibleZones() async { - final zones = await _databaseService.getAllImpossibleZones(); - if (!mounted) return; - setState(() { - _impossibleZones = zones; - }); - } + Future _loadImpossibleZones() => _annotations.loadImpossibleZones(); Future _addPrivacyZone(LatLng center) async { final l10n = AppLocalizations.of(context); @@ -1281,13 +1288,12 @@ class _MapScreenState extends State { _updateMapState(() => _zonePreview = null); if (draft != null) { - await _databaseService.addPrivacyZone( - center.latitude, - center.longitude, - draft.radiusMeters, - draft.label, + await _annotations.addPrivacyZone( + latitude: center.latitude, + longitude: center.longitude, + radiusMeters: draft.radiusMeters, + label: draft.label, ); - await _loadPrivacyZones(); if (!mounted) return; _showSnackBar(l10n.mapPrivacyZoneAdded); } @@ -1319,13 +1325,12 @@ class _MapScreenState extends State { _updateMapState(() => _zonePreview = null); if (draft == null) return; - await _databaseService.addImpossibleZone( - draft.center.latitude, - draft.center.longitude, - draft.radiusMeters, - draft.label, + await _annotations.addImpossibleZone( + latitude: draft.center.latitude, + longitude: draft.center.longitude, + radiusMeters: draft.radiusMeters, + label: draft.label, ); - await _loadImpossibleZones(); if (!mounted) return; _showSnackBar(l10n.settingsImpossibleZoneAdded); } @@ -1341,8 +1346,7 @@ class _MapScreenState extends State { ); if (confirmed == true) { - await _mapDataController.deleteSample(sample.id); - await _loadSamples(); + await _annotations.deleteSample(sample.id); if (!mounted) return; _showSnackBar(AppLocalizations.of(context).mapSampleDeleted); } @@ -1356,8 +1360,7 @@ class _MapScreenState extends State { ); if (confirmed == true) { - final deleted = await _mapDataController.deleteCoverage(coverage.id); - await _loadSamples(); + final deleted = await _annotations.deleteCoverageCell(coverage.id); if (!mounted) return; _showSnackBar( AppLocalizations.of(context).mapDeletedSamplesFromCell(deleted), From e24b6b48ca1737b9f84086ef6b90eb2b2a47dbce Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 08:31:30 +0300 Subject: [PATCH 18/33] Extract Android tracking permission flow from map screen --- lib/screens/map/tracking_permissions.dart | 152 ++++++++++++++++++++++ lib/screens/map_screen.dart | 122 ++--------------- 2 files changed, 163 insertions(+), 111 deletions(-) create mode 100644 lib/screens/map/tracking_permissions.dart diff --git a/lib/screens/map/tracking_permissions.dart b/lib/screens/map/tracking_permissions.dart new file mode 100644 index 0000000..c0f18fe --- /dev/null +++ b/lib/screens/map/tracking_permissions.dart @@ -0,0 +1,152 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../l10n/generated/app_localizations.dart'; +import '../../services/android_tracking_settings_service.dart'; +import 'dialogs/map_workflow_dialogs.dart'; + +/// Android permission prelude for starting tracking. +/// +/// Requests, in order: foreground location, precise location, background +/// location, battery optimizations exemption, and disabled Wi-Fi scan +/// throttling. Explanatory dialogs are shown before each system prompt. +/// +/// The helper owns no state: the caller passes the owning screen's [context] +/// for mounted checks, localization, and dialogs, the platform settings +/// service for the Wi-Fi throttling steps, and a getter for the +/// beacon-DB Wi-Fi positioning preference. +class TrackingPermissions { + const TrackingPermissions({ + required this.context, + required this.androidTrackingSettings, + required this.beaconDbWifiPositioning, + }); + + /// Screen context used for mounted checks, localization, and dialogs. + final BuildContext context; + + final AndroidTrackingSettingsService androidTrackingSettings; + + /// Whether beacon-DB Wi-Fi positioning is enabled; when true, Wi-Fi scan + /// throttling must be disabled before tracking starts. + final bool Function() beaconDbWifiPositioning; + + /// Runs the full permission chain; returns whether tracking may start. + Future prepareAndroidTracking() async { + if (!Platform.isAndroid) return true; + + final foregroundStatus = await Permission.locationWhenInUse.request(); + if (!foregroundStatus.isGranted) return true; + + final accuracy = await Geolocator.getLocationAccuracy(); + if (accuracy != LocationAccuracyStatus.precise) { + if (!context.mounted) return false; + final l10n = AppLocalizations.of(context); + await _showSettingsDialog( + title: l10n.mapPreciseLocationRequiredTitle, + message: l10n.mapPreciseLocationRequiredBody, + actionLabel: l10n.mapOpenAppSettings, + onOpen: openAppSettings, + ); + return false; + } + + var backgroundStatus = await Permission.locationAlways.status; + if (!backgroundStatus.isGranted) { + if (!context.mounted) return false; + final l10n = AppLocalizations.of(context); + final shouldRequest = await _showRequestDialog( + title: l10n.mapAllowLocationAllTheTimeTitle, + message: l10n.mapAllowLocationAllTheTimeBody, + ); + if (!shouldRequest) return false; + + backgroundStatus = await Permission.locationAlways.request(); + if (!backgroundStatus.isGranted) { + if (!context.mounted) return false; + final l10n = AppLocalizations.of(context); + await _showSettingsDialog( + title: l10n.mapBackgroundLocationRequiredTitle, + message: l10n.mapBackgroundLocationRequiredBody, + actionLabel: l10n.mapOpenAppSettings, + onOpen: openAppSettings, + ); + return false; + } + } + + final batteryStatus = await Permission.ignoreBatteryOptimizations.status; + if (!batteryStatus.isGranted) { + if (!context.mounted) return false; + final l10n = AppLocalizations.of(context); + final shouldRequest = await _showRequestDialog( + title: l10n.mapUnrestrictedBatteryTitle, + message: l10n.mapUnrestrictedBatteryBody, + ); + if (shouldRequest) { + await Permission.ignoreBatteryOptimizations.request(); + } + } + + if (beaconDbWifiPositioning()) { + if (!await requestWifiScanThrottlingDisabled()) return false; + } + + return true; + } + + /// Asks to disable Wi-Fi scan throttling via developer options; returns + /// whether tracking may proceed (false only when settings were opened and + /// the user must return after changing them). + Future requestWifiScanThrottlingDisabled() async { + if (!Platform.isAndroid) return true; + + final throttlingEnabled = await androidTrackingSettings + .isWifiScanThrottlingEnabled(); + if (throttlingEnabled == false || !context.mounted) return true; + + final l10n = AppLocalizations.of(context); + final openedSettings = await _showSettingsDialog( + title: l10n.mapDisableWifiThrottlingTitle, + message: l10n.mapDisableWifiThrottlingBody, + actionLabel: l10n.mapDeveloperOptions, + onOpen: androidTrackingSettings.openWifiScanThrottlingSettings, + ); + return !openedSettings; + } + + Future _showRequestDialog({ + required String title, + required String message, + }) async { + if (!context.mounted) return false; + return await showDialog( + context: context, + builder: (dialogContext) => + ContinueRequestDialog(title: title, message: message), + ) ?? + false; + } + + Future _showSettingsDialog({ + required String title, + required String message, + required String actionLabel, + required Future Function() onOpen, + }) async { + if (!context.mounted) return false; + final shouldOpen = await showDialog( + context: context, + builder: (dialogContext) => OpenSettingsDialog( + title: title, + message: message, + actionLabel: actionLabel, + ), + ); + if (shouldOpen != true) return false; + return onOpen(); + } +} diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index f82d113..03990ff 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -56,6 +56,7 @@ import 'map/map_annotations_controller.dart'; import 'map/map_runtime_bindings.dart'; import 'map/map_screen_controller.dart'; import 'map/map_settings_controller.dart'; +import 'map/tracking_permissions.dart'; import 'map/widgets/delete_mode_banner.dart'; import 'map/widgets/map_action_buttons.dart'; import 'map/widgets/map_control_panel.dart'; @@ -69,8 +70,6 @@ import 'package:screenshot/screenshot.dart'; import 'package:flutter_map_cache/flutter_map_cache.dart'; import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:dio_cache_interceptor_file_store/dio_cache_interceptor_file_store.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'dart:typed_data'; @@ -1019,117 +1018,18 @@ class _MapScreenState extends State { _showSnackBar(startMessage); } - Future _prepareAndroidTracking() async { - if (!Platform.isAndroid) return true; - - final foregroundStatus = await Permission.locationWhenInUse.request(); - if (!foregroundStatus.isGranted) return true; - - final accuracy = await Geolocator.getLocationAccuracy(); - if (accuracy != LocationAccuracyStatus.precise) { - if (!mounted) return false; - final l10n = AppLocalizations.of(context); - await _showSettingsDialog( - title: l10n.mapPreciseLocationRequiredTitle, - message: l10n.mapPreciseLocationRequiredBody, - actionLabel: l10n.mapOpenAppSettings, - onOpen: openAppSettings, - ); - return false; - } - - var backgroundStatus = await Permission.locationAlways.status; - if (!backgroundStatus.isGranted) { - if (!mounted) return false; - final l10n = AppLocalizations.of(context); - final shouldRequest = await _showRequestDialog( - title: l10n.mapAllowLocationAllTheTimeTitle, - message: l10n.mapAllowLocationAllTheTimeBody, - ); - if (!shouldRequest) return false; - - backgroundStatus = await Permission.locationAlways.request(); - if (!backgroundStatus.isGranted) { - if (!mounted) return false; - final l10n = AppLocalizations.of(context); - await _showSettingsDialog( - title: l10n.mapBackgroundLocationRequiredTitle, - message: l10n.mapBackgroundLocationRequiredBody, - actionLabel: l10n.mapOpenAppSettings, - onOpen: openAppSettings, - ); - return false; - } - } - - final batteryStatus = await Permission.ignoreBatteryOptimizations.status; - if (!batteryStatus.isGranted) { - if (!mounted) return false; - final l10n = AppLocalizations.of(context); - final shouldRequest = await _showRequestDialog( - title: l10n.mapUnrestrictedBatteryTitle, - message: l10n.mapUnrestrictedBatteryBody, - ); - if (shouldRequest) { - await Permission.ignoreBatteryOptimizations.request(); - } - } - - if (_beaconDbWifiPositioning) { - if (!await _requestWifiScanThrottlingDisabled()) return false; - } - - return true; - } - - Future _requestWifiScanThrottlingDisabled() async { - if (!Platform.isAndroid) return true; + /// Android tracking permission facade with screen dependencies injected. + TrackingPermissions get _trackingPermissions => TrackingPermissions( + context: context, + androidTrackingSettings: _androidTrackingSettings, + beaconDbWifiPositioning: () => _beaconDbWifiPositioning, + ); - final throttlingEnabled = await _androidTrackingSettings - .isWifiScanThrottlingEnabled(); - if (throttlingEnabled == false || !mounted) return true; + Future _prepareAndroidTracking() => + _trackingPermissions.prepareAndroidTracking(); - final l10n = AppLocalizations.of(context); - final openedSettings = await _showSettingsDialog( - title: l10n.mapDisableWifiThrottlingTitle, - message: l10n.mapDisableWifiThrottlingBody, - actionLabel: l10n.mapDeveloperOptions, - onOpen: _androidTrackingSettings.openWifiScanThrottlingSettings, - ); - return !openedSettings; - } - - Future _showRequestDialog({ - required String title, - required String message, - }) async { - if (!mounted) return false; - return await showDialog( - context: context, - builder: (context) => - ContinueRequestDialog(title: title, message: message), - ) ?? - false; - } - - Future _showSettingsDialog({ - required String title, - required String message, - required String actionLabel, - required Future Function() onOpen, - }) async { - if (!mounted) return false; - final shouldOpen = await showDialog( - context: context, - builder: (context) => OpenSettingsDialog( - title: title, - message: message, - actionLabel: actionLabel, - ), - ); - if (shouldOpen != true) return false; - return onOpen(); - } + Future _requestWifiScanThrottlingDisabled() => + _trackingPermissions.requestWifiScanThrottlingDisabled(); /// Data I/O facade with the screen's services and reload hooks injected. MapDataIo get _dataIo => MapDataIo( From 445acba0d9c5034fb821db16a7900c7407fa860e Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 08:37:21 +0300 Subject: [PATCH 19/33] Extract radio connection and contact flows from map screen --- lib/screens/map/connection_flow.dart | 255 +++++++++++++++++++++++++++ lib/screens/map_screen.dart | 205 +++------------------ 2 files changed, 279 insertions(+), 181 deletions(-) create mode 100644 lib/screens/map/connection_flow.dart diff --git a/lib/screens/map/connection_flow.dart b/lib/screens/map/connection_flow.dart new file mode 100644 index 0000000..dab0fa5 --- /dev/null +++ b/lib/screens/map/connection_flow.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:usb_serial/usb_serial.dart'; + +import '../../l10n/generated/app_localizations.dart'; +import '../../models/models.dart'; +import '../../services/database_service.dart'; +import '../../services/location_service.dart'; +import '../../services/settings_service.dart'; +import '../../utils/bluetooth_scan.dart'; +import '../../widgets/bluetooth_device_picker_dialog.dart'; +import 'dialogs/connection_dialogs.dart'; + +/// Companion radio connection orchestration for the map screen. +/// +/// The flow owns no state: it drives the USB/Bluetooth connection dialogs and +/// the LoRa companion service through the injected [locationService], and +/// delegates everything that belongs to the screen (snackbars, the connecting +/// flag, list updates, and reloads) to callbacks. Localization, dialogs, and +/// mounted checks resolve against the owning screen's [context]. +class ConnectionFlow { + const ConnectionFlow({ + required this.context, + required this.onShowSnackBar, + required this.locationService, + required this.settingsService, + required this.databaseService, + required this.isConnecting, + required this.setConnecting, + required this.loraConnected, + required this.onLoadSamples, + required this.onDeviceDisconnected, + required this.onRepeatersReplaced, + required this.onRepeatersFound, + }); + + /// Screen context used for mounted checks, localization, and dialogs. + final BuildContext context; + + /// Shows a transient message; the owner guards this callback with its own + /// mounted check. + final void Function(String message) onShowSnackBar; + + final LocationService locationService; + final SettingsService settingsService; + final DatabaseService databaseService; + + /// Current connecting flag; concurrent connections are refused while true. + final bool Function() isConnecting; + + /// Toggles the connecting flag on the screen (wrapped in setState). + final void Function(bool connecting) setConnecting; + + /// Whether a LoRa companion is currently connected. + final bool Function() loraConnected; + + /// Reloads samples after a successful connection or disconnect. + final Future Function() onLoadSamples; + + /// Applies screen state updates after the device was disconnected. + final VoidCallback onDeviceDisconnected; + + /// Applies scanned repeaters to the screen data (wrapped in setState). + final void Function(List repeaters) onRepeatersReplaced; + + /// Opens the repeater list after a successful scan. + final Future Function() onRepeatersFound; + + /// Asks for the connection method and runs the matching connection flow. + Future showConnectionDialog() async { + final method = await showDialog( + context: context, + builder: (dialogContext) => const ConnectionMethodDialog(), + ); + switch (method) { + case ConnectionMethod.usb: + await connectUsb(); + case ConnectionMethod.bluetooth: + await connectBluetooth(); + case null: + return; + } + } + + /// Scans for USB companions and connects to the selected one. + Future connectUsb() async { + if (isConnecting()) return; + setConnecting(true); + try { + final devices = await locationService.loraCompanion.scanUsbDevices(); + + if (!context.mounted) return; + + if (devices.isEmpty) { + onShowSnackBar(AppLocalizations.of(context).mapNoUsbDevices); + return; + } + + final selected = await showDialog( + context: context, + builder: (dialogContext) => UsbDeviceDialog(devices: devices), + ); + + if (selected != null) { + final connected = await locationService.loraCompanion.connectUsb( + selected, + ); + if (connected) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapConnectedViaUsb); + await onLoadSamples(); + } else { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapFailedConnectUsb); + } + } + } catch (e) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapUsbError('$e')); + } finally { + if (context.mounted) setConnecting(false); + } + } + + /// Offers recent, tracked, and bonded Bluetooth companions and connects to + /// the selected one, remembering the choice on success. + Future connectBluetooth() async { + if (isConnecting()) return; + setConnecting(true); + try { + final recent = await settingsService.getRecentBluetoothDevices(); + final tracked = [ + for (final row in await databaseService.getAllDevices()) + if (row['connection_type'] == 'bluetooth') + KnownBluetoothDevice( + remoteId: + bluetoothRemoteIdFromStoredId('${row['public_key'] ?? ''}') ?? + '', + name: '${row['name'] ?? ''}', + ), + ].where((device) => device.remoteId.isNotEmpty).toList(); + final bonded = await locationService.loraCompanion + .getBondedCompanionDevices(); + final known = collectKnownBluetoothDevices( + recent: recent, + tracked: tracked, + bonded: bonded, + ); + + if (!context.mounted) return; + final selected = await showDialog( + context: context, + builder: (dialogContext) => BluetoothDevicePickerDialog( + scan: locationService.loraCompanion.watchBluetoothScan( + knownDevices: known, + ), + ), + ); + + if (selected == null) return; + + if (!context.mounted) return; + onShowSnackBar( + AppLocalizations.of(context).mapConnectingTo(selected.displayName), + ); + + final connected = await locationService.loraCompanion.connectBluetooth( + BluetoothDevice.fromId(selected.remoteId), + ); + if (connected) { + await settingsService.rememberBluetoothDevice( + remoteId: selected.remoteId, + name: locationService.loraCompanion.deviceName ?? selected.name, + ); + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapConnectedViaBluetooth); + await onLoadSamples(); + } else { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapFailedConnectBluetooth); + } + } catch (e) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).bluetoothError('$e')); + } finally { + if (context.mounted) setConnecting(false); + } + } + + /// Disconnects the companion after confirmation and stops auto-ping and + /// Carpeater. + Future disconnectLoRa() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => const DisconnectDeviceDialog(), + ); + + if (confirmed == true) { + // Disable auto-ping and carpeater + locationService.disableAutoPing(); + locationService.carpeaterService.stop(); + onDeviceDisconnected(); + + await locationService.loraCompanion.disconnectDevice(); + await onLoadSamples(); + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapLoraDisconnected); + } + } + + /// Refreshes the companion contact list, waiting for it to process. + Future refreshContacts() async { + if (!loraConnected()) { + onShowSnackBar(AppLocalizations.of(context).mapConnectLoraFirst); + return; + } + + onShowSnackBar(AppLocalizations.of(context).mapRefreshingContactList); + + // Request full contact list from device + await locationService.loraCompanion.refreshContactList(); + + // Give it a moment to process + await Future.delayed(const Duration(seconds: 2)); + + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapContactListUpdated); + } + + /// Scans for repeaters, replaces the screen's repeater list with the result + /// and opens it. + Future scanForRepeaters() async { + if (!loraConnected()) { + onShowSnackBar(AppLocalizations.of(context).mapConnectLoraFirst); + return; + } + + onShowSnackBar(AppLocalizations.of(context).mapScanningForRepeaters); + + final repeaters = await locationService.loraCompanion.scanForRepeaters(); + + onRepeatersReplaced(repeaters); + + if (repeaters.isEmpty) { + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapNoRepeatersFound); + } else { + if (!context.mounted) return; + onShowSnackBar( + AppLocalizations.of(context).mapRepeatersFound(repeaters.length), + ); + await onRepeatersFound(); + } + } +} diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 03990ff..0ae88c6 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -26,10 +26,8 @@ import '../utils/compass_calibration.dart'; import '../utils/heading_utils.dart'; import '../utils/session_map_view.dart'; import '../utils/community_coverage.dart'; -import '../utils/bluetooth_scan.dart'; import '../utils/ping_burst.dart'; import '../widgets/compass_calibration.dart'; -import '../widgets/bluetooth_device_picker_dialog.dart'; import 'map/layers/coverage_prediction_layer.dart'; import 'map/layers/coverage_layer.dart'; import 'map/layers/community_coverage_layer.dart'; @@ -43,7 +41,6 @@ import 'map/layers/route_trail_layer.dart'; import 'map/layers/sample_cluster_layer.dart'; import 'map/layers/sample_heatmap_layer.dart'; import 'map/dialogs/appearance_dialogs.dart'; -import 'map/dialogs/connection_dialogs.dart'; import 'map/dialogs/coverage_tools_dialogs.dart'; import 'map/dialogs/map_entity_dialogs.dart'; import 'map/dialogs/map_workflow_dialogs.dart'; @@ -51,6 +48,7 @@ import 'map/dialogs/marker_dialogs.dart'; import 'map/dialogs/offline_tile_dialogs.dart'; import 'map/dialogs/update_flow.dart'; import 'map/dialogs/upload_endpoint_dialog.dart'; +import 'map/connection_flow.dart'; import 'map/data_io.dart'; import 'map/map_annotations_controller.dart'; import 'map/map_runtime_bindings.dart'; @@ -63,8 +61,6 @@ import 'map/widgets/map_control_panel.dart'; import 'map/widgets/map_quick_settings_panel.dart'; import '../services/widget_service.dart'; -import 'package:usb_serial/usb_serial.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:share_plus/share_plus.dart'; import 'package:screenshot/screenshot.dart'; import 'package:flutter_map_cache/flutter_map_cache.dart'; @@ -1936,144 +1932,29 @@ class _MapScreenState extends State { return (nodeId.length > 8 ? nodeId.substring(0, 8) : nodeId).toUpperCase(); } - void _showConnectionDialog() async { - final method = await showDialog( - context: context, - builder: (context) => const ConnectionMethodDialog(), - ); - switch (method) { - case ConnectionMethod.usb: - await _connectUsb(); - case ConnectionMethod.bluetooth: - await _connectBluetooth(); - case null: - return; - } - } - - Future _connectUsb() async { - if (_isConnecting) return; - setState(() => _isConnecting = true); - try { - final devices = await _locationService.loraCompanion.scanUsbDevices(); - - if (!mounted) return; - - if (devices.isEmpty) { - _showSnackBar(AppLocalizations.of(context).mapNoUsbDevices); - return; - } - - final selected = await showDialog( - context: context, - builder: (context) => UsbDeviceDialog(devices: devices), - ); - - if (selected != null) { - final connected = await _locationService.loraCompanion.connectUsb( - selected, - ); - if (connected) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapConnectedViaUsb); - await _loadSamples(); - } else { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapFailedConnectUsb); - } - } - } catch (e) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapUsbError('$e')); - } finally { - if (mounted) setState(() => _isConnecting = false); - } - } - - Future _connectBluetooth() async { - if (_isConnecting) return; - setState(() => _isConnecting = true); - try { - final recent = await _settingsService.getRecentBluetoothDevices(); - final tracked = [ - for (final row in await _databaseService.getAllDevices()) - if (row['connection_type'] == 'bluetooth') - KnownBluetoothDevice( - remoteId: - bluetoothRemoteIdFromStoredId('${row['public_key'] ?? ''}') ?? - '', - name: '${row['name'] ?? ''}', - ), - ].where((device) => device.remoteId.isNotEmpty).toList(); - final bonded = await _locationService.loraCompanion - .getBondedCompanionDevices(); - final known = collectKnownBluetoothDevices( - recent: recent, - tracked: tracked, - bonded: bonded, - ); - - if (!mounted) return; - final selected = await showDialog( - context: context, - builder: (context) => BluetoothDevicePickerDialog( - scan: _locationService.loraCompanion.watchBluetoothScan( - knownDevices: known, - ), - ), - ); - - if (selected == null) return; - - if (!mounted) return; - _showSnackBar( - AppLocalizations.of(context).mapConnectingTo(selected.displayName), - ); - - final connected = await _locationService.loraCompanion.connectBluetooth( - BluetoothDevice.fromId(selected.remoteId), - ); - if (connected) { - await _settingsService.rememberBluetoothDevice( - remoteId: selected.remoteId, - name: _locationService.loraCompanion.deviceName ?? selected.name, - ); - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapConnectedViaBluetooth); - await _loadSamples(); - } else { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapFailedConnectBluetooth); - } - } catch (e) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).bluetoothError('$e')); - } finally { - if (mounted) setState(() => _isConnecting = false); - } - } - - Future _disconnectLoRa() async { - final confirmed = await showDialog( - context: context, - builder: (context) => const DisconnectDeviceDialog(), - ); + /// Companion connection facade with screen-owned state callbacks injected. + ConnectionFlow get _connectionFlow => ConnectionFlow( + context: context, + onShowSnackBar: _showSnackBar, + locationService: _locationService, + settingsService: _settingsService, + databaseService: _databaseService, + isConnecting: () => _isConnecting, + setConnecting: (connecting) => setState(() => _isConnecting = connecting), + loraConnected: () => _loraConnected, + onLoadSamples: _loadSamples, + onDeviceDisconnected: () => setState(() { + _autoPingEnabled = false; + _carpeaterState = CarpeaterState.disabled; + }), + onRepeatersReplaced: (repeaters) => + setState(() => _mapDataController.replaceRepeaters(repeaters)), + onRepeatersFound: _showRepeatersDialog, + ); - if (confirmed == true) { - // Disable auto-ping and carpeater - _locationService.disableAutoPing(); - _locationService.carpeaterService.stop(); - setState(() { - _autoPingEnabled = false; - _carpeaterState = CarpeaterState.disabled; - }); + void _showConnectionDialog() => _connectionFlow.showConnectionDialog(); - await _locationService.loraCompanion.disconnectDevice(); - await _loadSamples(); - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapLoraDisconnected); - } - } + Future _disconnectLoRa() => _connectionFlow.disconnectLoRa(); String _localizedDuctingRisk(AppLocalizations l10n, String risk) { switch (risk) { @@ -2101,47 +1982,9 @@ class _MapScreenState extends State { } } - Future _refreshContacts() async { - if (!_loraConnected) { - _showSnackBar(AppLocalizations.of(context).mapConnectLoraFirst); - return; - } - - _showSnackBar(AppLocalizations.of(context).mapRefreshingContactList); - - // Request full contact list from device - await _locationService.loraCompanion.refreshContactList(); - - // Give it a moment to process - await Future.delayed(const Duration(seconds: 2)); - - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapContactListUpdated); - } + Future _refreshContacts() => _connectionFlow.refreshContacts(); - Future _scanForRepeaters() async { - if (!_loraConnected) { - _showSnackBar(AppLocalizations.of(context).mapConnectLoraFirst); - return; - } - - _showSnackBar(AppLocalizations.of(context).mapScanningForRepeaters); - - final repeaters = await _locationService.loraCompanion.scanForRepeaters(); - - setState(() => _mapDataController.replaceRepeaters(repeaters)); - - if (repeaters.isEmpty) { - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapNoRepeatersFound); - } else { - if (!mounted) return; - _showSnackBar( - AppLocalizations.of(context).mapRepeatersFound(repeaters.length), - ); - _showRepeatersDialog(); - } - } + Future _scanForRepeaters() => _connectionFlow.scanForRepeaters(); void _openSessionHistory() { Navigator.push( From 558aeb7aa146c78bb242aad9c4dfe1614028dcf2 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 08:44:08 +0300 Subject: [PATCH 20/33] Extract upload, community coverage, and offline tile flows --- lib/screens/map/dialogs/upload_flows.dart | 311 ++++++++++++++++++++++ lib/screens/map_screen.dart | 236 +++------------- 2 files changed, 345 insertions(+), 202 deletions(-) create mode 100644 lib/screens/map/dialogs/upload_flows.dart diff --git a/lib/screens/map/dialogs/upload_flows.dart b/lib/screens/map/dialogs/upload_flows.dart new file mode 100644 index 0000000..f87fc13 --- /dev/null +++ b/lib/screens/map/dialogs/upload_flows.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../../models/models.dart'; +import '../../../services/location_service.dart'; +import '../../../services/tile_download_service.dart'; +import '../../../services/upload_service.dart'; +import '../../settings/widgets/upload_endpoint_selection_dialog.dart'; +import 'offline_tile_dialogs.dart'; +import 'upload_endpoint_dialog.dart'; + +/// Sample upload orchestration for the map screen. +/// +/// The flow owns no state: it drives the endpoint selection and progress +/// dialogs against the injected [uploadService] and delegates screen-owned +/// concerns (snackbars, repeater names from screen data) to callbacks. +/// Localization, dialogs, and mounted checks resolve against the owning +/// screen's [context]. +class UploadFlow { + const UploadFlow({ + required this.context, + required this.onShowSnackBar, + required this.uploadService, + required this.locationService, + required this.repeaters, + }); + + /// Screen context used for mounted checks, localization, and dialogs. + final BuildContext context; + + /// Shows a transient message; the owner guards this callback with its own + /// mounted check. + final void Function(String message) onShowSnackBar; + + final UploadService uploadService; + final LocationService locationService; + + /// Currently known repeaters used to enrich uploads with repeater names. + final List Function() repeaters; + + /// Uploads all samples to the selected endpoints with a progress dialog. + Future uploadSamples() async { + final endpoints = await uploadService.getUploadEndpoints(); + final savedSelectedSites = await uploadService.getSelectedEndpoints(); + if (!context.mounted) return; + + final selectedSites = await showDialog>( + context: context, + builder: (dialogContext) => UploadEndpointSelectionDialog( + endpoints: endpoints, + initiallySelectedNames: savedSelectedSites, + ), + ); + if (!context.mounted || selectedSites == null || selectedSites.isEmpty) { + return; + } + + // Build repeater names map from discovered repeaters and LoRa service + final repeaterNames = {}; + for (final repeater in repeaters()) { + if (repeater.name != null) { + repeaterNames[repeater.id] = repeater.name!; + } + } + + final loraService = locationService.loraCompanion; + for (final contact in loraService.discoveredRepeaters) { + if (contact.name != null && !repeaterNames.containsKey(contact.id)) { + repeaterNames[contact.id] = contact.name!; + } + } + + final outcome = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => UploadProgressDialog( + upload: (onProgress) async { + if (selectedSites.isNotEmpty && endpoints.isNotEmpty) { + return uploadService.uploadToSelectedEndpoints( + endpointNames: selectedSites, + repeaterNames: repeaterNames, + onProgress: onProgress, + ); + } + + final result = await uploadService.uploadAllSamples( + repeaterNames: repeaterNames, + onProgress: (current, total) => onProgress('', current, total), + ); + return {UploadService.defaultEndpointName: result}; + }, + ), + ); + + if (outcome == null || !context.mounted) return; + if (outcome.error != null) { + onShowSnackBar( + AppLocalizations.of(context).mapUploadError('${outcome.error}'), + ); + return; + } + + await showDialog( + context: context, + builder: (dialogContext) => + UploadResultsDialog(results: outcome.results!), + ); + } + + /// Edits upload endpoints and their selection in a modal sheet. + Future manageUploadSites() async { + final endpoints = await uploadService.getUploadEndpoints(); + final selectedNames = await uploadService.getSelectedEndpoints(); + + if (!context.mounted) return; + final configuration = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (dialogContext) => ManageUploadSitesSheet( + initialEndpoints: endpoints, + initiallySelectedNames: selectedNames, + ), + ); + + if (configuration == null) return; + await uploadService.setUploadEndpoints(configuration.endpoints); + await uploadService.setSelectedEndpoints(configuration.selectedNames); + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapUploadSitesUpdated); + } +} + +/// Community coverage download orchestration for the map screen. +/// +/// The flow owns no state: it picks the endpoint, downloads (or falls back to +/// the cached) coverage, and hands the raw coverage map to [onCoverageLoaded] +/// for the screen to apply. +class CommunityCoverageFlow { + const CommunityCoverageFlow({ + required this.context, + required this.onShowSnackBar, + required this.uploadService, + required this.onCoverageLoaded, + }); + + /// Screen context used for mounted checks, localization, and dialogs. + final BuildContext context; + + /// Shows a transient message; the owner guards this callback with its own + /// mounted check. + final void Function(String message) onShowSnackBar; + + final UploadService uploadService; + + /// Applies downloaded coverage on the screen. + final void Function(Map coverage) onCoverageLoaded; + + /// Downloads community coverage from the chosen endpoint, falling back to + /// the cached copy on failure. + Future downloadCommunityCoverage() async { + // Get endpoint to download from + final endpoints = await uploadService.getUploadEndpoints(); + + UploadEndpoint? selectedEndpoint; + if (endpoints.length == 1) { + selectedEndpoint = endpoints.first; + } else { + // Let user pick which endpoint to download from + if (!context.mounted) return; + selectedEndpoint = await showDialog( + context: context, + builder: (dialogContext) => + CommunityCoverageEndpointDialog(endpoints: endpoints), + ); + } + + if (selectedEndpoint == null) return; + + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapDownloadingCoverage); + + final data = await uploadService.downloadCoverage( + selectedEndpoint.url, + onProgress: (current, total) { + // Update snackbar with progress (won't stack, just shows latest) + }, + ); + if (data != null && data['coverage'] != null) { + final coverage = data['coverage'] as Map; + onCoverageLoaded(coverage); + if (!context.mounted) return; + onShowSnackBar( + AppLocalizations.of(context) + .mapDownloadedCoverageCells(coverage.length), + ); + } else { + // Try loading from cache + final cached = await uploadService.loadCachedCoverage(); + if (cached != null && cached['coverage'] != null) { + onCoverageLoaded(cached['coverage'] as Map); + if (!context.mounted) return; + onShowSnackBar(AppLocalizations.of(context).mapLoadedCachedCoverage); + } else { + if (!context.mounted) return; + onShowSnackBar( + AppLocalizations.of(context).mapDownloadFailed( + uploadService.lastDownloadError ?? + AppLocalizations.of(context).mapUnknownError, + ), + ); + } + } + } +} + +/// Offline tile download orchestration for the map screen. +/// +/// The flow owns no state: it reads the visible map region and theme through +/// callbacks, runs the options and progress dialogs, and drives the +/// [TileDownloadService]. +class OfflineTileFlow { + const OfflineTileFlow({ + required this.context, + required this.onShowSnackBar, + required this.hasTileCache, + required this.getVisibleBounds, + required this.getCameraZoom, + required this.usesDarkMapTiles, + }); + + /// Screen context used for mounted checks, localization, and dialogs. + final BuildContext context; + + /// Shows a transient message; the owner guards this callback with its own + /// mounted check. + final void Function(String message) onShowSnackBar; + + /// Whether the offline tile cache has been initialized. + final bool Function() hasTileCache; + + /// The currently visible map bounds. + final LatLngBounds Function() getVisibleBounds; + + /// The current camera zoom. + final double Function() getCameraZoom; + + /// Whether dark basemap tiles should be downloaded. + final bool Function() usesDarkMapTiles; + + /// Downloads the visible region's tiles for offline use. + Future downloadOfflineTiles() async { + if (!hasTileCache()) { + onShowSnackBar(AppLocalizations.of(context).mapTileCacheNotInitialized); + return; + } + + final bounds = getVisibleBounds(); + final currentZoom = getCameraZoom().floor(); + final isDarkMode = usesDarkMapTiles(); + + final options = await showDialog( + context: context, + builder: (dialogContext) => + OfflineTileDownloadDialog(bounds: bounds, initialZoom: currentZoom), + ); + + if (options == null || !context.mounted) return; + + final urlTemplate = isDarkMode + ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png' + : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + + final cacheDir = + '${(await getApplicationDocumentsDirectory()).path}/tile_cache'; + final downloader = TileDownloadService(cacheDir); + final totalTiles = TileDownloadService.estimateTileCount( + bounds.southWest, + bounds.northEast, + options.minZoom, + options.maxZoom, + ); + + if (!context.mounted) return; + final outcome = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => OfflineTileDownloadProgressDialog( + totalTiles: totalTiles, + download: (onProgress) => downloader.downloadTiles( + sw: bounds.southWest, + ne: bounds.northEast, + minZoom: options.minZoom, + maxZoom: options.maxZoom, + urlTemplate: urlTemplate, + onProgress: onProgress, + ), + onCancel: downloader.cancel, + ), + ); + + if (outcome == null || !context.mounted) return; + final l10n = AppLocalizations.of(context); + if (outcome.cancelled) { + onShowSnackBar(l10n.mapDownloadCancelled(outcome.completed)); + } else { + onShowSnackBar(l10n.mapDownloadedTiles(outcome.succeeded, totalTiles)); + } + } +} diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 0ae88c6..970742e 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -45,9 +45,8 @@ import 'map/dialogs/coverage_tools_dialogs.dart'; import 'map/dialogs/map_entity_dialogs.dart'; import 'map/dialogs/map_workflow_dialogs.dart'; import 'map/dialogs/marker_dialogs.dart'; -import 'map/dialogs/offline_tile_dialogs.dart'; import 'map/dialogs/update_flow.dart'; -import 'map/dialogs/upload_endpoint_dialog.dart'; +import 'map/dialogs/upload_flows.dart'; import 'map/connection_flow.dart'; import 'map/data_io.dart'; import 'map/map_annotations_controller.dart'; @@ -80,7 +79,6 @@ import '../constants/app_version.dart'; import '../services/ducting_service.dart'; import '../services/carpeater_service.dart'; import '../services/sound_service.dart'; -import '../services/tile_download_service.dart'; import 'analytics_screen.dart'; import 'achievements_screen.dart'; import 'device_comparison_screen.dart'; @@ -2259,150 +2257,42 @@ class _MapScreenState extends State { } } - Future _uploadSamples() async { - final endpoints = await _uploadService.getUploadEndpoints(); - final savedSelectedSites = await _uploadService.getSelectedEndpoints(); - if (!mounted) return; - - final selectedSites = await showDialog>( - context: context, - builder: (context) => UploadEndpointSelectionDialog( - endpoints: endpoints, - initiallySelectedNames: savedSelectedSites, - ), - ); - if (!mounted || selectedSites == null || selectedSites.isEmpty) return; - - // Build repeater names map from discovered repeaters and LoRa service - final repeaterNames = {}; - for (final repeater in _repeaters) { - if (repeater.name != null) { - repeaterNames[repeater.id] = repeater.name!; - } - } - - final loraService = _locationService.loraCompanion; - for (final contact in loraService.discoveredRepeaters) { - if (contact.name != null && !repeaterNames.containsKey(contact.id)) { - repeaterNames[contact.id] = contact.name!; - } - } - - final outcome = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => UploadProgressDialog( - upload: (onProgress) async { - if (selectedSites.isNotEmpty && endpoints.isNotEmpty) { - return _uploadService.uploadToSelectedEndpoints( - endpointNames: selectedSites, - repeaterNames: repeaterNames, - onProgress: onProgress, - ); - } - - final result = await _uploadService.uploadAllSamples( - repeaterNames: repeaterNames, - onProgress: (current, total) => onProgress('', current, total), - ); - return {UploadService.defaultEndpointName: result}; - }, - ), - ); - - if (outcome == null || !mounted) return; - if (outcome.error != null) { - _showSnackBar( - AppLocalizations.of(context).mapUploadError('${outcome.error}'), - ); - return; - } - - await showDialog( - context: context, - builder: (context) => UploadResultsDialog(results: outcome.results!), - ); - } - - Future _manageUploadSites() async { - final endpoints = await _uploadService.getUploadEndpoints(); - final selectedNames = await _uploadService.getSelectedEndpoints(); - - if (!mounted) return; - final configuration = await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => ManageUploadSitesSheet( - initialEndpoints: endpoints, - initiallySelectedNames: selectedNames, - ), - ); - - if (configuration == null) return; - await _uploadService.setUploadEndpoints(configuration.endpoints); - await _uploadService.setSelectedEndpoints(configuration.selectedNames); - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapUploadSitesUpdated); - } - - Future _showOfflineTileDownload() async { - if (_tileCacheStore == null) { - _showSnackBar(AppLocalizations.of(context).mapTileCacheNotInitialized); - return; - } - - final bounds = _mapController.camera.visibleBounds; - final currentZoom = _mapController.camera.zoom.floor(); - final isDarkMode = _usesDarkMapTiles(context); - - final options = await showDialog( - context: context, - builder: (context) => - OfflineTileDownloadDialog(bounds: bounds, initialZoom: currentZoom), - ); + /// Upload facade with screen-owned snackbars and data callbacks injected. + UploadFlow get _uploadFlow => UploadFlow( + context: context, + onShowSnackBar: _showSnackBar, + uploadService: _uploadService, + locationService: _locationService, + repeaters: () => _repeaters, + ); - if (options == null || !mounted) return; + /// Community coverage facade; applies coverage through setState. + CommunityCoverageFlow get _communityCoverageFlow => CommunityCoverageFlow( + context: context, + onShowSnackBar: _showSnackBar, + uploadService: _uploadService, + onCoverageLoaded: (coverage) => setState(() { + _communityCoverage = coverage; + _showCommunityCoverage = true; + }), + ); - final urlTemplate = isDarkMode - ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png' - : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + /// Offline tile facade reading map camera state through callbacks. + OfflineTileFlow get _offlineTileFlow => OfflineTileFlow( + context: context, + onShowSnackBar: _showSnackBar, + hasTileCache: () => _tileCacheStore != null, + getVisibleBounds: () => _mapController.camera.visibleBounds, + getCameraZoom: () => _mapController.camera.zoom, + usesDarkMapTiles: () => _usesDarkMapTiles(context), + ); - final cacheDir = - '${(await getApplicationDocumentsDirectory()).path}/tile_cache'; - final downloader = TileDownloadService(cacheDir); - final totalTiles = TileDownloadService.estimateTileCount( - bounds.southWest, - bounds.northEast, - options.minZoom, - options.maxZoom, - ); + Future _uploadSamples() => _uploadFlow.uploadSamples(); - if (!mounted) return; - final outcome = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => OfflineTileDownloadProgressDialog( - totalTiles: totalTiles, - download: (onProgress) => downloader.downloadTiles( - sw: bounds.southWest, - ne: bounds.northEast, - minZoom: options.minZoom, - maxZoom: options.maxZoom, - urlTemplate: urlTemplate, - onProgress: onProgress, - ), - onCancel: downloader.cancel, - ), - ); + Future _manageUploadSites() => _uploadFlow.manageUploadSites(); - if (outcome == null || !mounted) return; - final l10n = AppLocalizations.of(context); - if (outcome.cancelled) { - _showSnackBar(l10n.mapDownloadCancelled(outcome.completed)); - } else { - _showSnackBar(l10n.mapDownloadedTiles(outcome.succeeded, totalTiles)); - } - } + Future _showOfflineTileDownload() => + _offlineTileFlow.downloadOfflineTiles(); Future _shareCoverageMap() async { try { @@ -2541,66 +2431,8 @@ class _MapScreenState extends State { if (selected != null) _mapController.move(selected.position, 15.0); } - Future _downloadCommunityCoverage() async { - // Get endpoint to download from - final endpoints = await _uploadService.getUploadEndpoints(); - - UploadEndpoint? selectedEndpoint; - if (endpoints.length == 1) { - selectedEndpoint = endpoints.first; - } else { - // Let user pick which endpoint to download from - if (!mounted) return; - selectedEndpoint = await showDialog( - context: context, - builder: (context) => - CommunityCoverageEndpointDialog(endpoints: endpoints), - ); - } - - if (selectedEndpoint == null) return; - - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapDownloadingCoverage); - - final data = await _uploadService.downloadCoverage( - selectedEndpoint.url, - onProgress: (current, total) { - // Update snackbar with progress (won't stack, just shows latest) - }, - ); - if (data != null && data['coverage'] != null) { - final coverage = data['coverage'] as Map; - setState(() { - _communityCoverage = coverage; - _showCommunityCoverage = true; - }); - if (!mounted) return; - _showSnackBar( - AppLocalizations.of(context) - .mapDownloadedCoverageCells(coverage.length), - ); - } else { - // Try loading from cache - final cached = await _uploadService.loadCachedCoverage(); - if (cached != null && cached['coverage'] != null) { - setState(() { - _communityCoverage = cached['coverage'] as Map; - _showCommunityCoverage = true; - }); - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapLoadedCachedCoverage); - } else { - if (!mounted) return; - _showSnackBar( - AppLocalizations.of(context).mapDownloadFailed( - _uploadService.lastDownloadError ?? - AppLocalizations.of(context).mapUnknownError, - ), - ); - } - } - } + Future _downloadCommunityCoverage() => + _communityCoverageFlow.downloadCommunityCoverage(); void _handleMapTap(LatLng point) { if (!_showCommunityCoverage || _communityCoverage == null) return; From 286fa7acdca1fbdaea2e46e833f463b736782b47 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 08:50:04 +0300 Subject: [PATCH 21/33] Extract theme flows and ducting presentation helpers --- lib/screens/map/dialogs/theme_flows.dart | 140 ++++++++++++++++++++++ lib/screens/map_screen.dart | 144 ++++------------------- lib/utils/ducting_presentation.dart | 34 ++++++ 3 files changed, 199 insertions(+), 119 deletions(-) create mode 100644 lib/screens/map/dialogs/theme_flows.dart create mode 100644 lib/utils/ducting_presentation.dart diff --git a/lib/screens/map/dialogs/theme_flows.dart b/lib/screens/map/dialogs/theme_flows.dart new file mode 100644 index 0000000..706d5a2 --- /dev/null +++ b/lib/screens/map/dialogs/theme_flows.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; + +import '../../../l10n/app_locale.dart'; +import '../../../l10n/generated/app_localizations.dart'; +import '../../../main.dart'; +import '../../../services/location_service.dart'; +import '../../../services/settings_service.dart'; +import 'appearance_dialogs.dart'; + +/// Whether the map should use dark basemap tiles for [mode]; the system +/// variant resolves against [platformBrightness]. +bool usesDarkMapTiles({ + required MapThemeMode mode, + required Brightness platformBrightness, +}) { + switch (mode) { + case MapThemeMode.light: + return false; + case MapThemeMode.dark: + return true; + case MapThemeMode.system: + return platformBrightness == Brightness.dark; + } +} + +/// Interface theme, language, and map theme selectors for the map screen. +/// +/// The flow owns no state: interface theme and language are applied through +/// the root `MyAppState` (`MyApp.of`), the map theme mode is read from and +/// reported to the screen through callbacks, and [locationService] refreshes +/// the foreground-service notification copy after a language change. +class ThemeFlow { + const ThemeFlow({ + required this.context, + required this.locationService, + required this.settingsService, + required this.mapThemeMode, + required this.onMapThemeModeChanged, + }); + + /// Screen context used for localization and dialogs. + final BuildContext context; + + final LocationService locationService; + final SettingsService settingsService; + + /// Currently selected map theme mode. + final MapThemeMode Function() mapThemeMode; + + /// Applies a newly selected map theme mode on the screen. + final void Function(MapThemeMode mode) onMapThemeModeChanged; + + /// Localized description of the current interface theme. + String interfaceThemeModeText() { + final l10n = AppLocalizations.of(context); + final appState = MyApp.of(context); + if (appState == null) return l10n.settingsThemeSystemDefault; + + switch (appState.themeMode) { + case ThemeMode.light: + return l10n.settingsThemeLight; + case ThemeMode.dark: + return l10n.settingsThemeDark; + case ThemeMode.system: + return l10n.settingsThemeSystemDefault; + } + } + + /// Shows the interface theme picker and applies the selection. + Future showInterfaceThemeSelector() async { + final appState = MyApp.of(context); + if (appState == null) return; + + final selected = await showDialog( + context: context, + builder: (dialogContext) => const InterfaceThemeDialog(), + ); + + if (selected != null) { + await appState.setThemeMode(selected); + } + } + + /// Localized description of the current language preference. + String appLocalePreferenceText() { + final l10n = AppLocalizations.of(context); + switch (MyApp.of(context)?.localePreference) { + case AppLocalePreference.en: + return l10n.languageEnglish; + case AppLocalePreference.ru: + return l10n.languageRussian; + case AppLocalePreference.system: + case null: + return l10n.languageSystem; + } + } + + /// Shows the language picker, applies the selection, and refreshes the + /// notification copy of the tracking service. + Future showLanguageSelector() async { + final appState = MyApp.of(context); + if (appState == null) return; + + final selected = await showDialog( + context: context, + builder: (dialogContext) => const AppLocaleDialog(), + ); + + if (selected != null) { + await appState.setAppLocalePreference(selected); + await locationService.refreshNotificationCopy(); + } + } + + /// Localized description of the current map theme mode. + String mapThemeModeText() { + final l10n = AppLocalizations.of(context); + switch (mapThemeMode()) { + case MapThemeMode.light: + return l10n.settingsThemeLight; + case MapThemeMode.dark: + return l10n.settingsThemeDark; + case MapThemeMode.system: + return l10n.settingsThemeSystemDefault; + } + } + + /// Shows the map theme picker and persists the selection. + Future showMapThemeSelector() async { + final selected = await showDialog( + context: context, + builder: (dialogContext) => const MapThemeDialog(), + ); + + if (selected != null) { + onMapThemeModeChanged(selected); + await settingsService.setMapThemeMode(selected); + } + } +} diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 970742e..84eca90 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -26,6 +26,7 @@ import '../utils/compass_calibration.dart'; import '../utils/heading_utils.dart'; import '../utils/session_map_view.dart'; import '../utils/community_coverage.dart'; +import '../utils/ducting_presentation.dart'; import '../utils/ping_burst.dart'; import '../widgets/compass_calibration.dart'; import 'map/layers/coverage_prediction_layer.dart'; @@ -40,11 +41,11 @@ import 'map/layers/repeater_layer.dart'; import 'map/layers/route_trail_layer.dart'; import 'map/layers/sample_cluster_layer.dart'; import 'map/layers/sample_heatmap_layer.dart'; -import 'map/dialogs/appearance_dialogs.dart'; import 'map/dialogs/coverage_tools_dialogs.dart'; import 'map/dialogs/map_entity_dialogs.dart'; import 'map/dialogs/map_workflow_dialogs.dart'; import 'map/dialogs/marker_dialogs.dart'; +import 'map/dialogs/theme_flows.dart'; import 'map/dialogs/update_flow.dart'; import 'map/dialogs/upload_flows.dart'; import 'map/connection_flow.dart'; @@ -71,9 +72,7 @@ import 'dart:typed_data'; import 'debug_log_screen.dart'; import 'debug_diagnostics_screen.dart'; import 'session_history_screen.dart'; -import '../main.dart'; import '../l10n/achievement_l10n.dart'; -import '../l10n/app_locale.dart'; import '../l10n/generated/app_localizations.dart'; import '../constants/app_version.dart'; import '../services/ducting_service.dart'; @@ -1514,11 +1513,11 @@ class _MapScreenState extends State { carpeaterState: _carpeaterState, ductingLabel: _showDucting && _currentDuctingRisk != DuctingRisk.unknown - ? _localizedDuctingRisk(l10n, _currentDuctingRisk) + ? localizedDuctingRisk(l10n, _currentDuctingRisk) : null, ductingColor: _showDucting && _currentDuctingRisk != DuctingRisk.unknown - ? _getDuctingColor(_currentDuctingRisk) + ? ductingRiskColor(_currentDuctingRisk) : null, batterySaverActive: _batterySaverActive, onConnect: _showConnectionDialog, @@ -1954,32 +1953,6 @@ class _MapScreenState extends State { Future _disconnectLoRa() => _connectionFlow.disconnectLoRa(); - String _localizedDuctingRisk(AppLocalizations l10n, String risk) { - switch (risk) { - case DuctingRisk.none: - return l10n.settingsNone; - case DuctingRisk.possible: - return l10n.mapDuctingPossible; - case DuctingRisk.likely: - return l10n.mapDuctingLikely; - default: - return l10n.settingsUnknown; - } - } - - Color _getDuctingColor(String risk) { - switch (risk) { - case 'none': - return Colors.green; - case 'possible': - return Colors.orange; - case 'likely': - return Colors.red; - default: - return Colors.grey; - } - } - Future _refreshContacts() => _connectionFlow.refreshContacts(); Future _scanForRepeaters() => _connectionFlow.scanForRepeaters(); @@ -2022,99 +1995,32 @@ class _MapScreenState extends State { ); } - String _getInterfaceThemeModeText() { - final l10n = AppLocalizations.of(context); - final appState = MyApp.of(context); - if (appState == null) return l10n.settingsThemeSystemDefault; - - switch (appState.themeMode) { - case ThemeMode.light: - return l10n.settingsThemeLight; - case ThemeMode.dark: - return l10n.settingsThemeDark; - case ThemeMode.system: - return l10n.settingsThemeSystemDefault; - } - } - - Future _showInterfaceThemeSelector() async { - final appState = MyApp.of(context); - if (appState == null) return; - - final selected = await showDialog( - context: context, - builder: (context) => const InterfaceThemeDialog(), - ); - - if (selected != null) { - await appState.setThemeMode(selected); - } - } - - String _getAppLocalePreferenceText() { - final l10n = AppLocalizations.of(context); - switch (MyApp.of(context)?.localePreference) { - case AppLocalePreference.en: - return l10n.languageEnglish; - case AppLocalePreference.ru: - return l10n.languageRussian; - case AppLocalePreference.system: - case null: - return l10n.languageSystem; - } - } + /// Theme/language facade with screen-owned map theme callbacks injected. + ThemeFlow get _themeFlow => ThemeFlow( + context: context, + locationService: _locationService, + settingsService: _settingsService, + mapThemeMode: () => _mapThemeMode, + onMapThemeModeChanged: (mode) => setState(() => _mapThemeMode = mode), + ); - Future _showLanguageSelector() async { - final appState = MyApp.of(context); - if (appState == null) return; + String _getInterfaceThemeModeText() => _themeFlow.interfaceThemeModeText(); - final selected = await showDialog( - context: context, - builder: (context) => const AppLocaleDialog(), - ); + Future _showInterfaceThemeSelector() => + _themeFlow.showInterfaceThemeSelector(); - if (selected != null) { - await appState.setAppLocalePreference(selected); - await _locationService.refreshNotificationCopy(); - } - } + String _getAppLocalePreferenceText() => _themeFlow.appLocalePreferenceText(); - String _getMapThemeModeText() { - final l10n = AppLocalizations.of(context); - switch (_mapThemeMode) { - case MapThemeMode.light: - return l10n.settingsThemeLight; - case MapThemeMode.dark: - return l10n.settingsThemeDark; - case MapThemeMode.system: - return l10n.settingsThemeSystemDefault; - } - } + Future _showLanguageSelector() => _themeFlow.showLanguageSelector(); - bool _usesDarkMapTiles(BuildContext context) { - switch (_mapThemeMode) { - case MapThemeMode.light: - return false; - case MapThemeMode.dark: - return true; - case MapThemeMode.system: - return MediaQuery.platformBrightnessOf(context) == Brightness.dark; - } - } + String _getMapThemeModeText() => _themeFlow.mapThemeModeText(); - Future _showMapThemeSelector() async { - final selected = await showDialog( - context: context, - builder: (context) => const MapThemeDialog(), - ); + bool _usesDarkMapTiles(BuildContext context) => usesDarkMapTiles( + mode: _mapThemeMode, + platformBrightness: MediaQuery.platformBrightnessOf(context), + ); - if (selected != null) { - setState(() { - _mapThemeMode = selected; - }); - await _settingsService.setMapThemeMode(selected); - } - } + Future _showMapThemeSelector() => _themeFlow.showMapThemeSelector(); String? _getRepeaterName(String? repeaterId) { if (repeaterId == null) return null; @@ -2169,10 +2075,10 @@ class _MapScreenState extends State { resolveRepeaterName: _getRepeaterName, ductingLabel: ductingRisk == null ? null - : _localizedDuctingRisk(l10n, ductingRisk), + : localizedDuctingRisk(l10n, ductingRisk), ductingColor: ductingRisk == null ? null - : _getDuctingColor(ductingRisk), + : ductingRiskColor(ductingRisk), ), ); } diff --git a/lib/utils/ducting_presentation.dart b/lib/utils/ducting_presentation.dart new file mode 100644 index 0000000..6e97c35 --- /dev/null +++ b/lib/utils/ducting_presentation.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../l10n/generated/app_localizations.dart'; +import '../services/ducting_service.dart'; + +/// Presentation helpers for ducting risk values. + +/// Localized label for a ducting risk value. +String localizedDuctingRisk(AppLocalizations l10n, String risk) { + switch (risk) { + case DuctingRisk.none: + return l10n.settingsNone; + case DuctingRisk.possible: + return l10n.mapDuctingPossible; + case DuctingRisk.likely: + return l10n.mapDuctingLikely; + default: + return l10n.settingsUnknown; + } +} + +/// Color used to render a ducting risk value. +Color ductingRiskColor(String risk) { + switch (risk) { + case 'none': + return Colors.green; + case 'possible': + return Colors.orange; + case 'likely': + return Colors.red; + default: + return Colors.grey; + } +} From 203b08dda491352140ba3d0bf8661533815ff3d3 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 08:54:54 +0300 Subject: [PATCH 22/33] Split map screen initialization into staged phases --- lib/screens/map_screen.dart | 72 +++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 84eca90..d3ea225 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -321,27 +321,57 @@ class _MapScreenState extends State { } Future _initialize(int generation) async { + if (!await _initializeTileCache(generation)) return; + if (!await _loadStartupSettings(generation)) return; + if (!await _loadStartupAnnotations(generation)) return; + + _bindRadioStreams(); + _bindLocationStreams(); + _syncCompassSubscription(); + _bindMapDataStreams(); + _bindAlertStreams(); + + if (!await _applyStartupAlertSettings(generation)) return; + + _bindTelemetryStreams(); + + await _loadStartupMapData(generation); + } + + /// Tile cache store and home screen widget setup. + Future _initializeTileCache(int generation) async { // Initialize tile cache store final cacheDir = await getApplicationDocumentsDirectory(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; _tileCacheStore = FileCacheStore('${cacheDir.path}/tile_cache'); // Initialize home screen widget await WidgetService.initialize(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; + return true; + } + /// Saved user settings snapshot. + Future _loadStartupSettings(int generation) async { // Load saved settings await _loadSettings(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; + return true; + } + /// Planned markers and privacy zones persisted in the database. + Future _loadStartupAnnotations(int generation) async { // Load planned markers and privacy zones await _loadMarkers(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; await _loadPrivacyZones(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; await _loadImpossibleZones(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; + return true; + } + void _bindRadioStreams() { // Subscribe to battery updates final loraService = _locationService.loraCompanion; _runtimeBindings.bind( @@ -411,7 +441,9 @@ class _MapScreenState extends State { }); }, ); + } + void _bindLocationStreams() { // Subscribe to position updates _runtimeBindings.bind( MapRuntimeSubscription.position, @@ -462,9 +494,9 @@ class _MapScreenState extends State { } }, ); + } - _syncCompassSubscription(); - + void _bindMapDataStreams() { // Subscribe to sample saved events - reload map when new samples are saved _runtimeBindings.bind( MapRuntimeSubscription.sampleSaved, @@ -493,7 +525,9 @@ class _MapScreenState extends State { ); }, ); + } + void _bindAlertStreams() { // Subscribe to new repeater discovery alerts _runtimeBindings.bind( MapRuntimeSubscription.newRepeater, @@ -561,24 +595,30 @@ class _MapScreenState extends State { ); }, ); + } + /// Achievement backfill and repeater alert configuration. + Future _applyStartupAlertSettings(int generation) async { // Check achievements on startup AchievementService().checkAndUnlock(); // Load known repeater IDs from DB so only truly new ones trigger alerts final knownIds = await _databaseService.getDistinctRepeaterIds(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; await _locationService.loraCompanion.loadKnownRepeaterIds(knownIds); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; // Load alert toggle settings final newRepeaterAlerts = await _settingsService .getNewRepeaterAlertsEnabled(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; _locationService.loraCompanion.setNewRepeaterAlertsEnabled( newRepeaterAlerts, ); + return true; + } + void _bindTelemetryStreams() { // Update distance immediately instead of waiting for a periodic map refresh. _runtimeBindings.bind( MapRuntimeSubscription.distance, @@ -606,20 +646,24 @@ class _MapScreenState extends State { }); }, ); + } + /// Samples, position search, and cached community coverage. + Future _loadStartupMapData(int generation) async { await _loadSamples(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; await _locationService.startPositionSearch(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; // Load cached community coverage for offline viewing final cached = await _uploadService.loadCachedCoverage(); - if (!_isInitializationCurrent(generation)) return; + if (!_isInitializationCurrent(generation)) return false; if (cached != null && cached['coverage'] != null) { setState(() { _communityCoverage = cached['coverage'] as Map; }); } + return true; } bool _isInitializationCurrent(int generation) => From 8cd89d171164b1c870fbb28b7c0353cb2bdd2cbe Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 09:00:20 +0300 Subject: [PATCH 23/33] Extract manual ping recording into a service --- lib/screens/map_screen.dart | 73 ++++-------------- lib/services/manual_ping_service.dart | 107 ++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 60 deletions(-) create mode 100644 lib/services/manual_ping_service.dart diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index d3ea225..57c5134 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -77,6 +77,7 @@ import '../l10n/generated/app_localizations.dart'; import '../constants/app_version.dart'; import '../services/ducting_service.dart'; import '../services/carpeater_service.dart'; +import '../services/manual_ping_service.dart'; import '../services/sound_service.dart'; import 'analytics_screen.dart'; import 'achievements_screen.dart'; @@ -1867,6 +1868,12 @@ class _MapScreenState extends State { ); } + /// Manual ping business logic with the screen's companion and DB wired in. + ManualPingService get _manualPingService => ManualPingService( + loraCompanion: _locationService.loraCompanion, + databaseService: _databaseService, + ); + Future _manualPing() async { if (!_loraConnected) { _showSnackBar(AppLocalizations.of(context).mapConnectLoraFirst); @@ -1884,76 +1891,22 @@ class _MapScreenState extends State { } _showSnackBar(AppLocalizations.of(context).mapSendingPing); - SoundService().playPingSent(); - // Send ping via LoRa companion - final result = await _locationService.loraCompanion.ping( - latitude: _currentPosition!.latitude, - longitude: _currentPosition!.longitude, + final outcome = await _manualPingService.ping( + position: _currentPosition!, timeoutSeconds: _discoveryTimeoutSeconds, - waitForAllResponses: true, - collectUntilTimeout: _thoroughResponseCollection, - ); - - final responses = result.responses; - final pingSuccess = - result.status == PingStatus.success && responses.isNotEmpty; - - if (pingSuccess) { - for (final response in responses) { - await SoundService().playForPingResult( - success: true, - snr: response.snr, - rssi: response.rssi, - ); - } - } else { - await SoundService().playForPingResult(success: false); - } - - // Create and save sample - final geohash = GeohashUtils.sampleKey( - _currentPosition!.latitude, - _currentPosition!.longitude, + thoroughResponseCollection: _thoroughResponseCollection, ); - if (pingSuccess) { - for (var index = 0; index < responses.length; index++) { - final response = responses[index]; - final sample = Sample( - id: '${DateTime.now().microsecondsSinceEpoch}_${index}_$geohash', - position: _currentPosition!, - timestamp: DateTime.now(), - path: response.nodeId, - geohash: geohash, - rssi: response.rssi, - snr: response.snr, - pingSuccess: true, - responseTimeMs: response.responseTimeMs, - deviceId: _locationService.loraCompanion.connectedDeviceId, - ); - await _databaseService.insertSample(sample); - } - } else { - final sample = Sample( - id: '${DateTime.now().microsecondsSinceEpoch}_$geohash', - position: _currentPosition!, - timestamp: DateTime.now(), - geohash: geohash, - pingSuccess: false, - responseTimeMs: result.responseTimeMs, - deviceId: _locationService.loraCompanion.connectedDeviceId, - ); - await _databaseService.insertSample(sample); - } - // Reload samples to update map await _loadSamples(); // Show result - if (pingSuccess) { + final result = outcome.result; + if (outcome.pingSuccess) { if (!mounted) return; final l10n = AppLocalizations.of(context); + final responses = result.responses; final summary = responses.length == 1 ? l10n.mapPingHeardBy(_shortNodeId(responses.single.nodeId)) : l10n.mapDiscoveryComplete(responses.length); diff --git a/lib/services/manual_ping_service.dart b/lib/services/manual_ping_service.dart new file mode 100644 index 0000000..02c35bf --- /dev/null +++ b/lib/services/manual_ping_service.dart @@ -0,0 +1,107 @@ +import 'package:latlong2/latlong.dart'; + +import '../models/models.dart'; +import '../utils/geohash_utils.dart'; +import 'database_service.dart'; +import 'lora_companion_service.dart'; +import 'sound_service.dart'; + +/// Outcome of a manual ping, for the caller to present to the user. +class ManualPingOutcome { + const ManualPingOutcome({required this.result, required this.pingSuccess}); + + /// Raw companion ping result (status, responses, timing, error). + final PingResult result; + + /// Whether the ping succeeded and produced at least one response sample. + final bool pingSuccess; +} + +/// Sends a manual LoRa ping and persists the resulting samples. +/// +/// Owns the business side of the manual ping flow: the ping request, the +/// result sounds, and Sample construction/insertion into the database. +/// Presentation (snackbars, sample list reloads) stays with the caller, who +/// inspects the returned [ManualPingOutcome]. +class ManualPingService { + ManualPingService({ + required this.loraCompanion, + required this.databaseService, + }); + + final LoRaCompanionService loraCompanion; + final DatabaseService databaseService; + + /// Sends a ping from [position], plays the result sounds, and stores one + /// sample per response (or a single failed sample). + Future ping({ + required LatLng position, + required int timeoutSeconds, + required bool thoroughResponseCollection, + }) async { + SoundService().playPingSent(); + + // Send ping via LoRa companion + final result = await loraCompanion.ping( + latitude: position.latitude, + longitude: position.longitude, + timeoutSeconds: timeoutSeconds, + waitForAllResponses: true, + collectUntilTimeout: thoroughResponseCollection, + ); + + final responses = result.responses; + final pingSuccess = + result.status == PingStatus.success && responses.isNotEmpty; + + if (pingSuccess) { + for (final response in responses) { + await SoundService().playForPingResult( + success: true, + snr: response.snr, + rssi: response.rssi, + ); + } + } else { + await SoundService().playForPingResult(success: false); + } + + // Create and save sample + final geohash = GeohashUtils.sampleKey( + position.latitude, + position.longitude, + ); + + if (pingSuccess) { + for (var index = 0; index < responses.length; index++) { + final response = responses[index]; + final sample = Sample( + id: '${DateTime.now().microsecondsSinceEpoch}_${index}_$geohash', + position: position, + timestamp: DateTime.now(), + path: response.nodeId, + geohash: geohash, + rssi: response.rssi, + snr: response.snr, + pingSuccess: true, + responseTimeMs: response.responseTimeMs, + deviceId: loraCompanion.connectedDeviceId, + ); + await databaseService.insertSample(sample); + } + } else { + final sample = Sample( + id: '${DateTime.now().microsecondsSinceEpoch}_$geohash', + position: position, + timestamp: DateTime.now(), + geohash: geohash, + pingSuccess: false, + responseTimeMs: result.responseTimeMs, + deviceId: loraCompanion.connectedDeviceId, + ); + await databaseService.insertSample(sample); + } + + return ManualPingOutcome(result: result, pingSuccess: pingSuccess); + } +} From baa336a838eaf6f3a613ad21bf96640833e0ac04 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 20:32:41 +0300 Subject: [PATCH 24/33] Refresh documentation status and add doc-impact config Update the map screen refactoring plan with the audit's composition-root checklist and current progress, mark completed audit findings in the audit report, fix the outdated storage-permission and export-format docs, regroup the documentation index, and add .dsh/doc-impact.yml mapping source areas to the documents they affect. Personal overrides are git-ignored and the plugin workflow is documented in AGENTS.md. --- .dsh/doc-impact.yml | 154 +++++++++++++++++++++ .gitignore | 1 + AGENTS.md | 5 + README.md | 4 +- docs/INSTALLATION.md | 5 +- docs/README.md | 15 +- docs/development/map-screen-refactoring.md | 96 ++++++++----- docs/development/refactoring-audit.md | 17 +++ docs/getting-started.md | 19 ++- 9 files changed, 270 insertions(+), 46 deletions(-) create mode 100644 .dsh/doc-impact.yml diff --git a/.dsh/doc-impact.yml b/.dsh/doc-impact.yml new file mode 100644 index 0000000..d95b696 --- /dev/null +++ b/.dsh/doc-impact.yml @@ -0,0 +1,154 @@ +# Documentation-impact rules for the dsh-doc-impact plugin +# (https://github.com/xarleyn/dsh-plugins/tree/main/plugins/dsh-doc-impact). +# +# When an agent turn touches the code globs of a rule, the plugin asks the +# agent to review or update the linked documents and resolve the impact via +# doc_impact_resolve. Personal overrides belong in +# .dsh/doc-impact.local.yml (git-ignored). +version: 1 +defaults: + mode: remind + scope: turn + changeDetection: auto +rules: + # The map screen composition root is being slimmed down; its structure and + # the refactoring plan must move together (steps and live checklist live in + # the plan document). + - id: map-refactoring-plan + description: Map screen structure is tracked by the refactoring plan + code: + include: + - lib/screens/map_screen.dart + - lib/screens/map/** + exclude: + - "**/*.g.dart" + docs: + - docs/development/map-screen-refactoring.md + direction: bidirectional + relation: synchronized + mode: require-review + + # The companion guide and the ping-debugging notes describe the behavior + # and frame flow implemented by these services. + - id: lora-companion-guide + description: Companion radio, protocol, and ping debugging documentation + code: + - lib/services/lora_companion_service.dart + - lib/services/meshcore_protocol.dart + - lib/services/manual_ping_service.dart + docs: + - docs/guides/lora-companion.md + - docs/development/debugging-pings.md + direction: code-to-docs + relation: documents + + # Release tooling, signing configuration, and CI define the documented + # release process. + - id: release-process + description: Release builds, versioning, signing, and CI + code: + - tool/version.dart + - tool/build_release.ps1 + - android/app/build.gradle.kts + - android/key.properties.example + - .github/workflows/ci.yml + - .github/workflows/release.yml + docs: + - docs/development/releasing.md + direction: bidirectional + relation: specification + + # Installation steps mirror package identity and requested permissions. + - id: installation-docs + description: Package identity, permissions, and installation steps + code: + - android/app/src/main/AndroidManifest.xml + - android/app/build.gradle.kts + docs: + - docs/INSTALLATION.md + direction: code-to-docs + relation: documents + + # Quick-start documents tracking, quality filtering, and settings behavior. + - id: getting-started + description: Quick-start describes tracking and settings behavior + code: + - lib/services/location_service.dart + - lib/services/location_quality_filter.dart + - lib/screens/settings/** + docs: + - docs/getting-started.md + direction: code-to-docs + relation: documents + + # Export format examples follow Sample.toJson and the export builders. + - id: export-format + description: Data export format documentation + code: + - lib/models/models.dart + - lib/utils/sample_export.dart + - lib/screens/map/data_io.dart + docs: + - docs/getting-started.md + - docs/guides/lora-companion.md + direction: code-to-docs + relation: documents + + # Version bumps sync pubspec, the in-app constant, and the changelog. + - id: changelog-version + description: Version bumps are recorded in CHANGELOG.md + code: + - pubspec.yaml + - lib/constants/app_version.dart + - tool/version.dart + docs: + - CHANGELOG.md + direction: code-to-docs + relation: synchronized + + # Design specs describe implemented features; keep them synchronized. + - id: impossible-zones-spec + description: Impossible Zones design spec + code: + - lib/models/impossible_zone.dart + - lib/services/location_quality_filter.dart + - lib/services/bad_fix_monitor.dart + docs: + - docs/superpowers/specs/2026-08-20-impossible-zones-design.md + direction: bidirectional + relation: specification + + - id: localization-spec + description: Localization design spec and implementation plan + code: + - lib/l10n/app_en.arb + - lib/l10n/app_ru.arb + docs: + - docs/superpowers/specs/2026-08-20-localization-design.md + - docs/superpowers/plans/2026-08-20-localization.md + direction: code-to-docs + relation: related + + # Map tile providers and the offline download policy are tracked by the + # provider investigation document. + - id: map-providers + description: Map provider choice and offline tile policy + code: + - lib/services/tile_download_service.dart + - lib/screens/map/dialogs/offline_tile_dialogs.dart + - lib/utils/initial_map_camera.dart + docs: + - docs/development/map-providers.md + direction: code-to-docs + relation: related + + # AGENTS.md documents the required workflow commands and lint conventions. + - id: agent-conventions + description: Repository conventions and required workflow + code: + - analysis_options.yaml + - .github/workflows/ci.yml + docs: + - AGENTS.md + direction: bidirectional + relation: specification diff --git a/.gitignore b/.gitignore index 9acd299..03d1a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ app.*.map.json # Local AI tooling .aider* +.dsh/doc-impact.local.yml # Local scratch scripts and data exports /tmp/ diff --git a/AGENTS.md b/AGENTS.md index bef6d42..3940924 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,11 @@ or a physical LoRa device, describe any manual device testing that remains. output. Distribute binaries through GitHub Releases. - Update documentation and examples when changing commands, paths, settings, or user-visible behavior. +- `.dsh/doc-impact.yml` maps source areas to the documents they affect for the + `dsh-doc-impact` agent plugin. When a change touches files covered by a rule, + review or update the linked documents and resolve the impact with the + plugin's `doc_impact_resolve` tool before finishing the turn. Personal + overrides belong in `.dsh/doc-impact.local.yml`, which is not committed. ## Architecture notes diff --git a/README.md b/README.md index e6e4e6d..1bc98b3 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ flutter pub get 3. Generate app icons: ```bash -flutter pub run flutter_launcher_icons +dart run flutter_launcher_icons ``` 4. Run on connected device: @@ -192,6 +192,8 @@ other Flutter platforms are not maintained here. - [LoRa companion guide](docs/guides/lora-companion.md) - [MeshCore authentication](docs/guides/meshcore-authentication.md) - [Ping debugging](docs/development/debugging-pings.md) +- [Android release builds](docs/development/releasing.md) +- [Map screen refactoring plan](docs/development/map-screen-refactoring.md) - [Changelog](CHANGELOG.md) ## Privacy diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index a8bcd85..2c657de 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -33,9 +33,12 @@ the old package permanently deletes its private data. On first launch, grant these permissions: - **Location** (Always) - Required for GPS tracking - **Bluetooth** - For Bluetooth LoRa device connection -- **Storage** - For exporting data - **USB** - For USB LoRa device connection (when connected) +No storage permissions are required: exports and imports go through the +system file picker / share sheet, and the app never asks for storage access +at runtime. + ## First Time Setup ### Join #meshwar Channel diff --git a/docs/README.md b/docs/README.md index 67de71d..2f49008 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,16 +13,23 @@ ## Development +- [Repository conventions and workflow (AGENTS.md)](../AGENTS.md) - [Map screen refactoring plan](development/map-screen-refactoring.md) - [Refactoring audit (2026-09-02)](development/refactoring-audit.md) +- [Debugging ping responses](development/debugging-pings.md) +- [Map provider investigation](development/map-providers.md) +- [Android release builds, versioning, signing, and GitHub Actions](development/releasing.md) + +## Design documents + - [Impossible GPS zones](superpowers/specs/2026-08-20-impossible-zones-design.md) - [In-app localization (en / ru)](superpowers/specs/2026-08-20-localization-design.md) - [Localization implementation plan](superpowers/plans/2026-08-20-localization.md) - [Fresh session map view](superpowers/specs/2026-08-19-fresh-session-map-design.md) -- [Debugging ping responses](development/debugging-pings.md) -- [Map provider investigation](development/map-providers.md) -- [Android release builds, versioning, signing, and GitHub Actions](development/releasing.md) -- [Fork changelog draft (RU)](changelog-fork-ru.md) + +## History + - [Version history](../CHANGELOG.md) +- [Fork changelog draft (RU)](changelog-fork-ru.md) Screenshot source files are stored in `docs/assets/screenshots/`. diff --git a/docs/development/map-screen-refactoring.md b/docs/development/map-screen-refactoring.md index 1890afa..151df5c 100644 --- a/docs/development/map-screen-refactoring.md +++ b/docs/development/map-screen-refactoring.md @@ -2,12 +2,12 @@ ## Context -`lib/screens/map_screen.dart` has grown into the application's main integration -point. It currently combines map rendering, tracking lifecycle, radio state, -settings, data transfer, offline maps, community coverage, navigation, and -dialogs in one `State` object. +`lib/screens/map_screen.dart` started as the application's main integration +point. It combined map rendering, tracking lifecycle, radio state, settings, +data transfer, offline maps, community coverage, navigation, and dialogs in +one `State` object. -At the start of this refactoring the file contains approximately: +At the start of this refactoring the file contained approximately: - 5,750 lines; - 99 methods; @@ -15,9 +15,14 @@ At the start of this refactoring the file contains approximately: - 39 dialogs; - 14 owned stream subscriptions. -The settings feature is physically split into part files, but those files use -extensions on `_MapScreenState`. This reduces individual file size without -reducing coupling. +The settings feature was physically split into part files, but those files +used extensions on `_MapScreenState`. This reduced individual file size +without reducing coupling. + +The [refactoring audit](refactoring-audit.md) (2026-09-02) re-measured the +file at 3,261 lines after stages 1-4 below were completed, and defined a +follow-up composition-root slim-down (stage 6 below). As of 2026-09-02 the +file is 2,359 lines. ## Goals @@ -38,39 +43,27 @@ reducing coupling. as part of mechanical extraction work. - Moving code into `part` files solely to make the main file shorter. -## Target structure +## Current structure ```text lib/screens/ - map_screen.dart + map_screen.dart # composition root: State, wiring, build map/ - map_view.dart - map_screen_controller.dart - layers/ - coverage_layer.dart - sample_layer.dart - repeater_layer.dart - route_layer.dart - position_layer.dart - prediction_layer.dart - widgets/ - map_control_panel.dart - map_action_buttons.dart - quick_settings_panel.dart - delete_mode_banner.dart - dialogs/ - map_entity_dialogs.dart - connection_dialog.dart - upload_dialogs.dart - marker_dialogs.dart -lib/utils/ - sample_export.dart - coverage_prediction.dart + map_screen_controller.dart # MapDataStore: samples, aggregation, LOD, filtering + map_settings_controller.dart # typed settings snapshot and commands + map_runtime_bindings.dart # staged stream/timer wiring and ownership + map_annotations_controller.dart # markers and privacy/exclusion zones + tracking_permissions.dart # Android permission pre-flight for tracking + connection_flow.dart # USB/BLE connect, contacts, scanning + data_io.dart # sample, settings, and database import/export + layers/ # independent map layers with constructor inputs + widgets/ # control panel, action buttons, layer stack, banners + dialogs/ # typed dialogs and screen workflows +lib/services/ + manual_ping_service.dart # manual ping recording (extracted from the screen) +lib/screens/settings/ # settings UI; settings_page.dart is still a part file ``` -The exact number of files may change as dependencies become clearer. Prefer a -cohesive 200-400 line file over many tiny single-use wrappers. - ## Implementation stages ### Stage 1: pure logic and leaf widgets @@ -134,6 +127,39 @@ cohesive 200-400 line file over many tiny single-use wrappers. - [x] Apply service-side settings through explicit commands. - [ ] Remove `part` dependencies between the settings feature and map screen. +### Stage 6: composition-root slim-down (2026-09 audit) + +Follows the step table in the [refactoring audit](refactoring-audit.md), +section 5.1. Each step is one commit with full verification. Status: + +- [x] 1. Update check flow -> `map/dialogs/update_flow.dart` (095227a). +- [x] 2. Sample, settings, and database import/export -> `map/data_io.dart` + (7b7eebb). +- [x] 3. Markers and zone persistence -> `map/map_annotations_controller.dart` + (5f1e67b). +- [x] 4. Tracking permission flow -> `map/tracking_permissions.dart` (e24b6b4). +- [x] 5. Connection, contacts, and scanning -> `map/connection_flow.dart` + (445acba). +- [ ] 6. Deduplicate the two screenshot/share sequences -> + `map/screenshot_flow.dart`. +- [x] 7. Upload, community coverage, and offline tile flows -> + `map/dialogs/upload_flows.dart` (558aeb7). +- [x] 8. Theme/language flows and ducting helpers -> + `map/dialogs/theme_flows.dart` (286fa7a). +- [x] 9. Split `_initialize` into staged phases in + `map_runtime_bindings.dart` (203b08d). +- [x] 10. Manual ping sample recording -> `lib/services/manual_ping_service.dart` + (8cd89d1). +- [ ] 11. `MapLayerStack` and typed panel callbacks -> + `map/widgets/map_layer_stack.dart`, `map/widgets/map_screen_actions.dart` + (in progress). +- [ ] 12. Decouple `lib/screens/settings/settings_page.dart` from + `_MapScreenState` via `MapUiSnapshot` + `MapUiActions` (same as stage 5). + +Steps 1-5 and 7-10 reduce `map_screen.dart` from 3,261 to 2,359 lines. +Step 11 targets the remaining 13-16-parameter panel invocations; step 12 +removes the last `part` file (~947 lines) and closes stage 5 as well. + ## Extraction rules - A map layer may depend on Flutter Map types and presentation helpers, but not diff --git a/docs/development/refactoring-audit.md b/docs/development/refactoring-audit.md index 1d28a49..4e1f74d 100644 --- a/docs/development/refactoring-audit.md +++ b/docs/development/refactoring-audit.md @@ -1,5 +1,22 @@ # Аудит рефакторинга проекта (2026-09-02) +> **Статус на 2026-09-02 (после аудита).** Отчёт ниже — снимок на момент +> обхода; большинство находок уже закрыто на этой ветке. Выполнено: +> §3.3 `tmp/` (abe57fd); §3.5 и §4.3 (be61544); §3.6 — утечка +> battery-подписки, залипшие contact-запросы, гонка скана и dispose-гонки +> (fe53ec5, 0f8f49d); §3.6 импорт БД и дыры экспорта §3.7 (64d362d); +> §3.7 пароль в secure storage и §3.8 зависимости (b1fc778); формулировка +> AGENTS.md (606e94f); §5.4 переносы тестов из корня (2bfadf5) и harness +> `pumpDialog` (4ca67ae); дубль upload-цикла (ea81281); дубли протокола и +> мёртвый парсер (7f7edb4); §3.10 и §5.5 — обновление доков, пометка +> upstream-MQTT разделов (f33a782); шаги 1–5 и 7–10 плана из §5.1 — +> `map_screen.dart` сокращён 3261 → 2359 строк (актуальный чек-лист — +> в `docs/development/map-screen-refactoring.md`, этап 6). +> +> Открытыми остаются: §3.9 гигиена манифеста (требует ручной проверки на +> устройстве), шаг 6 (дедупликация скриншот-потоков), шаг 12 / этап 5 +> (отклеивание part-файла настроек) и фазное разбиение сервисов из §5.2. + Подробный обход репозитория с целью найти точки рефакторинга: кандидатов на разбиение больших файлов, проблемы тестового набора, вопросы структуры каталогов и общей гигиены. Каждая находка отнесена к одной из категорий: diff --git a/docs/getting-started.md b/docs/getting-started.md index 5e43dcf..6fe447d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -85,11 +85,11 @@ The app language (System / English / Русский) is under Settings → App & - Age: Green (fresh) → Red (old) ### Data Management -- **Export**: Saves all collected samples as JSON file +- **Export**: Saves all collected samples as JSON, CSV, GPX, or KML - Save location is chosen in the system file picker (or shared via the Android share sheet) - - Suggested file name: `meshcore_export_YYYYMMDD_HHMMSS.json` - + - Suggested JSON file name: `meshcore_export_YYYYMMDD_HHMMSS.json` + - **Clear**: Deletes all collected data (with confirmation) ## Tips for Wardriving @@ -102,7 +102,9 @@ The app language (System / English / Русский) is under Settings → App & ## Data Format -Exported JSON contains an array of samples: +Exported JSON contains an array of samples. Ping-related fields (`rssi`, +`snr`, `pingSuccess`, `responseTimeMs`) are `null` for samples recorded +without a radio response: ```json [ { @@ -111,7 +113,14 @@ Exported JSON contains an array of samples: "lon": -122.4247, "timestamp": "2024-01-01T12:00:00.000Z", "path": null, - "geohash": "c23nb2q2" + "geohash": "c23nb2q2", + "rssi": -85, + "snr": 7, + "pingSuccess": true, + "responseTimeMs": 2350, + "ductingRisk": null, + "source": null, + "deviceId": null } ] ``` From d49b325b4a7f13e1c0baa7e6ec3ff4f6c6e6074d Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 21:17:00 +0300 Subject: [PATCH 25/33] Rebalance doc-impact rules and document Carpeater mode Drop noisy doc-impact links (settings glob to the quick-start, model barrel to export docs, CI to AGENTS.md, ARB churn to the dated localization spec) and keep only precise code-to-document mappings. Add coverage for the Carpeater guide, the fresh-session design spec, and the fork changelog draft. Document Carpeater mode - the largest fork feature without docs - mark the upstream MQTT authentication guide as removed in this fork, and mention the database backup in the quick start. --- .dsh/doc-impact.yml | 71 +++++++++++++++----------- README.md | 3 +- docs/README.md | 3 +- docs/getting-started.md | 5 ++ docs/guides/carpeater.md | 69 +++++++++++++++++++++++++ docs/guides/meshcore-authentication.md | 7 +++ 6 files changed, 126 insertions(+), 32 deletions(-) create mode 100644 docs/guides/carpeater.md diff --git a/.dsh/doc-impact.yml b/.dsh/doc-impact.yml index d95b696..6479d32 100644 --- a/.dsh/doc-impact.yml +++ b/.dsh/doc-impact.yml @@ -3,7 +3,8 @@ # # When an agent turn touches the code globs of a rule, the plugin asks the # agent to review or update the linked documents and resolve the impact via -# doc_impact_resolve. Personal overrides belong in +# doc_impact_resolve. Keep rules precise: each code glob should map only to +# documents that actually describe that code. Personal overrides belong in # .dsh/doc-impact.local.yml (git-ignored). version: 1 defaults: @@ -42,50 +43,62 @@ rules: direction: code-to-docs relation: documents - # Release tooling, signing configuration, and CI define the documented - # release process. + # Carpeater mode is a fork-specific feature with its own user guide. + - id: carpeater-guide + description: Carpeater mode guide + code: + - lib/services/carpeater_service.dart + - lib/screens/settings/sections/carpeater_section.dart + docs: + - docs/guides/carpeater.md + direction: code-to-docs + relation: documents + + # Release tooling, signing configuration, and the release workflow define + # the documented release process. - id: release-process - description: Release builds, versioning, signing, and CI + description: Release builds, versioning, signing, and GitHub Actions code: - tool/version.dart - tool/build_release.ps1 - android/app/build.gradle.kts - android/key.properties.example - - .github/workflows/ci.yml - .github/workflows/release.yml docs: - docs/development/releasing.md direction: bidirectional relation: specification - # Installation steps mirror package identity and requested permissions. + # Installation steps mirror the requested Android permissions. - id: installation-docs - description: Package identity, permissions, and installation steps + description: Permissions and installation steps code: - android/app/src/main/AndroidManifest.xml - - android/app/build.gradle.kts docs: - docs/INSTALLATION.md direction: code-to-docs relation: documents - # Quick-start documents tracking, quality filtering, and settings behavior. + # Quick-start documents specific tracking and positioning behavior; each + # mapped service implements a section of that document. - id: getting-started - description: Quick-start describes tracking and settings behavior + description: Quick-start tracking and positioning behavior code: - lib/services/location_service.dart - lib/services/location_quality_filter.dart - - lib/screens/settings/** + - lib/services/bad_fix_monitor.dart + - lib/services/radio_position_estimator.dart + - lib/services/wifi_location_service.dart + - lib/widgets/compass_calibration.dart docs: - docs/getting-started.md direction: code-to-docs relation: documents - # Export format examples follow Sample.toJson and the export builders. + # Export format examples follow the export builders and the data flows. - id: export-format description: Data export format documentation code: - - lib/models/models.dart - lib/utils/sample_export.dart - lib/screens/map/data_io.dart docs: @@ -94,15 +107,16 @@ rules: direction: code-to-docs relation: documents - # Version bumps sync pubspec, the in-app constant, and the changelog. + # Version bumps sync the in-app constant and are recorded in the changelog + # and the fork changelog draft. - id: changelog-version - description: Version bumps are recorded in CHANGELOG.md + description: Version bumps are recorded in the changelogs code: - - pubspec.yaml - lib/constants/app_version.dart - tool/version.dart docs: - CHANGELOG.md + - docs/changelog-fork-ru.md direction: code-to-docs relation: synchronized @@ -118,36 +132,33 @@ rules: direction: bidirectional relation: specification - - id: localization-spec - description: Localization design spec and implementation plan + - id: fresh-session-map-spec + description: Fresh session map view design spec code: - - lib/l10n/app_en.arb - - lib/l10n/app_ru.arb + - lib/widgets/tracking_play_button.dart + - lib/screens/map/map_screen_controller.dart docs: - - docs/superpowers/specs/2026-08-20-localization-design.md - - docs/superpowers/plans/2026-08-20-localization.md - direction: code-to-docs - relation: related + - docs/superpowers/specs/2026-08-19-fresh-session-map-design.md + direction: bidirectional + relation: specification - # Map tile providers and the offline download policy are tracked by the - # provider investigation document. + # The tile provider investigation tracks the provider choice and the + # offline download policy. - id: map-providers description: Map provider choice and offline tile policy code: - lib/services/tile_download_service.dart - lib/screens/map/dialogs/offline_tile_dialogs.dart - - lib/utils/initial_map_camera.dart docs: - docs/development/map-providers.md direction: code-to-docs relation: related - # AGENTS.md documents the required workflow commands and lint conventions. + # AGENTS.md points at analysis_options.yaml as the lint convention. - id: agent-conventions - description: Repository conventions and required workflow + description: Repository lint conventions in AGENTS.md code: - analysis_options.yaml - - .github/workflows/ci.yml docs: - AGENTS.md direction: bidirectional diff --git a/README.md b/README.md index 1bc98b3..0d46e2b 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,8 @@ other Flutter platforms are not maintained here. - [Getting started](docs/getting-started.md) - [Installation](docs/INSTALLATION.md) - [LoRa companion guide](docs/guides/lora-companion.md) -- [MeshCore authentication](docs/guides/meshcore-authentication.md) +- [Carpeater mode guide](docs/guides/carpeater.md) +- [MeshCore authentication](docs/guides/meshcore-authentication.md) *(upstream MQTT only)* - [Ping debugging](docs/development/debugging-pings.md) - [Android release builds](docs/development/releasing.md) - [Map screen refactoring plan](docs/development/map-screen-refactoring.md) diff --git a/docs/README.md b/docs/README.md index 2f49008..3dffd73 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,8 @@ ## Guides - [LoRa companion setup](guides/lora-companion.md) -- [MeshCore authentication](guides/meshcore-authentication.md) +- [Carpeater mode (car repeater)](guides/carpeater.md) +- [MeshCore authentication](guides/meshcore-authentication.md) *(upstream MQTT only - removed in this fork)* ## Development diff --git a/docs/getting-started.md b/docs/getting-started.md index 6fe447d..7b5a4ac 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -91,6 +91,11 @@ The app language (System / English / Русский) is under Settings → App & - Suggested JSON file name: `meshcore_export_YYYYMMDD_HHMMSS.json` - **Clear**: Deletes all collected data (with confirmation) +- **Database backup**: Settings → Backup → Export Database writes a full + snapshot of the SQLite database (samples, sessions, markers, zones, upload + tracking) to a file or the share sheet; Import Database restores it. This + is more complete than the JSON sample export and is the recommended way to + move devices. ## Tips for Wardriving diff --git a/docs/guides/carpeater.md b/docs/guides/carpeater.md new file mode 100644 index 0000000..21cdb44 --- /dev/null +++ b/docs/guides/carpeater.md @@ -0,0 +1,69 @@ +# Carpeater mode (car repeater) + +> **Fork feature.** Carpeater mode exists only in this fork; the upstream app +> has no such mode. + +Carpeater ("car repeater") mode records coverage from the vantage point of a +MeshCore repeater instead of your companion radio. The app logs into the chosen +repeater and drives neighbor-discovery cycles through it. This is useful when +the repeater has a better antenna or position than your mobile setup - for +example, mapping an area while sitting behind a hilltop repeater. + +## How it works + +1. While GPS tracking is active, the app logs into the target repeater over + the companion radio link. +2. Each discovery cycle asks the repeater for its current neighbors. The + neighbor table is cleared before every cycle, so cached neighbors from the + repeater's home location cannot create false coverage squares. +3. Cycle results are recorded like ordinary ping results at your current GPS + position: + - one green sample per heard neighbor (SNR is stored); + - one red dead-zone sample when the repeater hears nobody; + - the target repeater itself and your ignored-prefix list are filtered out. +4. Cycles repeat at the configured cycle interval until you stop tracking. + +Regular auto-ping is suspended while Carpeater drives the radio link, and the +tracking notification shows Carpeater status instead of the live ping counter. +Normal MeshCore messaging still works through the repeater while logged in. + +## Setup + +Settings → Carpeater: + +1. **Enable Carpeater mode**. +2. **Target repeater** - pick the repeater to drive from a searchable list of + previously found repeaters; rows show the advertised name and ID, and the + current selection is highlighted. +3. **Admin password** - the repeater's admin password. It is stored only in + platform secure storage on this device and never appears in settings + export/import or plaintext preferences. +4. **Cycle interval** - pause between discovery cycles: None (back-to-back), + 5 s, 10 s, 15 s, 30 s, 1 m, or 2 m. + +Then start GPS tracking as usual. Carpeater sampling runs only while tracking +is active; toggling the mode mid-tracking switches the link between ordinary +auto-ping and Carpeater discovery. + +## Behavior and limits + +- **Link loss**: if the radio connection drops, Carpeater pauses and resumes + automatically once the device reconnects. +- **Consecutive failures**: after 3 failed cycles in a row (for example the + repeater becomes unreachable or login is refused) the mode stops with an + error state. Use the retry action on the map status chip to log in again. +- **Samples**: Carpeater samples are tagged with the connected companion + device ID like all other samples. RSSI is not available in this mode; only + SNR is stored. + +## Troubleshooting + +- **Login fails**: verify the admin password and that the repeater permits + CLI login. If the password was changed on the repeater, update it in + Settings → Carpeater. +- **No coverage while driving**: remember that coverage reflects what the + *repeater* hears, not what your phone hears. Check that the repeater's + antenna actually reaches the area you are mapping. +- **Stops after a few cycles**: three consecutive failed cycles stop the + mode. Check the debug terminal for the underlying radio errors and use the + retry action once the link is stable. diff --git a/docs/guides/meshcore-authentication.md b/docs/guides/meshcore-authentication.md index d137bc3..04f7811 100644 --- a/docs/guides/meshcore-authentication.md +++ b/docs/guides/meshcore-authentication.md @@ -1,5 +1,12 @@ # MeshCore Authentication Setup +> **Fork note:** this guide documents the upstream app's MQTT broker +> authentication (Ed25519-signed tokens). **MQTT was removed in this fork** - +> the app now talks only to the companion radio over USB/Bluetooth, and +> repeater responses arrive as frames on that link (see the +> [LoRa companion guide](lora-companion.md)). Nothing below applies to this +> fork; it is kept only as a reference for the upstream implementation. + The app now supports MeshCore's Ed25519 signature-based authentication for MQTT. ## What You Need From 1fb390b4fb453a46ae2f38e47552129aa795b114 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Wed, 2 Sep 2026 21:47:52 +0300 Subject: [PATCH 26/33] Add feature guides for the main fork capabilities Write six user guides grounded in the changelog and code: recording sessions, markers and zones, positioning and location quality, data management, online coverage features (uploads, community coverage, offline tiles, connectivity), and analytics with achievements. Extend the doc-impact config with rules for the new guides, cross-link them from the documentation index and the quick start, and note the double-tap quick-settings and long-press add-menu map gestures there. Documented behavior follows the code: privacy zones filter uploads only, not sample exports. --- .dsh/doc-impact.yml | 67 ++++++++++++++++++++++++-- docs/README.md | 6 +++ docs/getting-started.md | 11 ++++- docs/guides/analytics.md | 56 ++++++++++++++++++++++ docs/guides/data-management.md | 54 +++++++++++++++++++++ docs/guides/markers-and-zones.md | 57 ++++++++++++++++++++++ docs/guides/online-coverage.md | 49 +++++++++++++++++++ docs/guides/positioning.md | 80 +++++++++++++++++++++++++++++++ docs/guides/recording-sessions.md | 50 +++++++++++++++++++ 9 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 docs/guides/analytics.md create mode 100644 docs/guides/data-management.md create mode 100644 docs/guides/markers-and-zones.md create mode 100644 docs/guides/online-coverage.md create mode 100644 docs/guides/positioning.md create mode 100644 docs/guides/recording-sessions.md diff --git a/.dsh/doc-impact.yml b/.dsh/doc-impact.yml index 6479d32..b88d596 100644 --- a/.dsh/doc-impact.yml +++ b/.dsh/doc-impact.yml @@ -79,10 +79,11 @@ rules: direction: code-to-docs relation: documents - # Quick-start documents specific tracking and positioning behavior; each - # mapped service implements a section of that document. + # Quick-start and the positioning guide document tracking, quality + # filtering, and positioning behavior; each mapped service implements a + # section of those documents. - id: getting-started - description: Quick-start tracking and positioning behavior + description: Quick-start and positioning guides code: - lib/services/location_service.dart - lib/services/location_quality_filter.dart @@ -92,17 +93,21 @@ rules: - lib/widgets/compass_calibration.dart docs: - docs/getting-started.md + - docs/guides/positioning.md direction: code-to-docs relation: documents - # Export format examples follow the export builders and the data flows. + # Export format examples follow the export builders, data flows, and the + # database backup service. - id: export-format - description: Data export format documentation + description: Data export and backup documentation code: - lib/utils/sample_export.dart - lib/screens/map/data_io.dart + - lib/services/database_backup_service.dart docs: - docs/getting-started.md + - docs/guides/data-management.md - docs/guides/lora-companion.md direction: code-to-docs relation: documents @@ -142,6 +147,57 @@ rules: direction: bidirectional relation: specification + # Recording sessions are a fork feature with their own guide. + - id: recording-sessions-guide + description: Recording sessions guide + code: + - lib/widgets/tracking_play_button.dart + - lib/screens/session_history_screen.dart + docs: + - docs/guides/recording-sessions.md + direction: code-to-docs + relation: documents + + # Markers and zones have their own guide. + - id: markers-zones-guide + description: Markers and zones guide + code: + - lib/screens/map/map_annotations_controller.dart + - lib/screens/map/dialogs/marker_dialogs.dart + - lib/screens/map/layers/zone_overlay_layer.dart + - lib/screens/map/layers/planned_marker_layer.dart + docs: + - docs/guides/markers-and-zones.md + direction: code-to-docs + relation: documents + + # Upload, community coverage, offline tiles, and connectivity detection. + - id: online-coverage-guide + description: Online coverage features guide + code: + - lib/services/upload_service.dart + - lib/screens/map/layers/community_coverage_layer.dart + - lib/services/internet_connectivity_service.dart + docs: + - docs/guides/online-coverage.md + direction: code-to-docs + relation: documents + + # Analytics screens and achievements have their own guide. + - id: analytics-guide + description: Analytics and statistics guide + code: + - lib/screens/analytics_screen.dart + - lib/screens/repeater_health_screen.dart + - lib/screens/signal_trend_screen.dart + - lib/screens/device_comparison_screen.dart + - lib/screens/achievements_screen.dart + - lib/services/achievement_service.dart + docs: + - docs/guides/analytics.md + direction: code-to-docs + relation: documents + # The tile provider investigation tracks the provider choice and the # offline download policy. - id: map-providers @@ -151,6 +207,7 @@ rules: - lib/screens/map/dialogs/offline_tile_dialogs.dart docs: - docs/development/map-providers.md + - docs/guides/online-coverage.md direction: code-to-docs relation: related diff --git a/docs/README.md b/docs/README.md index 3dffd73..88a2365 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,12 @@ - [LoRa companion setup](guides/lora-companion.md) - [Carpeater mode (car repeater)](guides/carpeater.md) +- [Recording sessions](guides/recording-sessions.md) +- [Markers and zones](guides/markers-and-zones.md) +- [Positioning and location quality](guides/positioning.md) +- [Data management](guides/data-management.md) +- [Online coverage features](guides/online-coverage.md) +- [Analytics and statistics](guides/analytics.md) - [MeshCore authentication](guides/meshcore-authentication.md) *(upstream MQTT only - removed in this fork)* ## Development diff --git a/docs/getting-started.md b/docs/getting-started.md index 7b5a4ac..7c3bd6a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,5 +1,9 @@ # MeshCore Wardrive - Quick Start +Feature deep dives - recording sessions, markers and zones, positioning, +data management, online coverage, and analytics - live in the +[documentation index](README.md). + ## Build and Install Build and install the app on an Android device from the repository root. @@ -50,7 +54,12 @@ that file to your Android device and install it. while stopped starts a fresh session that only shows this trip. After you stop, the map stays on that session until you short-press Play (show all) or pick another session in Settings → Session History. Stopping with no GPS - points asks whether to save the empty session. + points asks whether to save the empty session. See the + [sessions guide](guides/recording-sessions.md). +- **Quick Settings** (double-tap the play button): Compact panel with ping and + display controls +- **Long-press the map**: Choose what to add at that point - a planned + repeater, a privacy zone, or a GPS exclusion zone - **Settings Icon** (top right): Access display options ### Settings Options diff --git a/docs/guides/analytics.md b/docs/guides/analytics.md new file mode 100644 index 0000000..431fda8 --- /dev/null +++ b/docs/guides/analytics.md @@ -0,0 +1,56 @@ +# Analytics and statistics + +The fork ships several screens that analyze recorded data. They all read the +local database; nothing is sent anywhere. + +## Coverage analytics + +The Analytics screen (from the map menu) has five tabs: + +- **Score** - an overall coverage score for your recordings with the + underlying stats, and a shareable summary. +- **Time** - success rates by period of day, so you can see how propagation + changes between morning, day, evening, and night. +- **Goals** - progress toward coverage goals: pick an area and radius and + track how much of it you have covered. +- **Compare** - side-by-side comparison of coverage between sessions or time + periods. +- **Repeaters** - per-repeater reliability statistics with trend information. + +## Repeater health + +Settings → Statistics opens the Repeater Health screen: success rates per +repeater with 7-day vs 30-day trend direction, and a detail screen per +repeater with its response history. + +## Signal trends + +The Signal Trend screen charts RSSI, SNR, and ping response time over your +recorded samples - useful for spotting when a link is degrading and whether +it correlates with antenna, weather, or route changes. + +## Device comparison + +If you wardrive with several companion radios (samples are tagged with the +device ID), the Device Comparison screen picks two devices and compares their +success rates and coverage for the same area. + +## Achievements + +The Achievements screen tracks unlockable badges: + +- 📡 first ping, 💯 100, 🔥 1000, 👑 10000 pings; +- 📻 first repeater heard, 🗺️ 10, 🌐 50 repeaters; +- 🚗 10, 🛣️ 100, ✈️ 500 distance units - the thresholds follow the selected + distance unit, so 100 km and 100 miles unlock the same badge depending on + the setting; +- 🏘️ 50, 🏰 500 coverage cells; +- 🎬 first session, 🏆 50 sessions; +- 💎 a hidden badge that stays invisible until unlocked (see below). + +### Hidden legend badge + +The hidden 💎 badge unlocks while the connected companion radio advertises a +node name starting with `Ya_`, `Yakut`, or `Якут` (case-insensitive). The +name is taken from the radio's own MeshCore advert, which is separate from +the Bluetooth/USB transport name. diff --git a/docs/guides/data-management.md b/docs/guides/data-management.md new file mode 100644 index 0000000..c43e99c --- /dev/null +++ b/docs/guides/data-management.md @@ -0,0 +1,54 @@ +# Data management + +The app keeps all measurements in a local SQLite database. There are three +ways to get data in and out: sample exports, a full database backup, and +settings export/import. + +## Sample exports + +Exports go through the system file picker or the Android share sheet; no +storage permission is needed. + +| Format | Contents | +| --- | --- | +| JSON | Full sample records, suitable for import back into the app | +| CSV | One row per sample, spreadsheet-friendly | +| GPX | Track points for GPS tools | +| KML | Placemarks colored by ping result for Google Earth | + +A JSON export contains an array of samples with position, timestamp, geohash, +and the ping fields (`rssi`, `snr`, `pingSuccess`, `responseTimeMs`) when a +radio response was recorded. + +## Sample import + +Import accepts both the current unified format +(`{"samples": [...], "sessions": [...]}`) and the legacy plain sample array. +Sessions included in the export are restored with it, so moving to a new +installation keeps your session history. + +## Database backup (recommended) + +Settings → Backup offers a complete snapshot of the database: + +- **Export Database** writes a consistent copy of everything - samples, + sessions, upload tracking, planned markers, privacy zones, exclusion zones, + and devices - to a file or the share sheet. +- **Import Database** validates the picked file (SQLite signature, expected + schema, schema version) before anything is replaced, upgrades backups from + older app versions automatically, and keeps the previous database until the + restored copy reopens successfully. +- Tracking must be stopped before an import. + +A database backup is more complete than a JSON sample export and is the +recommended way to move devices. + +## Settings export/import + +Settings export/import moves preferences (map display options, quality +thresholds, discovery settings, upload endpoints, and so on). Both zone-layer +toggles, sample point size, optimistic coverage, Auto-Ping Pause, and the +link-loss alert participate in it. + +Credentials are never exported: the Carpeater admin password is stored in +platform secure storage and stays out of export files. diff --git a/docs/guides/markers-and-zones.md b/docs/guides/markers-and-zones.md new file mode 100644 index 0000000..ed059f6 --- /dev/null +++ b/docs/guides/markers-and-zones.md @@ -0,0 +1,57 @@ +# Markers and zones + +Long-pressing the map opens a menu with three things you can place at that +point: a **planned repeater**, a **privacy zone**, or a **GPS exclusion zone** +(also called an Impossible Zone). The same objects are managed from Settings. + +## Planned repeater markers + +A planned repeater is a note-to-yourself marker for a spot where a repeater +could be installed in the future. + +- Add it from the long-press menu; an optional label can be entered in the + dialog. +- Markers survive restarts and are stored in the database (and in database + backups). +- The planned-markers tile in Settings shows the current count and offers to + remove all markers (with confirmation). + +## Privacy zones + +A privacy zone is a circle whose data is excluded from uploads and exports. +Use it, for example, around your home so the published map never shows your +exact location. + +- Add it from the long-press menu or Settings. +- The radius can be set freely between 50 m and 10 km with a slider; an + editable meters field stays in sync with the slider and clamps typed values + into the range. +- **Preview on map** collapses the dialog into a small bar at the bottom of + the screen and draws the zone circle; the map stays interactive, and the + bar offers Edit, Add, and Close. +- Samples inside a privacy zone are excluded from **web map uploads**, so + published maps never show them. Sample exports (JSON/CSV/GPX/KML) currently + include all samples; keep privacy-zone data out of shared files by deleting + it first or sharing only uploads. + +## GPS exclusion zones (Impossible Zones) + +A GPS exclusion zone is a circle where you cannot physically be - typically +an airport or approach corridor. Fixes inside a zone are discarded entirely: +no sample, no ping, and the map keeps the last valid position instead of +jumping. + +- Add zones from the long-press menu or from Settings → Location Quality + Filters, which is also where the full list lives. +- The same radius slider and map preview are used. +- Zones are not drawn on the map by default; both zone layers can be shown + with their own Map Display toggles, which draw the zone center and its + configured radius. +- **Restore Defaults** in the quality-filter settings resets the thresholds, + not the zones. + +## Map display toggles + +Settings → Map Display provides independent toggles for the privacy-zone and +GPS-exclusion-zone overlays. Both choices participate in settings +export/import. diff --git a/docs/guides/online-coverage.md b/docs/guides/online-coverage.md new file mode 100644 index 0000000..100ebb9 --- /dev/null +++ b/docs/guides/online-coverage.md @@ -0,0 +1,49 @@ +# Online coverage features + +Three features connect your local wardrive data to the outside world: the +community coverage overlay, web map uploads, and offline map tiles. An +app-wide offline banner tracks connectivity; everything local (GPS tracking, +radio communication, sample storage) keeps working while offline. + +## Community coverage overlay + +The community overlay draws coverage aggregated from other users' uploads so +you can compare your own results against theirs. + +- Tiles are aligned to exact geohash bounds and use the same LOD precision as + your local coverage, so both grids line up cell for cell. +- The overlay is independent of the session filter: it stays visible while you + browse a single session and is hidden only by its own Map Display toggle. +- Tapping an overlay cell opens the same aggregated details as the local + coverage cells. + +## Web map uploads + +Recorded samples can be uploaded to one or more web maps. + +- **Endpoints** are managed in Settings: each has a name and URL, and several + can be enabled at once. Fresh installs default to the regional + `meshcoretel.ru` endpoint for Russian locales and `meshcoretel.io` + elsewhere. +- Uploads send batches with retry; GPS-only samples (where no ping was + attempted) are filtered out so they do not skew success rates on the live + map. +- Upload progress is shown during transfer. + +Samples inside a privacy zone are filtered out of uploads, so published maps +never show them (see [markers and zones](markers-and-zones.md)). + +## Offline tiles + +Map tiles from the current provider are cached on device as you browse, and +the **Download Offline Tiles** action fetches a chosen area for fully offline +use. Note that the public OpenStreetMap and CARTO endpoints used by default +have usage policies about bulk downloading and prefetching - the provider +investigation in the development docs tracks the compliant options. + +## Connectivity + +An app-wide offline banner detects restricted or unavailable internet access +with a short periodic connectivity check. GPS tracking, radio communication, +and local sample storage continue while offline; tile loading and uploads +will not. diff --git a/docs/guides/positioning.md b/docs/guides/positioning.md new file mode 100644 index 0000000..d518dbb --- /dev/null +++ b/docs/guides/positioning.md @@ -0,0 +1,80 @@ +# Positioning and location quality + +The app records from Android's fused location provider, so GPS, cellular, and +nearby Wi-Fi positioning work together. This guide covers how position quality +is filtered, what happens when quality degrades, and the optional positioning +sources. + +## Quality filters + +Android-reported mock fixes, fixes worse than the configured accuracy +threshold (250 m by default), and probable aircraft movement are excluded; +high-altitude roads are still supported. All thresholds are configurable in +Settings → Location Quality Filters: + +- maximum horizontal error; +- airborne altitude and speed limits; +- maximum wardrive speed, entered in km/h; +- **Restore Defaults** restores the built-in thresholds without touching + zones. + +## Auto-Ping Pause + +When several position fixes in a row are rejected by the quality filters, the +app pauses automatic pings instead of pinging a stale position. Pinging +resumes on the next valid fix, and a snackbar announces each pause and +resume. + +- Default: pause after 5 consecutive rejected fixes. +- The toggle and the threshold live in Settings → Location Quality Filters → + Auto-Ping Pause and participate in settings export/import. + +## Impossible Zones + +GPS or Wi-Fi fixes inside a user-defined Impossible Zone are discarded - no +sample, no ping - and the map holds the last valid position. Zones are +managed in Settings → Location Quality Filters or by long-pressing the map; +see [markers and zones](markers-and-zones.md). + +## Watchdog and recovery + +The map keeps searching for a fused position whenever it is open, even before +recording starts. A watchdog restarts stalled or closed location streams and +resumes automatically after Location Services are re-enabled. Tracking +startup failures report a specific cause: permission, settings, or service. + +## beaconDB Wi-Fi positioning (optional) + +When enabled in Settings, the app scans nearby access points every 30 seconds +and, on a valid estimate, prioritizes Wi-Fi positioning over the fused +provider. The active Wi-Fi position is shown with a cyan marker. + +- SSIDs are used only on the phone to exclude hidden and `_nomap` networks; + hidden, randomized, and stale networks are excluded from the lookup. +- IP and cell-position fallbacks are disabled: a valid estimate requires + internet access and at least two mapped access points. +- On enabling, the app links to Developer options so Wi-Fi scan throttling can + be disabled, and holds a Wi-Fi lock while tracking in the foreground. + +## Radio position estimate + +When at least three positioned repeaters answer the same ping, the app shows +a temporary grey marker with an uncertainty circle: a coarse RSSI-weighted +estimate of where you probably are. + +- This is not a GPS fix and can be inaccurate - terrain, antennas, and radio + propagation strongly affect RSSI. +- **Show Approximate Position** in map settings hides the marker without + stopping the calculation. + +## Direction arrow and compass + +The current-location marker can be switched between the classic blue circle +and a compass-aware direction arrow. In arrow mode the compass button toggles +heading-up map rotation; rotating the map manually stops heading-up tracking. +When a compass sensor is unavailable, the arrow falls back to GPS course while +moving. + +If Android reports the magnetometer as unreliable, a compact banner offers a +figure-8 calibration (Later hides it for a day). Calibration is also always +available from Settings or by long-pressing the compass button. diff --git a/docs/guides/recording-sessions.md b/docs/guides/recording-sessions.md new file mode 100644 index 0000000..df784a0 --- /dev/null +++ b/docs/guides/recording-sessions.md @@ -0,0 +1,50 @@ +# Recording sessions + +Every recording is a session: a time range with its own statistics and notes. +Sessions let you start a clean trip on a blank map without deleting any stored +data. + +## Starting a recording + +- **Short press Play** (bottom right): starts tracking and shows **all** + stored coverage, including new samples. The button turns red while + tracking. +- **Long press Play** (while not tracking): starts a new session on a **blank** + map that only shows this trip. Stored samples are not deleted. The button + uses a short vibration to confirm the long press. + +Samples are recorded after about 5 meters of movement. While tracking, the +map keeps searching for a position even before the recording starts, so the +session begins from an accurate fix. + +## Stopping + +- After you stop a long-press session, the map stays on that trip until you + short-press Play (show all) or pick another session in + Settings → Session History. +- Stopping with no GPS points asks whether to keep the empty session. +- Deleting the session currently on the map falls back to the latest remaining + saved session, or to a blank map when none remain. + +## Session History + +Settings → Session History lists every session, newest first. Each card +shows: + +- start and end time, recording duration, and distance; +- sample (GPS points) count, ping count, heard count, and success rate with a + color-coded percentage; +- your notes, if any. + +From a card you can: + +- **View on map** - open the map filtered to that session's time range; +- **Edit notes** - attach a free-form note (for example the route or the + radio used); +- **Delete** - removes the session record only; the samples themselves stay. + +## Design notes + +The session scope model (all / session / empty views) and the long-press +behavior are specified in the +[fresh session map view design](../superpowers/specs/2026-08-19-fresh-session-map-design.md). From 365519a3487319b00d100517951f6fcff22035ec Mon Sep 17 00:00:00 2001 From: xarleyn Date: Thu, 3 Sep 2026 02:52:19 +0300 Subject: [PATCH 27/33] Exclude privacy-zone samples from file exports Sample exports (JSON, CSV, GPX, KML) now drop samples inside privacy zones the same way uploads already did, so shared files never contain them. The SQLite database backup stays a complete snapshot by design - it is the only export that keeps privacy-zone data. Export snackbars and share texts report the filtered sample count. The zone-containment and filtering logic becomes testable static helpers with unit tests, and the dead isInPrivacyZone point check is removed. Guides updated to document the new policy. --- docs/guides/data-management.md | 7 +- docs/guides/markers-and-zones.md | 8 +-- lib/screens/map/data_io.dart | 13 ++-- lib/services/database_service.dart | 38 +++++----- test/privacy/privacy_zone_filter_test.dart | 83 ++++++++++++++++++++++ 5 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 test/privacy/privacy_zone_filter_test.dart diff --git a/docs/guides/data-management.md b/docs/guides/data-management.md index c43e99c..bd62af2 100644 --- a/docs/guides/data-management.md +++ b/docs/guides/data-management.md @@ -20,6 +20,9 @@ A JSON export contains an array of samples with position, timestamp, geohash, and the ping fields (`rssi`, `snr`, `pingSuccess`, `responseTimeMs`) when a radio response was recorded. +Samples inside privacy zones are excluded from every file export, so shared +files never contain them. The database backup below is the one exception. + ## Sample import Import accepts both the current unified format @@ -41,7 +44,9 @@ Settings → Backup offers a complete snapshot of the database: - Tracking must be stopped before an import. A database backup is more complete than a JSON sample export and is the -recommended way to move devices. +recommended way to move devices. Unlike the sample exports, it deliberately +keeps privacy-zone data: it is a complete snapshot of the local database, so +treat backup files with the same care as the device itself. ## Settings export/import diff --git a/docs/guides/markers-and-zones.md b/docs/guides/markers-and-zones.md index ed059f6..7765942 100644 --- a/docs/guides/markers-and-zones.md +++ b/docs/guides/markers-and-zones.md @@ -29,10 +29,10 @@ exact location. - **Preview on map** collapses the dialog into a small bar at the bottom of the screen and draws the zone circle; the map stays interactive, and the bar offers Edit, Add, and Close. -- Samples inside a privacy zone are excluded from **web map uploads**, so - published maps never show them. Sample exports (JSON/CSV/GPX/KML) currently - include all samples; keep privacy-zone data out of shared files by deleting - it first or sharing only uploads. +- Samples inside a privacy zone are excluded from **web map uploads** and + from sample exports (JSON/CSV/GPX/KML), so shared files never contain them. + The full database backup is the one exception: it is a complete snapshot of + the local database, including privacy-zone data. ## GPS exclusion zones (Impossible Zones) diff --git a/lib/screens/map/data_io.dart b/lib/screens/map/data_io.dart index 1e38e4a..3f76174 100644 --- a/lib/screens/map/data_io.dart +++ b/lib/screens/map/data_io.dart @@ -122,6 +122,9 @@ class MapDataIo { try { final samples = await locationService.getAllSamples(); + // Privacy-zone samples never leave the device through file exports; + // only the full database backup keeps them. + final exportSamples = await databaseService.filterByPrivacyZones(samples); final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); String content; String fileName; @@ -129,17 +132,17 @@ class MapDataIo { switch (format) { case SampleExportFormat.csv: - content = SampleExport.buildCsv(samples); + content = SampleExport.buildCsv(exportSamples); extension = 'csv'; fileName = 'meshcore_export_$timestamp.csv'; break; case SampleExportFormat.gpx: - content = SampleExport.buildGpx(samples); + content = SampleExport.buildGpx(exportSamples); extension = 'gpx'; fileName = 'meshcore_export_$timestamp.gpx'; break; case SampleExportFormat.kml: - content = SampleExport.buildKml(samples); + content = SampleExport.buildKml(exportSamples); extension = 'kml'; fileName = 'meshcore_export_$timestamp.kml'; break; @@ -172,7 +175,7 @@ class MapDataIo { if (!context.mounted) return; onShowSnackBar( AppLocalizations.of(context) - .mapExportedSamples(samples.length, format.displayName), + .mapExportedSamples(exportSamples.length, format.displayName), ); } else if (choice == ExportDestination.share) { final directory = await getExternalStorageDirectory(); @@ -185,7 +188,7 @@ class MapDataIo { files: [XFile(file.path)], subject: AppLocalizations.of(context).mapExportShareSubject, text: AppLocalizations.of(context) - .mapExportShareText(samples.length), + .mapExportShareText(exportSamples.length), ), ); if (!context.mounted) return; diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index 0506dca..5c17476 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -497,12 +497,17 @@ class DatabaseService { return samples.map((s) => s.toJson()).toList(); } - /// Export all data (samples + sessions + repeaters) as a unified JSON map + /// Export all data (samples + sessions + repeaters) as a unified JSON map. /// Pass discoveredRepeaters from the LoRa service to include them. + /// + /// Samples inside privacy zones are excluded so shared files never contain + /// them. The SQLite database backup (DatabaseBackupService) is the only + /// export that keeps privacy-zone data: it is a complete snapshot of the + /// local database. Future> exportAllData({ List>? repeaters, }) async { - final samples = await getAllSamples(); + final samples = await filterByPrivacyZones(await getAllSamples()); final sessions = await getAllSessions(); final data = { '_format': 'meshcore_wardrive_data', @@ -909,7 +914,7 @@ class DatabaseService { } /// Whether the point falls inside the given privacy-zone row. - static bool _isInsidePrivacyZone( + static bool isInsidePrivacyZone( Map zone, double lat, double lon, @@ -923,24 +928,15 @@ class DatabaseService { (zone['radius_meters'] as double); } - /// Check if a lat/lon point falls inside any privacy zone - /// Uses haversine approximation (good enough for small radii) - Future isInPrivacyZone(double lat, double lon) async { - final zones = await getAllPrivacyZones(); - for (final zone in zones) { - if (_isInsidePrivacyZone(zone, lat, lon)) return true; - } - return false; - } - - /// Filter a list of samples, removing those inside privacy zones - Future> filterByPrivacyZones(List samples) async { - final zones = await getAllPrivacyZones(); + /// Removes samples located inside any of the given privacy zones. + static List filterSamplesByPrivacyZones( + List samples, + List> zones, + ) { if (zones.isEmpty) return samples; - return samples.where((s) { for (final zone in zones) { - if (_isInsidePrivacyZone( + if (isInsidePrivacyZone( zone, s.position.latitude, s.position.longitude, @@ -952,6 +948,12 @@ class DatabaseService { }).toList(); } + /// Filter a list of samples, removing those inside privacy zones + Future> filterByPrivacyZones(List samples) async { + final zones = await getAllPrivacyZones(); + return filterSamplesByPrivacyZones(samples, zones); + } + // ============================================================================ // IMPOSSIBLE ZONES // ============================================================================ diff --git a/test/privacy/privacy_zone_filter_test.dart b/test/privacy/privacy_zone_filter_test.dart new file mode 100644 index 0000000..f4d1e24 --- /dev/null +++ b/test/privacy/privacy_zone_filter_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_wardrive/models/models.dart'; +import 'package:meshcore_wardrive/services/database_service.dart'; + +void main() { + Map zone(double lat, double lon, double radiusMeters) => { + 'lat': lat, + 'lon': lon, + 'radius_meters': radiusMeters, + }; + + Sample sample(double lat, double lon) => Sample( + id: 'sample_${lat}_$lon', + position: LatLng(lat, lon), + timestamp: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + geohash: 'c23nb2q2', + ); + + group('DatabaseService.isInsidePrivacyZone', () { + test('accepts the zone center', () { + expect( + DatabaseService.isInsidePrivacyZone( + zone(47.7, -122.4, 500), + 47.7, + -122.4, + ), + isTrue, + ); + }); + + test('accepts a point within the radius', () { + // 0.001 degree of latitude is roughly 111 meters. + expect( + DatabaseService.isInsidePrivacyZone( + zone(47.7, -122.4, 500), + 47.701, + -122.4, + ), + isTrue, + ); + }); + + test('rejects a point beyond the radius', () { + expect( + DatabaseService.isInsidePrivacyZone( + zone(47.7, -122.4, 500), + 47.71, + -122.4, + ), + isFalse, + ); + }); + }); + + group('DatabaseService.filterSamplesByPrivacyZones', () { + test('returns the same list when there are no zones', () { + final samples = [sample(47.7, -122.4), sample(47.8, -122.5)]; + expect( + DatabaseService.filterSamplesByPrivacyZones(samples, []), + same(samples), + ); + }); + + test('drops samples inside a zone and keeps the rest', () { + final samples = [sample(47.7, -122.4), sample(47.75, -122.4)]; + final filtered = DatabaseService.filterSamplesByPrivacyZones(samples, [ + zone(47.7, -122.4, 500), + ]); + expect(filtered, hasLength(1)); + expect(filtered.single.position.latitude, 47.75); + }); + + test('checks every zone', () { + final samples = [sample(47.7, -122.4), sample(47.8, -122.4)]; + final filtered = DatabaseService.filterSamplesByPrivacyZones(samples, [ + zone(47.7, -122.4, 500), + zone(47.8, -122.4, 500), + ]); + expect(filtered, isEmpty); + }); + }); +} From 475c706b8a1995c02b95c45ab61b457c09534e60 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Thu, 3 Sep 2026 03:14:44 +0300 Subject: [PATCH 28/33] Cover database backup export and restore with round-trip tests --- pubspec.lock | 80 ++++ pubspec.yaml | 1 + test/backup/database_backup_service_test.dart | 375 ++++++++++++++++++ test/helpers/fake_path_provider.dart | 15 + test/helpers/sqflite_ffi.dart | 9 + 5 files changed, 480 insertions(+) create mode 100644 test/helpers/fake_path_provider.dart create mode 100644 test/helpers/sqflite_ffi.dart diff --git a/pubspec.lock b/pubspec.lock index 3af944b..a7e8ef0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 + url: "https://pub.dev" + source: hosted + version: "2.0.0" collection: dependency: transitive description: @@ -461,6 +469,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" home_widget: dependency: "direct main" description: @@ -469,6 +485,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" http: dependency: "direct main" description: @@ -565,6 +589,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -605,6 +637,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "9d233b6f2d9c52e1a2b5fbe70451d2c10ac674d3bb419d0ec8de14989d437c26" + url: "https://pub.dev" + source: hosted + version: "0.19.4" package_info_plus: dependency: transitive description: @@ -765,6 +805,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" proj4dart: dependency: transitive description: @@ -773,6 +821,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" saver_gallery: dependency: "direct main" description: @@ -898,6 +962,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.11" + sqflite_common_ffi: + dependency: "direct dev" + description: + name: sqflite_common_ffi + sha256: d5564f1308cafbf064be498f2beb1e993e742985a7cca9e2e228d6cbccd84a05 + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" sqflite_darwin: dependency: transitive description: @@ -914,6 +986,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" + url: "https://pub.dev" + source: hosted + version: "3.5.2" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b5e0cb0..4b64a2a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -93,6 +93,7 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.4 + sqflite_common_ffi: ^2.4.2+1 flutter: generate: true diff --git a/test/backup/database_backup_service_test.dart b/test/backup/database_backup_service_test.dart index 2d71e3e..628636e 100644 --- a/test/backup/database_backup_service_test.dart +++ b/test/backup/database_backup_service_test.dart @@ -1,10 +1,20 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_wardrive/models/models.dart'; import 'package:meshcore_wardrive/services/database_backup_service.dart'; +import 'package:meshcore_wardrive/services/database_service.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite/sqflite.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import '../helpers/fake_path_provider.dart'; +import '../helpers/sqflite_ffi.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + useSqfliteFfi(); group('DatabaseBackupService.hasSqliteHeader', () { test('accepts the SQLite 3 signature', () { @@ -79,5 +89,370 @@ void main() { expect(service.validateBackupFile(file.path), throwsInvalidHeader()); }); + + test('rejects a database written by a newer app version', () async { + final path = '${tempDir.path}/newer.db'; + final db = await databaseFactoryFfi.openDatabase(path); + await db.execute('CREATE TABLE samples (id TEXT PRIMARY KEY)'); + await db.execute( + 'PRAGMA user_version = ${DatabaseService.databaseVersion + 1}', + ); + await db.close(); + + expect( + service.validateBackupFile(path), + throwsA( + isA().having( + (e) => e.error, + 'error', + DatabaseBackupValidationError.newerVersion, + ), + ), + ); + }); + + test('rejects a database without the samples table', () async { + final path = '${tempDir.path}/foreign.db'; + final db = await databaseFactoryFfi.openDatabase(path); + await db.execute('CREATE TABLE something_else (id INTEGER)'); + await db.close(); + + expect( + service.validateBackupFile(path), + throwsA( + isA().having( + (e) => e.error, + 'error', + DatabaseBackupValidationError.missingTables, + ), + ), + ); + }); }); + + group('DatabaseBackupService export and restore', () { + late Directory tempDir; + late DatabaseService databaseService; + late DatabaseBackupService backupService; + late String backupPath; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('db_backup_roundtrip'); + installFakePathProvider(tempDir); + databaseService = DatabaseService(); + backupService = DatabaseBackupService(databaseService: databaseService); + backupPath = p.join(tempDir.path, 'backup.db'); + await _seedDemoData(databaseService); + }); + + tearDown(() async { + try { + await databaseService.close(); + } catch (_) { + // The database may already be closed or failed to open; deleting the + // directory is best-effort either way. + } + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test( + 'exportToFile writes a complete backup that passes validation', + () async { + final target = File(p.join(tempDir.path, 'out', 'exported.db')); + await backupService.exportToFile(target.path); + + expect(await target.exists(), isTrue); + expect( + DatabaseBackupService.hasSqliteHeader(await target.readAsBytes()), + isTrue, + ); + await backupService.validateBackupFile(target.path); + + final probe = await openDatabase(target.path, readOnly: true); + try { + expect( + Sqflite.firstIntValue( + await probe.rawQuery('SELECT COUNT(*) FROM samples'), + ), + 3, + ); + expect( + Sqflite.firstIntValue( + await probe.rawQuery('SELECT COUNT(*) FROM sessions'), + ), + 1, + ); + expect( + Sqflite.firstIntValue( + await probe.rawQuery('SELECT COUNT(*) FROM uploads'), + ), + 1, + ); + } finally { + await probe.close(); + } + }, + ); + + test( + 'exportSnapshotBytes returns SQLite bytes and cleans up its file', + () async { + final bytes = await backupService.exportSnapshotBytes(); + + expect(DatabaseBackupService.hasSqliteHeader(bytes), isTrue); + expect( + File(p.join(tempDir.path, 'database_backup.db')).existsSync(), + isFalse, + reason: 'the temporary snapshot must not survive the export', + ); + }, + ); + + test( + 'exportToShareFile writes the backup under the requested name', + () async { + final file = await backupService.exportToShareFile(tempDir, 'share.db'); + + expect(file.path, p.join(tempDir.path, 'share.db')); + expect(await file.exists(), isTrue); + expect( + DatabaseBackupService.hasSqliteHeader(await file.readAsBytes()), + isTrue, + ); + }, + ); + + test('restore replaces live data with the backup content', () async { + await backupService.exportToFile(backupPath); + + // Mutate the live database so the restore has something to undo: + // wipe samples and plant an extra marker that the backup lacks. + await databaseService.deleteAllSamples(); + await databaseService.addMarker(10.0, 20.0, 'extra marker'); + expect(await databaseService.getSampleCount(), 0); + + await backupService.restoreFromFile(backupPath); + + final samples = await databaseService.getAllSamples(); + expect(samples.map((s) => s.id), unorderedEquals(['s1', 's2', 's3'])); + final s2 = samples.singleWhere((s) => s.id == 's2'); + expect(s2.position.latitude, 55.2); + expect(s2.rssi, -90); + expect(s2.snr, 5); + expect(s2.pingSuccess, isTrue); + expect(s2.responseTimeMs, 320); + expect(s2.ductingRisk, 'possible'); + expect(s2.source, 'unit-a'); + expect(s2.deviceId, 'pk1'); + + final session = (await databaseService.getAllSessions()).single; + expect(session.sampleCount, 3); + expect(session.pingCount, 2); + expect(session.successCount, 1); + expect(session.notes, 'seed'); + + // The marker added after the export must be gone again. + final markers = await databaseService.getAllMarkers(); + expect(markers, hasLength(1)); + expect(markers.single['label'], 'planned repeater'); + + expect( + (await databaseService.getAllPrivacyZones()).single['label'], + 'home', + ); + expect( + (await databaseService.getAllImpossibleZones()).single.label, + 'sea', + ); + expect( + (await databaseService.getAllDevices()).single['public_key'], + 'pk1', + ); + + // Per-endpoint upload state survives the restore. + final pending = await databaseService.getUnuploadedSamplesForEndpoint( + 'https://example.org/api', + ); + expect(pending.map((s) => s.id), unorderedEquals(['s2', 's3'])); + }); + + test('restore refuses a non-database file and keeps live data', () async { + final garbage = File(p.join(tempDir.path, 'garbage.db')); + await garbage.writeAsString('definitely not a database ' * 20); + + await expectLater( + backupService.restoreFromFile(garbage.path), + throwsA(isA()), + ); + expect(await databaseService.getSampleCount(), 3); + }); + + test('restore refuses a backup from a newer app version', () async { + final newerPath = p.join(tempDir.path, 'newer.db'); + final db = await databaseFactoryFfi.openDatabase(newerPath); + await db.execute( + 'CREATE TABLE samples (id TEXT PRIMARY KEY, geohash TEXT NOT NULL)', + ); + await db.execute( + 'PRAGMA user_version = ${DatabaseService.databaseVersion + 1}', + ); + await db.close(); + + await expectLater( + backupService.restoreFromFile(newerPath), + throwsA( + isA().having( + (e) => e.error, + 'error', + DatabaseBackupValidationError.newerVersion, + ), + ), + ); + expect(await databaseService.getSampleCount(), 3); + }); + + test('restore refuses a database without the samples table', () async { + final foreignPath = p.join(tempDir.path, 'foreign.db'); + final db = await databaseFactoryFfi.openDatabase(foreignPath); + await db.execute('CREATE TABLE something_else (id INTEGER)'); + await db.close(); + + await expectLater( + backupService.restoreFromFile(foreignPath), + throwsA( + isA().having( + (e) => e.error, + 'error', + DatabaseBackupValidationError.missingTables, + ), + ), + ); + expect(await databaseService.getSampleCount(), 3); + }); + + test('restore deletes stale sidecar files of the live database', () async { + await backupService.exportToFile(backupPath); + final livePath = (await databaseService.database).path; + await databaseService.close(); + + for (final suffix in const ['-wal', '-shm', '-journal']) { + await File('$livePath$suffix').writeAsString('stale $suffix'); + } + + await backupService.restoreFromFile(backupPath); + + for (final suffix in const ['-wal', '-shm', '-journal']) { + expect( + File('$livePath$suffix').existsSync(), + isFalse, + reason: 'stale sidecar $suffix must be removed', + ); + } + expect(await databaseService.getSampleCount(), 3); + }); + + test( + 'restore rolls back to the previous database when reopening fails', + () async { + // A backup that passes validation but breaks the upgrade path: it + // claims schema version 5 while already carrying the sessions table, + // so the version-6 migration (CREATE TABLE sessions) fails on reopen. + final brokenPath = p.join(tempDir.path, 'broken_migration.db'); + final db = await databaseFactoryFfi.openDatabase(brokenPath); + await db.execute(''' + CREATE TABLE samples ( + id TEXT PRIMARY KEY, + lat REAL NOT NULL, + lon REAL NOT NULL, + timestamp INTEGER NOT NULL, + path TEXT, + geohash TEXT NOT NULL, + rssi INTEGER, + snr INTEGER, + pingSuccess INTEGER, + observerNames TEXT, + uploaded INTEGER DEFAULT 0 + ) + '''); + await db.execute(''' + CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_time INTEGER NOT NULL, + end_time INTEGER, + distance_meters REAL DEFAULT 0, + sample_count INTEGER DEFAULT 0, + ping_count INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + notes TEXT + ) + '''); + await db.execute('PRAGMA user_version = 5'); + await db.close(); + + await expectLater( + backupService.restoreFromFile(brokenPath), + throwsA(anything), + ); + + // The previous database is back in place and fully usable. + expect(await databaseService.getSampleCount(), 3); + expect((await databaseService.getAllSessions()).single.notes, 'seed'); + }, + ); + }); +} + +/// Populates the database with one row per interesting shape: a plain GPS +/// sample, a fully attributed successful ping, and a failed ping. +Future _seedDemoData(DatabaseService db) async { + final base = DateTime.fromMillisecondsSinceEpoch(1700000000000); + + await db.insertSamples([ + Sample( + id: 's1', + position: const LatLng(55.1, 37.1), + timestamp: base, + geohash: 'ucfunr', + ), + Sample( + id: 's2', + position: const LatLng(55.2, 37.2), + timestamp: base.add(const Duration(minutes: 1)), + geohash: 'ucfunr', + rssi: -90, + snr: 5, + pingSuccess: true, + responseTimeMs: 320, + ductingRisk: 'possible', + source: 'unit-a', + deviceId: 'pk1', + ), + Sample( + id: 's3', + position: const LatLng(55.3, 37.3), + timestamp: base.add(const Duration(minutes: 2)), + geohash: 'ucfunr', + pingSuccess: false, + ), + ]); + + await db.createSession( + WSession( + startTime: base, + endTime: base.add(const Duration(hours: 1)), + distanceMeters: 1200.5, + sampleCount: 3, + pingCount: 2, + successCount: 1, + notes: 'seed', + ), + ); + + await db.addMarker(55.0, 37.0, 'planned repeater'); + await db.addPrivacyZone(55.5, 37.5, 250, 'home'); + await db.addImpossibleZone(56.0, 38.0, 1000, 'sea'); + await db.upsertDevice('pk1', 'unit-a', 'ble'); + await db.markSamplesAsUploadedToEndpoint(['s1'], 'https://example.org/api'); } diff --git a/test/helpers/fake_path_provider.dart b/test/helpers/fake_path_provider.dart new file mode 100644 index 0000000..56ef4de --- /dev/null +++ b/test/helpers/fake_path_provider.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Points the path_provider method channel at [directory], so services that +/// persist files (application documents, temporary and external storage +/// directories) all resolve to paths inside it. +void installFakePathProvider(Directory directory) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => directory.path, + ); +} diff --git a/test/helpers/sqflite_ffi.dart b/test/helpers/sqflite_ffi.dart new file mode 100644 index 0000000..9d70ead --- /dev/null +++ b/test/helpers/sqflite_ffi.dart @@ -0,0 +1,9 @@ +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Redirects the sqflite plugin to the FFI implementation so tests on the +/// host VM run against a real SQLite engine instead of the missing platform +/// channel. Call once per test file before any database access. +void useSqfliteFfi() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; +} From 3932b76e24f6fa9e740c9cfb4b3f0da4b4228a91 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Thu, 3 Sep 2026 03:28:36 +0300 Subject: [PATCH 29/33] Cover DatabaseService CRUD, JSON import, and stats with host tests --- test/database/database_import_test.dart | 303 ++++++++++++++ test/database/database_service_test.dart | 508 +++++++++++++++++++++++ test/helpers/database_harness.dart | 33 ++ 3 files changed, 844 insertions(+) create mode 100644 test/database/database_import_test.dart create mode 100644 test/database/database_service_test.dart create mode 100644 test/helpers/database_harness.dart diff --git a/test/database/database_import_test.dart b/test/database/database_import_test.dart new file mode 100644 index 0000000..aaf804c --- /dev/null +++ b/test/database/database_import_test.dart @@ -0,0 +1,303 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_wardrive/models/models.dart'; + +import '../helpers/database_harness.dart'; +import '../helpers/sqflite_ffi.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + useSqfliteFfi(); + + final harness = DatabaseHarness(); + final base = DateTime.fromMillisecondsSinceEpoch(1700000000000); + + setUp(harness.setUp); + tearDown(harness.tearDown); + + List seedSamples() => [ + Sample( + id: 's1', + position: const LatLng(55.1, 37.1), + timestamp: base, + geohash: 'ucfunr', + ), + Sample( + id: 's2', + position: const LatLng(55.2, 37.2), + timestamp: base.add(const Duration(minutes: 1)), + geohash: 'ucfunr', + path: 'rp1', + rssi: -90, + snr: 5, + pingSuccess: true, + responseTimeMs: 320, + source: 'unit-a', + deviceId: 'pk1', + ), + Sample( + id: 's3', + position: const LatLng(55.3, 37.3), + timestamp: base.add(const Duration(minutes: 2)), + geohash: 'ucfunr', + pingSuccess: false, + ), + ]; + + group('exportAllData', () { + test('embeds samples and sessions in the unified format', () async { + final db = harness.databaseService; + await db.insertSamples(seedSamples()); + await db.createSession( + WSession( + startTime: base, + endTime: base.add(const Duration(hours: 1)), + sampleCount: 3, + notes: 'session notes', + ), + ); + + final data = await db.exportAllData(); + expect(data['_format'], 'meshcore_wardrive_data'); + expect(data['_version'], 2); + + final samples = data['samples'] as List; + expect(samples, hasLength(3)); + expect( + (samples.first as Map)['id'], + anyOf('s1', 's2', 's3'), + ); + + final sessions = data['sessions'] as List; + expect( + (sessions.single as Map)['notes'], + 'session notes', + ); + }); + + test('omits the repeaters key when none are provided', () async { + final db = harness.databaseService; + final without = await db.exportAllData(); + expect(without.containsKey('repeaters'), isFalse); + + final withEmpty = await db.exportAllData(repeaters: []); + expect(withEmpty.containsKey('repeaters'), isFalse); + + final repeater = Repeater( + id: 'rp1', + position: const LatLng(56.0, 38.0), + name: 'Hill', + ); + final withRepeaters = await db.exportAllData( + repeaters: [repeater.toJson()], + ); + expect((withRepeaters['repeaters'] as List), hasLength(1)); + }); + + test( + 'excludes samples inside privacy zones from the shared JSON', + () async { + final db = harness.databaseService; + await db.insertSamples([ + Sample( + id: 'home', + position: const LatLng(55.0, 37.0), + timestamp: base, + geohash: 'ucfunr', + ), + Sample( + id: 'away', + position: const LatLng(55.5, 37.5), + timestamp: base, + geohash: 'ucfunr', + ), + ]); + await db.addPrivacyZone(55.0, 37.0, 250, 'home'); + + final data = await db.exportAllData(); + final ids = (data['samples'] as List).map( + (s) => (s as Map)['id'], + ); + expect(ids, ['away']); + }, + ); + }); + + group('importAllData unified format', () { + test('export → wipe → import restores samples and sessions', () async { + final db = harness.databaseService; + await db.insertSamples(seedSamples()); + await db.createSession( + WSession( + startTime: base, + endTime: base.add(const Duration(hours: 1)), + sampleCount: 3, + pingCount: 2, + successCount: 1, + notes: 'seed', + ), + ); + final exported = await db.exportAllData(); + + // Wipe everything the export contains. + await db.deleteAllSamples(); + for (final session in await db.getAllSessions()) { + await db.deleteSession(session.id!); + } + + final counts = await db.importAllData(exported); + expect(counts['samples'], 3); + expect(counts['sessions'], 1); + + final samples = await db.getAllSamples(); + expect(samples.map((s) => s.id), unorderedEquals(['s1', 's2', 's3'])); + final s2 = samples.singleWhere((s) => s.id == 's2'); + expect(s2.rssi, -90); + expect(s2.pingSuccess, isTrue); + expect(s2.path, 'rp1'); + expect(s2.source, 'unit-a'); + + final session = (await db.getAllSessions()).single; + expect(session.notes, 'seed'); + expect(session.sampleCount, 3); + expect(session.startTime, base); + }); + + test('re-importing the same export imports nothing new', () async { + final db = harness.databaseService; + await db.insertSamples(seedSamples()); + await db.createSession(WSession(startTime: base, notes: 'seed')); + final exported = await db.exportAllData(); + + final first = await db.importAllData(exported); + expect(first['samples'], 0, reason: 'ids already exist'); + expect(first['sessions'], 0, reason: 'start_time already exists'); + expect(await db.getSampleCount(), 3); + expect(await db.getAllSessions(), hasLength(1)); + }); + + test('deduplicates sessions by start_time across imports', () async { + final db = harness.databaseService; + final sessionJson = WSession(startTime: base, notes: 'v1').toJson(); + + final first = await db.importAllData({ + 'samples': >[], + 'sessions': [sessionJson], + }); + expect(first['sessions'], 1); + + final second = await db.importAllData({ + 'samples': >[], + 'sessions': [ + WSession( + startTime: base, + notes: 'different notes, same start', + ).toJson(), + ], + }); + expect(second['sessions'], 0); + + final sessions = await db.getAllSessions(); + expect(sessions, hasLength(1)); + expect(sessions.single.notes, 'v1'); + }); + + test('skips malformed rows and imports the rest', () async { + final db = harness.databaseService; + final valid = seedSamples().first.toJson(); + + final counts = await db.importAllData({ + 'samples': [ + valid, + // Missing geohash: Sample.fromJson cannot build the row. + { + 'id': 'no-geohash', + 'lat': 1.0, + 'lon': 2.0, + 'timestamp': base.toIso8601String(), + }, + // Non-numeric latitude. + { + 'id': 'bad-lat', + 'lat': 'north', + 'lon': 2.0, + 'timestamp': base.toIso8601String(), + 'geohash': 'ucfunr', + }, + // Session rows without startTime are dropped, valid ones kept. + ], + 'sessions': [ + WSession(startTime: base, notes: 'good').toJson(), + {'notes': 'no start time'}, + ], + }); + + expect(counts['samples'], 1); + expect(counts['sessions'], 1); + expect((await db.getAllSamples()).single.id, 's1'); + expect((await db.getAllSessions()).single.notes, 'good'); + }); + + test('rejects an unrecognized payload with FormatException', () async { + final db = harness.databaseService; + + await expectLater( + db.importAllData({'unexpected': 'shape'}), + throwsFormatException, + ); + await expectLater( + db.importAllData('just a string'), + throwsFormatException, + ); + expect(await db.getSampleCount(), 0); + }); + }); + + group('importAllData legacy format', () { + test('imports a plain array of samples', () async { + final db = harness.databaseService; + final counts = await db.importAllData( + seedSamples().map((s) => s.toJson()).toList(), + ); + + expect(counts['samples'], 3); + expect(counts['sessions'], 0); + expect(await db.getSampleCount(), 3); + }); + + test('re-importing a legacy array imports nothing new', () async { + final db = harness.databaseService; + final legacy = seedSamples().map((s) => s.toJson()).toList(); + + expect((await db.importAllData(legacy))['samples'], 3); + expect((await db.importAllData(legacy))['samples'], 0); + expect(await db.getSampleCount(), 3); + }); + }); + + group('importSamples', () { + test('returns the row delta and ignores duplicate ids', () async { + final db = harness.databaseService; + final rows = seedSamples().map((s) => s.toJson()).toList(); + + expect(await db.importSamples(rows), 3); + expect(await db.importSamples(rows), 0); + expect(await db.getSampleCount(), 3); + }); + + test( + 'skips rows that fail validation without aborting the batch', + () async { + final db = harness.databaseService; + final good = seedSamples().first.toJson(); + + final imported = await db.importSamples([ + good, + {'no': 'required fields'}, + ]); + expect(imported, 1); + expect((await db.getAllSamples()).single.id, 's1'); + }, + ); + }); +} diff --git a/test/database/database_service_test.dart b/test/database/database_service_test.dart new file mode 100644 index 0000000..f7b4b11 --- /dev/null +++ b/test/database/database_service_test.dart @@ -0,0 +1,508 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_wardrive/models/models.dart'; + +import '../helpers/database_harness.dart'; +import '../helpers/sqflite_ffi.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + useSqfliteFfi(); + + final harness = DatabaseHarness(); + final base = DateTime.fromMillisecondsSinceEpoch(1700000000000); + + Sample sample({ + String id = 's1', + double lat = 55.0, + double lon = 37.0, + DateTime? at, + String geohash = 'ucfunr', + String? path, + int? rssi, + int? snr, + bool? pingSuccess, + int? responseTimeMs, + String? source, + String? deviceId, + }) { + return Sample( + id: id, + position: LatLng(lat, lon), + timestamp: at ?? base, + geohash: geohash, + path: path, + rssi: rssi, + snr: snr, + pingSuccess: pingSuccess, + responseTimeMs: responseTimeMs, + source: source, + deviceId: deviceId, + ); + } + + setUp(harness.setUp); + tearDown(harness.tearDown); + + group('sample CRUD', () { + test('insert, duplicate id is ignored, newest first ordering', () async { + final db = harness.databaseService; + await db.insertSample(sample(id: 'a', at: base, lat: 1.0)); + await db.insertSample(sample(id: 'a', at: base, lat: 99.0)); + await db.insertSample( + sample(id: 'b', at: base.add(const Duration(minutes: 1)), lat: 2.0), + ); + + expect(await db.getSampleCount(), 2); + final all = await db.getAllSamples(); + expect(all.map((s) => s.id).toList(), ['b', 'a']); + expect(all.singleWhere((s) => s.id == 'a').position.latitude, 1.0); + + final mostRecent = await db.getMostRecentSample(); + expect(mostRecent?.id, 'b'); + }); + + test('path and ping attributes survive the round trip', () async { + final db = harness.databaseService; + await db.insertSample( + sample( + id: 'p1', + path: 'repeater-1', + rssi: -87, + snr: 6, + pingSuccess: true, + responseTimeMs: 250, + source: 'unit-a', + deviceId: 'pk1', + ), + ); + + final loaded = (await db.getMostRecentSample())!; + expect(loaded.path, 'repeater-1'); + expect(loaded.rssi, -87); + expect(loaded.pingSuccess, isTrue); + expect(loaded.responseTimeMs, 250); + expect(loaded.source, 'unit-a'); + expect(loaded.deviceId, 'pk1'); + }); + + test( + 'insertSamples stores a batch and deleteAllSamples clears it', + () async { + final db = harness.databaseService; + await db.insertSamples([ + sample(id: 'a', at: base), + sample(id: 'b', at: base), + sample(id: 'c', at: base), + ]); + expect(await db.getSampleCount(), 3); + + await db.deleteAllSamples(); + expect(await db.getSampleCount(), 0); + expect(await db.getMostRecentSample(), isNull); + }, + ); + + test( + 'deleteSample and deleteSamplesByGeohash remove only matches', + () async { + final db = harness.databaseService; + await db.insertSamples([ + sample(id: 'a', geohash: 'ucfxaaa1'), + sample(id: 'b', geohash: 'ucfxaaa2'), + sample(id: 'c', geohash: 'other999'), + ]); + + await db.deleteSample('a'); + expect((await db.getAllSamples()).map((s) => s.id), ['c', 'b']); + + final deleted = await db.deleteSamplesByGeohash('ucfxaaa'); + expect(deleted, 1); + expect((await db.getAllSamples()).single.id, 'c'); + }, + ); + }); + + group('time range queries', () { + test('getSamplesByTimeRange is inclusive on both ends', () async { + final db = harness.databaseService; + final t1 = base; + final t2 = base.add(const Duration(minutes: 1)); + final t3 = base.add(const Duration(minutes: 2)); + await db.insertSamples([ + sample(id: 'a', at: t1), + sample(id: 'b', at: t2), + sample(id: 'c', at: t3), + ]); + + final range = await db.getSamplesByTimeRange(t1, t3); + expect(range.map((s) => s.id), unorderedEquals(['a', 'b', 'c'])); + + final narrow = await db.getSamplesByTimeRange( + base.add(const Duration(seconds: 30)), + base.add(const Duration(minutes: 1, seconds: 30)), + ); + expect(narrow.map((s) => s.id), ['b']); + }); + + test( + 'getSamplesSince and deleteSamplesOlderThan split the history', + () async { + final db = harness.databaseService; + await db.insertSamples([ + sample(id: 'old', at: base), + sample(id: 'new', at: base.add(const Duration(hours: 1))), + ]); + + final cutoff = base.add(const Duration(minutes: 30)); + expect((await db.getSamplesSince(cutoff)).map((s) => s.id), ['new']); + + await db.deleteSamplesOlderThan(cutoff); + expect((await db.getAllSamples()).single.id, 'new'); + }, + ); + }); + + group('legacy upload tracking', () { + test( + 'markSamplesAsUploaded moves samples out of the pending list', + () async { + final db = harness.databaseService; + await db.insertSamples([sample(id: 'a'), sample(id: 'b')]); + + expect(await db.getUnuploadedSampleCount(), 2); + expect( + (await db.getUnuploadedSamples()).map((s) => s.id), + unorderedEquals(['a', 'b']), + ); + + await db.markSamplesAsUploaded(['a']); + expect(await db.getUnuploadedSampleCount(), 1); + expect((await db.getUnuploadedSamples()).single.id, 'b'); + }, + ); + }); + + group('per-endpoint upload tracking', () { + test('pending samples are tracked per endpoint', () async { + final db = harness.databaseService; + await db.insertSamples([sample(id: 'a'), sample(id: 'b')]); + const endpointA = 'https://a.example.org/api'; + const endpointB = 'https://b.example.org/api'; + + await db.markSamplesAsUploadedToEndpoint(['a'], endpointA); + + expect( + (await db.getUnuploadedSamplesForEndpoint(endpointA)).map((s) => s.id), + ['b'], + reason: 'a is uploaded to A, b is not', + ); + expect( + (await db.getUnuploadedSamplesForEndpoint(endpointB)).map((s) => s.id), + unorderedEquals(['a', 'b']), + reason: 'uploading to A does not affect B', + ); + + await db.markSamplesAsUploadedToEndpoint(['a'], endpointB); + expect( + (await db.getUnuploadedSamplesForEndpoint(endpointB)).map((s) => s.id), + ['b'], + reason: 'only b has never been uploaded to B', + ); + }); + + test( + 're-uploading to the same endpoint does not duplicate the row', + () async { + final db = harness.databaseService; + await db.insertSample(sample(id: 'a')); + const endpoint = 'https://a.example.org/api'; + + await db.markSamplesAsUploadedToEndpoint(['a'], endpoint); + await db.markSamplesAsUploadedToEndpoint(['a'], endpoint); + + final uploads = await (await db.database).query('uploads'); + expect(uploads, hasLength(1)); + expect(await db.getUnuploadedSamplesForEndpoint(endpoint), isEmpty); + }, + ); + }); + + group('sessions', () { + test('create, read newest first, update, delete', () async { + final db = harness.databaseService; + final first = WSession( + startTime: base, + endTime: base.add(const Duration(hours: 1)), + distanceMeters: 1000, + sampleCount: 10, + pingCount: 4, + successCount: 2, + notes: 'first', + ); + final second = WSession(startTime: base.add(const Duration(days: 1))); + + final firstId = await db.createSession(first); + await db.createSession(second); + + final sessions = await db.getAllSessions(); + expect(sessions, hasLength(2)); + expect(sessions.first.startTime, second.startTime); + final loaded = sessions.last; + expect(loaded.id, firstId); + expect(loaded.duration, const Duration(hours: 1)); + expect(loaded.successRate, 0.5); + + final updated = WSession( + id: firstId, + startTime: first.startTime, + endTime: first.endTime, + distanceMeters: 2000, + sampleCount: 12, + pingCount: 5, + successCount: 3, + notes: 'updated', + ); + await db.updateSession(updated); + final afterUpdate = (await db.getAllSessions()).singleWhere( + (s) => s.id == firstId, + ); + expect(afterUpdate.distanceMeters, 2000); + expect(afterUpdate.notes, 'updated'); + expect(afterUpdate.successRate, closeTo(0.6, 0.0001)); + + await db.deleteSession(firstId); + expect((await db.getAllSessions()).single.id, isNot(firstId)); + }); + + test( + 'getSessionSampleCounts aggregates ping outcomes in a window', + () async { + final db = harness.databaseService; + await db.insertSamples([ + sample(id: 'plain', at: base), + sample(id: 'failed', at: base, pingSuccess: false), + sample(id: 'success', at: base, pingSuccess: true), + sample( + id: 'outside', + at: base.add(const Duration(days: 1)), + pingSuccess: true, + ), + ]); + + final counts = await db.getSessionSampleCounts( + base.subtract(const Duration(minutes: 1)), + base.add(const Duration(minutes: 1)), + ); + expect(counts['total'], 3); + expect(counts['pings'], 2); + expect(counts['successes'], 1); + }, + ); + + test('getSessionSampleCounts returns zeros for an empty window', () async { + final db = harness.databaseService; + final counts = await db.getSessionSampleCounts(base, base); + expect(counts, {'total': 0, 'pings': 0, 'successes': 0}); + }); + }); + + group('sources and repeater paths', () { + test('getDistinctSources is sorted and skips null', () async { + final db = harness.databaseService; + await db.insertSamples([ + sample(id: 'a', source: 'unit-b'), + sample(id: 'b', source: 'unit-a'), + sample(id: 'c'), + ]); + + expect(await db.getDistinctSources(), ['unit-a', 'unit-b']); + expect((await db.getSamplesBySource('unit-a')).map((s) => s.id), ['b']); + }); + + test('getDistinctRepeaterIds uppercases and skips empty paths', () async { + final db = harness.databaseService; + await db.insertSamples([ + sample(id: 'a', path: 'rp1'), + sample(id: 'b', path: 'rp1'), + sample(id: 'c', path: 'rp2'), + sample(id: 'd', path: ''), + sample(id: 'e'), + ]); + + expect(await db.getDistinctRepeaterIds(), {'RP1', 'RP2'}); + }); + }); + + group('dead zone detection', () { + test('cell is dead only when it has pings and none succeeded', () async { + final db = harness.databaseService; + await db.insertSamples([ + sample(id: 'f1', geohash: 'failed01', pingSuccess: false), + sample(id: 'f2', geohash: 'failed01', pingSuccess: false), + sample(id: 'm1', geohash: 'mixed001', pingSuccess: false), + sample(id: 'm2', geohash: 'mixed001', pingSuccess: true), + sample(id: 'n1', geohash: 'noping01'), + ]); + + expect(await db.isDeadZoneCell('failed01'), isTrue); + expect(await db.isDeadZoneCell('mixed001'), isFalse); + expect( + await db.isDeadZoneCell('noping01'), + isFalse, + reason: 'samples without ping data are not dead-zone evidence', + ); + expect( + await db.isDeadZoneCell('empty000'), + isFalse, + reason: 'no samples at all is not a dead zone', + ); + }); + }); + + group('privacy zones', () { + test('zones persisted in the database filter samples out', () async { + final db = harness.databaseService; + final inside = sample(id: 'inside', lat: 55.0, lon: 37.0); + final outside = sample(id: 'outside', lat: 55.1, lon: 37.0); + + expect( + await db.filterByPrivacyZones([inside, outside]), + unorderedEquals([inside, outside]), + reason: 'no zones configured yet', + ); + + await db.addPrivacyZone(55.0, 37.0, 250, 'home'); + final filtered = await db.filterByPrivacyZones([inside, outside]); + expect(filtered.single.id, 'outside'); + }); + + test('deletePrivacyZone removes the zone and stops filtering', () async { + final db = harness.databaseService; + final id = await db.addPrivacyZone(55.0, 37.0, 250, 'home'); + expect((await db.getAllPrivacyZones()).single['label'], 'home'); + + await db.deletePrivacyZone(id); + expect(await db.getAllPrivacyZones(), isEmpty); + + final inside = sample(id: 'inside', lat: 55.0, lon: 37.0); + expect(await db.filterByPrivacyZones([inside]), [inside]); + }); + }); + + group('impossible zones', () { + test('findImpossibleZoneAt locates the containing zone', () async { + final db = harness.databaseService; + expect(await db.findImpossibleZoneAt(56.0, 38.0), isNull); + + await db.addImpossibleZone(56.0, 38.0, 1000, 'sea'); + final zone = await db.findImpossibleZoneAt(56.0, 38.0); + expect(zone, isNotNull); + expect(zone!.label, 'sea'); + expect(await db.findImpossibleZoneAt(56.1, 38.0), isNull); + + await db.deleteImpossibleZone(zone.id!); + expect(await db.getAllImpossibleZones(), isEmpty); + }); + }); + + group('devices', () { + test('upsertDevice inserts then updates keeping first_used', () async { + final db = harness.databaseService; + await db.upsertDevice('pk1', 'unit-a', 'ble'); + final created = (await db.getAllDevices()).single; + expect(created['name'], 'unit-a'); + expect(created['first_used'], created['last_used']); + + await db.upsertDevice('pk1', 'renamed', 'usb'); + final updated = (await db.getAllDevices()).single; + expect(updated['name'], 'renamed'); + expect(updated['connection_type'], 'usb'); + expect(updated['first_used'], created['first_used']); + expect(updated['last_used'], greaterThanOrEqualTo(created['last_used'])); + }); + + test('getAllDevices orders by last_used descending', () async { + final db = harness.databaseService; + await db.upsertDevice('pk-old', 'older', 'ble'); + // last_used is ms-resolution; a busy second could tie the two rows. + await Future.delayed(const Duration(milliseconds: 5)); + await db.upsertDevice('pk-new', 'newer', 'usb'); + + final devices = await db.getAllDevices(); + expect(devices.map((d) => d['public_key']).toList(), [ + 'pk-new', + 'pk-old', + ]); + }); + + test( + 'getDeviceStats aggregates only ping-bearing samples of the device', + () async { + final db = harness.databaseService; + await db.insertSamples([ + sample( + id: 'ok1', + geohash: 'aaaaa1', + pingSuccess: true, + snr: 10, + rssi: -80, + responseTimeMs: 100, + deviceId: 'pk1', + ), + sample( + id: 'ok2', + geohash: 'aaaaa2', + pingSuccess: true, + snr: 20, + rssi: -90, + responseTimeMs: 200, + deviceId: 'pk1', + ), + sample( + id: 'fail', + geohash: 'aaaaa3', + pingSuccess: false, + snr: 99, + rssi: -10, + responseTimeMs: 999, + deviceId: 'pk1', + ), + sample( + id: 'other-device', + geohash: 'aaaaa4', + pingSuccess: true, + snr: 50, + deviceId: 'pk2', + ), + sample(id: 'no-device', geohash: 'aaaaa5', pingSuccess: true), + ]); + + final stats = await db.getDeviceStats('pk1'); + expect(stats['totalPings'], 3); + expect(stats['successes'], 2); + expect(stats['failures'], 1); + expect(stats['successRate'], closeTo(2 / 3, 0.0001)); + expect(stats['uniqueCells'], 3); + // snr/rssi averages cover successful pings only; response time is + // averaged over every ping-bearing sample. + expect(stats['avgSnr'], closeTo(15.0, 0.0001)); + expect(stats['avgRssi'], closeTo(-85.0, 0.0001)); + expect(stats['avgResponseMs'], closeTo((100 + 200 + 999) / 3, 0.0001)); + }, + ); + + test('getDeviceStats returns zeros for an unknown device', () async { + final db = harness.databaseService; + final stats = await db.getDeviceStats('missing'); + expect(stats['totalPings'], 0); + expect(stats['successes'], 0); + expect(stats['failures'], 0); + expect(stats['successRate'], 0.0); + expect(stats['uniqueCells'], 0); + expect(stats['avgSnr'], isNull); + expect(stats['avgRssi'], isNull); + expect(stats['avgResponseMs'], isNull); + }); + }); +} diff --git a/test/helpers/database_harness.dart b/test/helpers/database_harness.dart new file mode 100644 index 0000000..d22a01f --- /dev/null +++ b/test/helpers/database_harness.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:meshcore_wardrive/services/database_service.dart'; + +import 'fake_path_provider.dart'; + +/// Fresh temporary SQLite database per test. +/// +/// [setUp] redirects path_provider into a new system-temp directory and +/// creates the service; [tearDown] closes the database so the directory can +/// be deleted on Windows. The harness relies on `useSqfliteFfi()` having been +/// called once in the test file's `main`. +class DatabaseHarness { + late Directory tempDir; + late DatabaseService databaseService; + + Future setUp() async { + tempDir = await Directory.systemTemp.createTemp('db_service_test'); + installFakePathProvider(tempDir); + databaseService = DatabaseService(); + } + + Future tearDown() async { + try { + await databaseService.close(); + } catch (_) { + // Already closed or never opened; directory deletion is best-effort. + } + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + } +} From e202c9a0acdb4d5010ce215a358442c65127650f Mon Sep 17 00:00:00 2001 From: xarleyn Date: Thu, 3 Sep 2026 03:33:37 +0300 Subject: [PATCH 30/33] Cover DatabaseService schema creation and migrations on host SQLite --- test/database/database_migration_test.dart | 187 +++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 test/database/database_migration_test.dart diff --git a/test/database/database_migration_test.dart b/test/database/database_migration_test.dart new file mode 100644 index 0000000..87fb887 --- /dev/null +++ b/test/database/database_migration_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_wardrive/models/models.dart'; +import 'package:meshcore_wardrive/services/database_service.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import '../helpers/database_harness.dart'; +import '../helpers/sqflite_ffi.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + useSqfliteFfi(); + + final harness = DatabaseHarness(); + const defaultEndpoint = 'https://meshwar-map.pages.dev/api/samples'; + + setUp(harness.setUp); + tearDown(harness.tearDown); + + /// Creates the database file at the standard app path with the schema and + /// user_version of an old release, so the next DatabaseService open has to + /// run the upgrade path. + Future createLegacyDatabase( + int version, + Future Function(Database db) build, + ) async { + final db = await databaseFactoryFfi.openDatabase( + p.join(harness.tempDir.path, 'meshcore_wardrive.db'), + ); + await build(db); + await db.execute('PRAGMA user_version = $version'); + await db.close(); + } + + Future> tableNames(Database db) async { + final rows = await db.rawQuery( + "SELECT name FROM sqlite_master WHERE type = 'table'", + ); + return rows.map((row) => row['name'] as String).toSet(); + } + + test('a fresh database contains every expected table', () async { + final db = await harness.databaseService.database; + + expect( + await tableNames(db), + containsAll([ + DatabaseService.tableSamples, + DatabaseService.tableDevices, + DatabaseService.tableDuctingCache, + DatabaseService.tableUploads, + DatabaseService.tableSessions, + DatabaseService.tableMarkers, + DatabaseService.tablePrivacyZones, + DatabaseService.tableImpossibleZones, + ]), + ); + }); + + test('v1 database upgrades in place and keeps its samples', () async { + await createLegacyDatabase(1, (db) async { + // Schema as it looked before the ping columns were introduced. + await db.execute(''' + CREATE TABLE samples ( + id TEXT PRIMARY KEY, + lat REAL NOT NULL, + lon REAL NOT NULL, + timestamp INTEGER NOT NULL, + path TEXT, + geohash TEXT NOT NULL + ) + '''); + await db.execute('CREATE INDEX idx_samples_geohash ON samples (geohash)'); + await db.execute( + 'CREATE INDEX idx_samples_timestamp ON samples (timestamp)', + ); + await db.execute( + "INSERT INTO samples (id, lat, lon, timestamp, path, geohash) " + "VALUES ('old1', 55.5, 37.5, 1600000000000, 'rp-old', 'ucfxzz')", + ); + }); + + // Opening through the service runs every migration from v1 to v13. + final db = await harness.databaseService.database; + + final legacyRows = await db.query('samples'); + expect(legacyRows, hasLength(1)); + expect(legacyRows.single['id'], 'old1'); + expect(legacyRows.single['lat'], 55.5); + expect(legacyRows.single['lon'], 37.5); + expect(legacyRows.single['path'], 'rp-old'); + + // The added columns exist and accept fully attributed samples. + await harness.databaseService.insertSample( + Sample( + id: 'new1', + position: const LatLng(56.0, 38.0), + timestamp: DateTime.fromMillisecondsSinceEpoch(1700000000000), + geohash: 'ucfunr', + rssi: -80, + snr: 4, + pingSuccess: true, + responseTimeMs: 120, + ductingRisk: 'none', + source: 'unit-a', + deviceId: 'pk1', + ), + ); + final loaded = await harness.databaseService.getMostRecentSample(); + expect(loaded!.id, 'new1'); + expect(loaded.rssi, -80); + expect(loaded.snr, 4); + expect(loaded.pingSuccess, isTrue); + expect(loaded.responseTimeMs, 120); + expect(loaded.ductingRisk, 'none'); + expect(loaded.source, 'unit-a'); + expect(loaded.deviceId, 'pk1'); + + // Every table introduced after v1 is present and usable. + expect( + await tableNames(db), + containsAll([ + DatabaseService.tableDuctingCache, + DatabaseService.tableUploads, + DatabaseService.tableSessions, + DatabaseService.tableMarkers, + DatabaseService.tablePrivacyZones, + DatabaseService.tableImpossibleZones, + DatabaseService.tableDevices, + ]), + ); + await harness.databaseService.createSession( + WSession(startTime: DateTime.fromMillisecondsSinceEpoch(0)), + ); + await harness.databaseService.addMarker(1.0, 2.0, 'm'); + await harness.databaseService.addPrivacyZone(1.0, 2.0, 100, 'z'); + await harness.databaseService.addImpossibleZone(1.0, 2.0, 100, 'i'); + await harness.databaseService.upsertDevice('pk1', 'unit-a', 'ble'); + expect(await harness.databaseService.getAllSessions(), hasLength(1)); + expect(await harness.databaseService.getAllMarkers(), hasLength(1)); + expect(await harness.databaseService.getAllPrivacyZones(), hasLength(1)); + expect(await harness.databaseService.getAllImpossibleZones(), hasLength(1)); + expect(await harness.databaseService.getAllDevices(), hasLength(1)); + }); + + test('v4 database migrates uploaded flags into the uploads table', () async { + await createLegacyDatabase(4, (db) async { + // Schema as of v4: the uploaded flag exists, the per-endpoint uploads + // table does not yet. + await db.execute(''' + CREATE TABLE samples ( + id TEXT PRIMARY KEY, + lat REAL NOT NULL, + lon REAL NOT NULL, + timestamp INTEGER NOT NULL, + path TEXT, + geohash TEXT NOT NULL, + rssi INTEGER, + snr INTEGER, + pingSuccess INTEGER, + observerNames TEXT, + uploaded INTEGER DEFAULT 0 + ) + '''); + await db.execute( + "INSERT INTO samples (id, lat, lon, timestamp, geohash, uploaded) " + "VALUES ('up1', 1.0, 2.0, 1600000000000, 'aaaaaa', 1)", + ); + await db.execute( + "INSERT INTO samples (id, lat, lon, timestamp, geohash, uploaded) " + "VALUES ('up2', 3.0, 4.0, 1600000000000, 'aaaaab', 0)", + ); + }); + + final db = await harness.databaseService.database; + + final uploads = await db.query(DatabaseService.tableUploads); + expect(uploads, hasLength(1)); + expect(uploads.single['sample_id'], 'up1'); + expect(uploads.single['endpoint_url'], defaultEndpoint); + + final pending = await harness.databaseService + .getUnuploadedSamplesForEndpoint(defaultEndpoint); + expect(pending.map((s) => s.id), ['up2']); + }); +} From 3d2b8836bb7b63f3649db9b770f2849d00e68a74 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Thu, 3 Sep 2026 03:33:37 +0300 Subject: [PATCH 31/33] Cover model JSON and SQLite serialization round trips --- test/models/models_test.dart | 296 +++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 test/models/models_test.dart diff --git a/test/models/models_test.dart b/test/models/models_test.dart new file mode 100644 index 0000000..4023cc1 --- /dev/null +++ b/test/models/models_test.dart @@ -0,0 +1,296 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_wardrive/models/models.dart'; + +void main() { + final base = DateTime.fromMillisecondsSinceEpoch(1700000000000); + + group('Sample JSON serialization', () { + test('toJson → fromJson preserves every field', () { + final sample = Sample( + id: 's1', + position: const LatLng(55.123456, 37.654321), + timestamp: base, + path: 'repeater-1', + geohash: 'ucfunr', + rssi: -90, + snr: 5, + pingSuccess: true, + responseTimeMs: 320, + ductingRisk: 'possible', + source: 'unit-a', + deviceId: 'pk1', + ); + + final restored = Sample.fromJson(sample.toJson()); + + expect(restored.id, sample.id); + expect(restored.position.latitude, sample.position.latitude); + expect(restored.position.longitude, sample.position.longitude); + expect(restored.timestamp, sample.timestamp); + expect(restored.path, sample.path); + expect(restored.geohash, sample.geohash); + expect(restored.rssi, sample.rssi); + expect(restored.snr, sample.snr); + expect(restored.pingSuccess, sample.pingSuccess); + expect(restored.responseTimeMs, sample.responseTimeMs); + expect(restored.ductingRisk, sample.ductingRisk); + expect(restored.source, sample.source); + expect(restored.deviceId, sample.deviceId); + }); + + test('toJson → fromJson keeps optional fields null', () { + final sample = Sample( + id: 's2', + position: const LatLng(1.0, 2.0), + timestamp: base, + geohash: 'aaaaaa', + ); + + final restored = Sample.fromJson(sample.toJson()); + + expect(restored.path, isNull); + expect(restored.rssi, isNull); + expect(restored.snr, isNull); + expect(restored.pingSuccess, isNull); + expect(restored.responseTimeMs, isNull); + expect(restored.ductingRisk, isNull); + expect(restored.source, isNull); + expect(restored.deviceId, isNull); + }); + + test('fromJson accepts integer coordinates', () { + final restored = Sample.fromJson({ + 'id': 's3', + 'lat': 55, + 'lon': 37, + 'timestamp': base.toIso8601String(), + 'geohash': 'ucfunr', + }); + + expect(restored.position.latitude, 55.0); + expect(restored.position.longitude, 37.0); + }); + }); + + group('Sample SQLite serialization', () { + test('toMap → fromMap preserves every field', () { + final sample = Sample( + id: 's1', + position: const LatLng(55.123456, 37.654321), + timestamp: base, + path: 'repeater-1', + geohash: 'ucfunr', + rssi: -90, + snr: 5, + pingSuccess: true, + responseTimeMs: 320, + ductingRisk: 'possible', + source: 'unit-a', + deviceId: 'pk1', + ); + + final restored = Sample.fromMap(sample.toMap()); + + expect(restored.id, sample.id); + expect(restored.position.latitude, sample.position.latitude); + expect(restored.position.longitude, sample.position.longitude); + expect(restored.timestamp, sample.timestamp); + expect(restored.path, sample.path); + expect(restored.rssi, sample.rssi); + expect(restored.snr, sample.snr); + expect(restored.pingSuccess, sample.pingSuccess); + expect(restored.responseTimeMs, sample.responseTimeMs); + expect(restored.ductingRisk, sample.ductingRisk); + expect(restored.source, sample.source); + expect(restored.deviceId, sample.deviceId); + }); + + test('pingSuccess maps to the 0/1/null column convention', () { + expect( + Sample( + id: 's', + position: const LatLng(1, 2), + timestamp: base, + geohash: 'g', + pingSuccess: true, + ).toMap()['pingSuccess'], + 1, + ); + expect( + Sample( + id: 's', + position: const LatLng(1, 2), + timestamp: base, + geohash: 'g', + pingSuccess: false, + ).toMap()['pingSuccess'], + 0, + ); + expect( + Sample( + id: 's', + position: const LatLng(1, 2), + timestamp: base, + geohash: 'g', + ).toMap()['pingSuccess'], + isNull, + ); + }); + }); + + group('WSession serialization', () { + test('toJson → fromJson preserves every field', () { + final session = WSession( + startTime: base, + endTime: base.add(const Duration(hours: 1)), + distanceMeters: 1500.5, + sampleCount: 42, + pingCount: 10, + successCount: 7, + notes: 'evening run', + ); + + final restored = WSession.fromJson(session.toJson()); + + expect(restored.startTime, session.startTime); + expect(restored.endTime, session.endTime); + expect(restored.distanceMeters, session.distanceMeters); + expect(restored.sampleCount, session.sampleCount); + expect(restored.pingCount, session.pingCount); + expect(restored.successCount, session.successCount); + expect(restored.notes, session.notes); + }); + + test('toMap → fromMap preserves the id and null end time', () { + final session = WSession(id: 7, startTime: base); + + final restored = WSession.fromMap(session.toMap()); + expect(restored.id, 7); + expect(restored.endTime, isNull); + expect(restored.duration, isNull); + + // A session without an id must not put a null id into the map, + // so SQLite assigns the autoincrement key on insert. + expect(WSession(startTime: base).toMap().containsKey('id'), isFalse); + }); + + test('successRate is zero without pings', () { + expect(WSession(startTime: base).successRate, 0.0); + expect( + WSession(startTime: base, pingCount: 4, successCount: 1).successRate, + 0.25, + ); + }); + }); + + group('Coverage JSON serialization', () { + test('toJson → fromJson preserves every field', () { + final coverage = Coverage( + id: 'ucfunr', + position: const LatLng(55.1, 37.2), + received: 12.0, + lost: 3.0, + lastReceived: base, + updated: base.add(const Duration(minutes: 1)), + repeaters: ['rp1', 'rp2'], + ); + + final restored = Coverage.fromJson(coverage.toJson()); + + expect(restored.id, coverage.id); + expect(restored.position.latitude, coverage.position.latitude); + expect(restored.position.longitude, coverage.position.longitude); + expect(restored.received, coverage.received); + expect(restored.lost, coverage.lost); + expect(restored.lastReceived, coverage.lastReceived); + expect(restored.updated, coverage.updated); + expect(restored.repeaters, coverage.repeaters); + }); + + test('fromJson fills defaults for missing optional fields', () { + final restored = Coverage.fromJson({ + 'id': 'ucfunr', + 'lat': 55.1, + 'lon': 37.2, + }); + + expect(restored.received, 0.0); + expect(restored.lost, 0.0); + expect(restored.lastReceived, isNull); + expect(restored.updated, isNull); + expect(restored.repeaters, isEmpty); + }); + }); + + group('Repeater JSON serialization', () { + test('toJson → fromJson preserves every field', () { + final repeater = Repeater( + id: 'rp1', + position: const LatLng(56.0, 38.0), + elevation: 210.5, + timestamp: base, + name: 'Hill', + rssi: -85, + snr: 6, + distance: 1234.5, + ); + + final restored = Repeater.fromJson(repeater.toJson()); + + expect(restored.id, repeater.id); + expect(restored.position.latitude, repeater.position.latitude); + expect(restored.position.longitude, repeater.position.longitude); + expect(restored.elevation, repeater.elevation); + expect(restored.timestamp, repeater.timestamp); + expect(restored.name, repeater.name); + expect(restored.rssi, repeater.rssi); + expect(restored.snr, repeater.snr); + expect(restored.distance, repeater.distance); + }); + + test('fromJson keeps optional fields null', () { + final restored = Repeater.fromJson({ + 'id': 'rp1', + 'lat': 56.0, + 'lon': 38.0, + }); + + expect(restored.elevation, isNull); + expect(restored.timestamp, isNull); + expect(restored.name, isNull); + expect(restored.rssi, isNull); + expect(restored.snr, isNull); + expect(restored.distance, isNull); + }); + }); + + group('NodeData deserialization', () { + test('fromJson parses nested samples and repeaters', () { + final data = NodeData.fromJson({ + 'samples': [ + { + 'id': 's1', + 'lat': 55.0, + 'lon': 37.0, + 'timestamp': base.toIso8601String(), + 'geohash': 'ucfunr', + }, + ], + 'repeaters': [ + {'id': 'rp1', 'lat': 56.0, 'lon': 38.0}, + ], + }); + + expect(data.samples.single.id, 's1'); + expect(data.repeaters.single.id, 'rp1'); + }); + + test('fromJson tolerates missing lists', () { + final data = NodeData.fromJson({}); + + expect(data.samples, isEmpty); + expect(data.repeaters, isEmpty); + }); + }); +} From 94f583f8c559dbb819c687149155ae1f83d62117 Mon Sep 17 00:00:00 2001 From: xarleyn Date: Thu, 3 Sep 2026 07:08:21 +0300 Subject: [PATCH 32/33] Finish MapControlPanel callback reconciliation and layer stack extraction --- docs/development/map-screen-refactoring.md | 6 +- .../map/widgets/map_control_panel.dart | 19 +- lib/screens/map/widgets/map_layer_stack.dart | 284 ++++++++++++++++++ .../map/widgets/map_screen_actions.dart | 35 +++ lib/screens/map_screen.dart | 29 +- test/map/map_overlays_test.dart | 11 +- 6 files changed, 353 insertions(+), 31 deletions(-) create mode 100644 lib/screens/map/widgets/map_layer_stack.dart create mode 100644 lib/screens/map/widgets/map_screen_actions.dart diff --git a/docs/development/map-screen-refactoring.md b/docs/development/map-screen-refactoring.md index 151df5c..384357e 100644 --- a/docs/development/map-screen-refactoring.md +++ b/docs/development/map-screen-refactoring.md @@ -151,8 +151,10 @@ section 5.1. Each step is one commit with full verification. Status: - [x] 10. Manual ping sample recording -> `lib/services/manual_ping_service.dart` (8cd89d1). - [ ] 11. `MapLayerStack` and typed panel callbacks -> - `map/widgets/map_layer_stack.dart`, `map/widgets/map_screen_actions.dart` - (in progress). + `map/widgets/map_layer_stack.dart`, `map/widgets/map_screen_actions.dart`. + Typed callbacks (`MapPanelCallbacks`, `MapMenuCallbacks`) are extracted and + wired into every call site; `MapLayerStack` is extracted but `_buildMap` + still assembles the layer list inline, so wiring it in remains. - [ ] 12. Decouple `lib/screens/settings/settings_page.dart` from `_MapScreenState` via `MapUiSnapshot` + `MapUiActions` (same as stage 5). diff --git a/lib/screens/map/widgets/map_control_panel.dart b/lib/screens/map/widgets/map_control_panel.dart index 8ede1f9..2dd522c 100644 --- a/lib/screens/map/widgets/map_control_panel.dart +++ b/lib/screens/map/widgets/map_control_panel.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../l10n/generated/app_localizations.dart'; import '../../../services/carpeater_service.dart'; import '../../../services/lora_companion_service.dart'; +import 'map_screen_actions.dart'; class MapControlPanel extends StatelessWidget { const MapControlPanel({ @@ -20,10 +21,7 @@ class MapControlPanel extends StatelessWidget { required this.ductingLabel, required this.ductingColor, required this.batterySaverActive, - required this.onConnect, - required this.onDisconnect, - required this.onManualPing, - required this.onCarpeaterRetry, + required this.actions, super.key, }); @@ -41,10 +39,7 @@ class MapControlPanel extends StatelessWidget { final String? ductingLabel; final Color? ductingColor; final bool batterySaverActive; - final VoidCallback onConnect; - final VoidCallback onDisconnect; - final VoidCallback onManualPing; - final VoidCallback onCarpeaterRetry; + final MapPanelCallbacks actions; @override Widget build(BuildContext context) { @@ -98,7 +93,7 @@ class MapControlPanel extends StatelessWidget { const Spacer(), if (!loraConnected) TextButton( - onPressed: isConnecting ? null : onConnect, + onPressed: isConnecting ? null : actions.onConnect, style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -114,7 +109,7 @@ class MapControlPanel extends StatelessWidget { if (loraConnected) ...[ IconButton( icon: const Icon(Icons.link_off, size: 16), - onPressed: onDisconnect, + onPressed: actions.onDisconnect, tooltip: l10n.mapDisconnect, color: Colors.red, padding: EdgeInsets.zero, @@ -123,7 +118,7 @@ class MapControlPanel extends StatelessWidget { const SizedBox(width: 4), IconButton( icon: const Icon(Icons.send, size: 18), - onPressed: onManualPing, + onPressed: actions.onManualPing, tooltip: l10n.mapManualPing, color: Colors.blue, padding: EdgeInsets.zero, @@ -161,7 +156,7 @@ class MapControlPanel extends StatelessWidget { padding: const EdgeInsets.only(top: 2), child: GestureDetector( onTap: carpeaterState == CarpeaterState.error - ? onCarpeaterRetry + ? actions.onCarpeaterRetry : null, child: _StatusBadge( color: carpeaterColor, diff --git a/lib/screens/map/widgets/map_layer_stack.dart b/lib/screens/map/widgets/map_layer_stack.dart new file mode 100644 index 0000000..1b5a9a3 --- /dev/null +++ b/lib/screens/map/widgets/map_layer_stack.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../../models/impossible_zone.dart'; +import '../../../models/models.dart'; +import '../../../services/aggregation_service.dart'; +import '../../../services/location_service.dart'; +import '../../../services/map_lod_service.dart'; +import '../../../services/radio_position_estimator.dart'; +import '../../../services/settings_service.dart'; +import '../map_screen_controller.dart'; +import '../layers/community_coverage_layer.dart'; +import '../layers/coverage_layer.dart'; +import '../layers/coverage_prediction_layer.dart'; +import '../layers/current_position_layer.dart'; +import '../layers/edge_layer.dart'; +import '../layers/planned_marker_layer.dart'; +import '../layers/radio_position_layer.dart'; +import '../layers/repeater_layer.dart'; +import '../layers/route_trail_layer.dart'; +import '../layers/sample_cluster_layer.dart'; +import '../layers/sample_heatmap_layer.dart'; +import '../layers/zone_overlay_layer.dart'; + +/// Preview of a zone being added, rendered as a temporary overlay. +typedef ZonePreview = ({LatLng center, double radiusMeters, Color color}); + +/// Assembles the map layer stack (everything above the tile layer). +/// +/// The widget owns no state: the screen passes its current data, flags, hit +/// notifiers, and callbacks. [buildLayers] returns a flat list so the caller +/// keeps the exact layer ordering inside `FlutterMap.children`. +class MapLayerStack extends StatelessWidget { + const MapLayerStack({ + required this.displaySamples, + required this.samples, + required this.repeaters, + required this.plannedMarkers, + required this.privacyZones, + required this.impossibleZones, + required this.aggregationResult, + required this.mapDataController, + required this.radioPositionEstimate, + required this.communityCoverage, + required this.zonePreview, + required this.visibleBounds, + required this.currentPosition, + required this.showRouteTrail, + required this.showHeatmap, + required this.showPredictionRings, + required this.showPrivacyZones, + required this.showGpsExclusionZones, + required this.showCommunityCoverage, + required this.showCoverage, + required this.showSamples, + required this.showEdges, + required this.showRepeaters, + required this.showRadioPosition, + required this.hideUiForScreenshot, + required this.mapLodZoom, + required this.mapLodEnabled, + required this.coveragePrecision, + required this.coverageLodPrecision, + required this.showSuccessfulOnly, + required this.showGpsSamples, + required this.sampleGeohashGrouping, + required this.fixedSampleMarkerSizeEnabled, + required this.sampleMarkerRadius, + required this.filterEdgesByWhitelist, + required this.includeOnlyRepeaters, + required this.colorMode, + required this.colorBlindMode, + required this.currentLocationMarkerStyle, + required this.positionSource, + required this.currentHeading, + required this.showPingPulse, + required this.heatmapReset, + required this.coverageHitNotifier, + required this.sampleHitNotifier, + required this.onCoverageTap, + required this.onClusterTap, + required this.onRepeaterTap, + required this.onRadioPositionTap, + required this.onMarkerTap, + super.key, + }); + + final List displaySamples; + final List samples; + final List repeaters; + final List> plannedMarkers; + final List> privacyZones; + final List impossibleZones; + final AggregationResult? aggregationResult; + final MapScreenController mapDataController; + final RadioPositionEstimate? radioPositionEstimate; + final Map? communityCoverage; + final ZonePreview? zonePreview; + final LatLngBounds visibleBounds; + final LatLng? currentPosition; + + final bool showRouteTrail; + final bool showHeatmap; + final bool showPredictionRings; + final bool showPrivacyZones; + final bool showGpsExclusionZones; + final bool showCommunityCoverage; + final bool showCoverage; + final bool showSamples; + final bool showEdges; + final bool showRepeaters; + final bool showRadioPosition; + final bool hideUiForScreenshot; + + final double mapLodZoom; + final bool mapLodEnabled; + final int coveragePrecision; + final int coverageLodPrecision; + final bool showSuccessfulOnly; + final bool showGpsSamples; + final bool sampleGeohashGrouping; + final bool fixedSampleMarkerSizeEnabled; + final double sampleMarkerRadius; + final bool filterEdgesByWhitelist; + final String? includeOnlyRepeaters; + final String colorMode; + final String colorBlindMode; + final CurrentLocationMarkerStyle currentLocationMarkerStyle; + final LocationPositionSource positionSource; + final double currentHeading; + final bool showPingPulse; + + final Stream heatmapReset; + final LayerHitNotifier coverageHitNotifier; + final LayerHitNotifier sampleHitNotifier; + + final ValueChanged onCoverageTap; + final ValueChanged onClusterTap; + final ValueChanged onRepeaterTap; + final void Function(String message) onRadioPositionTap; + final ValueChanged> onMarkerTap; + + @override + Widget build(BuildContext context) { + return Stack(children: buildLayers()); + } + + /// All map layers above the tile layer, in render order. + List buildLayers() { + return [ + if (showRouteTrail) + RouteTrailLayer( + samples: displaySamples, + colorBlindMode: colorBlindMode, + ), + if (showHeatmap) + SampleHeatmapLayer(samples: displaySamples, reset: heatmapReset), + if (showPredictionRings) + CoveragePredictionLayer( + samples: displaySamples, + repeaters: repeaters, + includeOnlyRepeaters: includeOnlyRepeaters, + ), + if (showPrivacyZones) + ZoneOverlayLayer( + zones: [ + for (final zone in privacyZones) + ZoneOverlay( + center: LatLng( + (zone['lat'] as num).toDouble(), + (zone['lon'] as num).toDouble(), + ), + radiusMeters: (zone['radius_meters'] as num).toDouble(), + ), + ], + color: Colors.blueGrey, + ), + if (showGpsExclusionZones) + ZoneOverlayLayer( + zones: [ + for (final zone in impossibleZones) + ZoneOverlay( + center: LatLng(zone.lat, zone.lon), + radiusMeters: zone.radiusMeters, + ), + ], + color: Colors.deepOrange, + ), + if (zonePreview != null) + ZoneOverlayLayer( + zones: [ + ZoneOverlay( + center: zonePreview!.center, + radiusMeters: zonePreview!.radiusMeters, + ), + ], + color: zonePreview!.color, + ), + if (showCommunityCoverage && communityCoverage != null) + CommunityCoverageLayer( + rawCoverage: communityCoverage!, + precision: coverageLodPrecision, + visibleBounds: visibleBounds, + ), + if (showCoverage) ..._buildCoverageLayers(), + if (showSamples) _buildSampleLayer(), + if (showEdges) _buildEdgeLayer(), + if (showRepeaters) + RepeaterLayer( + repeaters: repeaters, + colorBlindMode: colorBlindMode, + onRepeaterTap: onRepeaterTap, + ), + if (showRadioPosition && radioPositionEstimate != null) + RadioPositionLayer( + estimate: radioPositionEstimate!, + onTap: onRadioPositionTap, + ), + PlannedMarkerLayer(markers: plannedMarkers, onMarkerTap: onMarkerTap), + if (currentPosition != null && !hideUiForScreenshot) + CurrentPositionLayer( + position: currentPosition!, + style: currentLocationMarkerStyle, + source: positionSource, + heading: currentHeading, + showPingPulse: showPingPulse, + ), + ]; + } + + List _buildCoverageLayers() { + if (aggregationResult == null) return []; + final lod = mapDataController.coverageLod( + zoom: mapLodZoom, + enabled: mapLodEnabled, + maxPrecision: coveragePrecision, + successfulOnly: showSuccessfulOnly, + ); + return [ + CoverageLayer( + coverages: lod.coverages, + colorMode: colorMode, + colorBlindMode: colorBlindMode, + hitNotifier: coverageHitNotifier, + onCoverageTap: onCoverageTap, + ), + ]; + } + + Widget _buildSampleLayer() { + if (samples.isEmpty) return const SizedBox.shrink(); + final clusters = mapDataController.sampleClusters( + zoom: mapLodZoom, + lodEnabled: mapLodEnabled, + groupByGeohash: sampleGeohashGrouping, + showGpsSamples: showGpsSamples, + showSuccessfulOnly: showSuccessfulOnly, + includeOnlyRepeaters: includeOnlyRepeaters, + ); + return SampleClusterLayer( + clusters: clusters, + colorBlindMode: colorBlindMode, + fixedRadius: fixedSampleMarkerSizeEnabled ? sampleMarkerRadius : null, + hitNotifier: sampleHitNotifier, + onClusterTap: onClusterTap, + ); + } + + Widget _buildEdgeLayer() { + if (aggregationResult == null) return const SizedBox.shrink(); + final lod = mapDataController.coverageLod( + zoom: mapLodZoom, + enabled: mapLodEnabled, + maxPrecision: coveragePrecision, + successfulOnly: showSuccessfulOnly, + ); + return EdgeLayer( + edges: lod.edges, + filterByWhitelist: filterEdgesByWhitelist, + includeOnlyRepeaters: includeOnlyRepeaters, + ); + } +} diff --git a/lib/screens/map/widgets/map_screen_actions.dart b/lib/screens/map/widgets/map_screen_actions.dart new file mode 100644 index 0000000..9cb35e6 --- /dev/null +++ b/lib/screens/map/widgets/map_screen_actions.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +/// Callbacks for `MapControlPanel`. +class MapPanelCallbacks { + const MapPanelCallbacks({ + required this.onConnect, + required this.onDisconnect, + required this.onManualPing, + required this.onCarpeaterRetry, + }); + + final VoidCallback onConnect; + final VoidCallback onDisconnect; + final VoidCallback onManualPing; + final VoidCallback onCarpeaterRetry; +} + +/// Callbacks for `MapActionButtons`. +class MapMenuCallbacks { + const MapMenuCallbacks({ + required this.onCompassPressed, + required this.onCompassLongPressed, + required this.onLocationPressed, + required this.onToggleTracking, + required this.onStartFreshSession, + required this.onToggleQuickSettings, + }); + + final VoidCallback onCompassPressed; + final VoidCallback onCompassLongPressed; + final VoidCallback onLocationPressed; + final VoidCallback onToggleTracking; + final VoidCallback onStartFreshSession; + final VoidCallback onToggleQuickSettings; +} diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 57c5134..f9d0c51 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -59,6 +59,7 @@ import 'map/widgets/delete_mode_banner.dart'; import 'map/widgets/map_action_buttons.dart'; import 'map/widgets/map_control_panel.dart'; import 'map/widgets/map_quick_settings_panel.dart'; +import 'map/widgets/map_screen_actions.dart'; import '../services/widget_service.dart'; import 'package:share_plus/share_plus.dart'; @@ -1565,19 +1566,21 @@ class _MapScreenState extends State { ? ductingRiskColor(_currentDuctingRisk) : null, batterySaverActive: _batterySaverActive, - onConnect: _showConnectionDialog, - onDisconnect: _disconnectLoRa, - onManualPing: _manualPing, - onCarpeaterRetry: () async { - _showSnackBar(l10n.mapRetryingCarpeater); - final ok = await _locationService.startCarpeater(); - if (!mounted) return; - _showSnackBar( - ok - ? l10n.mapCarpeaterReconnected - : l10n.mapCarpeaterRetryFailed, - ); - }, + actions: MapPanelCallbacks( + onConnect: _showConnectionDialog, + onDisconnect: _disconnectLoRa, + onManualPing: _manualPing, + onCarpeaterRetry: () async { + _showSnackBar(l10n.mapRetryingCarpeater); + final ok = await _locationService.startCarpeater(); + if (!mounted) return; + _showSnackBar( + ok + ? l10n.mapCarpeaterReconnected + : l10n.mapCarpeaterRetryFailed, + ); + }, + ), ), if (_showQuickSettings) MapQuickSettingsPanel( diff --git a/test/map/map_overlays_test.dart b/test/map/map_overlays_test.dart index c03c738..0dfbede 100644 --- a/test/map/map_overlays_test.dart +++ b/test/map/map_overlays_test.dart @@ -4,6 +4,7 @@ import 'package:meshcore_wardrive/screens/map/widgets/delete_mode_banner.dart'; import 'package:meshcore_wardrive/screens/map/widgets/map_action_buttons.dart'; import 'package:meshcore_wardrive/screens/map/widgets/map_control_panel.dart'; import 'package:meshcore_wardrive/screens/map/widgets/map_quick_settings_panel.dart'; +import 'package:meshcore_wardrive/screens/map/widgets/map_screen_actions.dart'; import 'package:meshcore_wardrive/services/carpeater_service.dart'; import 'package:meshcore_wardrive/services/lora_companion_service.dart'; import 'package:meshcore_wardrive/utils/compass_calibration.dart'; @@ -147,10 +148,12 @@ void main() { ductingLabel: null, ductingColor: null, batterySaverActive: false, - onConnect: () => connects++, - onDisconnect: () {}, - onManualPing: () {}, - onCarpeaterRetry: () {}, + actions: MapPanelCallbacks( + onConnect: () => connects++, + onDisconnect: () {}, + onManualPing: () {}, + onCarpeaterRetry: () {}, + ), ), ], ), From 9cfb52c76e31a0d698be434cdf21bd34bc9dc9ff Mon Sep 17 00:00:00 2001 From: xarleyn Date: Thu, 3 Sep 2026 07:08:21 +0300 Subject: [PATCH 33/33] Release v1.0.45-x --- CHANGELOG.md | 46 ++++++++++++++++++++++++++++++++++ lib/constants/app_version.dart | 2 +- pubspec.yaml | 3 +-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f27504f..2582806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## v1.0.45-x - 2026-09-03 + +### Changed + +- Privacy-zone protection now also covers file exports: JSON, CSV, GPX, and + KML exports drop samples inside privacy zones the same way uploads already + did, so shared files never contain them. The SQLite database backup stays a + complete snapshot by design — it is the only export that keeps privacy-zone + data. Export snackbars and share texts report the filtered sample count. + +- Carpeater passwords move from plaintext settings to Android secure storage. + A password entered in Carpeater settings is never written to + SharedPreferences and is never included in settings export/import; a legacy + plaintext password saved by an older version migrates to secure storage on + its next read, so updating an existing installation keeps the saved + password. + +### Fixed + +- Database import on Android: the system file picker greys out `.db` files + because Android has no MIME mapping for that extension, making backups + impossible to select. The picker now accepts any file, and the picked file + is validated by content (SQLite signature, expected schema, schema version) + before anything is replaced. + +- Sample import (JSON) is now atomic: rows are validated before the + transaction and applied in a single batch, so a malformed row is skipped + instead of leaving a half-applied import behind. Duplicate samples are + skipped and the reported count reflects the rows actually imported. + +- Fixed resource leaks and races in the LoRa companion service; teardown on + disconnect and dispose is now deterministic. + +- Fixed dispose races in the location and Carpeater services that could crash + or leave listeners attached when tracking stopped mid-operation. + +### Technical + +- Continued map screen decomposition: staged initialization, extracted + connection/theme/upload/offline-tile/update flows, manual ping recording + moved into `ManualPingService`, typed callback objects for the control + panel and action buttons, and an extracted `MapLayerStack` widget. +- Host-SQLite test infrastructure (`sqflite_common_ffi`) and 60+ new tests + covering the database backup export/restore round trip, `DatabaseService` + CRUD, JSON import, schema migrations, and model serialization. + ## v1.0.44-x - 2026-09-01 ### Breaking change diff --git a/lib/constants/app_version.dart b/lib/constants/app_version.dart index c220f9c..ee2fc93 100644 --- a/lib/constants/app_version.dart +++ b/lib/constants/app_version.dart @@ -1,2 +1,2 @@ /// Application version synchronized from pubspec.yaml by tool/version.dart. -const String appVersion = '1.0.44-x'; +const String appVersion = '1.0.45-x'; diff --git a/pubspec.yaml b/pubspec.yaml index 4b64a2a..6dc3489 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,8 +18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # of the product and file versions while build-number is used as the build suffix. # Fork releases use the "-x" marker on the base version (1.0.44-x+47). # Bump to the next fork release with: dart run tool/version.dart bump -version: 1.0.44-x+47 - +version: 1.0.45-x+48 environment: sdk: ^3.13.0