diff --git a/.dsh/doc-impact.yml b/.dsh/doc-impact.yml new file mode 100644 index 0000000..b88d596 --- /dev/null +++ b/.dsh/doc-impact.yml @@ -0,0 +1,222 @@ +# 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. 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: + 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 + + # 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 GitHub Actions + code: + - tool/version.dart + - tool/build_release.ps1 + - android/app/build.gradle.kts + - android/key.properties.example + - .github/workflows/release.yml + docs: + - docs/development/releasing.md + direction: bidirectional + relation: specification + + # Installation steps mirror the requested Android permissions. + - id: installation-docs + description: Permissions and installation steps + code: + - android/app/src/main/AndroidManifest.xml + docs: + - docs/INSTALLATION.md + direction: code-to-docs + relation: documents + + # 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 and positioning guides + code: + - lib/services/location_service.dart + - lib/services/location_quality_filter.dart + - 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 + - docs/guides/positioning.md + direction: code-to-docs + relation: documents + + # Export format examples follow the export builders, data flows, and the + # database backup service. + - id: export-format + 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 + + # 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 the changelogs + code: + - lib/constants/app_version.dart + - tool/version.dart + docs: + - CHANGELOG.md + - docs/changelog-fork-ru.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: fresh-session-map-spec + description: Fresh session map view design spec + code: + - lib/widgets/tracking_play_button.dart + - lib/screens/map/map_screen_controller.dart + docs: + - docs/superpowers/specs/2026-08-19-fresh-session-map-design.md + 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 + description: Map provider choice and offline tile policy + code: + - lib/services/tile_download_service.dart + - 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 + + # AGENTS.md points at analysis_options.yaml as the lint convention. + - id: agent-conventions + description: Repository lint conventions in AGENTS.md + code: + - analysis_options.yaml + docs: + - AGENTS.md + direction: bidirectional + relation: specification diff --git a/.gitignore b/.gitignore index 70b0c2e..03d1a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,10 @@ app.*.map.json # Local AI tooling .aider* +.dsh/doc-impact.local.yml + +# Local scratch scripts and data exports +/tmp/ # Environment files may contain secrets .env diff --git a/AGENTS.md b/AGENTS.md index ad33719..3940924 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 @@ -135,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/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/README.md b/README.md index ccfbdb6..0d46e2b 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: @@ -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 ``` @@ -167,8 +190,11 @@ 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) - [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 f2c406a..88a2365 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,19 +9,34 @@ ## Guides - [LoRa companion setup](guides/lora-companion.md) -- [MeshCore authentication](guides/meshcore-authentication.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 +- [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 `assets/screenshots/`. +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..384357e 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,41 @@ 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`. + 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). + +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 new file mode 100644 index 0000000..4e1f74d --- /dev/null +++ b/docs/development/refactoring-audit.md @@ -0,0 +1,796 @@ +# Аудит рефакторинга проекта (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. + +Подробный обход репозитория с целью найти точки рефакторинга: кандидатов на +разбиение больших файлов, проблемы тестового набора, вопросы структуры +каталогов и общей гигиены. Каждая находка отнесена к одной из категорий: +**стоит поправить** (с планом и оценкой риска), **мелкий мусор** (можно +поправить в любой момент, низкой ценности), **оставить как есть**. + +## 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`) не + сработает на свежем клоне до первой сборки — уточнить формулировку + (исправлено в этой ветке: примечание добавлено в AGENTS.md). + +**Мелкий мусор:** + +- `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`); + формулировка исправлена в этой ветке: 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`), + упоминание 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) — какие этапы запускать. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1ca3b92..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. @@ -24,9 +28,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 @@ -48,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 @@ -83,11 +94,17 @@ The app language (System / English / Русский) is under Settings → App & - Age: Green (fresh) → Red (old) ### 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` - +- **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 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 @@ -99,7 +116,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 [ { @@ -108,7 +127,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 } ] ``` @@ -138,8 +164,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/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/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/data-management.md b/docs/guides/data-management.md new file mode 100644 index 0000000..bd62af2 --- /dev/null +++ b/docs/guides/data-management.md @@ -0,0 +1,59 @@ +# 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. + +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 +(`{"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. 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 + +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/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. diff --git a/docs/guides/markers-and-zones.md b/docs/guides/markers-and-zones.md new file mode 100644 index 0000000..7765942 --- /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** 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) + +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/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 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). 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/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/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/data_io.dart b/lib/screens/map/data_io.dart new file mode 100644 index 0000000..3f76174 --- /dev/null +++ b/lib/screens/map/data_io.dart @@ -0,0 +1,464 @@ +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(); + // 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; + String extension; + + switch (format) { + case SampleExportFormat.csv: + content = SampleExport.buildCsv(exportSamples); + extension = 'csv'; + fileName = 'meshcore_export_$timestamp.csv'; + break; + case SampleExportFormat.gpx: + content = SampleExport.buildGpx(exportSamples); + extension = 'gpx'; + fileName = 'meshcore_export_$timestamp.gpx'; + break; + case SampleExportFormat.kml: + content = SampleExport.buildKml(exportSamples); + 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(exportSamples.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(exportSamples.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/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/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/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/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/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/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/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_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/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 aebd3fe..f9d0c51 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'; @@ -27,12 +26,9 @@ 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/sample_export.dart'; +import '../utils/ducting_presentation.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'; import 'map/layers/coverage_layer.dart'; import 'map/layers/community_coverage_layer.dart'; @@ -45,50 +41,45 @@ 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/connection_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/offline_tile_dialogs.dart'; -import 'map/dialogs/upload_endpoint_dialog.dart'; +import 'map/dialogs/theme_flows.dart'; +import 'map/dialogs/update_flow.dart'; +import 'map/dialogs/upload_flows.dart'; +import 'map/connection_flow.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'; +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'; import 'map/widgets/map_quick_settings_panel.dart'; +import 'map/widgets/map_screen_actions.dart'; 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'; 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'; 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'; import '../services/carpeater_service.dart'; +import '../services/manual_ping_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'; @@ -332,27 +323,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( @@ -422,7 +443,9 @@ class _MapScreenState extends State { }); }, ); + } + void _bindLocationStreams() { // Subscribe to position updates _runtimeBindings.bind( MapRuntimeSubscription.position, @@ -473,9 +496,9 @@ class _MapScreenState extends State { } }, ); + } - _syncCompassSubscription(); - + void _bindMapDataStreams() { // Subscribe to sample saved events - reload map when new samples are saved _runtimeBindings.bind( MapRuntimeSubscription.sampleSaved, @@ -504,7 +527,9 @@ class _MapScreenState extends State { ); }, ); + } + void _bindAlertStreams() { // Subscribe to new repeater discovery alerts _runtimeBindings.bind( MapRuntimeSubscription.newRepeater, @@ -572,24 +597,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, @@ -617,20 +648,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) => @@ -1022,495 +1057,85 @@ 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; - - final throttlingEnabled = await _androidTrackingSettings - .isWifiScanThrottlingEnabled(); - if (throttlingEnabled == false || !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 (!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 _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, - ), - ); + /// Android tracking permission facade with screen dependencies injected. + TrackingPermissions get _trackingPermissions => TrackingPermissions( + context: context, + androidTrackingSettings: _androidTrackingSettings, + beaconDbWifiPositioning: () => _beaconDbWifiPositioning, + ); - if (choice == null) return; + Future _prepareAndroidTracking() => + _trackingPermissions.prepareAndroidTracking(); + + Future _requestWifiScanThrottlingDisabled() => + _trackingPermissions.requestWifiScanThrottlingDisabled(); + + /// 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, + ); - 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 _clearData() => _dataIo.clearData(); - Future _importDatabase() async { - final l10n = AppLocalizations.of(context); - if (_isTracking) { - _showSnackBar(l10n.settingsImportDatabaseStopTracking); - return; - } + Future _exportData() => _dataIo.exportData(); - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['db'], - ); + Future _importData() => _dataIo.importData(); - if (result == null || result.files.isEmpty) return; - final backupPath = result.files.single.path; - if (backupPath == null) return; + Future _exportSettings() => _dataIo.exportSettings(); - // Validate before destroying anything. - await _databaseBackupService.validateBackupFile(backupPath); + Future _importSettings() => _dataIo.importSettings(); - if (!mounted) return; - final confirmed = await showDialog( - context: context, - builder: (context) => const ImportDatabaseConfirmationDialog(), - ); - if (confirmed != true) return; + Future _exportDatabase() => _dataIo.exportDatabase(); - await _databaseBackupService.restoreFromFile(backupPath); + Future _importDatabase() => _dataIo.importDatabase(); - // Reload everything that is derived from the database. - _updateMapState(() => _sessionMapView = const SessionMapView.all()); - _mapDataController.invalidate(); - await _loadSamples(); - await _loadMarkers(); - await _loadPrivacyZones(); - await _loadImpossibleZones(); + // ============================================================================ + // PLANNED MARKERS + // ============================================================================ + /// Annotation CRUD facade with screen-owned update callbacks injected. + MapAnnotationsController get _annotations => MapAnnotationsController( + databaseService: _databaseService, + onMarkersLoaded: (markers) async { if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).settingsDatabaseImported); - } on DatabaseBackupException catch (e) { + setState(() { + _plannedMarkers = markers; + }); + }, + onPrivacyZonesLoaded: (zones) async { if (!mounted) return; - _showSnackBar(switch (e.error) { - DatabaseBackupValidationError.newerVersion => AppLocalizations.of( - context, - ).settingsDatabaseNewerVersion, - _ => AppLocalizations.of(context).settingsDatabaseInvalidFile, + setState(() { + _privacyZones = zones; }); - } catch (e) { + }, + onImpossibleZonesLoaded: (zones) async { if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapImportFailed('$e')); - } - } + setState(() { + _impossibleZones = zones; + }); + }, + loadSamples: _loadSamples, + deleteSampleById: _mapDataController.deleteSample, + deleteCoverageById: _mapDataController.deleteCoverage, + ); - // ============================================================================ - // PLANNED MARKERS - // ============================================================================ - - Future _loadMarkers() async { - final markers = await _databaseService.getAllMarkers(); - if (!mounted) return; - setState(() { - _plannedMarkers = markers; - }); - } + Future _loadMarkers() => _annotations.loadMarkers(); Future _handleMapLongPress(LatLng point) async { final action = await showModalBottomSheet( @@ -1540,12 +1165,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); } @@ -1571,8 +1195,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); } @@ -1581,21 +1204,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); @@ -1616,13 +1227,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); } @@ -1654,13 +1264,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); } @@ -1676,8 +1285,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); } @@ -1691,8 +1299,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), @@ -1767,61 +1374,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(), - ); + Future _checkForUpdates() => UpdateFlow( + context: context, + onShowSnackBar: _showSnackBar, + ).checkForUpdates(); - 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 _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(() { @@ -2000,26 +1559,28 @@ 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, - 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( @@ -2310,6 +1871,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); @@ -2327,76 +1894,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, + thoroughResponseCollection: _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, - ); - - 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); @@ -2416,212 +1929,33 @@ 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(), - ); - - if (confirmed == true) { - // Disable auto-ping and carpeater - _locationService.disableAutoPing(); - _locationService.carpeaterService.stop(); - setState(() { - _autoPingEnabled = false; - _carpeaterState = CarpeaterState.disabled; - }); - - await _locationService.loraCompanion.disconnectDevice(); - await _loadSamples(); - if (!mounted) return; - _showSnackBar(AppLocalizations.of(context).mapLoraDisconnected); - } - } - - 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() 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 _scanForRepeaters() async { - if (!_loraConnected) { - _showSnackBar(AppLocalizations.of(context).mapConnectLoraFirst); - return; - } + /// 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, + ); - _showSnackBar(AppLocalizations.of(context).mapScanningForRepeaters); + void _showConnectionDialog() => _connectionFlow.showConnectionDialog(); - final repeaters = await _locationService.loraCompanion.scanForRepeaters(); + Future _disconnectLoRa() => _connectionFlow.disconnectLoRa(); - setState(() => _mapDataController.replaceRepeaters(repeaters)); + Future _refreshContacts() => _connectionFlow.refreshContacts(); - 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( @@ -2661,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; @@ -2808,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), ), ); } @@ -2896,150 +2163,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 { @@ -3178,66 +2337,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; 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/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/database_service.dart b/lib/services/database_service.dart index 668f06a..5c17476 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 { @@ -488,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', @@ -559,38 +573,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 +647,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 +659,22 @@ class DatabaseService { final startMs = start.millisecondsSinceEpoch; final endMs = end.millisecondsSinceEpoch; - final totalResult = await db.rawQuery( - 'SELECT COUNT(*) FROM $tableSamples WHERE timestamp >= ? AND timestamp <= ?', + // 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 pingResult = await db.rawQuery( - 'SELECT COUNT(*) FROM $tableSamples WHERE timestamp >= ? AND timestamp <= ? AND pingSuccess IS NOT NULL', - [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 +774,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,40 +899,61 @@ class DatabaseService { await db.delete(tablePrivacyZones, where: 'id = ?', whereArgs: [id]); } - /// 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; - } - return false; + /// 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); } - /// Filter a list of samples, removing those inside privacy zones - Future> filterByPrivacyZones(List samples) async { - final zones = await getAllPrivacyZones(); + /// 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); + } + + /// 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) { - 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(); } + /// 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 // ============================================================================ @@ -965,6 +995,6 @@ class DatabaseService { Future close() async { final db = await database; await db.close(); - _database = null; + _databaseFuture = null; } } 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/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(); 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'); + } + } } 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); + } +} 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, diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 5f7836a..514e266 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1,16 +1,60 @@ import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 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 } 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 = @@ -63,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'; @@ -90,101 +137,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 +266,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 +347,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 +362,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 +409,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 +455,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 +528,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 +596,7 @@ class SettingsService { ); } - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setDouble( _maxHorizontalAccuracyMetersKey, settings.maxHorizontalAccuracyMeters, @@ -567,42 +632,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 +676,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 { @@ -678,39 +743,64 @@ 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 prefs = await SharedPreferences.getInstance(); - return prefs.getString(_carpeaterPasswordKey); + final secureValue = await _credentials.read(_carpeaterPasswordKey); + if (secureValue != null) { + return secureValue; + } + final prefs = await _prefs; + 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 SharedPreferences.getInstance(); 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 { - 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 +813,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 +828,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 +858,7 @@ class SettingsService { ...await getRecentBluetoothDevices(), ], ); - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString( _recentBluetoothDevicesKey, jsonEncode([ @@ -779,12 +869,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 +882,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 +948,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 +977,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 +993,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)); } @@ -962,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, @@ -974,11 +1067,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 +1082,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 +1106,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/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/services/upload_service.dart b/lib/services/upload_service.dart index b84eb98..c20f599 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 { @@ -144,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. @@ -371,7 +297,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 +314,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 +331,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 +343,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 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/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; + } +} 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/pubspec.lock b/pubspec.lock index e4ed0a7..a7e8ef0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,22 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - collection: + code_assets: dependency: transitive description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + name: code_assets + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 url: "https://pub.dev" source: hosted - version: "1.19.1" - convert: + version: "2.0.0" + collection: dependency: transitive description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "1.19.1" cross_file: dependency: transitive description: @@ -121,14 +121,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: @@ -477,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: @@ -485,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: @@ -581,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: @@ -621,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,14 +789,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: @@ -789,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: @@ -797,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: @@ -922,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: @@ -938,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 320fc21..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 @@ -29,8 +28,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 +63,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 @@ -96,6 +92,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/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/appearance_dialogs_test.dart b/test/appearance_dialogs_test.dart deleted file mode 100644 index bc736c3..0000000 --- a/test/appearance_dialogs_test.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -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'; - -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'), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.settingsThemeDark)); - await tester.pumpAndSettle(); - - expect(result, MapThemeMode.dark); - }); -} 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/connection_dialogs_test.dart b/test/connection_dialogs_test.dart deleted file mode 100644 index 8e753ef..0000000 --- a/test/connection_dialogs_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -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'; - -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'), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.mapScanBluetooth)); - await tester.pumpAndSettle(); - - expect(result, ConnectionMethod.bluetooth); - }); -} 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_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']); + }); +} 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/discovery_timeout_options_test.dart b/test/discovery/discovery_timeout_options_test.dart similarity index 92% rename from test/discovery_timeout_options_test.dart rename to test/discovery/discovery_timeout_options_test.dart index 114b561..07d6570 100644 --- a/test/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/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/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); + } + } +} 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/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/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; +} 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/map/appearance_dialogs_test.dart b/test/map/appearance_dialogs_test.dart new file mode 100644 index 0000000..b103464 --- /dev/null +++ b/test/map/appearance_dialogs_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +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/pump_dialog.dart'; + +void main() { + testWidgets('map theme dialog returns a typed theme', (tester) async { + MapThemeMode? result; + final l10n = await pumpDialog(tester, (context) async { + result = await showDialog( + context: context, + builder: (context) => const MapThemeDialog(), + ); + }); + + await openDialog(tester); + await tester.tap(find.text(l10n.settingsThemeDark)); + await tester.pumpAndSettle(); + + expect(result, MapThemeMode.dark); + }); +} diff --git a/test/map/connection_dialogs_test.dart b/test/map/connection_dialogs_test.dart new file mode 100644 index 0000000..51f5442 --- /dev/null +++ b/test/map/connection_dialogs_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_wardrive/screens/map/dialogs/connection_dialogs.dart'; + +import '../helpers/pump_dialog.dart'; + +void main() { + testWidgets('connection dialog returns a typed method', (tester) async { + ConnectionMethod? result; + final l10n = await pumpDialog(tester, (context) async { + result = await showDialog( + context: context, + builder: (context) => const ConnectionMethodDialog(), + ); + }); + + await openDialog(tester); + await tester.tap(find.text(l10n.mapScanBluetooth)); + await tester.pumpAndSettle(); + + expect(result, ConnectionMethod.bluetooth); + }); +} 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/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: () {}, + ), ), ], ), 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/map/marker_dialogs_test.dart b/test/map/marker_dialogs_test.dart new file mode 100644 index 0000000..becca90 --- /dev/null +++ b/test/map/marker_dialogs_test.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_wardrive/screens/map/dialogs/marker_dialogs.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 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(); + + expect(result, MapLongPressAction.impossibleZone); + }); + + testWidgets('planned marker dialog returns a typed delete action', ( + tester, + ) async { + PlannedMarkerAction? result; + 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 openDialog(tester); + await tester.tap(find.text(l10n.mapDelete)); + await tester.pumpAndSettle(); + + expect(result, PlannedMarkerAction.delete); + }); + + testWidgets('privacy zone dialog returns radius and optional label', ( + tester, + ) async { + PrivacyZoneDraft? result; + 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)); + await tester.pumpAndSettle(); + + expect(result?.radiusMeters, 2000); + expect(result?.label, 'Home'); + }); + + testWidgets('impossible zone dialog returns center radius and label', ( + tester, + ) async { + ImpossibleZoneDraft? result; + final center = LatLng(55.75, 37.62); + 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', + ); + await tester.enterText(find.byKey(const Key('zone_dialog_radius')), '5000'); + await tester.tap(find.text(l10n.settingsAddZone)); + await tester.pumpAndSettle(); + + expect(result?.center, center); + expect(result?.radiusMeters, 5000); + expect(result?.label, 'Airport'); + }); + + testWidgets('zone dialog clamps the typed radius to the allowed range', ( + tester, + ) async { + PrivacyZoneDraft? result; + 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', + ); + await tester.pump(); + + final slider = tester.widget(find.byType(Slider)); + expect(slider.value, 10000); + + await tester.tap(find.text(l10n.settingsAddZone)); + await tester.pumpAndSettle(); + + expect(result?.radiusMeters, 10000); + }); + + testWidgets('zone dialog slider updates the radius text field', ( + tester, + ) async { + await pumpDialog(tester, (context) async { + await 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'))) + .controller! + .text, + '1000', + ); + + await tester.drag(find.byType(Slider), const Offset(200, 0)); + await tester.pumpAndSettle(); + + final text = tester + .widget(find.byKey(const Key('zone_dialog_radius'))) + .controller! + .text; + expect(int.parse(text), greaterThan(1000)); + }); + + testWidgets('zone dialog collapses to a preview bar and reports radius', ( + tester, + ) async { + PrivacyZoneDraft? result; + final previewed = []; + 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'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('zone_preview_bar')), findsOneWidget); + expect(find.byKey(const Key('zone_dialog_radius')), findsNothing); + expect(previewed.last, 1000); + + // Resume editing: radius changes are reported for the live preview. + await tester.tap(find.byKey(const Key('zone_preview_resume'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('zone_dialog_radius')), '2500'); + await tester.pump(); + expect(previewed.last, 2500); + + // Collapse again and confirm straight from the preview bar. + await tester.tap(find.byKey(const Key('zone_dialog_preview'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('zone_preview_confirm'))); + await tester.pumpAndSettle(); + + expect(result?.radiusMeters, 2500); + expect(result?.label, isNull); + expect(previewed.last, 2500); + }); + + testWidgets('collapsible zone dialog stays content-sized', (tester) async { + await pumpDialog(tester, (context) async { + await 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 + // 24px vertical dialog insets). + final cards = find.byWidgetPredicate( + (widget) => widget is Material && widget.type == MaterialType.card, + ); + expect(cards, findsOneWidget); + expect(tester.getSize(cards).height, lessThan(600 - 24 * 2)); + }); +} diff --git a/test/marker_dialogs_test.dart b/test/marker_dialogs_test.dart deleted file mode 100644 index 9f55e35..0000000 --- a/test/marker_dialogs_test.dart +++ /dev/null @@ -1,309 +0,0 @@ -import 'package:flutter/material.dart'; -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'; - -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(); - await tester.tap(find.text(l10n.settingsAddImpossibleZone)); - await tester.pumpAndSettle(); - - expect(result, MapLongPressAction.impossibleZone); - }); - - testWidgets('planned marker dialog returns a typed delete action', ( - 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'), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.mapDelete)); - await tester.pumpAndSettle(); - - expect(result, PlannedMarkerAction.delete); - }); - - testWidgets('privacy zone dialog returns radius and optional label', ( - 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(); - 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)); - await tester.pumpAndSettle(); - - expect(result?.radiusMeters, 2000); - expect(result?.label, 'Home'); - }); - - testWidgets('impossible zone dialog returns center radius and label', ( - tester, - ) 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(); - await tester.enterText( - find.byKey(const Key('zone_dialog_label')), - 'Airport', - ); - await tester.enterText(find.byKey(const Key('zone_dialog_radius')), '5000'); - await tester.tap(find.text(l10n.settingsAddZone)); - await tester.pumpAndSettle(); - - expect(result?.center, center); - expect(result?.radiusMeters, 5000); - expect(result?.label, 'Airport'); - }); - - testWidgets('zone dialog clamps the typed radius to the allowed range', ( - 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(); - await tester.enterText( - find.byKey(const Key('zone_dialog_radius')), - '99999', - ); - await tester.pump(); - - final slider = tester.widget(find.byType(Slider)); - expect(slider.value, 10000); - - await tester.tap(find.text(l10n.settingsAddZone)); - await tester.pumpAndSettle(); - - expect(result?.radiusMeters, 10000); - }); - - 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(); - expect( - tester - .widget(find.byKey(const Key('zone_dialog_radius'))) - .controller! - .text, - '1000', - ); - - await tester.drag(find.byType(Slider), const Offset(200, 0)); - await tester.pumpAndSettle(); - - final text = tester - .widget(find.byKey(const Key('zone_dialog_radius'))) - .controller! - .text; - expect(int.parse(text), greaterThan(1000)); - }); - - testWidgets('zone dialog collapses to a preview bar and reports radius', ( - tester, - ) 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(); - - // Collapse: the form hides and the preview bar shows the current radius. - await tester.tap(find.byKey(const Key('zone_dialog_preview'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('zone_preview_bar')), findsOneWidget); - expect(find.byKey(const Key('zone_dialog_radius')), findsNothing); - expect(previewed.last, 1000); - - // Resume editing: radius changes are reported for the live preview. - await tester.tap(find.byKey(const Key('zone_preview_resume'))); - await tester.pumpAndSettle(); - await tester.enterText(find.byKey(const Key('zone_dialog_radius')), '2500'); - await tester.pump(); - expect(previewed.last, 2500); - - // Collapse again and confirm straight from the preview bar. - await tester.tap(find.byKey(const Key('zone_dialog_preview'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('zone_preview_confirm'))); - await tester.pumpAndSettle(); - - expect(result?.radiusMeters, 2500); - expect(result?.label, isNull); - expect(previewed.last, 2500); - }); - - 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(); - - // Exactly one dialog card, and it must keep its intrinsic height instead - // of filling the whole inset area (default 800x600 surface minus the - // 24px vertical dialog insets). - final cards = find.byWidgetPredicate( - (widget) => widget is Material && widget.type == MaterialType.card, - ); - expect(cards, findsOneWidget); - expect(tester.getSize(cards).height, lessThan(600 - 24 * 2)); - }); -} 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); + }); + }); +} 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/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); + }); + }); +} 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/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); + } } 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/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); 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