forked from iarv/potato-mesh
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
|
# 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
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -7,13 +7,14 @@
|
|||||||
[](https://github.com/l5yth/potato-mesh/issues)
|
[](https://github.com/l5yth/potato-mesh/issues)
|
||||||
[](https://matrix.to/#/#potatomesh:dod.ngo)
|
[](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.
|
||||||
|
* 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.
|
* Supplemental Python ingestor to feed the POST APIs of the Web app with data remotely.
|
||||||
* Shows new node notifications (first seen) in chat.
|
* Mobile app to _read_ messages on your local aether (no radio required).
|
||||||
* Allows searching and filtering for nodes in map and table view.
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
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: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');
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
20
configure.sh
20
configure.sh
@@ -41,14 +41,14 @@ read_with_default() {
|
|||||||
local prompt="$1"
|
local prompt="$1"
|
||||||
local default="$2"
|
local default="$2"
|
||||||
local var_name="$3"
|
local var_name="$3"
|
||||||
|
|
||||||
if [ -n "$default" ]; then
|
if [ -n "$default" ]; then
|
||||||
read -p "$prompt [$default]: " input
|
read -p "$prompt [$default]: " input
|
||||||
input=${input:-$default}
|
input=${input:-$default}
|
||||||
else
|
else
|
||||||
read -p "$prompt: " input
|
read -p "$prompt: " input
|
||||||
fi
|
fi
|
||||||
|
|
||||||
eval "$var_name='$input'"
|
eval "$var_name='$input'"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +87,13 @@ INSTANCE_DOMAIN=$(grep "^INSTANCE_DOMAIN=" .env 2>/dev/null | cut -d'=' -f2- | t
|
|||||||
DEBUG=$(grep "^DEBUG=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
|
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 "-------------------"
|
||||||
@@ -238,7 +238,7 @@ echo " Max Distance: ${MAX_DISTANCE}km"
|
|||||||
echo " Channel: $CHANNEL"
|
echo " Channel: $CHANNEL"
|
||||||
echo " Frequency: $FREQUENCY"
|
echo " Frequency: $FREQUENCY"
|
||||||
echo " Chat: ${CONTACT_LINK:-'Not set'}"
|
echo " Chat: ${CONTACT_LINK:-'Not set'}"
|
||||||
echo " Debug Logging: ${DEBUG}"
|
echo " Debug Logging: ${DEBUG}"
|
||||||
echo " Connection: ${CONNECTION}"
|
echo " Connection: ${CONNECTION}"
|
||||||
echo " API Token: ${API_TOKEN:0:8}..."
|
echo " API Token: ${API_TOKEN:0:8}..."
|
||||||
echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"
|
echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"
|
||||||
|
|||||||
Reference in New Issue
Block a user