From 2ae1e34d63fc63fb7be2fe7c5a3206a5e3dd08f3 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 25 Nov 2025 21:38:46 +0100 Subject: [PATCH] app: implement notifications (#511) * app: implement notifications * app: request permission for notifications --- .env.example | 33 +- Dockerfile | 2 +- README.md | 47 +- app/lib/dart_plugin_registrant.dart | 48 ++ app/lib/main.dart | 487 ++++++++++++++++++++- app/pubspec.lock | 84 +++- app/pubspec.yaml | 4 + app/test/background_sync_manager_test.dart | 170 +++++++ app/test/message_seen_tracker_test.dart | 119 +++++ configure.sh | 20 +- 10 files changed, 952 insertions(+), 62 deletions(-) create mode 100644 app/lib/dart_plugin_registrant.dart create mode 100644 app/test/background_sync_manager_test.dart create mode 100644 app/test/message_seen_tracker_test.dart diff --git a/.env.example b/.env.example index 78135f1..8db40a1 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,14 @@ # REQUIRED SETTINGS # ============================================================================= +# Public domain name for this PotatoMesh instance (required for webapp) +# Provide a hostname (with optional port) that resolves to the web service. +# Example: mesh.example.org or mesh.example.org:41447 +INSTANCE_DOMAIN="mesh.example.org" + # API authentication token (required for ingestor communication) # Generate a secure token: openssl rand -hex 32 -API_TOKEN=your-secure-api-token-here +API_TOKEN="your-secure-api-token-here" # Meshtastic connection target (required for ingestor) # Common serial paths: @@ -16,21 +21,21 @@ API_TOKEN=your-secure-api-token-here # - Windows (WSL): /dev/ttyS* # You may also provide an IP:PORT pair (e.g. 192.168.1.20:4403) or a # Bluetooth address (e.g. ED:4D:9E:95:CF:60). -CONNECTION=/dev/ttyACM0 +CONNECTION="/dev/ttyACM0" # ============================================================================= # SITE CUSTOMIZATION # ============================================================================= # Your mesh network name -SITE_NAME=My Meshtastic Network +SITE_NAME="My Meshtastic Network" # Default Meshtastic channel -CHANNEL=#LongFast +CHANNEL="#LongFast" # Default frequency for your region # Common frequencies: 868MHz (Europe), 915MHz (US), 433MHz (Worldwide) -FREQUENCY=915MHz +FREQUENCY="915MHz" # Map center coordinates (latitude, longitude) # Berlin, Germany: 52.502889, 13.404194 @@ -47,7 +52,7 @@ MAX_DISTANCE=42 # Community chat link or Matrix room for your community (optional) # Matrix aliases (e.g. #meshtastic-berlin:matrix.org) will be linked via matrix.to automatically. -CONTACT_LINK='#potatomesh:dod.ngo' +CONTACT_LINK="#potatomesh:dod.ngo" # Enable or disable PotatoMesh federation features (1=enabled, 0=disabled) FEDERATION=1 @@ -63,23 +68,17 @@ PRIVATE=0 # Debug mode (0=off, 1=on) DEBUG=0 -# Public domain name for this PotatoMesh instance -# Provide a hostname (with optional port) that resolves to the web service. -# Example: mesh.example.org or mesh.example.org:41447 -INSTANCE_DOMAIN=mesh.example.org +# Default map zoom override +# MAP_ZOOM=15 # Docker image architecture (linux-amd64, linux-arm64, linux-armv7) -POTATOMESH_IMAGE_ARCH=linux-amd64 +POTATOMESH_IMAGE_ARCH="linux-amd64" # Docker image tag (use "latest" for the newest release or pin to vX.Y) -POTATOMESH_IMAGE_TAG=latest +POTATOMESH_IMAGE_TAG="latest" # Docker Compose networking profile # Leave unset for Linux hosts (default host networking). # Set to "bridge" on Docker Desktop (macOS/Windows) if host networking # is unavailable. -# COMPOSE_PROFILES=bridge - -# Meshtastic channel index (0=primary, 1=secondary, etc.) -CHANNEL_INDEX=0 - +# COMPOSE_PROFILES="bridge" diff --git a/Dockerfile b/Dockerfile index 8819249..ae80977 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,10 +81,10 @@ EXPOSE 41447 ENV APP_ENV=production \ RACK_ENV=production \ SITE_NAME="PotatoMesh Demo" \ + INSTANCE_DOMAIN="potato.example.com" \ CHANNEL="#LongFast" \ FREQUENCY="915MHz" \ MAP_CENTER="38.761944,-27.090833" \ - MAP_ZOOM="" \ MAX_DISTANCE=42 \ CONTACT_LINK="#potatomesh:dod.ngo" \ DEBUG=0 diff --git a/README.md b/README.md index ced1641..15b5f6f 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,14 @@ [![Contributions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/l5yth/potato-mesh/issues) [![Matrix Chat](https://img.shields.io/badge/matrix-%23potatomesh:dod.ngo-blue)](https://matrix.to/#/#potatomesh:dod.ngo) -A simple Meshtastic-powered node dashboard for your local community. _No MQTT clutter, just local LoRa aether._ +A federated Meshtastic-powered node dashboard for your local community. _No MQTT clutter, just local LoRa aether._ * Web app with chat window and map view showing nodes, neighbors, telemetry, and messages. -* API to POST (authenticated) and to GET nodes and messages. + * API to POST (authenticated) and to GET nodes and messages. + * Shows new node notifications (first seen) in chat. + * Allows searching and filtering for nodes in map and table view. * Supplemental Python ingestor to feed the POST APIs of the Web app with data remotely. -* Shows new node notifications (first seen) in chat. -* Allows searching and filtering for nodes in map and table view. +* Mobile app to _read_ messages on your local aether (no radio required). Live demo for Berlin #MediumFast: [potatomesh.net](https://potatomesh.net) @@ -84,7 +85,6 @@ The web app can be configured with environment variables (defaults shown): | `DEBUG` | `0` | Set to `1` for verbose logging in the web and ingestor services. | | `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. | | `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. | -| `CONNECTION` | `/dev/ttyACM0` | Serial device, TCP endpoint, or Bluetooth target used by the ingestor to reach the Meshtastic radio. | The application derives SEO-friendly document titles, descriptions, and social preview tags from these existing configuration values and reuses the bundled @@ -114,8 +114,7 @@ PotatoMesh instances can optionally federate by publishing signed metadata and discovering peers. Federation is enabled by default and controlled with the `FEDERATION` environment variable. Set `FEDERATION=1` (default) to announce your instance, respond to remote crawlers, and crawl the wider network. Set -`FEDERATION=0` to keep your deployment isolated—federation requests will be -ignored and the ingestor will skip discovery tasks. Private mode still takes +`FEDERATION=0` to keep your deployment isolated. Private mode still takes precedence; when `PRIVATE=1`, federation features remain disabled regardless of the `FEDERATION` value. @@ -131,7 +130,7 @@ The web app contains an API: * GET `/api/nodes?limit=100` - returns the latest 100 nodes reported to the app * GET `/api/positions?limit=100` - returns the latest 100 position data -* GET `/api/messages?limit=100` - returns the latest 100 messages (disabled when `PRIVATE=1`) +* GET `/api/messages?limit=100&encrypted=false` - returns the latest 100 messages (disabled when `PRIVATE=1`) * GET `/api/telemetry?limit=100` - returns the latest 100 telemetry data * GET `/api/neighbors?limit=100` - returns the latest 100 neighbor tuples * GET `/api/instances` - returns known potato-mesh instances in other locations @@ -145,7 +144,7 @@ The web app contains an API: The `API_TOKEN` environment variable must be set to a non-empty value and match the token supplied in the `Authorization` header for `POST` requests. -### Observability +### Monitoring PotatoMesh ships with a Prometheus exporter mounted at `/metrics`. Consult [`PROMETHEUS.md`](./PROMETHEUS.md) for deployment guidance, metric details, and @@ -155,8 +154,8 @@ scrape configuration examples. The web app is not meant to be run locally connected to a Meshtastic node but rather on a remote host without access to a physical Meshtastic device. Therefore, it only -accepts data through the API POST endpoints. Benefit is, here multiple nodes across the -community can feed the dashboard with data. The web app handles messages and nodes +accepts data through the API POST endpoints. Benefit is, here _multiple nodes across the +community_ can feed the dashboard with data. The web app handles messages and nodes by ID and there will be no duplication. For convenience, the directory `./data` contains a Python ingestor. It connects to a @@ -192,25 +191,29 @@ an IP address (for example `192.168.1.20:4403`) to use the Meshtastic TCP interface. `CONNECTION` also accepts Bluetooth device addresses (e.g., `ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. -## Demos - -Post your nodes here: - -* - ## Docker Docker images are published on Github for each release: ```bash -docker pull ghcr.io/l5yth/potato-mesh/web:latest # newest release -docker pull ghcr.io/l5yth/potato-mesh/web:v3.0 # pinned historical release +docker pull ghcr.io/l5yth/potato-mesh/web:latest # newest release +docker pull ghcr.io/l5yth/potato-mesh/web:v0.5.5 # pinned historical release docker pull ghcr.io/l5yth/potato-mesh/ingestor:latest ``` -Set `POTATOMESH_IMAGE_TAG` in your `.env` (or environment) to deploy a specific -tagged release with Docker Compose. See the [Docker guide](DOCKER.md) for more -details and custom deployment instructions. +Feel free to run the [configure.sh](./configure.sh) script to set up your +environment. See the [Docker guide](DOCKER.md) for more details and custom +deployment instructions. + +## Mobile App + +A mobile _reader_ app is currently being worked on. Stay tuned for releases and updates. + +## Demos + +Post your nodes and screenshots here: + +* ## License diff --git a/app/lib/dart_plugin_registrant.dart b/app/lib/dart_plugin_registrant.dart new file mode 100644 index 0000000..a2f0e64 --- /dev/null +++ b/app/lib/dart_plugin_registrant.dart @@ -0,0 +1,48 @@ +// Copyright © 2025-26 l5yth & contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:shared_preferences_android/shared_preferences_android.dart'; +import 'package:shared_preferences_foundation/shared_preferences_foundation.dart'; + +/// Minimal plugin registrant for background isolates. +/// +/// The Workmanager-provided background Flutter engine does not automatically +/// invoke the app's plugin registrant, so we register only the plugins needed +/// by our background task (notifications and shared preferences). +class DartPluginRegistrant { + static bool _initialized = false; + + static void ensureInitialized() { + if (_initialized) return; + if (Platform.isAndroid) { + try { + AndroidFlutterLocalNotificationsPlugin.registerWith(); + } catch (_) {} + try { + SharedPreferencesAndroid.registerWith(); + } catch (_) {} + } else if (Platform.isIOS) { + try { + IOSFlutterLocalNotificationsPlugin.registerWith(); + } catch (_) {} + try { + SharedPreferencesFoundation.registerWith(); + } catch (_) {} + } + _initialized = true; + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index 837b49b..d109138 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -15,16 +15,21 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; +import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:workmanager/workmanager.dart'; +import 'package:potato_mesh_reader/dart_plugin_registrant.dart' + as dart_plugin_registrant; const String _gitVersionEnv = String.fromEnvironment('GIT_VERSION', defaultValue: ''); @@ -36,6 +41,326 @@ const String _gitDirtyEnv = String.fromEnvironment('GIT_DIRTY', defaultValue: ''); const Duration _requestTimeout = Duration(seconds: 5); const String _themePreferenceKey = 'mesh.themeMode'; +const String _notificationChannelId = 'mesh.messages'; +const String _notificationChannelName = 'Mesh messages'; +const String _notificationChannelDescription = + 'Alerts when new PotatoMesh messages arrive'; +const String _backgroundTaskName = 'mesh_message_poll'; +const String _backgroundTaskId = 'mesh.message.poll'; +const Duration _backgroundFetchInterval = Duration(minutes: 15); + +/// Client interface used to deliver notifications when unseen messages arrive. +abstract class NotificationClient { + const NotificationClient(); + + /// Performs any platform-specific initialization, such as channel creation. + Future initialize(); + + /// Shows a notification for an unseen message. + Future showNewMessage({ + required MeshMessage message, + required String domain, + String? senderShortName, + }); +} + +/// No-op notification client used in tests and web builds. +class NoopNotificationClient implements NotificationClient { + const NoopNotificationClient(); + + @override + Future initialize() async {} + + @override + Future showNewMessage({ + required MeshMessage message, + required String domain, + String? senderShortName, + }) async {} +} + +/// Platform-aware notification client backed by the Flutter Local Notifications plugin. +class LocalNotificationClient implements NotificationClient { + LocalNotificationClient({FlutterLocalNotificationsPlugin? plugin}) + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + + final FlutterLocalNotificationsPlugin _plugin; + bool _initialized = false; + + AndroidNotificationChannel get _channel => const AndroidNotificationChannel( + _notificationChannelId, + _notificationChannelName, + description: _notificationChannelDescription, + importance: Importance.high, + ); + + @override + Future initialize() async { + if (_initialized) return; + if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) { + // Unit tests and desktop builds do not have a notification host. + _initialized = true; + return; + } + const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosInit = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + final settings = + const InitializationSettings(android: androidInit, iOS: iosInit); + await _plugin.initialize(settings); + + final android = _plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + await android?.createNotificationChannel(_channel); + if (Platform.isAndroid) { + final enabled = await android?.areNotificationsEnabled() ?? true; + if (!enabled) { + final granted = + await android?.requestNotificationsPermission() ?? false; + debugPrint('D/Notifications: permission requested; granted=$granted'); + } + } + debugPrint('D/Notifications: initialized'); + + _initialized = true; + } + + NotificationDetails _notificationDetails() { + const androidDetails = AndroidNotificationDetails( + _notificationChannelId, + _notificationChannelName, + channelDescription: _notificationChannelDescription, + importance: Importance.high, + priority: Priority.high, + enableVibration: true, + playSound: true, + ); + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); + return const NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + } + + @override + Future showNewMessage({ + required MeshMessage message, + required String domain, + String? senderShortName, + }) async { + await initialize(); + if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return; + debugPrint('D/Notifications: showing message ${message.id} on $domain'); + final displaySender = senderShortName?.trim().isNotEmpty == true + ? senderShortName!.trim() + : message.fromShort; + final channel = message.channelName?.trim().isNotEmpty == true + ? message.channelName!.trim() + : domain; + final title = 'New message from $displaySender'; + final body = message.text.trim().isNotEmpty + ? message.text.trim() + : 'New message on $channel'; + await _plugin.show( + message.id.hashCode.abs(), + title, + body, + _notificationDetails(), + payload: domain, + ); + } +} + +/// Callback dispatcher used by the Workmanager plugin. +@pragma('vm:entry-point') +void _workmanagerCallbackDispatcher() { + Workmanager().executeTask((task, inputData) async { + try { + WidgetsFlutterBinding.ensureInitialized(); + dart_plugin_registrant.DartPluginRegistrant.ensureInitialized(); + return await BackgroundSyncManager.handleBackgroundTask( + task, + inputData, + ); + } catch (error, stackTrace) { + debugPrint('E/BackgroundSync dispatcher failed: $error\n$stackTrace'); + return false; + } + }); +} + +/// Workmanager abstraction to simplify testing. +abstract class WorkmanagerAdapter { + const WorkmanagerAdapter(); + + Future initialize(Function dispatcher); + + Future registerPeriodicTask( + String taskId, + String taskName, { + Duration frequency, + ExistingPeriodicWorkPolicy existingWorkPolicy, + Duration? initialDelay, + Constraints? constraints, + }); + + Future cancelAll(); +} + +/// Real Workmanager adapter used in production builds. +class FlutterWorkmanagerAdapter implements WorkmanagerAdapter { + FlutterWorkmanagerAdapter({Workmanager? delegate}) + : _delegate = delegate ?? Workmanager(); + + final Workmanager _delegate; + + @override + Future initialize(Function dispatcher) { + return _delegate.initialize(dispatcher); + } + + @override + Future registerPeriodicTask( + String taskId, + String taskName, { + Duration frequency = _backgroundFetchInterval, + ExistingPeriodicWorkPolicy existingWorkPolicy = + ExistingPeriodicWorkPolicy.keep, + Duration? initialDelay, + Constraints? constraints, + }) { + return _delegate.registerPeriodicTask( + taskId, + taskName, + frequency: frequency, + existingWorkPolicy: existingWorkPolicy, + initialDelay: initialDelay, + constraints: constraints, + inputData: const {}, + ); + } + + @override + Future cancelAll() { + return _delegate.cancelAll(); + } +} + +/// Factories used to build repositories and notifications for background work. +class BackgroundDependencies { + const BackgroundDependencies({ + required this.repositoryBuilder, + required this.notificationBuilder, + }); + + final Future Function() repositoryBuilder; + final Future Function() notificationBuilder; +} + +/// Schedules and executes periodic background message refreshes. +class BackgroundSyncManager { + BackgroundSyncManager({ + required this.workmanager, + required this.dependencies, + }); + + final WorkmanagerAdapter workmanager; + final BackgroundDependencies dependencies; + + static final BackgroundDependencies _defaultDependencies = + BackgroundDependencies( + repositoryBuilder: () async => MeshRepository(), + notificationBuilder: () async => LocalNotificationClient(), + ); + static BackgroundDependencies? _registeredDependencies; + + /// Registers dependencies for the Workmanager callback and schedules polling. + Future initialize({bool debugMode = false}) async { + _registeredDependencies = dependencies; + await workmanager.initialize(_workmanagerCallbackDispatcher); + } + + /// Schedules the periodic background fetch task. + Future ensurePeriodicTask() { + return workmanager.registerPeriodicTask( + _backgroundTaskId, + _backgroundTaskName, + frequency: _backgroundFetchInterval, + existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, + initialDelay: const Duration(minutes: 1), + constraints: Constraints( + networkType: NetworkType.connected, + ), + ); + } + + /// Clears registered dependencies; intended for tests only. + @visibleForTesting + static void resetForTest() { + _registeredDependencies = null; + } + + /// Executes the background task to fetch messages and post notifications. + @pragma('vm:entry-point') + static Future handleBackgroundTask( + String task, + Map? inputData, + ) async { + WidgetsFlutterBinding.ensureInitialized(); + final deps = _registeredDependencies ?? + ((Platform.isAndroid || Platform.isIOS) ? _defaultDependencies : null); + if (deps == null) { + debugPrint('D/BackgroundSync: no dependencies registered; skipping'); + return true; + } + try { + debugPrint('D/BackgroundSync: start task=$task'); + final repository = await deps.repositoryBuilder(); + final notification = await deps.notificationBuilder(); + await notification.initialize(); + + final domain = await repository.loadSelectedDomainOrDefault(); + final messages = await repository.loadMessages(domain: domain); + final unseen = await repository.detectUnseenMessages( + domain: domain, + messages: messages, + ); + + debugPrint( + 'D/BackgroundSync: task=$task domain=$domain fetched=${messages.length} unseen=${unseen.length}'); + + for (final message in unseen) { + final sender = NodeShortNameCache.fallbackShortName( + message.lookupNodeId.isNotEmpty + ? message.lookupNodeId + : message.fromId, + ); + debugPrint( + 'D/BackgroundSync: notifying message=${message.id} sender=$sender'); + await notification.showNewMessage( + message: message, + domain: domain, + senderShortName: sender, + ); + } + return true; + } on SocketException catch (error) { + debugPrint('W/BackgroundSync: network unavailable ($error); will retry'); + return true; + } catch (error, stackTrace) { + debugPrint('E/BackgroundSync failed: $error\n$stackTrace'); + // Return true to avoid aggressive retries if the environment blocks plugins. + return true; + } + } +} void _logHttp(String message) { debugPrint('D/$message'); @@ -65,8 +390,24 @@ Map _decodeJsonMapSync(String body) { return decoded; } -void main() { - runApp(const PotatoMeshReaderApp()); +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final notificationClient = LocalNotificationClient(); + await notificationClient.initialize(); + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { + final backgroundManager = BackgroundSyncManager( + workmanager: FlutterWorkmanagerAdapter(), + dependencies: BackgroundDependencies( + repositoryBuilder: () async => MeshRepository(), + notificationBuilder: () async => LocalNotificationClient(), + ), + ); + await backgroundManager.initialize(); + await backgroundManager.ensurePeriodicTask(); + } + runApp(PotatoMeshReaderApp( + notificationClient: notificationClient, + )); } /// Persistent storage for the theme choice so the UI can honor user intent. @@ -116,6 +457,7 @@ class PotatoMeshReaderApp extends StatefulWidget { this.bootstrapper, this.enableAutoRefresh = true, this.themeStore = const ThemePreferenceStore(), + this.notificationClient = const NoopNotificationClient(), }); /// Fetch function injected to simplify testing and offline previews. @@ -141,6 +483,9 @@ class PotatoMeshReaderApp extends StatefulWidget { /// Storage used to persist the chosen theme. final ThemePreferenceStore themeStore; + /// Client responsible for platform notifications. + final NotificationClient notificationClient; + @override State createState() => _PotatoMeshReaderAppState(); } @@ -149,6 +494,7 @@ class _PotatoMeshReaderAppState extends State { late String _endpointDomain; int _endpointVersion = 0; late final MeshRepository _repository; + late final NotificationClient _notificationClient; final GlobalKey _messengerKey = GlobalKey(); BootstrapProgress _progress = @@ -163,6 +509,7 @@ class _PotatoMeshReaderAppState extends State { super.initState(); _endpointDomain = widget.initialDomain; _repository = widget.repository ?? MeshRepository(); + _notificationClient = widget.notificationClient; NodeShortNameCache.instance.registerResolver(_repository); _loadThemeMode(); _startBootstrap(); @@ -378,6 +725,7 @@ class _PotatoMeshReaderAppState extends State { resetToken: _endpointVersion, domain: domain, repository: _repository, + notificationClient: _notificationClient, instanceName: instanceName, enableAutoRefresh: widget.enableAutoRefresh, initialMessages: initialMessages, @@ -514,6 +862,7 @@ class MeshLocalStore { static const String _instancesKey = 'mesh.instances'; static const String _selectedDomainKey = 'mesh.selectedDomain'; + static const String _lastSeenKey = 'mesh.lastSeen'; String _safeKey(String domain) { final base = domain.trim().isEmpty ? 'potatomesh.net' : domain.trim(); @@ -592,6 +941,14 @@ class MeshLocalStore { return const []; } } + + Future saveLastSeenMessageKey(String domain, String key) async { + await _prefs.setString('$_lastSeenKey.${_safeKey(domain)}', key); + } + + String? loadLastSeenMessageKey(String domain) { + return _prefs.getString('$_lastSeenKey.${_safeKey(domain)}'); + } } /// Provider used by [NodeShortNameCache] to resolve cached node metadata. @@ -611,6 +968,7 @@ class MeshRepository implements MeshNodeResolver { SharedPreferences? _prefs; MeshLocalStore? _store; + MessageSeenTracker? _tracker; final http.Client? _client; final Random _random; @@ -631,6 +989,22 @@ class MeshRepository implements MeshNodeResolver { return _store!; } + /// Returns the last selected domain, defaulting to the provided fallback. + Future loadSelectedDomainOrDefault( + {String fallback = 'potatomesh.net'}) async { + final store = await _ensureStore(); + final cached = store.loadSelectedDomain(); + _selectedDomain = (cached != null && cached.isNotEmpty) ? cached : fallback; + return _selectedDomain; + } + + Future _ensureTracker() async { + if (_tracker != null) return _tracker!; + final store = await _ensureStore(); + _tracker = MessageSeenTracker(store); + return _tracker!; + } + /// Persist the selected domain choice without performing network calls. Future rememberSelectedDomain(String domain) async { _selectedDomain = _domainKey(domain); @@ -798,6 +1172,15 @@ class MeshRepository implements MeshNodeResolver { } } + /// Returns any messages that arrived after the last recorded entry for a domain. + Future> detectUnseenMessages({ + required String domain, + required List messages, + }) async { + final tracker = await _ensureTracker(); + return tracker.unseenSince(domain: domain, messages: messages); + } + /// Stores a nodes snapshot for quick lookup without refetching mid-session. Future> loadNodes({required String domain}) async { await _ensureStore(); @@ -1143,10 +1526,12 @@ class MeshRepository implements MeshNodeResolver { List _mergeMessages(String domain, List incoming) { final key = _domainKey(domain); final existing = List.from(_messagesByDomain[key] ?? const []); - final seen = existing.map(_messageKey).toSet(); + final seen = existing.map(MessageSeenTracker.messageKey).toSet(); for (final msg in incoming) { - final key = _messageKey(msg); - if (seen.contains(key)) continue; + final key = MessageSeenTracker.messageKey(msg); + if (seen.contains(key)) { + continue; + } existing.add(msg); seen.add(key); } @@ -1158,10 +1543,6 @@ class MeshRepository implements MeshNodeResolver { return sorted; } - String _messageKey(MeshMessage msg) { - return '${msg.id}-${msg.rxIso}-${msg.fromId}-${msg.text}'; - } - Future _hydrateMissingNodes({ required String domain, required List messages, @@ -1222,6 +1603,50 @@ class MeshRepository implements MeshNodeResolver { } } +/// Tracks and persists the most recently seen message per domain. +class MessageSeenTracker { + MessageSeenTracker(this._store); + + final MeshLocalStore _store; + + /// Returns unseen messages that arrived after the last recorded message, + /// while updating the persisted marker to the newest entry. + Future> unseenSince({ + required String domain, + required List messages, + }) async { + if (messages.isEmpty) return const []; + + final lastSeen = _store.loadLastSeenMessageKey(domain); + final ordered = sortMessagesByRxTime(List.from(messages)); + final latestKey = messageKey(ordered.last); + await _store.saveLastSeenMessageKey(domain, latestKey); + + if (lastSeen == null || lastSeen.isEmpty) { + return const []; + } + + var markerFound = false; + final unseen = []; + for (final message in ordered) { + if (markerFound) { + unseen.add(message); + continue; + } + if (messageKey(message) == lastSeen) { + markerFound = true; + } + } + + return markerFound ? unseen : const []; + } + + /// Returns the stable key used to detect repeated messages. + static String messageKey(MeshMessage msg) { + return '${msg.id}-${msg.rxIso}-${msg.fromId}-${msg.text}'; + } +} + /// Displays the fetched mesh messages and supports pull-to-refresh. class MessagesScreen extends StatefulWidget { const MessagesScreen({ @@ -1234,6 +1659,7 @@ class MessagesScreen extends StatefulWidget { this.initialMessages = const [], this.instanceName, this.enableAutoRefresh = true, + this.notificationClient = const NoopNotificationClient(), }); /// Fetch function used to load messages from the PotatoMesh API. @@ -1260,6 +1686,9 @@ class MessagesScreen extends StatefulWidget { /// Whether periodic background refresh is enabled. final bool enableAutoRefresh; + /// Client used to deliver local notifications for unseen messages. + final NotificationClient notificationClient; + @override State createState() => _MessagesScreenState(); } @@ -1272,10 +1701,14 @@ class _MessagesScreenState extends State Timer? _refreshTimer; bool _isForeground = true; int _fetchVersion = 0; + late final NotificationClient _notificationClient; + late final Future _notificationReady; @override void initState() { super.initState(); + _notificationClient = widget.notificationClient; + _notificationReady = _notificationClient.initialize(); _messages = List.from(widget.initialMessages); _future = Future.value(_messages); _startFetch(clear: _messages.isEmpty); @@ -1291,6 +1724,10 @@ class _MessagesScreenState extends State @override void didUpdateWidget(covariant MessagesScreen oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.notificationClient != widget.notificationClient) { + _notificationClient = widget.notificationClient; + _notificationReady = _notificationClient.initialize(); + } if (oldWidget.fetcher != widget.fetcher || oldWidget.resetToken != widget.resetToken || oldWidget.enableAutoRefresh != widget.enableAutoRefresh) { @@ -1358,7 +1795,7 @@ class _MessagesScreenState extends State } String _messageKey(MeshMessage msg) { - return '${msg.id}-${msg.rxIso}-${msg.fromId}-${msg.text}'; + return MessageSeenTracker.messageKey(msg); } void _scheduleScrollToBottom({int retries = 5}) { @@ -1388,6 +1825,35 @@ class _MessagesScreenState extends State } } + Future _notifyUnseenMessages(List fetched) async { + final repo = widget.repository; + if (repo == null || fetched.isEmpty) return; + try { + final unseen = await repo.detectUnseenMessages( + domain: widget.domain, + messages: fetched, + ); + if (_isForeground || unseen.isEmpty) { + return; + } + await _notificationReady; + for (final message in unseen) { + final sender = NodeShortNameCache.fallbackShortName( + message.lookupNodeId.isNotEmpty + ? message.lookupNodeId + : message.fromId, + ); + await _notificationClient.showNewMessage( + message: message, + domain: widget.domain, + senderShortName: sender, + ); + } + } catch (error) { + debugPrint('D/Notification error: $error'); + } + } + Future _startFetch( {bool clear = false, bool appendOnly = false}) async { final version = ++_fetchVersion; @@ -1404,6 +1870,7 @@ class _MessagesScreenState extends State final msgs = await future; if (version != _fetchVersion) return; _appendMessages(msgs); + await _notifyUnseenMessages(msgs); } catch (error) { if (appendOnly) { debugPrint('D/Failed to append messages: $error'); diff --git a/app/pubspec.lock b/app/pubspec.lock index b09c88f..f1e1ff3 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" fake_async: dependency: transitive description: @@ -142,6 +150,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + url: "https://pub.dev" + source: hosted + version: "19.5.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + url: "https://pub.dev" + source: hosted + version: "1.0.3" flutter_native_splash: dependency: "direct dev" description: @@ -361,7 +401,7 @@ packages: source: hosted version: "2.5.3" shared_preferences_android: - dependency: transitive + dependency: "direct main" description: name: shared_preferences_android sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b" @@ -369,7 +409,7 @@ packages: source: hosted version: "2.4.17" shared_preferences_foundation: - dependency: transitive + dependency: "direct main" description: name: shared_preferences_foundation sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" @@ -461,6 +501,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" typed_data: dependency: transitive description: @@ -597,6 +645,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" + url: "https://pub.dev" + source: hosted + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" xdg_directories: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 3086390..48d27ff 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -14,6 +14,10 @@ dependencies: flutter_svg: ^2.0.10+1 url_launcher: ^6.3.1 shared_preferences: ^2.3.2 + flutter_local_notifications: ^19.5.0 + workmanager: ^0.9.0+3 + shared_preferences_android: any + shared_preferences_foundation: any dev_dependencies: flutter_test: diff --git a/app/test/background_sync_manager_test.dart b/app/test/background_sync_manager_test.dart new file mode 100644 index 0000000..ad93c9a --- /dev/null +++ b/app/test/background_sync_manager_test.dart @@ -0,0 +1,170 @@ +// Copyright © 2025-26 l5yth & contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:potato_mesh_reader/main.dart'; +import 'package:workmanager/workmanager.dart'; + +class _FakeWorkmanagerAdapter implements WorkmanagerAdapter { + bool initialized = false; + bool registered = false; + Duration? frequency; + ExistingPeriodicWorkPolicy? policy; + Constraints? constraints; + Duration? initialDelay; + Function? dispatcher; + + @override + Future initialize(Function dispatcher) async { + initialized = true; + this.dispatcher = dispatcher; + } + + @override + Future registerPeriodicTask( + String taskId, + String taskName, { + Duration frequency = const Duration(minutes: 15), + ExistingPeriodicWorkPolicy existingWorkPolicy = + ExistingPeriodicWorkPolicy.keep, + Duration? initialDelay, + Constraints? constraints, + }) async { + registered = true; + this.frequency = frequency; + policy = existingWorkPolicy; + this.constraints = constraints; + this.initialDelay = initialDelay; + } + + @override + Future cancelAll() async {} +} + +class _FakeNotificationClient extends NotificationClient { + _FakeNotificationClient(); + + int calls = 0; + + @override + Future initialize() async {} + + @override + Future showNewMessage({ + required MeshMessage message, + required String domain, + String? senderShortName, + }) async { + calls += 1; + } +} + +class _FakeRepository extends MeshRepository { + _FakeRepository({ + required this.domain, + required this.messages, + required this.unseen, + }) : super(); + + final String domain; + final List messages; + final List unseen; + int loadMessagesCalls = 0; + + @override + Future loadSelectedDomainOrDefault( + {String fallback = 'potatomesh.net'}) async { + return domain; + } + + @override + Future> loadMessages({required String domain}) async { + loadMessagesCalls += 1; + return messages; + } + + @override + Future> detectUnseenMessages({ + required String domain, + required List messages, + }) async { + return unseen; + } +} + +MeshMessage _buildMessage(int id, String text) { + final rx = DateTime.utc(2024, 1, 1, 12, id); + return MeshMessage( + id: id, + rxTime: rx, + rxIso: rx.toIso8601String(), + fromId: '!tester$id', + nodeId: '!tester$id', + toId: '^', + channel: 1, + channelName: 'Main', + portnum: 'TEXT', + text: text, + rssi: -50, + snr: 1.0, + hopLimit: 1, + ); +} + +void main() { + setUp(() { + BackgroundSyncManager.resetForTest(); + }); + + test('registers and schedules background task', () async { + final fakeWork = _FakeWorkmanagerAdapter(); + final fakeRepo = _FakeRepository( + domain: 'potatomesh.net', + messages: [_buildMessage(1, 'hello')], + unseen: [_buildMessage(2, 'new')], + ); + final notifier = _FakeNotificationClient(); + + final manager = BackgroundSyncManager( + workmanager: fakeWork, + dependencies: BackgroundDependencies( + repositoryBuilder: () async => fakeRepo, + notificationBuilder: () async => notifier, + ), + ); + + await manager.initialize(); + await manager.ensurePeriodicTask(); + + expect(fakeWork.initialized, isTrue); + expect(fakeWork.registered, isTrue); + expect(fakeWork.policy, ExistingPeriodicWorkPolicy.keep); + expect(fakeWork.frequency, const Duration(minutes: 15)); + expect(fakeWork.constraints?.networkType, NetworkType.connected); + expect(fakeWork.initialDelay, const Duration(minutes: 1)); + + final handled = + await BackgroundSyncManager.handleBackgroundTask('task', {}); + + expect(handled, isTrue); + expect(fakeRepo.loadMessagesCalls, 1); + expect(notifier.calls, 1); + }); + + test('returns true and no-ops when dependencies are missing', () async { + final handled = + await BackgroundSyncManager.handleBackgroundTask('task', {}); + expect(handled, isTrue); + }); +} diff --git a/app/test/message_seen_tracker_test.dart b/app/test/message_seen_tracker_test.dart new file mode 100644 index 0000000..f2c44c1 --- /dev/null +++ b/app/test/message_seen_tracker_test.dart @@ -0,0 +1,119 @@ +// Copyright © 2025-26 l5yth & contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:potato_mesh_reader/main.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +MeshMessage _buildMessage({ + required int id, + required int minute, + String text = 'msg', +}) { + final rxTime = DateTime.utc(2024, 1, 1, 12, minute); + return MeshMessage( + id: id, + rxTime: rxTime, + rxIso: rxTime.toIso8601String(), + fromId: '!sender$id', + nodeId: '!sender$id', + toId: '^', + channel: 1, + channelName: 'Main', + portnum: 'TEXT', + text: text, + rssi: -40, + snr: 1.2, + hopLimit: 1, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('marks latest message seen when no prior marker exists', () async { + final prefs = await SharedPreferences.getInstance(); + final store = MeshLocalStore(prefs); + final tracker = MessageSeenTracker(store); + final messages = [ + _buildMessage(id: 1, minute: 1), + _buildMessage(id: 2, minute: 2), + ]; + + final unseen = + await tracker.unseenSince(domain: 'potatomesh.net', messages: messages); + + expect(unseen, isEmpty); + expect( + store.loadLastSeenMessageKey('potatomesh.net'), + MessageSeenTracker.messageKey(messages.last), + ); + }); + + test('returns messages that arrive after last seen marker', () async { + final prefs = await SharedPreferences.getInstance(); + final store = MeshLocalStore(prefs); + final tracker = MessageSeenTracker(store); + final first = _buildMessage(id: 1, minute: 1, text: 'old'); + final second = _buildMessage(id: 2, minute: 2, text: 'new'); + final third = _buildMessage(id: 3, minute: 3, text: 'newer'); + + await store.saveLastSeenMessageKey( + 'potatomesh.net', + MessageSeenTracker.messageKey(first), + ); + + final unseen = await tracker.unseenSince( + domain: 'potatomesh.net', + messages: [first, second, third], + ); + + expect(unseen.length, 2); + expect(unseen.first.text, 'new'); + expect(unseen.last.text, 'newer'); + expect( + store.loadLastSeenMessageKey('potatomesh.net'), + MessageSeenTracker.messageKey(third), + ); + }); + + test('ignores notifications if last seen marker is missing from payload', + () async { + final prefs = await SharedPreferences.getInstance(); + final store = MeshLocalStore(prefs); + final tracker = MessageSeenTracker(store); + final messages = [ + _buildMessage(id: 10, minute: 1), + _buildMessage(id: 11, minute: 2), + ]; + + await store.saveLastSeenMessageKey( + 'potatomesh.net', + 'nonexistent-key', + ); + + final unseen = + await tracker.unseenSince(domain: 'potatomesh.net', messages: messages); + + expect(unseen, isEmpty); + expect( + store.loadLastSeenMessageKey('potatomesh.net'), + MessageSeenTracker.messageKey(messages.last), + ); + }); +} diff --git a/configure.sh b/configure.sh index bdcbeca..272ef9b 100755 --- a/configure.sh +++ b/configure.sh @@ -41,14 +41,14 @@ read_with_default() { local prompt="$1" local default="$2" local var_name="$3" - + if [ -n "$default" ]; then read -p "$prompt [$default]: " input input=${input:-$default} else read -p "$prompt: " input fi - + eval "$var_name='$input'" } @@ -87,6 +87,13 @@ INSTANCE_DOMAIN=$(grep "^INSTANCE_DOMAIN=" .env 2>/dev/null | cut -d'=' -f2- | t DEBUG=$(grep "^DEBUG=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0") CONNECTION=$(grep "^CONNECTION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "/dev/ttyACM0") +echo "" +echo "🌐 Domain Settings" +echo "------------------" +echo "Provide the public hostname that clients should use to reach this PotatoMesh instance." +echo "Leave blank to allow automatic detection via reverse DNS." +read_with_default "Instance domain (e.g. mesh.example.org)" "$INSTANCE_DOMAIN" INSTANCE_DOMAIN + echo "📍 Location Settings" echo "-------------------" read_with_default "Site Name (your mesh network name)" "$SITE_NAME" SITE_NAME @@ -136,13 +143,6 @@ echo "Use serial devices like /dev/ttyACM0, TCP endpoints such as tcp://host:por echo "or Bluetooth addresses when supported." read_with_default "Connection target" "$CONNECTION" CONNECTION -echo "" -echo "🌐 Domain Settings" -echo "------------------" -echo "Provide the public hostname that clients should use to reach this PotatoMesh instance." -echo "Leave blank to allow automatic detection via reverse DNS." -read_with_default "Instance domain (e.g. mesh.example.org)" "$INSTANCE_DOMAIN" INSTANCE_DOMAIN - echo "" echo "🔐 Security Settings" echo "-------------------" @@ -238,7 +238,7 @@ echo " Max Distance: ${MAX_DISTANCE}km" echo " Channel: $CHANNEL" echo " Frequency: $FREQUENCY" echo " Chat: ${CONTACT_LINK:-'Not set'}" -echo " Debug Logging: ${DEBUG}" +echo " Debug Logging: ${DEBUG}" echo " Connection: ${CONNECTION}" echo " API Token: ${API_TOKEN:0:8}..." echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"