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:
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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!',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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 [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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