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:
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -8,6 +9,7 @@ import '../../api/marianumconnect/auth/device_token_name.dart';
|
||||
import '../../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
|
||||
import '../../model/account_data.dart';
|
||||
import '../../push/push_registration.dart';
|
||||
import '../../widget_data/widget_sync.dart';
|
||||
|
||||
/// Owns the login flow's transient state (loading, last error) so it can be
|
||||
@@ -48,6 +50,10 @@ class LoginController extends ChangeNotifier {
|
||||
tokenName: await DeviceTokenName.resolve(),
|
||||
);
|
||||
await AccountData().setData(user, password);
|
||||
// Mint the Nextcloud app password now so it's ready for the push
|
||||
// registration and subsequent NC calls. Non-blocking: on failure push
|
||||
// stays off and retries on the next start.
|
||||
unawaited(PushRegistration().ensureAppPassword());
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user