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
@@ -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,
);
}
}