mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-18 00:16:26 +10:00
fix foreground service and add notification nav
wraps MaterialApp in WithForegroundService to keep alive when swiped away persists last connected device and clears on manual disconnect to allow reconnect after kill added lifecycle tracking to iOS and keep android notification alive with heartbeat add notification navigation change screen tests to be less brittle address PR commnets
This commit is contained in:
@@ -40,6 +40,7 @@ import '../storage/contact_settings_store.dart';
|
||||
import '../storage/contact_store.dart';
|
||||
import '../storage/message_store.dart';
|
||||
import '../storage/unread_store.dart';
|
||||
import '../storage/last_device_store.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/battery_utils.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
@@ -281,6 +282,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
final ContactDiscoveryStore _discoveryContactStore = ContactDiscoveryStore();
|
||||
final ChannelStore _channelStore = ChannelStore();
|
||||
final UnreadStore _unreadStore = UnreadStore();
|
||||
final LastDeviceStore _lastDeviceStore = LastDeviceStore();
|
||||
List<Channel> _cachedChannels = [];
|
||||
final Map<int, bool> _channelSmazEnabled = {};
|
||||
bool _lastSentWasCliCommand =
|
||||
@@ -768,6 +770,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_appDebugLogService = appDebugLogService;
|
||||
_backgroundService = backgroundService;
|
||||
_timeoutPredictionService = timeoutPredictionService;
|
||||
|
||||
// When the app resumes from background, check if we need to reconnect.
|
||||
_backgroundService?.onResume = _onAppResumed;
|
||||
|
||||
_usbManager.setDebugLogService(_appDebugLogService);
|
||||
_tcpConnector.setDebugLogService(_appDebugLogService);
|
||||
|
||||
@@ -1879,6 +1885,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
);
|
||||
|
||||
_setState(MeshCoreConnectionState.connected);
|
||||
_lastDeviceStore.persistLastDevice(_deviceId!, _deviceDisplayName!);
|
||||
if (_shouldGateInitialChannelSync) {
|
||||
_hasReceivedDeviceInfo = false;
|
||||
_pendingInitialChannelSync = true;
|
||||
@@ -2225,6 +2232,56 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
/// Called by [BackgroundService] when the app returns to the foreground.
|
||||
/// If the BLE connection was lost while backgrounded, this kicks off an
|
||||
/// immediate reconnect attempt instead of waiting for the next timer tick.
|
||||
void _onAppResumed() {
|
||||
if (_shouldAutoReconnect &&
|
||||
_state != MeshCoreConnectionState.connected &&
|
||||
_state != MeshCoreConnectionState.connecting) {
|
||||
_appDebugLogService?.info(
|
||||
'App resumed – triggering reconnect check',
|
||||
tag: 'Lifecycle',
|
||||
);
|
||||
_cancelReconnectTimer();
|
||||
_scheduleReconnect();
|
||||
} else if (_state == MeshCoreConnectionState.disconnected &&
|
||||
_lastDeviceId == null) {
|
||||
// App was fully restarted (swiped away). Try to restore from prefs.
|
||||
tryAutoReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to reconnect to the last persisted BLE device.
|
||||
///
|
||||
/// Called on fresh app start (after a swipe-away kill) so the user is
|
||||
/// brought straight back to the connected state instead of the scan screen.
|
||||
Future<bool> tryAutoReconnect() async {
|
||||
if (_state == MeshCoreConnectionState.connecting ||
|
||||
_state == MeshCoreConnectionState.connected) {
|
||||
return false;
|
||||
}
|
||||
final deviceId = _lastDeviceStore.getPersistedDeviceId();
|
||||
if (deviceId!.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final displayName = _lastDeviceStore.getPersistedDeviceName();
|
||||
_appDebugLogService?.info(
|
||||
'Auto-reconnecting to $deviceId ($displayName)',
|
||||
tag: 'Lifecycle',
|
||||
);
|
||||
|
||||
try {
|
||||
final device = BluetoothDevice.fromId(deviceId);
|
||||
await connect(device, displayName: displayName);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_appDebugLogService?.error('Auto-reconnect failed: $e', tag: 'Lifecycle');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect({
|
||||
bool manual = true,
|
||||
bool skipBleDeviceDisconnect = false,
|
||||
@@ -2245,6 +2302,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
if (manual) {
|
||||
_manualDisconnect = true;
|
||||
_cancelReconnectTimer();
|
||||
_lastDeviceStore.clearPersistedDevice();
|
||||
_notificationService.cancelAll();
|
||||
unawaited(_backgroundService?.stop());
|
||||
} else {
|
||||
_manualDisconnect = false;
|
||||
@@ -4910,6 +4969,17 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// Public accessor to find a channel by its index.
|
||||
Channel? findChannelByIndex(int index) => _findChannelByIndex(index);
|
||||
|
||||
/// Find a contact by its public key hex string.
|
||||
Contact? findContactByKeyHex(String keyHex) {
|
||||
return _contacts.cast<Contact?>().firstWhere(
|
||||
(c) => c?.publicKeyHex == keyHex,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
void _maybeIncrementChannelUnread(
|
||||
ChannelMessage message, {
|
||||
required bool isNew,
|
||||
|
||||
+126
-51
@@ -1,10 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'screens/channel_chat_screen.dart';
|
||||
import 'screens/chat_screen.dart';
|
||||
import 'screens/chrome_required_screen.dart';
|
||||
import 'screens/discovery_screen.dart';
|
||||
import 'utils/platform_info.dart';
|
||||
|
||||
import 'connector/meshcore_connector.dart';
|
||||
@@ -125,7 +131,7 @@ https://creativecommons.org/licenses/by/4.0/
|
||||
});
|
||||
}
|
||||
|
||||
class MeshCoreApp extends StatelessWidget {
|
||||
class MeshCoreApp extends StatefulWidget {
|
||||
final MeshCoreConnector connector;
|
||||
final MessageRetryService retryService;
|
||||
final PathHistoryService pathHistoryService;
|
||||
@@ -155,67 +161,136 @@ class MeshCoreApp extends StatelessWidget {
|
||||
required this.timeoutPredictionService,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MeshCoreApp> createState() => _MeshCoreAppState();
|
||||
}
|
||||
|
||||
class _MeshCoreAppState extends State<MeshCoreApp> {
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
StreamSubscription<NotificationTapEvent>? _notificationTapSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_notificationTapSubscription = NotificationService().onNotificationTapped
|
||||
.listen(_handleNotificationTap);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notificationTapSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleNotificationTap(NotificationTapEvent event) {
|
||||
final navigator = _navigatorKey.currentState;
|
||||
if (navigator == null) return;
|
||||
|
||||
switch (event.type) {
|
||||
case NotificationTapEventType.message:
|
||||
if (event.id == null) return;
|
||||
final contact = widget.connector.findContactByKeyHex(event.id!);
|
||||
if (contact == null) return;
|
||||
widget.connector.markContactRead(contact.publicKeyHex);
|
||||
navigator.push(
|
||||
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
|
||||
);
|
||||
break;
|
||||
case NotificationTapEventType.channel:
|
||||
if (event.id == null) return;
|
||||
final channelIndex = int.tryParse(event.id!);
|
||||
if (channelIndex == null) return;
|
||||
final channel = widget.connector.findChannelByIndex(channelIndex);
|
||||
if (channel == null) return;
|
||||
widget.connector.markChannelRead(channelIndex);
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChannelChatScreen(channel: channel),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case NotificationTapEventType.advert:
|
||||
// Clear every advert notification — the discovery
|
||||
// list the user is about to see contains them all.
|
||||
NotificationService().clearAllAdvertNotifications();
|
||||
final ids = widget.connector.allContacts
|
||||
.map((c) => c.publicKeyHex)
|
||||
.toList();
|
||||
NotificationService().clearAdvertNotifications(ids);
|
||||
navigator.push(
|
||||
MaterialPageRoute(builder: (_) => const DiscoveryScreen()),
|
||||
);
|
||||
break;
|
||||
case NotificationTapEventType.batch:
|
||||
// Batch summaries have no single target; no-op.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: connector),
|
||||
ChangeNotifierProvider.value(value: retryService),
|
||||
ChangeNotifierProvider.value(value: pathHistoryService),
|
||||
ChangeNotifierProvider.value(value: appSettingsService),
|
||||
ChangeNotifierProvider.value(value: bleDebugLogService),
|
||||
ChangeNotifierProvider.value(value: appDebugLogService),
|
||||
ChangeNotifierProvider.value(value: chatTextScaleService),
|
||||
ChangeNotifierProvider.value(value: translationService),
|
||||
ChangeNotifierProvider.value(value: uiViewStateService),
|
||||
Provider.value(value: storage),
|
||||
Provider.value(value: mapTileCacheService),
|
||||
ChangeNotifierProvider.value(value: timeoutPredictionService),
|
||||
ChangeNotifierProvider.value(value: widget.connector),
|
||||
ChangeNotifierProvider.value(value: widget.retryService),
|
||||
ChangeNotifierProvider.value(value: widget.pathHistoryService),
|
||||
ChangeNotifierProvider.value(value: widget.appSettingsService),
|
||||
ChangeNotifierProvider.value(value: widget.bleDebugLogService),
|
||||
ChangeNotifierProvider.value(value: widget.appDebugLogService),
|
||||
ChangeNotifierProvider.value(value: widget.chatTextScaleService),
|
||||
ChangeNotifierProvider.value(value: widget.translationService),
|
||||
ChangeNotifierProvider.value(value: widget.uiViewStateService),
|
||||
Provider.value(value: widget.storage),
|
||||
Provider.value(value: widget.mapTileCacheService),
|
||||
ChangeNotifierProvider.value(value: widget.timeoutPredictionService),
|
||||
],
|
||||
child: Consumer<AppSettingsService>(
|
||||
builder: (context, settingsService, child) {
|
||||
return MaterialApp(
|
||||
title: 'MeshCore Open',
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: _localeFromSetting(
|
||||
settingsService.settings.languageOverride,
|
||||
),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||
useMaterial3: true,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
return WithForegroundTask(
|
||||
child: MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
title: 'MeshCore Open',
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: _localeFromSetting(
|
||||
settingsService.settings.languageOverride,
|
||||
),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: Brightness.dark,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||
useMaterial3: true,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
),
|
||||
useMaterial3: true,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
),
|
||||
themeMode: _themeModeFromSetting(
|
||||
settingsService.settings.themeMode,
|
||||
),
|
||||
builder: (context, child) {
|
||||
// Update notification service with resolved locale
|
||||
final locale = Localizations.localeOf(context);
|
||||
NotificationService().setLocale(locale);
|
||||
return child ?? const SizedBox.shrink();
|
||||
},
|
||||
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
|
||||
? const ChromeRequiredScreen()
|
||||
: const ScannerScreen(),
|
||||
),
|
||||
themeMode: _themeModeFromSetting(
|
||||
settingsService.settings.themeMode,
|
||||
),
|
||||
builder: (context, child) {
|
||||
// Update notification service with resolved locale
|
||||
final locale = Localizations.localeOf(context);
|
||||
NotificationService().setLocale(locale);
|
||||
return child ?? const SizedBox.shrink();
|
||||
},
|
||||
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
|
||||
? const ChromeRequiredScreen()
|
||||
: const ScannerScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
@@ -31,6 +32,20 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
DiscoverySortOption discoverySortOption = DiscoverySortOption.lastSeen;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_clearAdvertNotifications();
|
||||
}
|
||||
|
||||
void _clearAdvertNotifications() {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final ids = connector.allContacts.map((c) => c.publicKeyHex).toList();
|
||||
final ns = NotificationService();
|
||||
ns.clearAllAdvertNotifications();
|
||||
ns.clearAdvertNotifications(ids);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/linux_ble_error_classifier.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/device_tile.dart';
|
||||
@@ -43,6 +44,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
isCurrentRoute &&
|
||||
!_changedNavigation) {
|
||||
_changedNavigation = true;
|
||||
// Prompt for notification permission on first
|
||||
// connect so notifications work out of the box
|
||||
// on Android 13+.
|
||||
NotificationService().requestPermissions();
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const ContactsScreen()),
|
||||
@@ -53,6 +58,12 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
|
||||
_connector.addListener(_connectionListener);
|
||||
|
||||
// If the app was killed (swipe-away) and relaunched, try to reconnect
|
||||
// to the last known device so the user doesn't have to scan again.
|
||||
if (_connector.state == MeshCoreConnectionState.disconnected) {
|
||||
_connector.tryAutoReconnect();
|
||||
}
|
||||
|
||||
_bluetoothStateSubscription = FlutterBluePlus.adapterState.listen(
|
||||
(state) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -1,54 +1,121 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
|
||||
class BackgroundService {
|
||||
/// Manages a foreground service (Android) and app lifecycle awareness
|
||||
/// (Android + iOS) to keep the BLE connection alive when the app is
|
||||
/// backgrounded or swiped away from the recents drawer.
|
||||
class BackgroundService with WidgetsBindingObserver {
|
||||
bool _initialized = false;
|
||||
bool _serviceRunning = false;
|
||||
|
||||
/// Optional callback invoked when the OS resumes the app after it was
|
||||
/// paused or detached. The connector hooks this to trigger a reconnect
|
||||
/// check so the BLE link is restored promptly.
|
||||
VoidCallback? onResume;
|
||||
|
||||
/// Optional callback invoked when the app is about to be suspended.
|
||||
/// The connector can use this to persist critical state.
|
||||
VoidCallback? onPause;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (!PlatformInfo.isAndroid || _initialized) return;
|
||||
FlutterForegroundTask.init(
|
||||
androidNotificationOptions: AndroidNotificationOptions(
|
||||
channelId: 'meshcore_background',
|
||||
channelName: 'MeshCore Background',
|
||||
channelDescription: 'Keeps MeshCore running in the background.',
|
||||
channelImportance: NotificationChannelImportance.LOW,
|
||||
priority: NotificationPriority.LOW,
|
||||
),
|
||||
iosNotificationOptions: const IOSNotificationOptions(
|
||||
showNotification: false,
|
||||
playSound: false,
|
||||
),
|
||||
foregroundTaskOptions: ForegroundTaskOptions(
|
||||
eventAction: ForegroundTaskEventAction.repeat(5000),
|
||||
autoRunOnBoot: false,
|
||||
allowWifiLock: false,
|
||||
),
|
||||
);
|
||||
if (_initialized) return;
|
||||
|
||||
// Register for app lifecycle events on all mobile platforms.
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
if (PlatformInfo.isAndroid) {
|
||||
FlutterForegroundTask.init(
|
||||
androidNotificationOptions: AndroidNotificationOptions(
|
||||
channelId: 'meshcore_background',
|
||||
channelName: 'MeshCore Background',
|
||||
channelDescription: 'Keeps MeshCore running in the background.',
|
||||
channelImportance: NotificationChannelImportance.LOW,
|
||||
priority: NotificationPriority.LOW,
|
||||
),
|
||||
iosNotificationOptions: const IOSNotificationOptions(
|
||||
showNotification: false,
|
||||
playSound: false,
|
||||
),
|
||||
foregroundTaskOptions: ForegroundTaskOptions(
|
||||
eventAction: ForegroundTaskEventAction.repeat(5000),
|
||||
autoRunOnBoot: false,
|
||||
allowWifiLock: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
if (!PlatformInfo.isAndroid) return;
|
||||
if (!PlatformInfo.isMobile) return;
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
final running = await FlutterForegroundTask.isRunningService;
|
||||
if (running) return;
|
||||
await FlutterForegroundTask.startService(
|
||||
notificationTitle: 'MeshCore running',
|
||||
notificationText: 'Keeping BLE connected',
|
||||
callback: startCallback,
|
||||
);
|
||||
|
||||
// Android: start the foreground service so the OS keeps the process alive
|
||||
// even when the user swipes the app away.
|
||||
if (PlatformInfo.isAndroid) {
|
||||
final running = await FlutterForegroundTask.isRunningService;
|
||||
if (!running) {
|
||||
await FlutterForegroundTask.startService(
|
||||
notificationTitle: 'MeshCore running',
|
||||
notificationText: 'Keeping BLE connected',
|
||||
callback: startCallback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// iOS: the bluetooth-central UIBackgroundMode (Info.plist) combined with
|
||||
// CoreBluetooth state restoration (handled by flutter_blue_plus) keeps the
|
||||
// BLE connection alive. No additional service is needed, but we track
|
||||
// the logical "running" state so callers behave consistently.
|
||||
_serviceRunning = true;
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
if (!PlatformInfo.isAndroid) return;
|
||||
final running = await FlutterForegroundTask.isRunningService;
|
||||
if (!running) return;
|
||||
await FlutterForegroundTask.stopService();
|
||||
if (!PlatformInfo.isMobile) return;
|
||||
|
||||
if (PlatformInfo.isAndroid) {
|
||||
final running = await FlutterForegroundTask.isRunningService;
|
||||
if (running) {
|
||||
await FlutterForegroundTask.stopService();
|
||||
}
|
||||
}
|
||||
_serviceRunning = false;
|
||||
}
|
||||
|
||||
bool get isRunning => _serviceRunning;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WidgetsBindingObserver – app lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
onResume?.call();
|
||||
break;
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
onPause?.call();
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.hidden:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Foreground-service isolate entry point (Android)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void startCallback() {
|
||||
FlutterForegroundTask.setTaskHandler(_MeshCoreTaskHandler());
|
||||
@@ -56,10 +123,25 @@ void startCallback() {
|
||||
|
||||
class _MeshCoreTaskHandler extends TaskHandler {
|
||||
@override
|
||||
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {}
|
||||
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {
|
||||
// The handler runs in a separate isolate. Its purpose is to keep the
|
||||
// foreground-service notification alive so that Android does not kill
|
||||
// the main isolate (where the BLE connection lives).
|
||||
//
|
||||
// Heavy BLE work stays in the main isolate; we just need the service
|
||||
// to exist.
|
||||
}
|
||||
|
||||
@override
|
||||
void onRepeatEvent(DateTime timestamp) {}
|
||||
void onRepeatEvent(DateTime timestamp) {
|
||||
// Periodically update the notification so the system considers the
|
||||
// service active. This also acts as a heartbeat.
|
||||
FlutterForegroundTask.updateService(
|
||||
notificationTitle: 'MeshCore running',
|
||||
notificationText:
|
||||
'Connected · ${timestamp.toLocal().hour.toString().padLeft(2, '0')}:${timestamp.toLocal().minute.toString().padLeft(2, '0')}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform, File;
|
||||
import 'dart:ui';
|
||||
|
||||
@@ -8,6 +9,21 @@ import '../helpers/reaction_helper.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
|
||||
enum NotificationTapEventType { message, channel, advert, batch }
|
||||
|
||||
/// Payload emitted when the user taps a notification.
|
||||
class NotificationTapEvent {
|
||||
// The type of notification tap event [NotificationTapEventType]
|
||||
final NotificationTapEventType type;
|
||||
|
||||
/// For messages: the contact public key hex.
|
||||
/// For channels: the channel index as a string.
|
||||
/// For adverts: the contact public key hex.
|
||||
final String? id;
|
||||
|
||||
const NotificationTapEvent({required this.type, this.id});
|
||||
}
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
@@ -17,6 +33,15 @@ class NotificationService {
|
||||
FlutterLocalNotificationsPlugin();
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// Stream of notification tap events for navigation handling.
|
||||
final StreamController<NotificationTapEvent> _tapController =
|
||||
StreamController<NotificationTapEvent>.broadcast();
|
||||
|
||||
/// Listen to this stream to handle navigation when a notification
|
||||
/// is tapped.
|
||||
Stream<NotificationTapEvent> get onNotificationTapped =>
|
||||
_tapController.stream;
|
||||
|
||||
// Locale for localized notification strings
|
||||
Locale _locale = const Locale('en');
|
||||
|
||||
@@ -167,6 +192,10 @@ class NotificationService {
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
|
||||
// Group per contact so each conversation is collapsible
|
||||
// independently in the notification shade.
|
||||
final groupKey = contactId != null ? 'msg_$contactId' : 'meshcore_messages';
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'messages',
|
||||
'Messages',
|
||||
@@ -175,6 +204,7 @@ class NotificationService {
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
number: badgeCount,
|
||||
groupKey: groupKey,
|
||||
);
|
||||
|
||||
final iosDetails = DarwinNotificationDetails(
|
||||
@@ -205,6 +235,13 @@ class NotificationService {
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'message:$contactId',
|
||||
);
|
||||
await _postGroupSummary(
|
||||
groupKey: groupKey,
|
||||
channelId: 'messages',
|
||||
channelName: 'Messages',
|
||||
title: contactName,
|
||||
payload: 'message:$contactId',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to show message notification: $e');
|
||||
}
|
||||
@@ -217,6 +254,8 @@ class NotificationService {
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
|
||||
const groupKey = 'meshcore_adverts';
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'adverts',
|
||||
'Advertisements',
|
||||
@@ -224,6 +263,7 @@ class NotificationService {
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
groupKey: groupKey,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
@@ -254,6 +294,15 @@ class NotificationService {
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'advert:$contactId',
|
||||
);
|
||||
await _postGroupSummary(
|
||||
groupKey: groupKey,
|
||||
channelId: 'adverts',
|
||||
channelName: 'Advertisements',
|
||||
title: _l10n.notification_activityTitle,
|
||||
payload: 'advert:',
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to show advert notification: $e');
|
||||
}
|
||||
@@ -267,6 +316,12 @@ class NotificationService {
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
|
||||
// Group per channel so each channel is collapsible
|
||||
// independently in the notification shade.
|
||||
final groupKey = channelIndex != null
|
||||
? 'ch_$channelIndex'
|
||||
: 'meshcore_channels';
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'channel_messages',
|
||||
'Channel Messages',
|
||||
@@ -275,6 +330,7 @@ class NotificationService {
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
number: badgeCount,
|
||||
groupKey: groupKey,
|
||||
);
|
||||
|
||||
final iosDetails = DarwinNotificationDetails(
|
||||
@@ -310,11 +366,70 @@ class NotificationService {
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'channel:$channelIndex',
|
||||
);
|
||||
await _postGroupSummary(
|
||||
groupKey: groupKey,
|
||||
channelId: 'channel_messages',
|
||||
channelName: 'Channel Messages',
|
||||
title: channelName,
|
||||
payload: 'channel:$channelIndex',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to show channel notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Android group summary helper
|
||||
// ---------------------------------------------------------------
|
||||
// Android requires a notification with setAsGroupSummary for
|
||||
// each groupKey. This is what the user sees (and taps) when
|
||||
// the OS collapses individual notifications in a group.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// Post (or replace) the group summary notification for
|
||||
/// [groupKey]. The summary's [payload] controls where tapping
|
||||
/// the collapsed group navigates.
|
||||
Future<void> _postGroupSummary({
|
||||
required String groupKey,
|
||||
required String channelId,
|
||||
required String channelName,
|
||||
required String title,
|
||||
required String payload,
|
||||
Importance importance = Importance.high,
|
||||
Priority priority = Priority.high,
|
||||
}) async {
|
||||
if (!PlatformInfo.isAndroid) return;
|
||||
|
||||
final details = AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
importance: importance,
|
||||
priority: priority,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
groupKey: groupKey,
|
||||
setAsGroupSummary: true,
|
||||
);
|
||||
|
||||
// Use a stable ID derived from the groupKey so each
|
||||
// group's summary replaces itself, never duplicates.
|
||||
final summaryId = 'summary:$groupKey'.hashCode;
|
||||
|
||||
try {
|
||||
await _notifications.show(
|
||||
id: summaryId,
|
||||
title: title,
|
||||
body: null,
|
||||
notificationDetails: NotificationDetails(android: details),
|
||||
payload: payload,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Failed to post group summary '
|
||||
'($groupKey): $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a privacy-safe identifier for debug logging.
|
||||
/// - advert: shows device name (body contains contactName)
|
||||
/// - message: shows "from: sender" (avoids logging message content)
|
||||
@@ -332,14 +447,42 @@ class NotificationService {
|
||||
|
||||
void _onNotificationTapped(NotificationResponse response) {
|
||||
final payload = response.payload;
|
||||
if (payload != null) {
|
||||
debugPrint('Notification tapped: $payload');
|
||||
// Handle navigation based on payload
|
||||
// This can be extended to navigate to specific screens
|
||||
if (payload == null) return;
|
||||
debugPrint('Notification tapped: $payload');
|
||||
|
||||
if (payload.startsWith('message:')) {
|
||||
final contactId = payload.substring('message:'.length);
|
||||
_tapController.add(
|
||||
NotificationTapEvent(
|
||||
type: NotificationTapEventType.message,
|
||||
id: contactId,
|
||||
),
|
||||
);
|
||||
} else if (payload.startsWith('channel:')) {
|
||||
final channelIndex = payload.substring('channel:'.length);
|
||||
_tapController.add(
|
||||
NotificationTapEvent(
|
||||
type: NotificationTapEventType.channel,
|
||||
id: channelIndex,
|
||||
),
|
||||
);
|
||||
} else if (payload.startsWith('advert:')) {
|
||||
final contactId = payload.substring('advert:'.length);
|
||||
_tapController.add(
|
||||
NotificationTapEvent(
|
||||
type: NotificationTapEventType.advert,
|
||||
id: contactId,
|
||||
),
|
||||
);
|
||||
} else if (payload == 'batch') {
|
||||
_tapController.add(
|
||||
const NotificationTapEvent(type: NotificationTapEventType.batch),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelAll() async {
|
||||
_pendingNotifications.clear();
|
||||
await _notifications.cancelAll();
|
||||
}
|
||||
|
||||
@@ -352,6 +495,11 @@ class NotificationService {
|
||||
String contactId,
|
||||
int totalUnreadCount,
|
||||
) async {
|
||||
// Purge any queued notifications for this contact so the batch timer
|
||||
// doesn't re-post a notification the user has already seen.
|
||||
_pendingNotifications.removeWhere(
|
||||
(n) => n.type == _NotificationType.message && n.id == contactId,
|
||||
);
|
||||
if (!await _ensureInitialized()) return;
|
||||
await _notifications.cancel(id: contactId.hashCode);
|
||||
await _updateBadge(totalUnreadCount);
|
||||
@@ -362,6 +510,13 @@ class NotificationService {
|
||||
int channelIndex,
|
||||
int totalUnreadCount,
|
||||
) async {
|
||||
// Purge any queued notifications for this channel so the batch timer
|
||||
// doesn't re-post a notification the user has already seen.
|
||||
_pendingNotifications.removeWhere(
|
||||
(n) =>
|
||||
n.type == _NotificationType.channelMessage &&
|
||||
n.id == channelIndex.toString(),
|
||||
);
|
||||
if (!await _ensureInitialized()) return;
|
||||
await _notifications.cancel(id: channelIndex.hashCode);
|
||||
await _updateBadge(totalUnreadCount);
|
||||
@@ -375,6 +530,21 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel every advert notification including the group
|
||||
/// summary. Called when the user opens the discovery list
|
||||
/// (which shows all discovered nodes anyway).
|
||||
Future<void> clearAllAdvertNotifications() async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
// Cancel the group summary.
|
||||
final summaryId = 'summary:meshcore_adverts'.hashCode;
|
||||
await _notifications.cancel(id: summaryId);
|
||||
// Individual adverts are cancelled by the OS when their
|
||||
// group summary is removed, but on some OEMs we need to
|
||||
// cancel them explicitly. We don't track IDs, so the
|
||||
// caller should also pass known IDs through
|
||||
// clearAdvertNotifications() when available.
|
||||
}
|
||||
|
||||
Future<void> _updateBadge(int count) async {
|
||||
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
|
||||
// On Apple platforms, set the badge number directly via a silent update.
|
||||
@@ -545,7 +715,13 @@ class NotificationService {
|
||||
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
|
||||
// Group by type
|
||||
// Show each notification individually — the Android
|
||||
// groupKey on each type will cluster them automatically.
|
||||
for (final notification in batch) {
|
||||
await _showNotificationImmediately(notification);
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
final messages = batch
|
||||
.where((n) => n.type == _NotificationType.message)
|
||||
.toList();
|
||||
@@ -556,48 +732,20 @@ class NotificationService {
|
||||
.where((n) => n.type == _NotificationType.channelMessage)
|
||||
.toList();
|
||||
|
||||
// Build summary text using localized plurals
|
||||
final parts = <String>[];
|
||||
if (messages.isNotEmpty) {
|
||||
parts.add(_l10n.notification_messagesCount(messages.length));
|
||||
parts.add('${messages.length} messages');
|
||||
}
|
||||
if (channelMsgs.isNotEmpty) {
|
||||
parts.add(_l10n.notification_channelMessagesCount(channelMsgs.length));
|
||||
parts.add('${channelMsgs.length} channel msgs');
|
||||
}
|
||||
if (adverts.isNotEmpty) {
|
||||
parts.add(_l10n.notification_newNodesCount(adverts.length));
|
||||
parts.add('${adverts.length} adverts');
|
||||
}
|
||||
|
||||
if (parts.isEmpty) return;
|
||||
|
||||
// Show first few device names in batch summary for debugging (only if adverts exist)
|
||||
final deviceInfo = adverts.isNotEmpty
|
||||
? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})'
|
||||
: '';
|
||||
debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo');
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'batch_summary',
|
||||
'Activity Summary',
|
||||
channelDescription: 'Batched notification summaries',
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
debugPrint(
|
||||
'[Notification] batch dispatched: '
|
||||
'${parts.join(", ")}',
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(android: androidDetails);
|
||||
|
||||
try {
|
||||
await _notifications.show(
|
||||
id: 'batch_summary'.hashCode,
|
||||
title: _l10n.notification_activityTitle,
|
||||
body: parts.join(', '),
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'batch',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to show batch summary notification: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class LastDeviceStore {
|
||||
static const _prefKeyLastDeviceId = 'bg_last_device_id';
|
||||
static const _prefKeyLastDeviceName = 'bg_last_device_name';
|
||||
|
||||
Future<void> persistLastDevice(
|
||||
String deviceId,
|
||||
String deviceDisplayName,
|
||||
) async {
|
||||
final prefs = PrefsManager.instance;
|
||||
await prefs.setString(_prefKeyLastDeviceId, deviceId);
|
||||
await prefs.setString(_prefKeyLastDeviceName, deviceDisplayName);
|
||||
}
|
||||
|
||||
String? getPersistedDeviceId() {
|
||||
final prefs = PrefsManager.instance;
|
||||
final deviceId = prefs.getString(_prefKeyLastDeviceId);
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
String? getPersistedDeviceName() {
|
||||
final prefs = PrefsManager.instance;
|
||||
final displayName = prefs.getString(_prefKeyLastDeviceName);
|
||||
return displayName;
|
||||
}
|
||||
|
||||
Future<void> clearPersistedDevice() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
await prefs.remove(_prefKeyLastDeviceId);
|
||||
await prefs.remove(_prefKeyLastDeviceName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user