implemented an E2E-encrypted Nextcloud push-v2 notification system with support for RSA decryption and signature verification; introduced an iOS Notification Service Extension and native AppDelegate handlers for Talk actions (inline reply and mark-as-read); replaced the legacy notification registration with a new lifecycle managing app passwords and secure keypair storage; added background message handling with tray synchronization and a test notification utility in the settings.

This commit is contained in:
2026-07-04 22:50:18 +02:00
parent 32f7c311bc
commit 74a2ddd17f
56 changed files with 2987 additions and 285 deletions
+88
View File
@@ -0,0 +1,88 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../api/marianumcloud/nextcloud_ocs.dart';
/// Result of a successful Nextcloud push-v2 registration.
class NextcloudPushRegistration {
/// Per-user server public key (PEM). Forwarded to MarianumConnect as
/// `userPublicKey` and used device-side to verify push signatures.
final String publicKey;
/// Opaque device identifier assigned by Nextcloud.
final String deviceIdentifier;
/// Signature over [deviceIdentifier], forwarded as `deviceIdentifierSignature`.
final String signature;
/// True when Nextcloud created a new subscription (HTTP 201); false when the
/// existing one was already up to date (HTTP 200).
final bool created;
const NextcloudPushRegistration({
required this.publicKey,
required this.deviceIdentifier,
required this.signature,
required this.created,
});
}
/// Thin client for Nextcloud's push-v2 device endpoints under
/// `/ocs/v2.php/apps/notifications/api/v2/push`. Uses the shared OCS headers,
/// which authenticate with the app password once available (the push
/// registration binds to it).
class NextcloudPushApi {
static const _path = 'apps/notifications/api/v2/push';
final http.Client _client;
NextcloudPushApi({http.Client? client}) : _client = client ?? http.Client();
/// Registers (or refreshes) this device. [devicePublicKeyPem] must be the
/// 64-column SPKI PEM. [proxyServer] is the MarianumConnect push-proxy base
/// URL (with trailing slash).
Future<NextcloudPushRegistration> register({
required String pushTokenHash,
required String devicePublicKeyPem,
required String proxyServer,
}) async {
final response = await _client.post(
NextcloudOcs.uri(_path),
headers: NextcloudOcs.headers(),
body: {
'pushTokenHash': pushTokenHash,
'devicePublicKey': devicePublicKeyPem,
'proxyServer': proxyServer,
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('NC push register HTTP ${response.statusCode}');
}
final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data'];
if (data is! Map) throw Exception('NC push register: malformed response');
final publicKey = data['publicKey'] as String?;
final deviceIdentifier = data['deviceIdentifier'] as String?;
final signature = data['signature'] as String?;
if (publicKey == null || deviceIdentifier == null || signature == null) {
throw Exception('NC push register: missing fields');
}
return NextcloudPushRegistration(
publicKey: publicKey,
deviceIdentifier: deviceIdentifier,
signature: signature,
created: response.statusCode == 201,
);
}
/// Unregisters this device from Nextcloud push. Returns true when the server
/// responded 202, meaning the proxy subscription should also be removed.
Future<bool> unregister() async {
final response = await _client.delete(
NextcloudOcs.uri(_path),
headers: NextcloudOcs.headers(),
);
return response.statusCode == 202;
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:localstore/localstore.dart';
/// A rendered notification's bookkeeping, keyed by the Nextcloud notification
/// id (`nid`). Lets a later delete-push (which only carries the `nid`) find and
/// cancel the exact tray notification that was shown.
class NidEntry {
final int nid;
final int notificationId;
final String tag;
final String? chatToken;
const NidEntry({
required this.nid,
required this.notificationId,
required this.tag,
this.chatToken,
});
Map<String, dynamic> toJson() => {
'nid': nid,
'notificationId': notificationId,
'tag': tag,
if (chatToken != null) 'chatToken': chatToken,
};
factory NidEntry.fromJson(Map<String, dynamic> json) => NidEntry(
nid: (json['nid'] as num).toInt(),
notificationId: (json['notificationId'] as num).toInt(),
tag: json['tag'] as String,
chatToken: json['chatToken'] as String?,
);
}
/// Persists the `nid → tray notification` mapping via [Localstore] so both the
/// foreground and background isolates can resolve delete-pushes.
class NidStore {
static const _collection = 'push_nids';
final Localstore _db;
NidStore({Localstore? db}) : _db = db ?? Localstore.instance;
Future<void> put(NidEntry entry) =>
_db.collection(_collection).doc('${entry.nid}').set(entry.toJson());
Future<NidEntry?> get(int nid) async {
final data = await _db.collection(_collection).doc('$nid').get();
if (data == null) return null;
return NidEntry.fromJson(data);
}
Future<void> delete(int nid) =>
_db.collection(_collection).doc('$nid').delete();
Future<List<NidEntry>> all() async {
final docs = await _db.collection(_collection).get();
if (docs == null) return const [];
return docs.values
.map((e) => NidEntry.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> clear() async {
final docs = await _db.collection(_collection).get();
if (docs == null) return;
for (final id in docs.keys) {
// Localstore keys are the full document paths (/push_nids/<nid>).
final nid = int.tryParse(id.split('/').last);
if (nid != null) await delete(nid);
}
}
}
+101
View File
@@ -0,0 +1,101 @@
import 'dart:convert';
import 'dart:developer';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http;
import '../api/marianumcloud/nextcloud_ocs.dart';
import '../model/account_data.dart';
import 'nid_store.dart';
/// Notification action identifiers shared between the renderer (which attaches
/// the actions) and the response handlers (which dispatch them).
const String kTalkReplyActionId = 'TALK_REPLY';
const String kTalkMarkReadActionId = 'TALK_MARK_READ';
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
/// background isolate spawned by flutter_local_notifications, so it may not
/// share any app state — it reads credentials straight from secure storage via
/// the [AccountData] singleton after awaiting population.
class PushActions {
/// Background entry point for notification actions. Must be a top-level or
/// static function annotated with `vm:entry-point` so AOT keeps it alive.
@pragma('vm:entry-point')
static Future<void> handleBackgroundResponse(
NotificationResponse response,
) async {
final chatToken = _chatTokenFrom(response.payload);
if (chatToken == null) return;
switch (response.actionId) {
case kTalkReplyActionId:
final text = response.input?.trim();
if (text != null && text.isNotEmpty) {
await sendReply(chatToken, text);
}
await markRead(chatToken);
break;
case kTalkMarkReadActionId:
await markRead(chatToken);
await _cancelForToken(response);
break;
default:
break;
}
}
static Future<void> sendReply(String chatToken, String message) async {
await _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
}
static Future<void> markRead(String chatToken) async {
await _ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
}
static Future<void> _ocsPost(String path, {Map<String, String>? body}) async {
try {
await AccountData().waitForPopulation();
final response = await http.post(
NextcloudOcs.uri(path),
headers: NextcloudOcs.headers(),
body: body,
);
if (response.statusCode < 200 || response.statusCode >= 300) {
log('Push action $path -> HTTP ${response.statusCode}');
}
} on Object catch (e) {
log('Push action $path failed: $e');
}
}
static Future<void> _cancelForToken(NotificationResponse response) async {
final nid = _nidFrom(response.payload);
if (nid == null) return;
try {
await NidStore().delete(nid);
} on Object catch (e) {
log('Push action nid cleanup failed: $e');
}
}
static String? _chatTokenFrom(String? payload) =>
_payloadField(payload, 'chatToken');
static int? _nidFrom(String? payload) {
final raw = _payloadField(payload, 'nid');
return raw == null ? null : int.tryParse(raw);
}
static String? _payloadField(String? payload, String key) {
if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final value = map[key];
return value?.toString();
} on Object {
return null;
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypton/crypton.dart';
import 'package:pointycastle/export.dart' as pc;
import 'push_subject.dart';
/// Verifies and decrypts the encrypted `subject` of a Nextcloud push-v2
/// notification. Pure crypto, no plugin/platform access, so it runs safely
/// inside the FCM background isolate.
///
/// - Signature: `SHA512withRSA` over the *encrypted* subject bytes, verified
/// with the per-user server public key returned at registration.
/// - Encryption: the subject is encrypted with the device public key, so the
/// device decrypts with its private key. Nextcloud 32 defaults to
/// OAEP (SHA-1/MGF1-SHA-1); older instances use PKCS#1 v1.5. We try OAEP
/// first and fall back to PKCS#1.
class PushDecryptor {
final RSAPrivateKey devicePrivateKey;
/// Per-user server public key (the `publicKey` from the NC registration
/// response). When null, signature verification is skipped.
final RSAPublicKey? serverPublicKey;
const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey});
/// Returns true when [signatureBase64] is a valid server signature over the
/// encrypted subject. Returns true when no server key is configured (the
/// proxy already verified the signature before forwarding).
bool verify(String subjectBase64, String signatureBase64) {
final key = serverPublicKey;
if (key == null) return true;
try {
final signed = Uint8List.fromList(base64.decode(subjectBase64));
final signature = Uint8List.fromList(base64.decode(signatureBase64));
return key.verifySHA512Signature(signed, signature);
} on Object {
return false;
}
}
/// Decrypts the base64 subject into a [PushSubject], or returns null when
/// neither padding scheme yields valid JSON.
PushSubject? decrypt(String subjectBase64) {
final encrypted = Uint8List.fromList(base64.decode(subjectBase64));
final plain = _decryptOaep(encrypted) ?? _decryptPkcs1(encrypted);
if (plain == null) return null;
try {
final json = jsonDecode(plain) as Map<String, dynamic>;
return PushSubject.fromJson(json);
} on Object {
return null;
}
}
String? _decryptOaep(Uint8List data) =>
_tryDecrypt(pc.OAEPEncoding(pc.RSAEngine()), data);
String? _decryptPkcs1(Uint8List data) =>
_tryDecrypt(pc.PKCS1Encoding(pc.RSAEngine()), data);
String? _tryDecrypt(pc.AsymmetricBlockCipher cipher, Uint8List data) {
try {
cipher.init(
false,
pc.PrivateKeyParameter<pc.RSAPrivateKey>(
devicePrivateKey.asPointyCastle,
),
);
final out = cipher.process(data);
return utf8.decode(out);
} on Object {
return null;
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:crypton/crypton.dart';
import 'package:flutter/foundation.dart';
import 'push_secure_storage.dart';
/// An RSA-2048 keypair exported as PEM strings ready for storage and for the
/// Nextcloud push-v2 registration.
@immutable
class PushKeypairPems {
/// PKCS#1 private-key PEM (`-----BEGIN RSA PRIVATE KEY-----`).
final String privateKeyPem;
/// SPKI public-key PEM in Nextcloud's expected shape: base64 wrapped at 64
/// characters per line. For RSA-2048 this is exactly 450 or 451 characters.
final String publicKeyPem;
const PushKeypairPems({
required this.privateKeyPem,
required this.publicKeyPem,
});
}
/// Generates a fresh keypair. Pure and synchronous so it can be run both inside
/// an isolate ([PushKeypair.generate]) and directly from unit tests. The public
/// PEM uses [RSAPublicKey.toFormattedPEM] which produces the 64-column SPKI
/// layout Nextcloud validates against.
PushKeypairPems generatePushKeypairPems() {
final keypair = RSAKeypair.fromRandom();
return PushKeypairPems(
privateKeyPem: keypair.privateKey.toPEM(),
publicKeyPem: keypair.publicKey.toFormattedPEM(),
);
}
PushKeypairPems _generateInIsolate(void _) => generatePushKeypairPems();
/// Persists and lazily generates the device RSA keypair used for push
/// encryption. The private key never leaves the secure keystore; the public
/// key PEM is what gets registered with Nextcloud.
class PushKeypair {
static const _privateKeyKey = 'push_device_private_key_pem';
static const _publicKeyKey = 'push_device_public_key_pem';
final FlutterSecureStorageLike _storage;
const PushKeypair({FlutterSecureStorageLike? storage})
: _storage = storage ?? const _DefaultStorage();
/// Returns the stored keypair PEMs, generating and persisting a fresh keypair
/// on first use. Generation is offloaded to an isolate because RSA-2048 key
/// generation blocks the UI thread for a noticeable moment.
Future<PushKeypairPems> ensure() async {
final existing = await _load();
if (existing != null) return existing;
final generated = await compute(_generateInIsolate, null);
await _storage.write(key: _privateKeyKey, value: generated.privateKeyPem);
await _storage.write(key: _publicKeyKey, value: generated.publicKeyPem);
return generated;
}
/// Loads the stored private key, or `null` when no keypair exists yet.
Future<RSAPrivateKey?> loadPrivateKey() async {
final pem = await _storage.read(key: _privateKeyKey);
if (pem == null || pem.isEmpty) return null;
return RSAPrivateKey.fromPEM(pem);
}
Future<String?> loadPublicKeyPem() => _storage.read(key: _publicKeyKey);
Future<void> clear() async {
await _storage.delete(key: _privateKeyKey);
await _storage.delete(key: _publicKeyKey);
}
Future<PushKeypairPems?> _load() async {
final priv = await _storage.read(key: _privateKeyKey);
final pub = await _storage.read(key: _publicKeyKey);
if (priv == null || priv.isEmpty || pub == null || pub.isEmpty) return null;
return PushKeypairPems(privateKeyPem: priv, publicKeyPem: pub);
}
}
/// Minimal storage contract so tests can inject an in-memory fake instead of
/// touching the platform keystore.
abstract class FlutterSecureStorageLike {
Future<String?> read({required String key});
Future<void> write({required String key, required String? value});
Future<void> delete({required String key});
}
class _DefaultStorage implements FlutterSecureStorageLike {
const _DefaultStorage();
@override
Future<String?> read({required String key}) =>
pushSecureStorage.read(key: key);
@override
Future<void> write({required String key, required String? value}) =>
pushSecureStorage.write(key: key, value: value);
@override
Future<void> delete({required String key}) =>
pushSecureStorage.delete(key: key);
}
+193
View File
@@ -0,0 +1,193 @@
import 'dart:developer';
import 'package:crypton/crypton.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../notification/notification_service.dart';
import 'nid_store.dart';
import 'push_decryptor.dart';
import 'push_keypair.dart';
import 'push_registration_store.dart';
import 'push_renderer.dart';
import 'push_subject.dart';
/// How an incoming FCM payload should be interpreted.
enum PushKind {
/// Encrypted Nextcloud push-v2 notification (`subject` + `signature`).
nextcloud,
/// Plaintext MarianumConnect direct push (`source == "connect"`).
connect,
/// Neither — ignored.
unknown,
}
/// Classifies a raw FCM data map. Pure, so it's unit-testable and safe in any
/// isolate. Nextcloud pushes are distinguished by the presence of both
/// `subject` and `signature`; Connect pushes by `source == "connect"`.
PushKind classifyPush(Map<String, dynamic> data) {
final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false;
final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false;
if (hasSubject && hasSignature) return PushKind.nextcloud;
if (data['source'] == 'connect') return PushKind.connect;
return PushKind.unknown;
}
/// Verifies, decrypts, and renders incoming push messages. Delete-pushes cancel
/// the matching tray notification via [NidStore]. Works both in the FCM
/// background isolate and the foreground.
class PushMessageHandler {
final PushKeypair _keypair;
final PushRegistrationStore _registrationStore;
final PushRenderer _renderer;
final NidStore _nidStore;
PushMessageHandler({
PushKeypair? keypair,
PushRegistrationStore? registrationStore,
PushRenderer? renderer,
NidStore? nidStore,
}) : _keypair = keypair ?? const PushKeypair(),
_registrationStore = registrationStore ?? const PushRegistrationStore(),
_renderer = renderer ?? PushRenderer(),
_nidStore = nidStore ?? NidStore();
/// Background isolate entry point registered with
/// `FirebaseMessaging.onBackgroundMessage`.
@pragma('vm:entry-point')
static Future<void> onBackgroundMessage(RemoteMessage message) async {
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message);
}
/// Processes [message]. In the foreground, pass [foreground] true and
/// [openChatToken] so a message for the currently open chat is suppressed
/// (the long-poll already shows it) instead of raising a tray notification.
Future<void> handle(
RemoteMessage message, {
bool foreground = false,
String? openChatToken,
}) async {
final data = message.data;
switch (classifyPush(data)) {
case PushKind.connect:
await _handleConnect(message, foreground: foreground);
break;
case PushKind.nextcloud:
await _handleNextcloud(
data,
foreground: foreground,
openChatToken: openChatToken,
);
break;
case PushKind.unknown:
break;
}
}
Future<void> _handleConnect(
RemoteMessage message, {
required bool foreground,
}) async {
// On iOS the alert is delivered natively by the system; only Android needs
// to render the plaintext payload locally.
final data = message.data;
final title = data['title'] as String?;
final body = data['body'] as String?;
if (title == null) return;
await _renderer.renderConnect(
title: title,
body: body ?? '',
data: data.map((k, v) => MapEntry(k, '$v')),
);
}
Future<void> _handleNextcloud(
Map<String, dynamic> data, {
required bool foreground,
required String? openChatToken,
}) async {
final subjectBase64 = data['subject'] as String;
final signatureBase64 = data['signature'] as String;
final privateKey = await _keypair.loadPrivateKey();
if (privateKey == null) {
log('Push: no device private key, cannot decrypt');
return;
}
final serverPublicKey = await _loadServerPublicKey();
final decryptor = PushDecryptor(
devicePrivateKey: privateKey,
serverPublicKey: serverPublicKey,
);
if (!decryptor.verify(subjectBase64, signatureBase64)) {
log('Push: signature verification failed');
return;
}
final subject = decryptor.decrypt(subjectBase64);
if (subject == null) {
log('Push: could not decrypt subject');
return;
}
if (subject.isAnyDelete) {
await _handleDelete(subject);
return;
}
// Foreground + the referenced chat already open: the long-poll renders the
// message, so just make sure no stale tray entry lingers.
if (foreground &&
subject.isTalk &&
subject.id != null &&
subject.id == openChatToken) {
return;
}
await _renderer.render(subject);
}
Future<void> _handleDelete(PushSubject subject) async {
if (subject.deleteAll) {
final all = await _nidStore.all();
for (final entry in all) {
await _cancel(entry);
}
await _nidStore.clear();
return;
}
final nids = <int>[
if (subject.delete && subject.nid != null) subject.nid!,
...subject.nids,
];
for (final nid in nids) {
final entry = await _nidStore.get(nid);
if (entry != null) await _cancel(entry);
await _nidStore.delete(nid);
}
}
Future<void> _cancel(NidEntry entry) async {
try {
await NotificationService().flutterLocalNotificationsPlugin.cancel(
id: entry.notificationId,
tag: entry.tag,
);
} on Object catch (e) {
log('Push: cancel ${entry.nid} failed: $e');
}
}
Future<RSAPublicKey?> _loadServerPublicKey() async {
final pem = await _registrationStore.serverPublicKeyPem();
if (pem == null || pem.isEmpty) return null;
try {
return RSAPublicKey.fromPEM(pem);
} on Object {
return null;
}
}
}
+251
View File
@@ -0,0 +1,251 @@
import 'dart:developer';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
import 'package:package_info_plus/package_info_plus.dart';
import '../api/marianumcloud/app_password/delete_app_password.dart';
import '../api/marianumcloud/app_password/get_app_password.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../model/account_data.dart';
import '../model/endpoint_data.dart';
import 'nextcloud_push_api.dart';
import 'push_keypair.dart';
import 'push_registration_store.dart';
/// Orchestrates the full push-v2 registration lifecycle:
/// Nextcloud device registration → MarianumConnect proxy registration, plus
/// unregister and token-refresh handling.
class PushRegistration {
final PushKeypair _keypair;
final PushRegistrationStore _store;
final NextcloudPushApi _nextcloud;
PushRegistration({
PushKeypair? keypair,
PushRegistrationStore? store,
NextcloudPushApi? nextcloud,
}) : _keypair = keypair ?? const PushKeypair(),
_store = store ?? const PushRegistrationStore(),
_nextcloud = nextcloud ?? NextcloudPushApi();
String get _platform => Platform.isIOS ? 'ios' : 'android';
/// Derives the push-proxy base URL from the active MarianumConnect endpoint,
/// so a beta/dev build registers against the matching proxy automatically.
String get _proxyServer => '${MarianumConnectEndpoint.current()}/push-proxy/';
/// Nextcloud origin the registration targets (full origin, no trailing
/// slash) — persisted alongside the registration to detect endpoint changes.
String get _ncBaseUrl => 'https://${EndpointData().nextcloud().full()}';
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
/// registration binds to it, so it must be obtained before registering.
Future<void> ensureAppPassword() async {
if (AccountData().hasAppPassword()) return;
try {
final appPassword = await GetAppPassword().run();
await AccountData().setAppPassword(appPassword);
} on Object catch (e) {
log('Push: could not obtain app password (non-blocking): $e');
}
}
/// Registers this device end-to-end. No-op-safe: transport failures are
/// logged and swallowed so callers can fire-and-forget.
Future<void> register() async {
try {
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken == null || fcmToken.isEmpty) {
log('Push: no FCM token, skipping registration');
return;
}
await ensureAppPassword();
await _persistNativeAuthContext();
final proxyServer = _proxyServer;
final ncBaseUrl = _ncBaseUrl;
final pems = await _keypair.ensure();
final registration = await _nextcloud.register(
pushTokenHash: generatePushTokenHash(fcmToken),
devicePublicKeyPem: pems.publicKeyPem,
proxyServer: proxyServer,
);
await _store.save(
deviceIdentifier: registration.deviceIdentifier,
serverPublicKeyPem: registration.publicKey,
fcmToken: fcmToken,
proxyServer: proxyServer,
ncBaseUrl: ncBaseUrl,
);
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
await PushDeviceRegister().run(
deviceIdentifier: registration.deviceIdentifier,
deviceIdentifierSignature: registration.signature,
userPublicKey: registration.publicKey,
pushToken: fcmToken,
platform: _platform,
appVersion: appVersion,
);
log('Push: registered (created=${registration.created})');
} on Object catch (e) {
log('Push: registration failed: $e');
}
}
/// Writes the username and Nextcloud base URL into the shared keychain so the
/// native iOS Talk action handler can authenticate OCS calls without the
/// Flutter engine. Best-effort — a failure here must not abort registration.
Future<void> _persistNativeAuthContext() async {
try {
final endpoint = EndpointData().nextcloud();
await _store.saveNativeAuthContext(
username: AccountData().getUsername(),
baseUrl: 'https://${endpoint.full()}',
);
} on Object catch (e) {
log('Push: could not persist native auth context: $e');
}
}
/// Removes the subscription from Nextcloud and the proxy. Best-effort.
Future<void> unregister() async {
final deviceIdentifier = await _store.deviceIdentifier();
try {
await _nextcloud.unregister();
} on Object catch (e) {
log('Push: NC unregister failed: $e');
}
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
try {
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
} on Object catch (e) {
log('Push: proxy unregister failed: $e');
}
}
await _store.clear();
}
/// Pure decision for whether a persisted registration endpoint no longer
/// matches the currently active one. A missing/empty stored value never
/// forces a re-registration — old installs (pre endpoint-tracking) heal via
/// the regular register-on-start path instead of a forced extra roundtrip.
static bool endpointChanged({
required String? registered,
required String current,
}) => registered != null && registered.isNotEmpty && registered != current;
/// True when an existing registration was made against a different
/// MarianumConnect proxy or Nextcloud base URL than the ones currently
/// configured (dev-tools endpoint switch, live/beta/custom).
Future<bool> needsEndpointReRegistration() async {
if (!await _store.isRegistered()) return false;
return endpointChanged(
registered: await _store.registeredProxyServer(),
current: _proxyServer,
) ||
endpointChanged(
registered: await _store.registeredNcBaseUrl(),
current: _ncBaseUrl,
);
}
/// Re-registers when the active endpoints diverge from the registered ones.
/// The NC POST updates the existing subscription server-side to the new
/// proxyServer URL; afterwards the device row lands at the NEW backend via
/// `PUT me/push-device`. No DELETE at the old proxy: its bearer token belongs
/// to a different token universe (cleared on endpoint switch) and the stale
/// row ages out through the backend's cleanup cron once pushes stop.
Future<void> reRegisterIfEndpointChanged() async {
if (!await needsEndpointReRegistration()) return;
log('Push: endpoint changed, re-registering');
await register();
}
/// Pure decision for whether push may be delivered given an OS permission
/// [status]: only an explicit denial blocks registration. `authorized` and
/// `provisional` obviously allow it; `notDetermined` is kept permissive so a
/// transient plugin/platform hiccup never silently disables push (the OS
/// simply won't show notifications until the user decides).
static bool isPermissionUsable(AuthorizationStatus status) =>
status != AuthorizationStatus.denied;
/// Requests the OS notification permission (covers iOS + Android 13) and
/// returns whether registration should proceed. Errors from the plugin are
/// treated as usable — better a possibly-idle registration than silently
/// losing push over a transient failure.
static Future<bool> requestOsPermission() async {
try {
final settings = await FirebaseMessaging.instance.requestPermission();
return isPermissionUsable(settings.authorizationStatus);
} on Object catch (e) {
log('Push: requestPermission failed: $e');
return true;
}
}
/// True when the user has explicitly denied the OS notification permission.
/// Read-only (no prompt) — used by the settings UI to surface the state.
static Future<bool> isOsPermissionDenied() async {
try {
final settings = await FirebaseMessaging.instance
.getNotificationSettings();
return settings.authorizationStatus == AuthorizationStatus.denied;
} on Object {
return false;
}
}
/// Registers this device when push is both user-enabled and backend-capable.
/// Requests the OS notification permission first (covers iOS + Android 13);
/// an explicit denial skips registration entirely so NC/proxy never push to a
/// device that cannot display notifications. Safe to call on every start —
/// Nextcloud dedups an unchanged registration — which also self-heals a
/// device whose registration was lost.
static Future<void> syncSubscription({
required bool enabled,
required bool capable,
}) async {
if (!(enabled && capable)) return;
if (!await requestOsPermission()) {
log('Push: OS notification permission denied, skipping registration');
return;
}
final registration = PushRegistration();
// register() below refreshes an unchanged subscription anyway; the check
// only surfaces the endpoint switch in the log for diagnosability.
if (await registration.needsEndpointReRegistration()) {
log('Push: registered endpoints outdated, re-registering');
}
await registration.register();
}
/// Re-registers after an FCM token refresh. The Nextcloud device identifier
/// stays stable across refreshes, so this simply re-runs registration (NC
/// first, then the proxy) with the new token.
Future<void> onTokenRefresh() => register();
/// Full teardown for logout: unregister push, revoke the app password, then
/// clear it locally. Ordered so the proxy stops pushing before credentials
/// are gone.
Future<void> logoutCleanup() async {
await unregister();
try {
await DeleteAppPassword().run();
} on Object catch (e) {
log('Push: delete app password failed: $e');
}
await AccountData().clearAppPassword();
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'push_secure_storage.dart';
/// Persists the bookkeeping produced by a successful push registration:
/// the Nextcloud device identifier, the per-user server public key (needed to
/// verify incoming push signatures), the FCM token the registration was made
/// with (so a token refresh can be detected) and the endpoints it was bound to
/// (so an endpoint switch in the dev tools can be detected).
class PushRegistrationStore {
static const _deviceIdentifierKey = 'push_device_identifier';
static const _serverPublicKeyKey = 'push_server_public_key_pem';
static const _registeredTokenKey = 'push_registered_fcm_token';
static const _proxyServerKey = 'push_registered_proxy_server';
static const _ncBaseUrlKey = 'push_registered_nc_base_url';
// Native-only context: the iOS AppDelegate answers Talk notification actions
// (reply / mark-as-read) directly via URLSession while the Flutter engine is
// not guaranteed to run. It needs the Nextcloud username and base URL from the
// shared (group-scoped) keychain; the app password already lives there
// (AccountData writes `nextcloud_app_password` group-scoped).
static const _usernameKey = 'nextcloud_username';
static const _baseUrlKey = 'nextcloud_base_url';
const PushRegistrationStore();
Future<void> save({
required String deviceIdentifier,
required String serverPublicKeyPem,
required String fcmToken,
required String proxyServer,
required String ncBaseUrl,
}) async {
await pushSecureStorage.write(
key: _deviceIdentifierKey,
value: deviceIdentifier,
);
await pushSecureStorage.write(
key: _serverPublicKeyKey,
value: serverPublicKeyPem,
);
await pushSecureStorage.write(key: _registeredTokenKey, value: fcmToken);
await pushSecureStorage.write(key: _proxyServerKey, value: proxyServer);
await pushSecureStorage.write(key: _ncBaseUrlKey, value: ncBaseUrl);
}
/// Persists the username and Nextcloud base URL group-scoped so the native
/// iOS Talk action handler (AppDelegate) can authenticate OCS calls. The base
/// URL is a full origin like `https://cloud.marianum-fulda.de` (domain +
/// optional path, no trailing slash).
Future<void> saveNativeAuthContext({
required String username,
required String baseUrl,
}) async {
await pushSecureStorage.write(key: _usernameKey, value: username);
await pushSecureStorage.write(key: _baseUrlKey, value: baseUrl);
}
Future<String?> deviceIdentifier() =>
pushSecureStorage.read(key: _deviceIdentifierKey);
Future<String?> serverPublicKeyPem() =>
pushSecureStorage.read(key: _serverPublicKeyKey);
Future<String?> registeredFcmToken() =>
pushSecureStorage.read(key: _registeredTokenKey);
/// Proxy-server URL the current registration was made with.
Future<String?> registeredProxyServer() =>
pushSecureStorage.read(key: _proxyServerKey);
/// Nextcloud base URL the current registration was made against.
Future<String?> registeredNcBaseUrl() =>
pushSecureStorage.read(key: _ncBaseUrlKey);
/// True when a registration has been persisted (used by the cold-start
/// self-heal to decide whether to (re-)register).
Future<bool> isRegistered() async =>
(await registeredFcmToken())?.isNotEmpty ?? false;
Future<void> clear() async {
await pushSecureStorage.delete(key: _deviceIdentifierKey);
await pushSecureStorage.delete(key: _serverPublicKeyKey);
await pushSecureStorage.delete(key: _registeredTokenKey);
await pushSecureStorage.delete(key: _proxyServerKey);
await pushSecureStorage.delete(key: _ncBaseUrlKey);
await pushSecureStorage.delete(key: _usernameKey);
await pushSecureStorage.delete(key: _baseUrlKey);
}
}
+196
View File
@@ -0,0 +1,196 @@
import 'dart:convert';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../notification/notification_service.dart';
import '../notification/notification_tasks.dart';
import 'nid_store.dart';
import 'push_actions.dart';
import 'push_subject.dart';
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages get a [MessagingStyleInformation] with inline
/// reply + mark-as-read actions and a per-chat tag; everything else renders in
/// a generic channel.
class PushRenderer {
static const talkChannelId = 'talk_messages';
static const talkChannelName = 'Talk-Nachrichten';
static const generalChannelId = 'nextcloud_general';
static const generalChannelName = 'Benachrichtigungen';
static const String iosTalkCategory = 'TALK_MESSAGE';
final NidStore _nidStore;
PushRenderer({NidStore? nidStore}) : _nidStore = nidStore ?? NidStore();
FlutterLocalNotificationsPlugin get _plugin =>
NotificationService().flutterLocalNotificationsPlugin;
/// Creates the Android notification channels. Safe to call repeatedly.
static Future<void> ensureChannels() async {
final android = NotificationService().flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (android == null) return;
await android.createNotificationChannel(
const AndroidNotificationChannel(
talkChannelId,
talkChannelName,
description: 'Neue Nachrichten aus Nextcloud Talk',
importance: Importance.high,
),
);
await android.createNotificationChannel(
const AndroidNotificationChannel(
generalChannelId,
generalChannelName,
description: 'Allgemeine Benachrichtigungen',
),
);
}
/// Renders a decrypted Nextcloud push subject.
Future<void> render(PushSubject subject) async {
if (subject.isTalk) {
await _renderTalk(subject);
} else {
await _renderGeneric(subject);
}
}
Future<void> _renderTalk(PushSubject subject) async {
final nid = subject.nid ?? _fallbackId(subject.id);
final chatToken = subject.id;
final tag = chatToken != null
? NotificationTasks.chatTag(chatToken)
: 'talk_$nid';
final text = subject.subject ?? 'Neue Nachricht';
final (senderName, messageText) = _splitSender(text);
final payload = _payload(chatToken: chatToken, nid: nid);
final messagingStyle = MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'),
conversationTitle: senderName,
groupConversation: false,
messages: [
Message(messageText, DateTime.now(), Person(name: senderName)),
],
);
final androidDetails = AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
tag: tag,
styleInformation: messagingStyle,
actions: const [
AndroidNotificationAction(
kTalkReplyActionId,
'Antworten',
showsUserInterface: false,
cancelNotification: false,
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
),
AndroidNotificationAction(
kTalkMarkReadActionId,
'Gelesen',
showsUserInterface: false,
),
],
);
final iosDetails = DarwinNotificationDetails(
threadIdentifier: tag,
categoryIdentifier: iosTalkCategory,
);
await _nidStore.put(
NidEntry(nid: nid, notificationId: nid, tag: tag, chatToken: chatToken),
);
await _plugin.show(
id: nid,
title: senderName,
body: messageText,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
payload: payload,
);
}
Future<void> _renderGeneric(PushSubject subject) async {
final nid = subject.nid ?? _fallbackId(subject.subject);
const androidDetails = AndroidNotificationDetails(
generalChannelId,
generalChannelName,
);
const iosDetails = DarwinNotificationDetails();
await _nidStore.put(
NidEntry(nid: nid, notificationId: nid, tag: 'nc_$nid'),
);
await _plugin.show(
id: nid,
title: subject.subject ?? 'Neue Benachrichtigung',
body: null,
notificationDetails: const NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
payload: _payload(chatToken: null, nid: nid),
);
}
/// Renders a plaintext MarianumConnect direct push (Android only — iOS shows
/// the native alert itself).
Future<void> renderConnect({
required String title,
required String body,
Map<String, String>? data,
}) async {
final id = _fallbackId('$title$body');
const androidDetails = AndroidNotificationDetails(
generalChannelId,
generalChannelName,
importance: Importance.high,
priority: Priority.high,
);
await _plugin.show(
id: id,
title: title,
body: body,
notificationDetails: const NotificationDetails(android: androidDetails),
payload: data == null ? null : jsonEncode(data),
);
}
String _payload({required String? chatToken, required int nid}) =>
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
/// Splits a `"Sender: message"` subject into its parts, falling back to a
/// generic sender label when there's no delimiter.
(String, String) _splitSender(String subject) {
final idx = subject.indexOf(': ');
if (idx > 0 && idx < subject.length - 2) {
return (subject.substring(0, idx), subject.substring(idx + 2));
}
return ('Talk', subject);
}
/// Deterministic non-negative 31-bit id from a string, used when the push
/// carries no `nid`.
int _fallbackId(String? seed) {
if (seed == null || seed.isEmpty) return 0;
var hash = 0;
for (final unit in seed.codeUnits) {
hash = (hash * 31 + unit) & 0x7fffffff;
}
return hash;
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Keychain access group shared between the Runner and the (Phase 3) iOS
/// Notification Service Extension so the NSE can read the RSA private key,
/// the server public key and the Nextcloud app password to decrypt pushes
/// while the app is not running.
///
/// The value reuses the existing app-group id already present in the iOS
/// project (`ios/Runner/Runner.entitlements`). Phase 3 must additionally list
/// it under `keychain-access-groups` for both the Runner and the NSE target.
/// On Android `groupId` is ignored, so this is a no-op there.
const String kPushKeychainGroup = 'group.eu.mhsl.marianum.mobile.client.widget';
/// [IOSOptions] used for every push-related secure-storage entry. Uses
/// `first_unlock` accessibility so the NSE can read the key material after the
/// first device unlock following a reboot (the NSE may run while locked).
const IOSOptions kPushIosOptions = IOSOptions(
groupId: kPushKeychainGroup,
accessibility: KeychainAccessibility.first_unlock,
);
/// Shared secure storage instance for all push key material and registration
/// bookkeeping. Kept separate from [AccountData]'s default storage because the
/// entries here are group-scoped for NSE access.
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
iOptions: kPushIosOptions,
);
+66
View File
@@ -0,0 +1,66 @@
/// The decrypted `subject` JSON of a Nextcloud push-v2 notification.
///
/// Covers the full shape the notifications app emits, including the three
/// delete variants which the neon `DecryptedSubject` helper only partially
/// models (it lacks `delete-multiple`/`nids`).
class PushSubject {
/// Nextcloud notification id.
final int? nid;
/// App that raised the notification (e.g. `spreed` for Talk).
final String? app;
/// Human-readable subject line.
final String? subject;
/// Notification type (e.g. `chat`, `background`).
final String? type;
/// App-specific object id. For Talk this is the chat/room token.
final String? id;
final bool delete;
final bool deleteMultiple;
final bool deleteAll;
/// Notification ids to remove for a `delete-multiple` push.
final List<int> nids;
const PushSubject({
this.nid,
this.app,
this.subject,
this.type,
this.id,
this.delete = false,
this.deleteMultiple = false,
this.deleteAll = false,
this.nids = const [],
});
bool get isAnyDelete => delete || deleteMultiple || deleteAll;
/// True when this notification originates from Nextcloud Talk.
bool get isTalk => app == 'spreed';
factory PushSubject.fromJson(Map<String, dynamic> json) {
final rawNids = json['nids'];
return PushSubject(
nid: (json['nid'] as num?)?.toInt(),
app: json['app'] as String?,
subject: json['subject'] as String?,
type: json['type'] as String?,
id: json['id'] as String?,
delete: json['delete'] == true,
deleteMultiple: json['delete-multiple'] == true,
deleteAll: json['delete-all'] == true,
nids: rawNids is List
? rawNids
.whereType<Object>()
.map((e) => e is num ? e.toInt() : int.tryParse('$e'))
.whereType<int>()
.toList()
: const [],
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'push_actions.dart';
/// Routes foreground notification interactions from the single
/// flutter_local_notifications response callback. Action responses (reply /
/// mark-read) are dispatched straight to [PushActions]; a plain tap publishes
/// the target chat token via [pendingChatToken] for [App] to navigate to.
class PushTapRouter {
PushTapRouter._();
/// Chat token of the most recently tapped Talk notification, or null. [App]
/// listens to this and opens the chat, then resets it to null.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
static void handleResponse(NotificationResponse response) {
final actionId = response.actionId;
if (actionId == kTalkReplyActionId || actionId == kTalkMarkReadActionId) {
// Reuse the isolate-safe action dispatch for foreground actions too.
PushActions.handleBackgroundResponse(response);
return;
}
final token = _chatTokenFrom(response.payload);
if (token != null) pendingChatToken.value = token;
}
static String? _chatTokenFrom(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final token = map['chatToken'];
return token is String && token.isNotEmpty ? token : null;
} on Object {
return null;
}
}
}