implemented an E2E-encrypted Nextcloud push-v2 notification system with support for RSA decryption and signature verification; introduced an iOS Notification Service Extension and native AppDelegate handlers for Talk actions (inline reply and mark-as-read); replaced the legacy notification registration with a new lifecycle managing app passwords and secure keypair storage; added background message handling with tray synchronization and a test notification utility in the settings.
This commit is contained in:
@@ -4,44 +4,36 @@ import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../push/push_message_handler.dart';
|
||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../widget/debug/debug_tile.dart';
|
||||
import '../widget/debug/json_viewer.dart';
|
||||
import '../widget/info_dialog.dart';
|
||||
import 'notification_tasks.dart';
|
||||
|
||||
// `vm:entry-point` keeps this alive through AOT tree-shaking — the FCM
|
||||
// background isolate looks the class up by name from native code.
|
||||
@pragma('vm:entry-point')
|
||||
/// Bridges FCM lifecycle callbacks to the push pipeline. Background messages are
|
||||
/// handled directly by [PushMessageHandler.onBackgroundMessage]; this class
|
||||
/// covers the foreground and app-opened paths where a [BuildContext] is
|
||||
/// available.
|
||||
class NotificationController {
|
||||
@pragma('vm:entry-point')
|
||||
static Future<void> onBackgroundMessageHandler(RemoteMessage message) async {
|
||||
NotificationTasks.updateBadgeCount(message);
|
||||
}
|
||||
|
||||
static Future<void> onForegroundMessageHandler(
|
||||
RemoteMessage message,
|
||||
BuildContext context,
|
||||
) async {
|
||||
final pushToken = _extractChatToken(message);
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
// hasOpenChat, not currentToken: currentToken sticks around after
|
||||
// leaveChat so didPopNext can re-claim a stacked chat.
|
||||
final activeToken = chatBloc.state.data?.currentToken ?? '';
|
||||
final chatIsOpen =
|
||||
chatBloc.hasOpenChat &&
|
||||
pushToken != null &&
|
||||
pushToken.isNotEmpty &&
|
||||
pushToken == activeToken;
|
||||
|
||||
NotificationTasks.updateBadgeCount(message);
|
||||
|
||||
if (chatIsOpen) {
|
||||
// Long-poll handles the message; just dismiss any stray tray entry.
|
||||
unawaited(NotificationTasks.clearNotificationsForChat(pushToken));
|
||||
return;
|
||||
}
|
||||
final openChatToken = chatBloc.hasOpenChat
|
||||
? (chatBloc.state.data?.currentToken ?? '')
|
||||
: null;
|
||||
|
||||
await PushMessageHandler().handle(
|
||||
message,
|
||||
foreground: true,
|
||||
openChatToken: openChatToken,
|
||||
);
|
||||
await NotificationTasks.refreshBadge();
|
||||
if (!context.mounted) return;
|
||||
NotificationTasks.updateProviders(context);
|
||||
}
|
||||
|
||||
@@ -54,6 +46,7 @@ class NotificationController {
|
||||
chatToken: _extractChatToken(message),
|
||||
);
|
||||
NotificationTasks.updateProviders(context);
|
||||
unawaited(NotificationTasks.refreshBadge());
|
||||
|
||||
DebugTile(context).run(() {
|
||||
InfoDialog.show(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../push/push_actions.dart';
|
||||
import '../push/push_renderer.dart';
|
||||
import '../push/push_tap_router.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
|
||||
@@ -15,7 +19,27 @@ class NotificationService {
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
final iosSettings = DarwinInitializationSettings();
|
||||
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
|
||||
// so both platforms expose the same quick actions. The actual delivery of
|
||||
// these while the app is terminated is handled by the (Phase 3) NSE.
|
||||
final iosSettings = DarwinInitializationSettings(
|
||||
notificationCategories: [
|
||||
DarwinNotificationCategory(
|
||||
PushRenderer.iosTalkCategory,
|
||||
actions: [
|
||||
DarwinNotificationAction.text(
|
||||
kTalkReplyActionId,
|
||||
'Antworten',
|
||||
buttonTitle: 'Senden',
|
||||
options: const {
|
||||
DarwinNotificationActionOption.authenticationRequired,
|
||||
},
|
||||
),
|
||||
DarwinNotificationAction.plain(kTalkMarkReadActionId, 'Gelesen'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final initializationSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
@@ -24,34 +48,9 @@ class NotificationService {
|
||||
|
||||
await flutterLocalNotificationsPlugin.initialize(
|
||||
settings: initializationSettings,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
required int badgeCount,
|
||||
}) async {
|
||||
const androidPlatformChannelSpecifics = AndroidNotificationDetails(
|
||||
'marmobile',
|
||||
'Marianum Fulda',
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
ticker: 'Marianum Fulda',
|
||||
);
|
||||
|
||||
const iosPlatformChannelSpecifics = DarwinNotificationDetails();
|
||||
|
||||
const platformChannelSpecifics = NotificationDetails(
|
||||
android: androidPlatformChannelSpecifics,
|
||||
iOS: iosPlatformChannelSpecifics,
|
||||
);
|
||||
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
id: 0,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: platformChannelSpecifics,
|
||||
onDidReceiveNotificationResponse: PushTapRouter.handleResponse,
|
||||
onDidReceiveBackgroundNotificationResponse:
|
||||
PushActions.handleBackgroundResponse,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eraser/eraser.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||
@@ -12,10 +11,18 @@ import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import 'notification_service.dart';
|
||||
|
||||
class NotificationTasks {
|
||||
static void updateBadgeCount(RemoteMessage notification) {
|
||||
FlutterAppBadge.count(
|
||||
int.parse((notification.data['unreadCount'] as String?) ?? '0'),
|
||||
);
|
||||
/// Recomputes the app badge from the notifications currently in the tray.
|
||||
/// Deterministic — no server-provided counter to drift out of sync — so the
|
||||
/// badge always matches what the user actually sees. Called after rendering,
|
||||
/// cancelling, or opening the app.
|
||||
static Future<void> refreshBadge() async {
|
||||
try {
|
||||
final plugin = NotificationService().flutterLocalNotificationsPlugin;
|
||||
final actives = await plugin.getActiveNotifications();
|
||||
await FlutterAppBadge.count(actives.length);
|
||||
} on Object catch (e) {
|
||||
log('Badge refresh failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-chat tag scheme. MUST match the Notify backend, which sets this
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/mhsl/notify/register/notify_register.dart';
|
||||
import '../api/mhsl/notify/register/notify_register_params.dart';
|
||||
import '../model/account_data.dart';
|
||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../widget/confirm_dialog.dart';
|
||||
|
||||
class NotifyUpdater {
|
||||
static ConfirmDialog enableAfterDisclaimer(
|
||||
SettingsCubit settings,
|
||||
) => ConfirmDialog(
|
||||
title: 'Warnung',
|
||||
icon: Icons.warning_amber,
|
||||
content:
|
||||
''
|
||||
'Die Push-Benachrichtigungen werden durch mhsl.eu versendet.\n\n'
|
||||
'Durch das aktivieren dieser Funktion wird dein Nutzername, dein Password und eine Geräte-ID von mhsl dauerhaft gespeichert und verarbeitet.\n\n'
|
||||
'Für mehr Informationen drücke lange auf die Einstellungsoption!',
|
||||
confirmButton: 'Aktivieren',
|
||||
onConfirm: () {
|
||||
unawaited(
|
||||
FirebaseMessaging.instance.requestPermission(provisional: false),
|
||||
);
|
||||
settings.val(write: true).notificationSettings.enabled = true;
|
||||
unawaited(NotifyUpdater.registerToServer());
|
||||
},
|
||||
);
|
||||
|
||||
static Future<void> registerToServer() async {
|
||||
final fcmToken = await FirebaseMessaging.instance.getToken();
|
||||
if (fcmToken == null) {
|
||||
throw Exception(
|
||||
'Failed to register push notification because there is no FBC token!',
|
||||
);
|
||||
}
|
||||
|
||||
unawaited(
|
||||
NotifyRegister(
|
||||
NotifyRegisterParams(
|
||||
username: AccountData().getUsername(),
|
||||
password: AccountData().getPassword(),
|
||||
fcmToken: fcmToken,
|
||||
),
|
||||
).run(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user