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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user