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
@@ -67,10 +67,7 @@ class DefaultSettings {
showPastEvents: false,
),
fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS),
notificationSettings: NotificationSettings(
askUsageDismissed: false,
enabled: false,
),
notificationSettings: NotificationSettings(enabled: true),
devToolsSettings: DevToolsSettings(
checkerboardOffscreenLayers: false,
checkerboardRasterCacheImages: false,
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart';
import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../../../../model/account_data.dart';
import '../../../../push/push_registration.dart';
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
import '../../../../state/app/modules/account/bloc/account_state.dart';
import '../../../../widget/app_progress_indicator.dart';
@@ -192,10 +193,12 @@ class _AccountSectionState extends State<AccountSection> {
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
}
// Best-effort revoke of the MC bearer token before we wipe local credentials.
// The token storage itself is cleared inside AuthLogout regardless of network
// success, so an offline logout still gets us into a clean local state.
// Ordered teardown: unregister push at Nextcloud + proxy and revoke the app
// password (while Nextcloud credentials are still available), THEN revoke the
// MC bearer token, and finally wipe local credentials. Each step is
// best-effort so an offline logout still reaches a clean local state.
Future<void> _performLogout() async {
await PushRegistration().logoutCleanup();
await AuthLogout().run();
await AccountData().removeData();
_cachedDisplayName = null;
@@ -1,12 +1,16 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../notification/notify_updater.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../api/marianumconnect/queries/push_device_test/push_device_test.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_registration_store.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../utils/haptics.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/info_dialog.dart';
class TalkSection extends StatelessWidget {
const TalkSection({super.key});
@@ -51,32 +55,134 @@ class TalkSection extends StatelessWidget {
leading: const CenteredLeading(
Icon(Icons.notifications_active_outlined),
),
title: const Text('Push-Benachrichtigungen aktivieren'),
subtitle: const Text('Lange tippen für mehr Informationen'),
title: const Text('Push-Benachrichtigungen'),
subtitle: const Text('Neue Talk-Nachrichten direkt aufs Gerät'),
trailing: Checkbox(
value: notificationSettings.enabled,
onChanged: (e) {
Haptics.selection();
if (e!) {
NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context);
final enabled = e ?? false;
settings.val(write: true).notificationSettings.enabled = enabled;
if (enabled) {
final messenger = ScaffoldMessenger.of(context);
unawaited(() async {
// Only register when the OS permission isn't explicitly
// denied — otherwise NC + proxy would push into the void.
if (await PushRegistration.requestOsPermission()) {
await PushRegistration().register();
} else {
messenger.showSnackBar(
const SnackBar(
content: Text(
'Benachrichtigungen sind in den Systemeinstellungen '
'deaktiviert — bitte dort erlauben.',
),
),
);
}
}());
} else {
settings.val(write: true).notificationSettings.enabled = e;
unawaited(PushRegistration().unregister());
}
},
),
onLongPress: () => _showInfoDialog(context),
),
if (notificationSettings.enabled) const _TestNotificationTile(),
],
);
}
void _showInfoDialog(BuildContext context) => InfoDialog.show(
context,
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n'
'Der extene Server verwendet die Zugangsdaten um sich maschinell in Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n'
'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!',
title: 'Info über Push',
);
}
/// "Send a test notification" action, shown only while push is enabled. The
/// button stays disabled until a registration is confirmed present, then calls
/// the backend and reports the result via a SnackBar.
class _TestNotificationTile extends StatefulWidget {
const _TestNotificationTile();
@override
State<_TestNotificationTile> createState() => _TestNotificationTileState();
}
class _TestNotificationTileState extends State<_TestNotificationTile> {
static const _permissionDeniedHint =
'Benachrichtigungen sind in den Systemeinstellungen deaktiviert';
bool _registered = false;
bool _permissionDenied = false;
bool _sending = false;
@override
void initState() {
super.initState();
_loadState();
}
Future<void> _loadState() async {
final registered = await const PushRegistrationStore().isRegistered();
final denied = await PushRegistration.isOsPermissionDenied();
if (!mounted) return;
setState(() {
_registered = registered;
_permissionDenied = denied;
});
}
Future<void> _sendTest() async {
if (_sending) return;
Haptics.selection();
final messenger = ScaffoldMessenger.of(context);
// Re-check right before sending: the user may have flipped the OS
// permission in the system settings since this tile was built.
final denied = await PushRegistration.isOsPermissionDenied();
if (!mounted) return;
if (denied) {
setState(() => _permissionDenied = true);
messenger.showSnackBar(
const SnackBar(
content: Text('$_permissionDeniedHint — bitte dort erlauben.'),
),
);
return;
}
setState(() {
_permissionDenied = false;
_sending = true;
});
String message;
try {
final devices = await PushDeviceTest().run();
message = devices >= 1
? 'Testbenachrichtigung an $devices Gerät(e) gesendet'
: 'Kein Gerät registriert — Push-Registrierung prüfen';
} on Object catch (e) {
message = errorToUserMessage(e);
}
if (!mounted) return;
setState(() => _sending = false);
messenger.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
if (!_registered) return const SizedBox.shrink();
return ListTile(
leading: const CenteredLeading(Icon(Icons.send_outlined)),
title: const Text('Testbenachrichtigung senden'),
subtitle: _permissionDenied
? Text(
_permissionDeniedHint,
style: TextStyle(color: Theme.of(context).colorScheme.error),
)
: const Text('Prüft, ob Push auf diesem Gerät ankommt'),
trailing: _sending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.arrow_right),
enabled: !_sending,
onTap: _sending ? null : _sendTest,
);
}
}
@@ -1,7 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumconnect/auth/token_storage.dart';
// Prefixed: the dio endpoint singleton shares its name with the enum from
// dev_tools_settings.dart imported below.
import '../../../../api/marianumconnect/marianumconnect_endpoint.dart'
as mc_api;
import '../../../../push/push_registration.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/dev_tools_settings.dart';
import '../../../../storage/settings.dart' as model;
@@ -35,6 +42,20 @@ class MarianumConnectEndpointPicker {
mutable.marianumConnectEndpoint = next;
if (custom != null) mutable.marianumConnectCustomUrl = custom;
await const MarianumConnectTokenStorage().clear();
// main.dart's BlocBuilder syncs the dio endpoint singleton on
// its next rebuild, but the push re-registration below must
// see the new base URL right now — update it here first
// (idempotent, same value the rebuild would set).
mc_api.MarianumConnectEndpoint.update(
settings
.val()
.devToolsSettings
.resolveMarianumConnectBaseUrl(),
);
// A push registration bound to the old proxy would keep
// routing pushes there; no-op when not registered or the
// endpoints are unchanged.
unawaited(PushRegistration().reRegisterIfEndpointChanged());
},
);
},
-41
View File
@@ -1,9 +1,7 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_split_view/flutter_split_view.dart';
import '../../../notification/notify_updater.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
@@ -11,7 +9,6 @@ import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/confirm_dialog.dart';
import '../../../widget/info_dialog.dart';
import '../../../widget/placeholder_view.dart';
import 'join_chat.dart';
import 'search_chat.dart';
@@ -46,7 +43,6 @@ class _ChatListViewState extends State<_ChatListView> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_maybeAskForNotificationPermission();
_maybeOpenPendingChat();
});
}
@@ -71,43 +67,6 @@ class _ChatListViewState extends State<_ChatListView> {
);
}
void _maybeAskForNotificationPermission() {
final notificationSettings = _settings.val().notificationSettings;
if (notificationSettings.enabled ||
notificationSettings.askUsageDismissed) {
return;
}
_settings.val(write: true).notificationSettings.askUsageDismissed = true;
ConfirmDialog(
icon: Icons.notifications_active_outlined,
title: 'Benachrichtigungen aktivieren',
content:
'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.',
confirmButton: 'Weiter',
onConfirm: () {
FirebaseMessaging.instance.requestPermission(provisional: false).then((
value,
) {
if (!mounted) return;
switch (value.authorizationStatus) {
case AuthorizationStatus.authorized:
NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context);
break;
case AuthorizationStatus.denied:
InfoDialog.show(
context,
'Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.',
);
break;
default:
break;
}
});
},
).asDialog(context);
}
@override
Widget build(BuildContext context) {
final bloc = context.read<ChatListBloc>();