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:
2026-07-04 22:50:18 +02:00
parent 32f7c311bc
commit 74a2ddd17f
56 changed files with 2987 additions and 285 deletions
+16 -23
View File
@@ -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(
+28 -29
View File
@@ -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,
);
}
}
+12 -5
View File
@@ -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
-51
View File
@@ -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(),
);
}
}