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
@@ -0,0 +1,20 @@
import 'package:http/http.dart' as http;
import '../nextcloud_ocs.dart';
/// Revokes the current app password server-side via
/// `DELETE /ocs/v2.php/core/apppassword`. Best-effort: the shared OCS headers
/// authenticate with the app password itself (it revokes the credential it was
/// made with) and the result is ignored — logout clears local state regardless.
class DeleteAppPassword {
final http.Client _client;
DeleteAppPassword({http.Client? client}) : _client = client ?? http.Client();
Future<void> run() async {
await _client.delete(
NextcloudOcs.uri('core/apppassword'),
headers: NextcloudOcs.headers(),
);
}
}
@@ -0,0 +1,45 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]),
/// which is what the push-v2 registration binds to.
///
/// Must authenticate with the *real* password — an app password cannot mint
/// another one.
class GetAppPassword {
final http.Client _client;
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
/// Returns the freshly minted app password. Throws on any transport or
/// protocol error — callers treat push registration as best-effort and swallow
/// failures.
Future<String> run() async {
final response = await _client.get(
NextcloudOcs.uri('core/getapppassword'),
headers: {
...NextcloudOcs.headers(),
// Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one —
// this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('getapppassword HTTP ${response.statusCode}');
}
final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data'];
final appPassword = data is Map ? data['apppassword'] as String? : null;
if (appPassword == null || appPassword.isEmpty) {
throw Exception('getapppassword: no apppassword in response');
}
return appPassword;
}
}
@@ -11,7 +11,15 @@ class CapabilitiesResponse {
@JsonKey(defaultValue: false)
final bool viewForeignTimetables;
CapabilitiesResponse({required this.viewForeignTimetables});
/// Whether the backend push-proxy feature is configured and enabled for this
/// user. The app only registers for push when this is true.
@JsonKey(defaultValue: false)
final bool pushNotifications;
CapabilitiesResponse({
required this.viewForeignTimetables,
required this.pushNotifications,
});
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
_$CapabilitiesResponseFromJson(json);
@@ -10,8 +10,12 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
Map<String, dynamic> json,
) => CapabilitiesResponse(
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
pushNotifications: json['pushNotifications'] as bool? ?? false,
);
Map<String, dynamic> _$CapabilitiesResponseToJson(
CapabilitiesResponse instance,
) => <String, dynamic>{'viewForeignTimetables': instance.viewForeignTimetables};
) => <String, dynamic>{
'viewForeignTimetables': instance.viewForeignTimetables,
'pushNotifications': instance.pushNotifications,
};
@@ -0,0 +1,40 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
/// Registers (upserts) this device's push subscription with MarianumConnect via
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
/// device-identifier signature, stores the routing metadata and starts
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
class PushDeviceRegister {
final Dio _dio;
PushDeviceRegister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<void> run({
required String deviceIdentifier,
required String deviceIdentifierSignature,
required String userPublicKey,
required String pushToken,
required String platform,
String? appVersion,
}) async {
try {
await _dio.put<void>(
MarianumConnectEndpoint.resolve('me/push-device'),
data: {
'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature,
'userPublicKey': userPublicKey,
'pushToken': pushToken,
'platform': platform,
'appVersion': ?appVersion,
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
/// Triggers a test push to all of the current user's registered devices via
/// `POST /api/mobile/v1/me/push-device/test`. Returns the number of devices the
/// backend dispatched to (0 when none are registered).
class PushDeviceTest {
final Dio _dio;
PushDeviceTest({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<int> run() async {
try {
final response = await _dio.post<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('me/push-device/test'),
);
return (response.data?['devices'] as num?)?.toInt() ?? 0;
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
/// Removes this device's push subscription from MarianumConnect via
/// `DELETE /api/mobile/v1/me/push-device?deviceIdentifier=...`. Idempotent
/// (204 even when the row is already gone).
class PushDeviceUnregister {
final Dio _dio;
PushDeviceUnregister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<void> run({required String deviceIdentifier}) async {
try {
await _dio.delete<void>(
MarianumConnectEndpoint.resolve('me/push-device'),
queryParameters: {'deviceIdentifier': deviceIdentifier},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -1,22 +0,0 @@
import 'dart:convert';
import 'dart:developer';
import 'package:http/http.dart' as http;
import '../../mhsl_api.dart';
import 'notify_register_params.dart';
class NotifyRegister extends MhslApi<void> {
NotifyRegisterParams params;
NotifyRegister(this.params) : super('notify/register/');
@override
void assemble(String raw) {}
@override
Future<http.Response> request(Uri uri) {
var requestString = jsonEncode(params.toJson());
log('register at push proxy with username ${params.username}');
return http.post(uri, body: requestString);
}
}
@@ -1,20 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'notify_register_params.g.dart';
@JsonSerializable()
class NotifyRegisterParams {
String username;
String password;
String fcmToken;
NotifyRegisterParams({
required this.username,
required this.password,
required this.fcmToken,
});
factory NotifyRegisterParams.fromJson(Map<String, dynamic> json) =>
_$NotifyRegisterParamsFromJson(json);
Map<String, dynamic> toJson() => _$NotifyRegisterParamsToJson(this);
}
@@ -1,23 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'notify_register_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
NotifyRegisterParams _$NotifyRegisterParamsFromJson(
Map<String, dynamic> json,
) => NotifyRegisterParams(
username: json['username'] as String,
password: json['password'] as String,
fcmToken: json['fcmToken'] as String,
);
Map<String, dynamic> _$NotifyRegisterParamsToJson(
NotifyRegisterParams instance,
) => <String, dynamic>{
'username': instance.username,
'password': instance.password,
'fcmToken': instance.fcmToken,
};
+28 -9
View File
@@ -12,7 +12,8 @@ import 'main.dart';
import 'model/data_cleaner.dart';
import 'notification/notification_controller.dart';
import 'notification/notification_tasks.dart';
import 'notification/notify_updater.dart';
import 'push/push_registration.dart';
import 'push/push_tap_router.dart';
import 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/app_modules.dart';
@@ -85,6 +86,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
}
}
void _onPushTapPending() {
final token = PushTapRouter.pendingChatToken.value;
if (token == null || !mounted) return;
PushTapRouter.pendingChatToken.value = null;
NotificationTasks.navigateToTalk(context, chatToken: token);
}
Future<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return;
@@ -165,22 +173,32 @@ class _AppState extends State<App> with WidgetsBindingObserver {
UpdateUserIndex.index();
// A refreshed FCM token invalidates the existing push subscription — the
// NC device identifier stays stable, so we simply re-register (NC first,
// then the proxy). Debounced so a burst of refreshes triggers one call.
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
void update() => NotifyUpdater.registerToServer();
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(
(_) => update(),
);
update();
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((
_,
) {
Debouncer.debounce(
'pushTokenRefresh',
const Duration(seconds: 3),
() => unawaited(PushRegistration().onTokenRefresh()),
);
});
}
// Android renders pushes locally, so a tap arrives via the local
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return;
NotificationController.onForegroundMessageHandler(message, context);
});
FirebaseMessaging.onBackgroundMessage(
NotificationController.onBackgroundMessageHandler,
);
// iOS delivers alert pushes (Connect direct pushes, and NC pushes rendered
// by the NSE) natively; a tap surfaces here.
_onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
message,
) {
@@ -202,6 +220,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
+30 -2
View File
@@ -25,6 +25,10 @@ import 'app.dart';
import 'background/widget_background_task.dart';
import 'firebase_options.dart';
import 'model/account_data.dart';
import 'notification/notification_service.dart';
import 'push/push_message_handler.dart';
import 'push/push_registration.dart';
import 'push/push_renderer.dart';
import 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/bloc/account_bloc.dart';
@@ -87,6 +91,13 @@ Future<void> main() async {
await Future.wait(initialisationTasks);
log('app initialisation done!');
// Local notifications: init the plugin (with tap/action callbacks) and the
// Android channels, then register the FCM background isolate handler that
// decrypts and renders Nextcloud pushes while the app is not in foreground.
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
FirebaseMessaging.onBackgroundMessage(PushMessageHandler.onBackgroundMessage);
// Wire up the home-screen widget bridge before runApp so any widget render
// triggered during startup hits initialised native storage.
await WidgetSync.ensureInitialized();
@@ -207,8 +218,14 @@ class _MainState extends State<Main> {
_scheduleSessionValidation(accountBloc);
// Cold start while already logged in: the account status doesn't
// change, so the loggedIn listener below never fires — refresh
// capabilities here.
unawaited(context.read<CapabilitiesCubit>().load());
// capabilities here, then self-heal the push registration.
final settingsCubit = context.read<SettingsCubit>();
unawaited(
context.read<CapabilitiesCubit>().load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, context.read<CapabilitiesCubit>());
}),
);
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
}
});
@@ -225,6 +242,17 @@ class _MainState extends State<Main> {
unawaited(ListFilesCache.prefetchRootListing());
}
/// Registers/self-heals the push subscription when push is user-enabled and
/// the backend advertises the capability. Fire-and-forget.
void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) {
unawaited(
PushRegistration.syncSubscription(
enabled: settings.val().notificationSettings.enabled,
capable: capabilities.canReceivePushNotifications,
),
);
}
void _scheduleSessionValidation(AccountBloc accountBloc) {
unawaited(
SessionValidator.probeStored(
+56
View File
@@ -6,9 +6,14 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../push/push_secure_storage.dart';
class AccountData {
static const _usernameField = 'username';
static const _passwordField = 'password';
// App password lives in the push-shared (group-scoped) keystore so the iOS
// Notification Service Extension can authenticate Nextcloud calls too.
static const _appPasswordField = 'nextcloud_app_password';
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
@@ -23,6 +28,7 @@ class AccountData {
String? _username;
String? _password;
String? _appPassword;
String getUsername() {
if (_username == null) throw Exception('Username not initialized');
@@ -58,14 +64,49 @@ class AccountData {
_populated = Completer();
_username = null;
_password = null;
_appPassword = null;
await _secureStorage.delete(key: _usernameField);
await _secureStorage.delete(key: _passwordField);
await _clearAppPasswordStorage();
}
/// Persists a freshly minted Nextcloud app password. After this every
/// [getBasicAuthHeader] call authenticates with the app password instead of
/// the real password.
Future<void> setAppPassword(String appPassword) async {
_appPassword = appPassword;
try {
await pushSecureStorage.write(key: _appPasswordField, value: appPassword);
} on Object {
// Group-scoped keystore may be unavailable (e.g. iOS entitlement not yet
// provisioned). Keeping it in memory still lets this session use it.
}
}
Future<void> clearAppPassword() async {
_appPassword = null;
await _clearAppPasswordStorage();
}
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
Future<void> _clearAppPasswordStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordField);
} on Object {
// ignore — nothing stored or keystore unavailable
}
}
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
_username = await _secureStorage.read(key: _usernameField);
_password = await _secureStorage.read(key: _passwordField);
try {
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
} on Object {
_appPassword = null;
}
if (!_populated.isCompleted) _populated.complete();
}
@@ -97,6 +138,21 @@ class AccountData {
/// Prefer this over embedding credentials in URLs — error logs and crash
/// reports often capture the URL but not headers.
String getBasicAuthHeader() {
if (!isPopulated()) {
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
// Prefer the scoped app password once available; it survives real-password
// rotation and is what the push-v2 registration is bound to.
final secret = _appPassword ?? _password;
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
}
/// Basic-auth header that always uses the real password. Needed exactly once,
/// to mint the app password via `core/getapppassword` (an app password cannot
/// mint another).
String getRealPasswordBasicAuthHeader() {
if (!isPopulated()) {
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
+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(),
);
}
}
+88
View File
@@ -0,0 +1,88 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../api/marianumcloud/nextcloud_ocs.dart';
/// Result of a successful Nextcloud push-v2 registration.
class NextcloudPushRegistration {
/// Per-user server public key (PEM). Forwarded to MarianumConnect as
/// `userPublicKey` and used device-side to verify push signatures.
final String publicKey;
/// Opaque device identifier assigned by Nextcloud.
final String deviceIdentifier;
/// Signature over [deviceIdentifier], forwarded as `deviceIdentifierSignature`.
final String signature;
/// True when Nextcloud created a new subscription (HTTP 201); false when the
/// existing one was already up to date (HTTP 200).
final bool created;
const NextcloudPushRegistration({
required this.publicKey,
required this.deviceIdentifier,
required this.signature,
required this.created,
});
}
/// Thin client for Nextcloud's push-v2 device endpoints under
/// `/ocs/v2.php/apps/notifications/api/v2/push`. Uses the shared OCS headers,
/// which authenticate with the app password once available (the push
/// registration binds to it).
class NextcloudPushApi {
static const _path = 'apps/notifications/api/v2/push';
final http.Client _client;
NextcloudPushApi({http.Client? client}) : _client = client ?? http.Client();
/// Registers (or refreshes) this device. [devicePublicKeyPem] must be the
/// 64-column SPKI PEM. [proxyServer] is the MarianumConnect push-proxy base
/// URL (with trailing slash).
Future<NextcloudPushRegistration> register({
required String pushTokenHash,
required String devicePublicKeyPem,
required String proxyServer,
}) async {
final response = await _client.post(
NextcloudOcs.uri(_path),
headers: NextcloudOcs.headers(),
body: {
'pushTokenHash': pushTokenHash,
'devicePublicKey': devicePublicKeyPem,
'proxyServer': proxyServer,
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('NC push register HTTP ${response.statusCode}');
}
final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data'];
if (data is! Map) throw Exception('NC push register: malformed response');
final publicKey = data['publicKey'] as String?;
final deviceIdentifier = data['deviceIdentifier'] as String?;
final signature = data['signature'] as String?;
if (publicKey == null || deviceIdentifier == null || signature == null) {
throw Exception('NC push register: missing fields');
}
return NextcloudPushRegistration(
publicKey: publicKey,
deviceIdentifier: deviceIdentifier,
signature: signature,
created: response.statusCode == 201,
);
}
/// Unregisters this device from Nextcloud push. Returns true when the server
/// responded 202, meaning the proxy subscription should also be removed.
Future<bool> unregister() async {
final response = await _client.delete(
NextcloudOcs.uri(_path),
headers: NextcloudOcs.headers(),
);
return response.statusCode == 202;
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:localstore/localstore.dart';
/// A rendered notification's bookkeeping, keyed by the Nextcloud notification
/// id (`nid`). Lets a later delete-push (which only carries the `nid`) find and
/// cancel the exact tray notification that was shown.
class NidEntry {
final int nid;
final int notificationId;
final String tag;
final String? chatToken;
const NidEntry({
required this.nid,
required this.notificationId,
required this.tag,
this.chatToken,
});
Map<String, dynamic> toJson() => {
'nid': nid,
'notificationId': notificationId,
'tag': tag,
if (chatToken != null) 'chatToken': chatToken,
};
factory NidEntry.fromJson(Map<String, dynamic> json) => NidEntry(
nid: (json['nid'] as num).toInt(),
notificationId: (json['notificationId'] as num).toInt(),
tag: json['tag'] as String,
chatToken: json['chatToken'] as String?,
);
}
/// Persists the `nid → tray notification` mapping via [Localstore] so both the
/// foreground and background isolates can resolve delete-pushes.
class NidStore {
static const _collection = 'push_nids';
final Localstore _db;
NidStore({Localstore? db}) : _db = db ?? Localstore.instance;
Future<void> put(NidEntry entry) =>
_db.collection(_collection).doc('${entry.nid}').set(entry.toJson());
Future<NidEntry?> get(int nid) async {
final data = await _db.collection(_collection).doc('$nid').get();
if (data == null) return null;
return NidEntry.fromJson(data);
}
Future<void> delete(int nid) =>
_db.collection(_collection).doc('$nid').delete();
Future<List<NidEntry>> all() async {
final docs = await _db.collection(_collection).get();
if (docs == null) return const [];
return docs.values
.map((e) => NidEntry.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> clear() async {
final docs = await _db.collection(_collection).get();
if (docs == null) return;
for (final id in docs.keys) {
// Localstore keys are the full document paths (/push_nids/<nid>).
final nid = int.tryParse(id.split('/').last);
if (nid != null) await delete(nid);
}
}
}
+101
View File
@@ -0,0 +1,101 @@
import 'dart:convert';
import 'dart:developer';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http;
import '../api/marianumcloud/nextcloud_ocs.dart';
import '../model/account_data.dart';
import 'nid_store.dart';
/// Notification action identifiers shared between the renderer (which attaches
/// the actions) and the response handlers (which dispatch them).
const String kTalkReplyActionId = 'TALK_REPLY';
const String kTalkMarkReadActionId = 'TALK_MARK_READ';
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
/// background isolate spawned by flutter_local_notifications, so it may not
/// share any app state — it reads credentials straight from secure storage via
/// the [AccountData] singleton after awaiting population.
class PushActions {
/// Background entry point for notification actions. Must be a top-level or
/// static function annotated with `vm:entry-point` so AOT keeps it alive.
@pragma('vm:entry-point')
static Future<void> handleBackgroundResponse(
NotificationResponse response,
) async {
final chatToken = _chatTokenFrom(response.payload);
if (chatToken == null) return;
switch (response.actionId) {
case kTalkReplyActionId:
final text = response.input?.trim();
if (text != null && text.isNotEmpty) {
await sendReply(chatToken, text);
}
await markRead(chatToken);
break;
case kTalkMarkReadActionId:
await markRead(chatToken);
await _cancelForToken(response);
break;
default:
break;
}
}
static Future<void> sendReply(String chatToken, String message) async {
await _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
}
static Future<void> markRead(String chatToken) async {
await _ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
}
static Future<void> _ocsPost(String path, {Map<String, String>? body}) async {
try {
await AccountData().waitForPopulation();
final response = await http.post(
NextcloudOcs.uri(path),
headers: NextcloudOcs.headers(),
body: body,
);
if (response.statusCode < 200 || response.statusCode >= 300) {
log('Push action $path -> HTTP ${response.statusCode}');
}
} on Object catch (e) {
log('Push action $path failed: $e');
}
}
static Future<void> _cancelForToken(NotificationResponse response) async {
final nid = _nidFrom(response.payload);
if (nid == null) return;
try {
await NidStore().delete(nid);
} on Object catch (e) {
log('Push action nid cleanup failed: $e');
}
}
static String? _chatTokenFrom(String? payload) =>
_payloadField(payload, 'chatToken');
static int? _nidFrom(String? payload) {
final raw = _payloadField(payload, 'nid');
return raw == null ? null : int.tryParse(raw);
}
static String? _payloadField(String? payload, String key) {
if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final value = map[key];
return value?.toString();
} on Object {
return null;
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypton/crypton.dart';
import 'package:pointycastle/export.dart' as pc;
import 'push_subject.dart';
/// Verifies and decrypts the encrypted `subject` of a Nextcloud push-v2
/// notification. Pure crypto, no plugin/platform access, so it runs safely
/// inside the FCM background isolate.
///
/// - Signature: `SHA512withRSA` over the *encrypted* subject bytes, verified
/// with the per-user server public key returned at registration.
/// - Encryption: the subject is encrypted with the device public key, so the
/// device decrypts with its private key. Nextcloud 32 defaults to
/// OAEP (SHA-1/MGF1-SHA-1); older instances use PKCS#1 v1.5. We try OAEP
/// first and fall back to PKCS#1.
class PushDecryptor {
final RSAPrivateKey devicePrivateKey;
/// Per-user server public key (the `publicKey` from the NC registration
/// response). When null, signature verification is skipped.
final RSAPublicKey? serverPublicKey;
const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey});
/// Returns true when [signatureBase64] is a valid server signature over the
/// encrypted subject. Returns true when no server key is configured (the
/// proxy already verified the signature before forwarding).
bool verify(String subjectBase64, String signatureBase64) {
final key = serverPublicKey;
if (key == null) return true;
try {
final signed = Uint8List.fromList(base64.decode(subjectBase64));
final signature = Uint8List.fromList(base64.decode(signatureBase64));
return key.verifySHA512Signature(signed, signature);
} on Object {
return false;
}
}
/// Decrypts the base64 subject into a [PushSubject], or returns null when
/// neither padding scheme yields valid JSON.
PushSubject? decrypt(String subjectBase64) {
final encrypted = Uint8List.fromList(base64.decode(subjectBase64));
final plain = _decryptOaep(encrypted) ?? _decryptPkcs1(encrypted);
if (plain == null) return null;
try {
final json = jsonDecode(plain) as Map<String, dynamic>;
return PushSubject.fromJson(json);
} on Object {
return null;
}
}
String? _decryptOaep(Uint8List data) =>
_tryDecrypt(pc.OAEPEncoding(pc.RSAEngine()), data);
String? _decryptPkcs1(Uint8List data) =>
_tryDecrypt(pc.PKCS1Encoding(pc.RSAEngine()), data);
String? _tryDecrypt(pc.AsymmetricBlockCipher cipher, Uint8List data) {
try {
cipher.init(
false,
pc.PrivateKeyParameter<pc.RSAPrivateKey>(
devicePrivateKey.asPointyCastle,
),
);
final out = cipher.process(data);
return utf8.decode(out);
} on Object {
return null;
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:crypton/crypton.dart';
import 'package:flutter/foundation.dart';
import 'push_secure_storage.dart';
/// An RSA-2048 keypair exported as PEM strings ready for storage and for the
/// Nextcloud push-v2 registration.
@immutable
class PushKeypairPems {
/// PKCS#1 private-key PEM (`-----BEGIN RSA PRIVATE KEY-----`).
final String privateKeyPem;
/// SPKI public-key PEM in Nextcloud's expected shape: base64 wrapped at 64
/// characters per line. For RSA-2048 this is exactly 450 or 451 characters.
final String publicKeyPem;
const PushKeypairPems({
required this.privateKeyPem,
required this.publicKeyPem,
});
}
/// Generates a fresh keypair. Pure and synchronous so it can be run both inside
/// an isolate ([PushKeypair.generate]) and directly from unit tests. The public
/// PEM uses [RSAPublicKey.toFormattedPEM] which produces the 64-column SPKI
/// layout Nextcloud validates against.
PushKeypairPems generatePushKeypairPems() {
final keypair = RSAKeypair.fromRandom();
return PushKeypairPems(
privateKeyPem: keypair.privateKey.toPEM(),
publicKeyPem: keypair.publicKey.toFormattedPEM(),
);
}
PushKeypairPems _generateInIsolate(void _) => generatePushKeypairPems();
/// Persists and lazily generates the device RSA keypair used for push
/// encryption. The private key never leaves the secure keystore; the public
/// key PEM is what gets registered with Nextcloud.
class PushKeypair {
static const _privateKeyKey = 'push_device_private_key_pem';
static const _publicKeyKey = 'push_device_public_key_pem';
final FlutterSecureStorageLike _storage;
const PushKeypair({FlutterSecureStorageLike? storage})
: _storage = storage ?? const _DefaultStorage();
/// Returns the stored keypair PEMs, generating and persisting a fresh keypair
/// on first use. Generation is offloaded to an isolate because RSA-2048 key
/// generation blocks the UI thread for a noticeable moment.
Future<PushKeypairPems> ensure() async {
final existing = await _load();
if (existing != null) return existing;
final generated = await compute(_generateInIsolate, null);
await _storage.write(key: _privateKeyKey, value: generated.privateKeyPem);
await _storage.write(key: _publicKeyKey, value: generated.publicKeyPem);
return generated;
}
/// Loads the stored private key, or `null` when no keypair exists yet.
Future<RSAPrivateKey?> loadPrivateKey() async {
final pem = await _storage.read(key: _privateKeyKey);
if (pem == null || pem.isEmpty) return null;
return RSAPrivateKey.fromPEM(pem);
}
Future<String?> loadPublicKeyPem() => _storage.read(key: _publicKeyKey);
Future<void> clear() async {
await _storage.delete(key: _privateKeyKey);
await _storage.delete(key: _publicKeyKey);
}
Future<PushKeypairPems?> _load() async {
final priv = await _storage.read(key: _privateKeyKey);
final pub = await _storage.read(key: _publicKeyKey);
if (priv == null || priv.isEmpty || pub == null || pub.isEmpty) return null;
return PushKeypairPems(privateKeyPem: priv, publicKeyPem: pub);
}
}
/// Minimal storage contract so tests can inject an in-memory fake instead of
/// touching the platform keystore.
abstract class FlutterSecureStorageLike {
Future<String?> read({required String key});
Future<void> write({required String key, required String? value});
Future<void> delete({required String key});
}
class _DefaultStorage implements FlutterSecureStorageLike {
const _DefaultStorage();
@override
Future<String?> read({required String key}) =>
pushSecureStorage.read(key: key);
@override
Future<void> write({required String key, required String? value}) =>
pushSecureStorage.write(key: key, value: value);
@override
Future<void> delete({required String key}) =>
pushSecureStorage.delete(key: key);
}
+193
View File
@@ -0,0 +1,193 @@
import 'dart:developer';
import 'package:crypton/crypton.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../notification/notification_service.dart';
import 'nid_store.dart';
import 'push_decryptor.dart';
import 'push_keypair.dart';
import 'push_registration_store.dart';
import 'push_renderer.dart';
import 'push_subject.dart';
/// How an incoming FCM payload should be interpreted.
enum PushKind {
/// Encrypted Nextcloud push-v2 notification (`subject` + `signature`).
nextcloud,
/// Plaintext MarianumConnect direct push (`source == "connect"`).
connect,
/// Neither — ignored.
unknown,
}
/// Classifies a raw FCM data map. Pure, so it's unit-testable and safe in any
/// isolate. Nextcloud pushes are distinguished by the presence of both
/// `subject` and `signature`; Connect pushes by `source == "connect"`.
PushKind classifyPush(Map<String, dynamic> data) {
final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false;
final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false;
if (hasSubject && hasSignature) return PushKind.nextcloud;
if (data['source'] == 'connect') return PushKind.connect;
return PushKind.unknown;
}
/// Verifies, decrypts, and renders incoming push messages. Delete-pushes cancel
/// the matching tray notification via [NidStore]. Works both in the FCM
/// background isolate and the foreground.
class PushMessageHandler {
final PushKeypair _keypair;
final PushRegistrationStore _registrationStore;
final PushRenderer _renderer;
final NidStore _nidStore;
PushMessageHandler({
PushKeypair? keypair,
PushRegistrationStore? registrationStore,
PushRenderer? renderer,
NidStore? nidStore,
}) : _keypair = keypair ?? const PushKeypair(),
_registrationStore = registrationStore ?? const PushRegistrationStore(),
_renderer = renderer ?? PushRenderer(),
_nidStore = nidStore ?? NidStore();
/// Background isolate entry point registered with
/// `FirebaseMessaging.onBackgroundMessage`.
@pragma('vm:entry-point')
static Future<void> onBackgroundMessage(RemoteMessage message) async {
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message);
}
/// Processes [message]. In the foreground, pass [foreground] true and
/// [openChatToken] so a message for the currently open chat is suppressed
/// (the long-poll already shows it) instead of raising a tray notification.
Future<void> handle(
RemoteMessage message, {
bool foreground = false,
String? openChatToken,
}) async {
final data = message.data;
switch (classifyPush(data)) {
case PushKind.connect:
await _handleConnect(message, foreground: foreground);
break;
case PushKind.nextcloud:
await _handleNextcloud(
data,
foreground: foreground,
openChatToken: openChatToken,
);
break;
case PushKind.unknown:
break;
}
}
Future<void> _handleConnect(
RemoteMessage message, {
required bool foreground,
}) async {
// On iOS the alert is delivered natively by the system; only Android needs
// to render the plaintext payload locally.
final data = message.data;
final title = data['title'] as String?;
final body = data['body'] as String?;
if (title == null) return;
await _renderer.renderConnect(
title: title,
body: body ?? '',
data: data.map((k, v) => MapEntry(k, '$v')),
);
}
Future<void> _handleNextcloud(
Map<String, dynamic> data, {
required bool foreground,
required String? openChatToken,
}) async {
final subjectBase64 = data['subject'] as String;
final signatureBase64 = data['signature'] as String;
final privateKey = await _keypair.loadPrivateKey();
if (privateKey == null) {
log('Push: no device private key, cannot decrypt');
return;
}
final serverPublicKey = await _loadServerPublicKey();
final decryptor = PushDecryptor(
devicePrivateKey: privateKey,
serverPublicKey: serverPublicKey,
);
if (!decryptor.verify(subjectBase64, signatureBase64)) {
log('Push: signature verification failed');
return;
}
final subject = decryptor.decrypt(subjectBase64);
if (subject == null) {
log('Push: could not decrypt subject');
return;
}
if (subject.isAnyDelete) {
await _handleDelete(subject);
return;
}
// Foreground + the referenced chat already open: the long-poll renders the
// message, so just make sure no stale tray entry lingers.
if (foreground &&
subject.isTalk &&
subject.id != null &&
subject.id == openChatToken) {
return;
}
await _renderer.render(subject);
}
Future<void> _handleDelete(PushSubject subject) async {
if (subject.deleteAll) {
final all = await _nidStore.all();
for (final entry in all) {
await _cancel(entry);
}
await _nidStore.clear();
return;
}
final nids = <int>[
if (subject.delete && subject.nid != null) subject.nid!,
...subject.nids,
];
for (final nid in nids) {
final entry = await _nidStore.get(nid);
if (entry != null) await _cancel(entry);
await _nidStore.delete(nid);
}
}
Future<void> _cancel(NidEntry entry) async {
try {
await NotificationService().flutterLocalNotificationsPlugin.cancel(
id: entry.notificationId,
tag: entry.tag,
);
} on Object catch (e) {
log('Push: cancel ${entry.nid} failed: $e');
}
}
Future<RSAPublicKey?> _loadServerPublicKey() async {
final pem = await _registrationStore.serverPublicKeyPem();
if (pem == null || pem.isEmpty) return null;
try {
return RSAPublicKey.fromPEM(pem);
} on Object {
return null;
}
}
}
+251
View File
@@ -0,0 +1,251 @@
import 'dart:developer';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
import 'package:package_info_plus/package_info_plus.dart';
import '../api/marianumcloud/app_password/delete_app_password.dart';
import '../api/marianumcloud/app_password/get_app_password.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../model/account_data.dart';
import '../model/endpoint_data.dart';
import 'nextcloud_push_api.dart';
import 'push_keypair.dart';
import 'push_registration_store.dart';
/// Orchestrates the full push-v2 registration lifecycle:
/// Nextcloud device registration → MarianumConnect proxy registration, plus
/// unregister and token-refresh handling.
class PushRegistration {
final PushKeypair _keypair;
final PushRegistrationStore _store;
final NextcloudPushApi _nextcloud;
PushRegistration({
PushKeypair? keypair,
PushRegistrationStore? store,
NextcloudPushApi? nextcloud,
}) : _keypair = keypair ?? const PushKeypair(),
_store = store ?? const PushRegistrationStore(),
_nextcloud = nextcloud ?? NextcloudPushApi();
String get _platform => Platform.isIOS ? 'ios' : 'android';
/// Derives the push-proxy base URL from the active MarianumConnect endpoint,
/// so a beta/dev build registers against the matching proxy automatically.
String get _proxyServer => '${MarianumConnectEndpoint.current()}/push-proxy/';
/// Nextcloud origin the registration targets (full origin, no trailing
/// slash) — persisted alongside the registration to detect endpoint changes.
String get _ncBaseUrl => 'https://${EndpointData().nextcloud().full()}';
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
/// registration binds to it, so it must be obtained before registering.
Future<void> ensureAppPassword() async {
if (AccountData().hasAppPassword()) return;
try {
final appPassword = await GetAppPassword().run();
await AccountData().setAppPassword(appPassword);
} on Object catch (e) {
log('Push: could not obtain app password (non-blocking): $e');
}
}
/// Registers this device end-to-end. No-op-safe: transport failures are
/// logged and swallowed so callers can fire-and-forget.
Future<void> register() async {
try {
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken == null || fcmToken.isEmpty) {
log('Push: no FCM token, skipping registration');
return;
}
await ensureAppPassword();
await _persistNativeAuthContext();
final proxyServer = _proxyServer;
final ncBaseUrl = _ncBaseUrl;
final pems = await _keypair.ensure();
final registration = await _nextcloud.register(
pushTokenHash: generatePushTokenHash(fcmToken),
devicePublicKeyPem: pems.publicKeyPem,
proxyServer: proxyServer,
);
await _store.save(
deviceIdentifier: registration.deviceIdentifier,
serverPublicKeyPem: registration.publicKey,
fcmToken: fcmToken,
proxyServer: proxyServer,
ncBaseUrl: ncBaseUrl,
);
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
await PushDeviceRegister().run(
deviceIdentifier: registration.deviceIdentifier,
deviceIdentifierSignature: registration.signature,
userPublicKey: registration.publicKey,
pushToken: fcmToken,
platform: _platform,
appVersion: appVersion,
);
log('Push: registered (created=${registration.created})');
} on Object catch (e) {
log('Push: registration failed: $e');
}
}
/// Writes the username and Nextcloud base URL into the shared keychain so the
/// native iOS Talk action handler can authenticate OCS calls without the
/// Flutter engine. Best-effort — a failure here must not abort registration.
Future<void> _persistNativeAuthContext() async {
try {
final endpoint = EndpointData().nextcloud();
await _store.saveNativeAuthContext(
username: AccountData().getUsername(),
baseUrl: 'https://${endpoint.full()}',
);
} on Object catch (e) {
log('Push: could not persist native auth context: $e');
}
}
/// Removes the subscription from Nextcloud and the proxy. Best-effort.
Future<void> unregister() async {
final deviceIdentifier = await _store.deviceIdentifier();
try {
await _nextcloud.unregister();
} on Object catch (e) {
log('Push: NC unregister failed: $e');
}
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
try {
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
} on Object catch (e) {
log('Push: proxy unregister failed: $e');
}
}
await _store.clear();
}
/// Pure decision for whether a persisted registration endpoint no longer
/// matches the currently active one. A missing/empty stored value never
/// forces a re-registration — old installs (pre endpoint-tracking) heal via
/// the regular register-on-start path instead of a forced extra roundtrip.
static bool endpointChanged({
required String? registered,
required String current,
}) => registered != null && registered.isNotEmpty && registered != current;
/// True when an existing registration was made against a different
/// MarianumConnect proxy or Nextcloud base URL than the ones currently
/// configured (dev-tools endpoint switch, live/beta/custom).
Future<bool> needsEndpointReRegistration() async {
if (!await _store.isRegistered()) return false;
return endpointChanged(
registered: await _store.registeredProxyServer(),
current: _proxyServer,
) ||
endpointChanged(
registered: await _store.registeredNcBaseUrl(),
current: _ncBaseUrl,
);
}
/// Re-registers when the active endpoints diverge from the registered ones.
/// The NC POST updates the existing subscription server-side to the new
/// proxyServer URL; afterwards the device row lands at the NEW backend via
/// `PUT me/push-device`. No DELETE at the old proxy: its bearer token belongs
/// to a different token universe (cleared on endpoint switch) and the stale
/// row ages out through the backend's cleanup cron once pushes stop.
Future<void> reRegisterIfEndpointChanged() async {
if (!await needsEndpointReRegistration()) return;
log('Push: endpoint changed, re-registering');
await register();
}
/// Pure decision for whether push may be delivered given an OS permission
/// [status]: only an explicit denial blocks registration. `authorized` and
/// `provisional` obviously allow it; `notDetermined` is kept permissive so a
/// transient plugin/platform hiccup never silently disables push (the OS
/// simply won't show notifications until the user decides).
static bool isPermissionUsable(AuthorizationStatus status) =>
status != AuthorizationStatus.denied;
/// Requests the OS notification permission (covers iOS + Android 13) and
/// returns whether registration should proceed. Errors from the plugin are
/// treated as usable — better a possibly-idle registration than silently
/// losing push over a transient failure.
static Future<bool> requestOsPermission() async {
try {
final settings = await FirebaseMessaging.instance.requestPermission();
return isPermissionUsable(settings.authorizationStatus);
} on Object catch (e) {
log('Push: requestPermission failed: $e');
return true;
}
}
/// True when the user has explicitly denied the OS notification permission.
/// Read-only (no prompt) — used by the settings UI to surface the state.
static Future<bool> isOsPermissionDenied() async {
try {
final settings = await FirebaseMessaging.instance
.getNotificationSettings();
return settings.authorizationStatus == AuthorizationStatus.denied;
} on Object {
return false;
}
}
/// Registers this device when push is both user-enabled and backend-capable.
/// Requests the OS notification permission first (covers iOS + Android 13);
/// an explicit denial skips registration entirely so NC/proxy never push to a
/// device that cannot display notifications. Safe to call on every start —
/// Nextcloud dedups an unchanged registration — which also self-heals a
/// device whose registration was lost.
static Future<void> syncSubscription({
required bool enabled,
required bool capable,
}) async {
if (!(enabled && capable)) return;
if (!await requestOsPermission()) {
log('Push: OS notification permission denied, skipping registration');
return;
}
final registration = PushRegistration();
// register() below refreshes an unchanged subscription anyway; the check
// only surfaces the endpoint switch in the log for diagnosability.
if (await registration.needsEndpointReRegistration()) {
log('Push: registered endpoints outdated, re-registering');
}
await registration.register();
}
/// Re-registers after an FCM token refresh. The Nextcloud device identifier
/// stays stable across refreshes, so this simply re-runs registration (NC
/// first, then the proxy) with the new token.
Future<void> onTokenRefresh() => register();
/// Full teardown for logout: unregister push, revoke the app password, then
/// clear it locally. Ordered so the proxy stops pushing before credentials
/// are gone.
Future<void> logoutCleanup() async {
await unregister();
try {
await DeleteAppPassword().run();
} on Object catch (e) {
log('Push: delete app password failed: $e');
}
await AccountData().clearAppPassword();
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'push_secure_storage.dart';
/// Persists the bookkeeping produced by a successful push registration:
/// the Nextcloud device identifier, the per-user server public key (needed to
/// verify incoming push signatures), the FCM token the registration was made
/// with (so a token refresh can be detected) and the endpoints it was bound to
/// (so an endpoint switch in the dev tools can be detected).
class PushRegistrationStore {
static const _deviceIdentifierKey = 'push_device_identifier';
static const _serverPublicKeyKey = 'push_server_public_key_pem';
static const _registeredTokenKey = 'push_registered_fcm_token';
static const _proxyServerKey = 'push_registered_proxy_server';
static const _ncBaseUrlKey = 'push_registered_nc_base_url';
// Native-only context: the iOS AppDelegate answers Talk notification actions
// (reply / mark-as-read) directly via URLSession while the Flutter engine is
// not guaranteed to run. It needs the Nextcloud username and base URL from the
// shared (group-scoped) keychain; the app password already lives there
// (AccountData writes `nextcloud_app_password` group-scoped).
static const _usernameKey = 'nextcloud_username';
static const _baseUrlKey = 'nextcloud_base_url';
const PushRegistrationStore();
Future<void> save({
required String deviceIdentifier,
required String serverPublicKeyPem,
required String fcmToken,
required String proxyServer,
required String ncBaseUrl,
}) async {
await pushSecureStorage.write(
key: _deviceIdentifierKey,
value: deviceIdentifier,
);
await pushSecureStorage.write(
key: _serverPublicKeyKey,
value: serverPublicKeyPem,
);
await pushSecureStorage.write(key: _registeredTokenKey, value: fcmToken);
await pushSecureStorage.write(key: _proxyServerKey, value: proxyServer);
await pushSecureStorage.write(key: _ncBaseUrlKey, value: ncBaseUrl);
}
/// Persists the username and Nextcloud base URL group-scoped so the native
/// iOS Talk action handler (AppDelegate) can authenticate OCS calls. The base
/// URL is a full origin like `https://cloud.marianum-fulda.de` (domain +
/// optional path, no trailing slash).
Future<void> saveNativeAuthContext({
required String username,
required String baseUrl,
}) async {
await pushSecureStorage.write(key: _usernameKey, value: username);
await pushSecureStorage.write(key: _baseUrlKey, value: baseUrl);
}
Future<String?> deviceIdentifier() =>
pushSecureStorage.read(key: _deviceIdentifierKey);
Future<String?> serverPublicKeyPem() =>
pushSecureStorage.read(key: _serverPublicKeyKey);
Future<String?> registeredFcmToken() =>
pushSecureStorage.read(key: _registeredTokenKey);
/// Proxy-server URL the current registration was made with.
Future<String?> registeredProxyServer() =>
pushSecureStorage.read(key: _proxyServerKey);
/// Nextcloud base URL the current registration was made against.
Future<String?> registeredNcBaseUrl() =>
pushSecureStorage.read(key: _ncBaseUrlKey);
/// True when a registration has been persisted (used by the cold-start
/// self-heal to decide whether to (re-)register).
Future<bool> isRegistered() async =>
(await registeredFcmToken())?.isNotEmpty ?? false;
Future<void> clear() async {
await pushSecureStorage.delete(key: _deviceIdentifierKey);
await pushSecureStorage.delete(key: _serverPublicKeyKey);
await pushSecureStorage.delete(key: _registeredTokenKey);
await pushSecureStorage.delete(key: _proxyServerKey);
await pushSecureStorage.delete(key: _ncBaseUrlKey);
await pushSecureStorage.delete(key: _usernameKey);
await pushSecureStorage.delete(key: _baseUrlKey);
}
}
+196
View File
@@ -0,0 +1,196 @@
import 'dart:convert';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../notification/notification_service.dart';
import '../notification/notification_tasks.dart';
import 'nid_store.dart';
import 'push_actions.dart';
import 'push_subject.dart';
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages get a [MessagingStyleInformation] with inline
/// reply + mark-as-read actions and a per-chat tag; everything else renders in
/// a generic channel.
class PushRenderer {
static const talkChannelId = 'talk_messages';
static const talkChannelName = 'Talk-Nachrichten';
static const generalChannelId = 'nextcloud_general';
static const generalChannelName = 'Benachrichtigungen';
static const String iosTalkCategory = 'TALK_MESSAGE';
final NidStore _nidStore;
PushRenderer({NidStore? nidStore}) : _nidStore = nidStore ?? NidStore();
FlutterLocalNotificationsPlugin get _plugin =>
NotificationService().flutterLocalNotificationsPlugin;
/// Creates the Android notification channels. Safe to call repeatedly.
static Future<void> ensureChannels() async {
final android = NotificationService().flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (android == null) return;
await android.createNotificationChannel(
const AndroidNotificationChannel(
talkChannelId,
talkChannelName,
description: 'Neue Nachrichten aus Nextcloud Talk',
importance: Importance.high,
),
);
await android.createNotificationChannel(
const AndroidNotificationChannel(
generalChannelId,
generalChannelName,
description: 'Allgemeine Benachrichtigungen',
),
);
}
/// Renders a decrypted Nextcloud push subject.
Future<void> render(PushSubject subject) async {
if (subject.isTalk) {
await _renderTalk(subject);
} else {
await _renderGeneric(subject);
}
}
Future<void> _renderTalk(PushSubject subject) async {
final nid = subject.nid ?? _fallbackId(subject.id);
final chatToken = subject.id;
final tag = chatToken != null
? NotificationTasks.chatTag(chatToken)
: 'talk_$nid';
final text = subject.subject ?? 'Neue Nachricht';
final (senderName, messageText) = _splitSender(text);
final payload = _payload(chatToken: chatToken, nid: nid);
final messagingStyle = MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'),
conversationTitle: senderName,
groupConversation: false,
messages: [
Message(messageText, DateTime.now(), Person(name: senderName)),
],
);
final androidDetails = AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
tag: tag,
styleInformation: messagingStyle,
actions: const [
AndroidNotificationAction(
kTalkReplyActionId,
'Antworten',
showsUserInterface: false,
cancelNotification: false,
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
),
AndroidNotificationAction(
kTalkMarkReadActionId,
'Gelesen',
showsUserInterface: false,
),
],
);
final iosDetails = DarwinNotificationDetails(
threadIdentifier: tag,
categoryIdentifier: iosTalkCategory,
);
await _nidStore.put(
NidEntry(nid: nid, notificationId: nid, tag: tag, chatToken: chatToken),
);
await _plugin.show(
id: nid,
title: senderName,
body: messageText,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
payload: payload,
);
}
Future<void> _renderGeneric(PushSubject subject) async {
final nid = subject.nid ?? _fallbackId(subject.subject);
const androidDetails = AndroidNotificationDetails(
generalChannelId,
generalChannelName,
);
const iosDetails = DarwinNotificationDetails();
await _nidStore.put(
NidEntry(nid: nid, notificationId: nid, tag: 'nc_$nid'),
);
await _plugin.show(
id: nid,
title: subject.subject ?? 'Neue Benachrichtigung',
body: null,
notificationDetails: const NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
payload: _payload(chatToken: null, nid: nid),
);
}
/// Renders a plaintext MarianumConnect direct push (Android only — iOS shows
/// the native alert itself).
Future<void> renderConnect({
required String title,
required String body,
Map<String, String>? data,
}) async {
final id = _fallbackId('$title$body');
const androidDetails = AndroidNotificationDetails(
generalChannelId,
generalChannelName,
importance: Importance.high,
priority: Priority.high,
);
await _plugin.show(
id: id,
title: title,
body: body,
notificationDetails: const NotificationDetails(android: androidDetails),
payload: data == null ? null : jsonEncode(data),
);
}
String _payload({required String? chatToken, required int nid}) =>
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
/// Splits a `"Sender: message"` subject into its parts, falling back to a
/// generic sender label when there's no delimiter.
(String, String) _splitSender(String subject) {
final idx = subject.indexOf(': ');
if (idx > 0 && idx < subject.length - 2) {
return (subject.substring(0, idx), subject.substring(idx + 2));
}
return ('Talk', subject);
}
/// Deterministic non-negative 31-bit id from a string, used when the push
/// carries no `nid`.
int _fallbackId(String? seed) {
if (seed == null || seed.isEmpty) return 0;
var hash = 0;
for (final unit in seed.codeUnits) {
hash = (hash * 31 + unit) & 0x7fffffff;
}
return hash;
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Keychain access group shared between the Runner and the (Phase 3) iOS
/// Notification Service Extension so the NSE can read the RSA private key,
/// the server public key and the Nextcloud app password to decrypt pushes
/// while the app is not running.
///
/// The value reuses the existing app-group id already present in the iOS
/// project (`ios/Runner/Runner.entitlements`). Phase 3 must additionally list
/// it under `keychain-access-groups` for both the Runner and the NSE target.
/// On Android `groupId` is ignored, so this is a no-op there.
const String kPushKeychainGroup = 'group.eu.mhsl.marianum.mobile.client.widget';
/// [IOSOptions] used for every push-related secure-storage entry. Uses
/// `first_unlock` accessibility so the NSE can read the key material after the
/// first device unlock following a reboot (the NSE may run while locked).
const IOSOptions kPushIosOptions = IOSOptions(
groupId: kPushKeychainGroup,
accessibility: KeychainAccessibility.first_unlock,
);
/// Shared secure storage instance for all push key material and registration
/// bookkeeping. Kept separate from [AccountData]'s default storage because the
/// entries here are group-scoped for NSE access.
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
iOptions: kPushIosOptions,
);
+66
View File
@@ -0,0 +1,66 @@
/// The decrypted `subject` JSON of a Nextcloud push-v2 notification.
///
/// Covers the full shape the notifications app emits, including the three
/// delete variants which the neon `DecryptedSubject` helper only partially
/// models (it lacks `delete-multiple`/`nids`).
class PushSubject {
/// Nextcloud notification id.
final int? nid;
/// App that raised the notification (e.g. `spreed` for Talk).
final String? app;
/// Human-readable subject line.
final String? subject;
/// Notification type (e.g. `chat`, `background`).
final String? type;
/// App-specific object id. For Talk this is the chat/room token.
final String? id;
final bool delete;
final bool deleteMultiple;
final bool deleteAll;
/// Notification ids to remove for a `delete-multiple` push.
final List<int> nids;
const PushSubject({
this.nid,
this.app,
this.subject,
this.type,
this.id,
this.delete = false,
this.deleteMultiple = false,
this.deleteAll = false,
this.nids = const [],
});
bool get isAnyDelete => delete || deleteMultiple || deleteAll;
/// True when this notification originates from Nextcloud Talk.
bool get isTalk => app == 'spreed';
factory PushSubject.fromJson(Map<String, dynamic> json) {
final rawNids = json['nids'];
return PushSubject(
nid: (json['nid'] as num?)?.toInt(),
app: json['app'] as String?,
subject: json['subject'] as String?,
type: json['type'] as String?,
id: json['id'] as String?,
delete: json['delete'] == true,
deleteMultiple: json['delete-multiple'] == true,
deleteAll: json['delete-all'] == true,
nids: rawNids is List
? rawNids
.whereType<Object>()
.map((e) => e is num ? e.toInt() : int.tryParse('$e'))
.whereType<int>()
.toList()
: const [],
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'push_actions.dart';
/// Routes foreground notification interactions from the single
/// flutter_local_notifications response callback. Action responses (reply /
/// mark-read) are dispatched straight to [PushActions]; a plain tap publishes
/// the target chat token via [pendingChatToken] for [App] to navigate to.
class PushTapRouter {
PushTapRouter._();
/// Chat token of the most recently tapped Talk notification, or null. [App]
/// listens to this and opens the chat, then resets it to null.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
static void handleResponse(NotificationResponse response) {
final actionId = response.actionId;
if (actionId == kTalkReplyActionId || actionId == kTalkMarkReadActionId) {
// Reuse the isolate-safe action dispatch for foreground actions too.
PushActions.handleBackgroundResponse(response);
return;
}
final token = _chatTokenFrom(response.payload);
if (token != null) pendingChatToken.value = token;
}
static String? _chatTokenFrom(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final token = map['chatToken'];
return token is String && token.isNotEmpty ? token : null;
} on Object {
return null;
}
}
}
@@ -13,6 +13,8 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
bool get canViewForeignTimetables => state.viewForeignTimetables;
bool get canReceivePushNotifications => state.pushNotifications;
/// Refreshes capabilities from the server. On any failure (endpoint not yet
/// live, network error, 4xx) the previously hydrated flags are kept but the
/// state is marked `loaded` — a failed fetch never silently grants a
@@ -23,6 +25,7 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
emit(
CapabilitiesState(
viewForeignTimetables: response.viewForeignTimetables,
pushNotifications: response.pushNotifications,
loaded: true,
),
);
@@ -7,6 +7,7 @@ part 'capabilities_state.g.dart';
abstract class CapabilitiesState with _$CapabilitiesState {
const factory CapabilitiesState({
@Default(false) bool viewForeignTimetables,
@Default(false) bool pushNotifications,
// Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed".
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$CapabilitiesState {
bool get viewForeignTimetables;// Whether a capability response (or a definitive failure) has been
bool get viewForeignTimetables; bool get pushNotifications;// Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed".
bool get loaded;
@@ -31,16 +31,16 @@ $CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStat
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.loaded, loaded) || other.loaded == loaded));
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded);
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded);
@override
String toString() {
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, loaded: $loaded)';
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)';
}
@@ -51,7 +51,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> {
factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl;
@useResult
$Res call({
bool viewForeignTimetables, bool loaded
bool viewForeignTimetables, bool pushNotifications, bool loaded
});
@@ -68,9 +68,10 @@ class _$CapabilitiesStateCopyWithImpl<$Res>
/// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? loaded = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) {
return _then(_self.copyWith(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as bool,
));
@@ -157,10 +158,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _CapabilitiesState() when $default != null:
return $default(_that.viewForeignTimetables,_that.loaded);case _:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
return orElse();
}
@@ -178,10 +179,10 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool loaded) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded) $default,) {final _that = this;
switch (_that) {
case _CapabilitiesState():
return $default(_that.viewForeignTimetables,_that.loaded);case _:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
throw StateError('Unexpected subclass');
}
@@ -198,10 +199,10 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _:
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool loaded)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,) {final _that = this;
switch (_that) {
case _CapabilitiesState() when $default != null:
return $default(_that.viewForeignTimetables,_that.loaded);case _:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
return null;
}
@@ -213,10 +214,11 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _:
@JsonSerializable()
class _CapabilitiesState implements CapabilitiesState {
const _CapabilitiesState({this.viewForeignTimetables = false, this.loaded = false});
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.loaded = false});
factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json);
@override@JsonKey() final bool viewForeignTimetables;
@override@JsonKey() final bool pushNotifications;
// Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed".
@@ -235,16 +237,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.loaded, loaded) || other.loaded == loaded));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded);
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded);
@override
String toString() {
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, loaded: $loaded)';
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)';
}
@@ -255,7 +257,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS
factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl;
@override @useResult
$Res call({
bool viewForeignTimetables, bool loaded
bool viewForeignTimetables, bool pushNotifications, bool loaded
});
@@ -272,9 +274,10 @@ class __$CapabilitiesStateCopyWithImpl<$Res>
/// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? loaded = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) {
return _then(_CapabilitiesState(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as bool,
));
@@ -9,11 +9,13 @@ part of 'capabilities_state.dart';
_CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
_CapabilitiesState(
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
pushNotifications: json['pushNotifications'] as bool? ?? false,
loaded: json['loaded'] as bool? ?? false,
);
Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
<String, dynamic>{
'viewForeignTimetables': instance.viewForeignTimetables,
'pushNotifications': instance.pushNotifications,
'loaded': instance.loaded,
};
+5 -5
View File
@@ -4,13 +4,13 @@ part 'notification_settings.g.dart';
@JsonSerializable()
class NotificationSettings {
bool askUsageDismissed;
/// Whether push notifications are enabled. Defaults to `true` — the OS
/// permission prompt at login is now the gate, so there is no separate
/// in-app opt-in step anymore.
@JsonKey(defaultValue: true)
bool enabled;
NotificationSettings({
required this.askUsageDismissed,
required this.enabled,
});
NotificationSettings({this.enabled = true});
factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
_$NotificationSettingsFromJson(json);
+2 -8
View File
@@ -8,14 +8,8 @@ part of 'notification_settings.dart';
NotificationSettings _$NotificationSettingsFromJson(
Map<String, dynamic> json,
) => NotificationSettings(
askUsageDismissed: json['askUsageDismissed'] as bool,
enabled: json['enabled'] as bool,
);
) => NotificationSettings(enabled: json['enabled'] as bool? ?? true);
Map<String, dynamic> _$NotificationSettingsToJson(
NotificationSettings instance,
) => <String, dynamic>{
'askUsageDismissed': instance.askUsageDismissed,
'enabled': instance.enabled,
};
) => <String, dynamic>{'enabled': instance.enabled};
+6
View File
@@ -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());
},
);
},
-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>();