mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
app: implement notifications (#511)
* app: implement notifications * app: request permission for notifications
This commit is contained in:
33
.env.example
33
.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"
|
||||
|
||||
@@ -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
|
||||
|
||||
47
README.md
47
README.md
@@ -7,13 +7,14 @@
|
||||
[](https://github.com/l5yth/potato-mesh/issues)
|
||||
[](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:
|
||||
|
||||
* <https://github.com/l5yth/potato-mesh/discussions/258>
|
||||
|
||||
## 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:
|
||||
|
||||
* <https://github.com/l5yth/potato-mesh/discussions/258>
|
||||
|
||||
## License
|
||||
|
||||
|
||||
48
app/lib/dart_plugin_registrant.dart
Normal file
48
app/lib/dart_plugin_registrant.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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<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) {
|
||||
debugPrint('D/$message');
|
||||
@@ -65,8 +390,24 @@ Map<String, dynamic> _decodeJsonMapSync(String body) {
|
||||
return decoded;
|
||||
}
|
||||
|
||||
void main() {
|
||||
runApp(const PotatoMeshReaderApp());
|
||||
Future<void> 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<PotatoMeshReaderApp> createState() => _PotatoMeshReaderAppState();
|
||||
}
|
||||
@@ -149,6 +494,7 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
||||
late String _endpointDomain;
|
||||
int _endpointVersion = 0;
|
||||
late final MeshRepository _repository;
|
||||
late final NotificationClient _notificationClient;
|
||||
final GlobalKey<ScaffoldMessengerState> _messengerKey =
|
||||
GlobalKey<ScaffoldMessengerState>();
|
||||
BootstrapProgress _progress =
|
||||
@@ -163,6 +509,7 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
||||
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<PotatoMeshReaderApp> {
|
||||
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<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.
|
||||
@@ -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<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.
|
||||
Future<void> 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<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.
|
||||
Future<List<MeshNode>> loadNodes({required String domain}) async {
|
||||
await _ensureStore();
|
||||
@@ -1143,10 +1526,12 @@ class MeshRepository implements MeshNodeResolver {
|
||||
List<MeshMessage> _mergeMessages(String domain, List<MeshMessage> incoming) {
|
||||
final key = _domainKey(domain);
|
||||
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) {
|
||||
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<void> _hydrateMissingNodes({
|
||||
required String domain,
|
||||
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.
|
||||
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<MessagesScreen> createState() => _MessagesScreenState();
|
||||
}
|
||||
@@ -1272,10 +1701,14 @@ class _MessagesScreenState extends State<MessagesScreen>
|
||||
Timer? _refreshTimer;
|
||||
bool _isForeground = true;
|
||||
int _fetchVersion = 0;
|
||||
late final NotificationClient _notificationClient;
|
||||
late final Future<void> _notificationReady;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_notificationClient = widget.notificationClient;
|
||||
_notificationReady = _notificationClient.initialize();
|
||||
_messages = List<MeshMessage>.from(widget.initialMessages);
|
||||
_future = Future.value(_messages);
|
||||
_startFetch(clear: _messages.isEmpty);
|
||||
@@ -1291,6 +1724,10 @@ class _MessagesScreenState extends State<MessagesScreen>
|
||||
@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<MessagesScreen>
|
||||
}
|
||||
|
||||
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<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(
|
||||
{bool clear = false, bool appendOnly = false}) async {
|
||||
final version = ++_fetchVersion;
|
||||
@@ -1404,6 +1870,7 @@ class _MessagesScreenState extends State<MessagesScreen>
|
||||
final msgs = await future;
|
||||
if (version != _fetchVersion) return;
|
||||
_appendMessages(msgs);
|
||||
await _notifyUnseenMessages(msgs);
|
||||
} catch (error) {
|
||||
if (appendOnly) {
|
||||
debugPrint('D/Failed to append messages: $error');
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
170
app/test/background_sync_manager_test.dart
Normal file
170
app/test/background_sync_manager_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
119
app/test/message_seen_tracker_test.dart
Normal file
119
app/test/message_seen_tracker_test.dart
Normal 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),
|
||||
);
|
||||
});
|
||||
}
|
||||
14
configure.sh
14
configure.sh
@@ -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 "-------------------"
|
||||
|
||||
Reference in New Issue
Block a user