app: implement notifications (#511)

* app: implement notifications

* app: request permission for notifications
This commit is contained in:
l5y
2025-11-25 21:38:46 +01:00
committed by GitHub
parent e432c843c3
commit 2ae1e34d63
10 changed files with 952 additions and 62 deletions

View File

@@ -5,9 +5,14 @@
# REQUIRED SETTINGS # 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) # API authentication token (required for ingestor communication)
# Generate a secure token: openssl rand -hex 32 # 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) # Meshtastic connection target (required for ingestor)
# Common serial paths: # Common serial paths:
@@ -16,21 +21,21 @@ API_TOKEN=your-secure-api-token-here
# - Windows (WSL): /dev/ttyS* # - Windows (WSL): /dev/ttyS*
# You may also provide an IP:PORT pair (e.g. 192.168.1.20:4403) or a # 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). # Bluetooth address (e.g. ED:4D:9E:95:CF:60).
CONNECTION=/dev/ttyACM0 CONNECTION="/dev/ttyACM0"
# ============================================================================= # =============================================================================
# SITE CUSTOMIZATION # SITE CUSTOMIZATION
# ============================================================================= # =============================================================================
# Your mesh network name # Your mesh network name
SITE_NAME=My Meshtastic Network SITE_NAME="My Meshtastic Network"
# Default Meshtastic channel # Default Meshtastic channel
CHANNEL=#LongFast CHANNEL="#LongFast"
# Default frequency for your region # Default frequency for your region
# Common frequencies: 868MHz (Europe), 915MHz (US), 433MHz (Worldwide) # Common frequencies: 868MHz (Europe), 915MHz (US), 433MHz (Worldwide)
FREQUENCY=915MHz FREQUENCY="915MHz"
# Map center coordinates (latitude, longitude) # Map center coordinates (latitude, longitude)
# Berlin, Germany: 52.502889, 13.404194 # Berlin, Germany: 52.502889, 13.404194
@@ -47,7 +52,7 @@ MAX_DISTANCE=42
# Community chat link or Matrix room for your community (optional) # 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. # 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) # Enable or disable PotatoMesh federation features (1=enabled, 0=disabled)
FEDERATION=1 FEDERATION=1
@@ -63,23 +68,17 @@ PRIVATE=0
# Debug mode (0=off, 1=on) # Debug mode (0=off, 1=on)
DEBUG=0 DEBUG=0
# Public domain name for this PotatoMesh instance # Default map zoom override
# Provide a hostname (with optional port) that resolves to the web service. # MAP_ZOOM=15
# Example: mesh.example.org or mesh.example.org:41447
INSTANCE_DOMAIN=mesh.example.org
# Docker image architecture (linux-amd64, linux-arm64, linux-armv7) # 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) # 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 # Docker Compose networking profile
# Leave unset for Linux hosts (default host networking). # Leave unset for Linux hosts (default host networking).
# Set to "bridge" on Docker Desktop (macOS/Windows) if host networking # Set to "bridge" on Docker Desktop (macOS/Windows) if host networking
# is unavailable. # is unavailable.
# COMPOSE_PROFILES=bridge # COMPOSE_PROFILES="bridge"
# Meshtastic channel index (0=primary, 1=secondary, etc.)
CHANNEL_INDEX=0

View File

@@ -81,10 +81,10 @@ EXPOSE 41447
ENV APP_ENV=production \ ENV APP_ENV=production \
RACK_ENV=production \ RACK_ENV=production \
SITE_NAME="PotatoMesh Demo" \ SITE_NAME="PotatoMesh Demo" \
INSTANCE_DOMAIN="potato.example.com" \
CHANNEL="#LongFast" \ CHANNEL="#LongFast" \
FREQUENCY="915MHz" \ FREQUENCY="915MHz" \
MAP_CENTER="38.761944,-27.090833" \ MAP_CENTER="38.761944,-27.090833" \
MAP_ZOOM="" \
MAX_DISTANCE=42 \ MAX_DISTANCE=42 \
CONTACT_LINK="#potatomesh:dod.ngo" \ CONTACT_LINK="#potatomesh:dod.ngo" \
DEBUG=0 DEBUG=0

View File

@@ -7,13 +7,14 @@
[![Contributions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/l5yth/potato-mesh/issues) [![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) [![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. * 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.
* Supplemental Python ingestor to feed the POST APIs of the Web app with data remotely.
* Shows new node notifications (first seen) in chat. * Shows new node notifications (first seen) in chat.
* Allows searching and filtering for nodes in map and table view. * 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.
* Mobile app to _read_ messages on your local aether (no radio required).
Live demo for Berlin #MediumFast: [potatomesh.net](https://potatomesh.net) 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. | | `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. | | `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. | | `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 The application derives SEO-friendly document titles, descriptions, and social
preview tags from these existing configuration values and reuses the bundled 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 discovering peers. Federation is enabled by default and controlled with the
`FEDERATION` environment variable. Set `FEDERATION=1` (default) to announce your `FEDERATION` environment variable. Set `FEDERATION=1` (default) to announce your
instance, respond to remote crawlers, and crawl the wider network. Set instance, respond to remote crawlers, and crawl the wider network. Set
`FEDERATION=0` to keep your deployment isolated—federation requests will be `FEDERATION=0` to keep your deployment isolated. Private mode still takes
ignored and the ingestor will skip discovery tasks. Private mode still takes
precedence; when `PRIVATE=1`, federation features remain disabled regardless of precedence; when `PRIVATE=1`, federation features remain disabled regardless of
the `FEDERATION` value. 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/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/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/telemetry?limit=100` - returns the latest 100 telemetry data
* GET `/api/neighbors?limit=100` - returns the latest 100 neighbor tuples * GET `/api/neighbors?limit=100` - returns the latest 100 neighbor tuples
* GET `/api/instances` - returns known potato-mesh instances in other locations * 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. 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 PotatoMesh ships with a Prometheus exporter mounted at `/metrics`. Consult
[`PROMETHEUS.md`](./PROMETHEUS.md) for deployment guidance, metric details, and [`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 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 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 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 community_ can feed the dashboard with data. The web app handles messages and nodes
by ID and there will be no duplication. by ID and there will be no duplication.
For convenience, the directory `./data` contains a Python ingestor. It connects to a 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., interface. `CONNECTION` also accepts Bluetooth device addresses (e.g.,
`ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. `ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available.
## Demos
Post your nodes here:
* <https://github.com/l5yth/potato-mesh/discussions/258>
## Docker ## Docker
Docker images are published on Github for each release: Docker images are published on Github for each release:
```bash ```bash
docker pull ghcr.io/l5yth/potato-mesh/web:latest # newest release 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:v0.5.5 # pinned historical release
docker pull ghcr.io/l5yth/potato-mesh/ingestor:latest docker pull ghcr.io/l5yth/potato-mesh/ingestor:latest
``` ```
Set `POTATOMESH_IMAGE_TAG` in your `.env` (or environment) to deploy a specific Feel free to run the [configure.sh](./configure.sh) script to set up your
tagged release with Docker Compose. See the [Docker guide](DOCKER.md) for more environment. See the [Docker guide](DOCKER.md) for more details and custom
details and custom deployment instructions. 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:
* <https://github.com/l5yth/potato-mesh/discussions/258>
## License ## License

View File

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

View File

@@ -15,16 +15,21 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.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 = const String _gitVersionEnv =
String.fromEnvironment('GIT_VERSION', defaultValue: ''); String.fromEnvironment('GIT_VERSION', defaultValue: '');
@@ -36,6 +41,326 @@ const String _gitDirtyEnv =
String.fromEnvironment('GIT_DIRTY', defaultValue: ''); String.fromEnvironment('GIT_DIRTY', defaultValue: '');
const Duration _requestTimeout = Duration(seconds: 5); const Duration _requestTimeout = Duration(seconds: 5);
const String _themePreferenceKey = 'mesh.themeMode'; 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<void> initialize();
/// Shows a notification for an unseen message.
Future<void> 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<void> initialize() async {}
@override
Future<void> 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<void> 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<void> 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<void> initialize(Function dispatcher);
Future<void> registerPeriodicTask(
String taskId,
String taskName, {
Duration frequency,
ExistingPeriodicWorkPolicy existingWorkPolicy,
Duration? initialDelay,
Constraints? constraints,
});
Future<void> cancelAll();
}
/// Real Workmanager adapter used in production builds.
class FlutterWorkmanagerAdapter implements WorkmanagerAdapter {
FlutterWorkmanagerAdapter({Workmanager? delegate})
: _delegate = delegate ?? Workmanager();
final Workmanager _delegate;
@override
Future<void> initialize(Function dispatcher) {
return _delegate.initialize(dispatcher);
}
@override
Future<void> 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<void> 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<MeshRepository> Function() repositoryBuilder;
final Future<NotificationClient> 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<void> initialize({bool debugMode = false}) async {
_registeredDependencies = dependencies;
await workmanager.initialize(_workmanagerCallbackDispatcher);
}
/// Schedules the periodic background fetch task.
Future<void> 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<bool> handleBackgroundTask(
String task,
Map<String, dynamic>? 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) { void _logHttp(String message) {
debugPrint('D/$message'); debugPrint('D/$message');
@@ -65,8 +390,24 @@ Map<String, dynamic> _decodeJsonMapSync(String body) {
return decoded; return decoded;
} }
void main() { Future<void> main() async {
runApp(const PotatoMeshReaderApp()); 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. /// Persistent storage for the theme choice so the UI can honor user intent.
@@ -116,6 +457,7 @@ class PotatoMeshReaderApp extends StatefulWidget {
this.bootstrapper, this.bootstrapper,
this.enableAutoRefresh = true, this.enableAutoRefresh = true,
this.themeStore = const ThemePreferenceStore(), this.themeStore = const ThemePreferenceStore(),
this.notificationClient = const NoopNotificationClient(),
}); });
/// Fetch function injected to simplify testing and offline previews. /// Fetch function injected to simplify testing and offline previews.
@@ -141,6 +483,9 @@ class PotatoMeshReaderApp extends StatefulWidget {
/// Storage used to persist the chosen theme. /// Storage used to persist the chosen theme.
final ThemePreferenceStore themeStore; final ThemePreferenceStore themeStore;
/// Client responsible for platform notifications.
final NotificationClient notificationClient;
@override @override
State<PotatoMeshReaderApp> createState() => _PotatoMeshReaderAppState(); State<PotatoMeshReaderApp> createState() => _PotatoMeshReaderAppState();
} }
@@ -149,6 +494,7 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
late String _endpointDomain; late String _endpointDomain;
int _endpointVersion = 0; int _endpointVersion = 0;
late final MeshRepository _repository; late final MeshRepository _repository;
late final NotificationClient _notificationClient;
final GlobalKey<ScaffoldMessengerState> _messengerKey = final GlobalKey<ScaffoldMessengerState> _messengerKey =
GlobalKey<ScaffoldMessengerState>(); GlobalKey<ScaffoldMessengerState>();
BootstrapProgress _progress = BootstrapProgress _progress =
@@ -163,6 +509,7 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
super.initState(); super.initState();
_endpointDomain = widget.initialDomain; _endpointDomain = widget.initialDomain;
_repository = widget.repository ?? MeshRepository(); _repository = widget.repository ?? MeshRepository();
_notificationClient = widget.notificationClient;
NodeShortNameCache.instance.registerResolver(_repository); NodeShortNameCache.instance.registerResolver(_repository);
_loadThemeMode(); _loadThemeMode();
_startBootstrap(); _startBootstrap();
@@ -378,6 +725,7 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
resetToken: _endpointVersion, resetToken: _endpointVersion,
domain: domain, domain: domain,
repository: _repository, repository: _repository,
notificationClient: _notificationClient,
instanceName: instanceName, instanceName: instanceName,
enableAutoRefresh: widget.enableAutoRefresh, enableAutoRefresh: widget.enableAutoRefresh,
initialMessages: initialMessages, initialMessages: initialMessages,
@@ -514,6 +862,7 @@ class MeshLocalStore {
static const String _instancesKey = 'mesh.instances'; static const String _instancesKey = 'mesh.instances';
static const String _selectedDomainKey = 'mesh.selectedDomain'; static const String _selectedDomainKey = 'mesh.selectedDomain';
static const String _lastSeenKey = 'mesh.lastSeen';
String _safeKey(String domain) { String _safeKey(String domain) {
final base = domain.trim().isEmpty ? 'potatomesh.net' : domain.trim(); final base = domain.trim().isEmpty ? 'potatomesh.net' : domain.trim();
@@ -592,6 +941,14 @@ class MeshLocalStore {
return const []; return const [];
} }
} }
Future<void> 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. /// Provider used by [NodeShortNameCache] to resolve cached node metadata.
@@ -611,6 +968,7 @@ class MeshRepository implements MeshNodeResolver {
SharedPreferences? _prefs; SharedPreferences? _prefs;
MeshLocalStore? _store; MeshLocalStore? _store;
MessageSeenTracker? _tracker;
final http.Client? _client; final http.Client? _client;
final Random _random; final Random _random;
@@ -631,6 +989,22 @@ class MeshRepository implements MeshNodeResolver {
return _store!; return _store!;
} }
/// Returns the last selected domain, defaulting to the provided fallback.
Future<String> loadSelectedDomainOrDefault(
{String fallback = 'potatomesh.net'}) async {
final store = await _ensureStore();
final cached = store.loadSelectedDomain();
_selectedDomain = (cached != null && cached.isNotEmpty) ? cached : fallback;
return _selectedDomain;
}
Future<MessageSeenTracker> _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. /// Persist the selected domain choice without performing network calls.
Future<void> rememberSelectedDomain(String domain) async { Future<void> rememberSelectedDomain(String domain) async {
_selectedDomain = _domainKey(domain); _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<List<MeshMessage>> detectUnseenMessages({
required String domain,
required List<MeshMessage> messages,
}) async {
final tracker = await _ensureTracker();
return tracker.unseenSince(domain: domain, messages: messages);
}
/// Stores a nodes snapshot for quick lookup without refetching mid-session. /// Stores a nodes snapshot for quick lookup without refetching mid-session.
Future<List<MeshNode>> loadNodes({required String domain}) async { Future<List<MeshNode>> loadNodes({required String domain}) async {
await _ensureStore(); await _ensureStore();
@@ -1143,10 +1526,12 @@ class MeshRepository implements MeshNodeResolver {
List<MeshMessage> _mergeMessages(String domain, List<MeshMessage> incoming) { List<MeshMessage> _mergeMessages(String domain, List<MeshMessage> incoming) {
final key = _domainKey(domain); final key = _domainKey(domain);
final existing = List<MeshMessage>.from(_messagesByDomain[key] ?? const []); final existing = List<MeshMessage>.from(_messagesByDomain[key] ?? const []);
final seen = existing.map(_messageKey).toSet(); final seen = existing.map(MessageSeenTracker.messageKey).toSet();
for (final msg in incoming) { for (final msg in incoming) {
final key = _messageKey(msg); final key = MessageSeenTracker.messageKey(msg);
if (seen.contains(key)) continue; if (seen.contains(key)) {
continue;
}
existing.add(msg); existing.add(msg);
seen.add(key); seen.add(key);
} }
@@ -1158,10 +1543,6 @@ class MeshRepository implements MeshNodeResolver {
return sorted; return sorted;
} }
String _messageKey(MeshMessage msg) {
return '${msg.id}-${msg.rxIso}-${msg.fromId}-${msg.text}';
}
Future<void> _hydrateMissingNodes({ Future<void> _hydrateMissingNodes({
required String domain, required String domain,
required List<MeshMessage> messages, required List<MeshMessage> 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<List<MeshMessage>> unseenSince({
required String domain,
required List<MeshMessage> messages,
}) async {
if (messages.isEmpty) return const [];
final lastSeen = _store.loadLastSeenMessageKey(domain);
final ordered = sortMessagesByRxTime(List<MeshMessage>.from(messages));
final latestKey = messageKey(ordered.last);
await _store.saveLastSeenMessageKey(domain, latestKey);
if (lastSeen == null || lastSeen.isEmpty) {
return const [];
}
var markerFound = false;
final unseen = <MeshMessage>[];
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. /// Displays the fetched mesh messages and supports pull-to-refresh.
class MessagesScreen extends StatefulWidget { class MessagesScreen extends StatefulWidget {
const MessagesScreen({ const MessagesScreen({
@@ -1234,6 +1659,7 @@ class MessagesScreen extends StatefulWidget {
this.initialMessages = const [], this.initialMessages = const [],
this.instanceName, this.instanceName,
this.enableAutoRefresh = true, this.enableAutoRefresh = true,
this.notificationClient = const NoopNotificationClient(),
}); });
/// Fetch function used to load messages from the PotatoMesh API. /// Fetch function used to load messages from the PotatoMesh API.
@@ -1260,6 +1686,9 @@ class MessagesScreen extends StatefulWidget {
/// Whether periodic background refresh is enabled. /// Whether periodic background refresh is enabled.
final bool enableAutoRefresh; final bool enableAutoRefresh;
/// Client used to deliver local notifications for unseen messages.
final NotificationClient notificationClient;
@override @override
State<MessagesScreen> createState() => _MessagesScreenState(); State<MessagesScreen> createState() => _MessagesScreenState();
} }
@@ -1272,10 +1701,14 @@ class _MessagesScreenState extends State<MessagesScreen>
Timer? _refreshTimer; Timer? _refreshTimer;
bool _isForeground = true; bool _isForeground = true;
int _fetchVersion = 0; int _fetchVersion = 0;
late final NotificationClient _notificationClient;
late final Future<void> _notificationReady;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_notificationClient = widget.notificationClient;
_notificationReady = _notificationClient.initialize();
_messages = List<MeshMessage>.from(widget.initialMessages); _messages = List<MeshMessage>.from(widget.initialMessages);
_future = Future.value(_messages); _future = Future.value(_messages);
_startFetch(clear: _messages.isEmpty); _startFetch(clear: _messages.isEmpty);
@@ -1291,6 +1724,10 @@ class _MessagesScreenState extends State<MessagesScreen>
@override @override
void didUpdateWidget(covariant MessagesScreen oldWidget) { void didUpdateWidget(covariant MessagesScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.notificationClient != widget.notificationClient) {
_notificationClient = widget.notificationClient;
_notificationReady = _notificationClient.initialize();
}
if (oldWidget.fetcher != widget.fetcher || if (oldWidget.fetcher != widget.fetcher ||
oldWidget.resetToken != widget.resetToken || oldWidget.resetToken != widget.resetToken ||
oldWidget.enableAutoRefresh != widget.enableAutoRefresh) { oldWidget.enableAutoRefresh != widget.enableAutoRefresh) {
@@ -1358,7 +1795,7 @@ class _MessagesScreenState extends State<MessagesScreen>
} }
String _messageKey(MeshMessage msg) { String _messageKey(MeshMessage msg) {
return '${msg.id}-${msg.rxIso}-${msg.fromId}-${msg.text}'; return MessageSeenTracker.messageKey(msg);
} }
void _scheduleScrollToBottom({int retries = 5}) { void _scheduleScrollToBottom({int retries = 5}) {
@@ -1388,6 +1825,35 @@ class _MessagesScreenState extends State<MessagesScreen>
} }
} }
Future<void> _notifyUnseenMessages(List<MeshMessage> 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<void> _startFetch( Future<void> _startFetch(
{bool clear = false, bool appendOnly = false}) async { {bool clear = false, bool appendOnly = false}) async {
final version = ++_fetchVersion; final version = ++_fetchVersion;
@@ -1404,6 +1870,7 @@ class _MessagesScreenState extends State<MessagesScreen>
final msgs = await future; final msgs = await future;
if (version != _fetchVersion) return; if (version != _fetchVersion) return;
_appendMessages(msgs); _appendMessages(msgs);
await _notifyUnseenMessages(msgs);
} catch (error) { } catch (error) {
if (appendOnly) { if (appendOnly) {
debugPrint('D/Failed to append messages: $error'); debugPrint('D/Failed to append messages: $error');

View File

@@ -97,6 +97,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.2" version: "1.0.2"
dbus:
dependency: transitive
description:
name: dbus
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -142,6 +150,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_native_splash:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -361,7 +401,7 @@ packages:
source: hosted source: hosted
version: "2.5.3" version: "2.5.3"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: "direct main"
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b" sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b"
@@ -369,7 +409,7 @@ packages:
source: hosted source: hosted
version: "2.4.17" version: "2.4.17"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: "direct main"
description: description:
name: shared_preferences_foundation name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
@@ -461,6 +501,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.7"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -597,6 +645,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.15.0" 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: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@@ -14,6 +14,10 @@ dependencies:
flutter_svg: ^2.0.10+1 flutter_svg: ^2.0.10+1
url_launcher: ^6.3.1 url_launcher: ^6.3.1
shared_preferences: ^2.3.2 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: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -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<void> initialize(Function dispatcher) async {
initialized = true;
this.dispatcher = dispatcher;
}
@override
Future<void> 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<void> cancelAll() async {}
}
class _FakeNotificationClient extends NotificationClient {
_FakeNotificationClient();
int calls = 0;
@override
Future<void> initialize() async {}
@override
Future<void> 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<MeshMessage> messages;
final List<MeshMessage> unseen;
int loadMessagesCalls = 0;
@override
Future<String> loadSelectedDomainOrDefault(
{String fallback = 'potatomesh.net'}) async {
return domain;
}
@override
Future<List<MeshMessage>> loadMessages({required String domain}) async {
loadMessagesCalls += 1;
return messages;
}
@override
Future<List<MeshMessage>> detectUnseenMessages({
required String domain,
required List<MeshMessage> 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);
});
}

View File

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

View File

@@ -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") 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") 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 "📍 Location Settings"
echo "-------------------" echo "-------------------"
read_with_default "Site Name (your mesh network name)" "$SITE_NAME" SITE_NAME 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." echo "or Bluetooth addresses when supported."
read_with_default "Connection target" "$CONNECTION" CONNECTION 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 ""
echo "🔐 Security Settings" echo "🔐 Security Settings"
echo "-------------------" echo "-------------------"