diff --git a/commitlint.yaml b/commitlint.yaml index d25d7a104ee..845cae9feb5 100644 --- a/commitlint.yaml +++ b/commitlint.yaml @@ -31,6 +31,7 @@ rules: - nextcloud_test_presets - notes_app - notifications_app + - notifications_push_repository - release - sort_box - talk_app diff --git a/packages/neon_framework/packages/notifications_push_repository/LICENSE b/packages/neon_framework/packages/notifications_push_repository/LICENSE new file mode 120000 index 00000000000..f0b83dad961 --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/LICENSE @@ -0,0 +1 @@ +../../../../assets/AGPL-3.0.txt \ No newline at end of file diff --git a/packages/neon_framework/packages/notifications_push_repository/analysis_options.yaml b/packages/neon_framework/packages/notifications_push_repository/analysis_options.yaml new file mode 100644 index 00000000000..bff1b129f3c --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:neon_lints/dart.yaml + +custom_lint: + rules: + - avoid_exports: false diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/notifications_push_repository.dart b/packages/neon_framework/packages/notifications_push_repository/lib/notifications_push_repository.dart new file mode 100644 index 00000000000..2e6b1a4760a --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/notifications_push_repository.dart @@ -0,0 +1,4 @@ +export 'src/models/models.dart' show PushNotification; +export 'src/notifications_push_repository.dart'; +export 'src/notifications_push_storage.dart'; +export 'src/utils/encryption.dart' show parseEncryptedPushNotifications; diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/models/models.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/models.dart new file mode 100644 index 00000000000..e39459f01af --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/models.dart @@ -0,0 +1,2 @@ +export 'push_notification.dart'; +export 'push_subscription.dart'; diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_notification.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_notification.dart new file mode 100644 index 00000000000..a70018b4431 --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_notification.dart @@ -0,0 +1,63 @@ +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; +import 'package:built_value/standard_json_plugin.dart'; +import 'package:crypton/crypton.dart'; +import 'package:nextcloud/notifications.dart' as notifications; + +part 'push_notification.g.dart'; + +/// Data for a push notification. +abstract class PushNotification implements Built { + /// Creates a new [PushNotification]. + factory PushNotification([void Function(PushNotificationBuilder)? updates]) = _$PushNotification; + + const PushNotification._(); + + /// Creates a new PushNotification object from the given [json] data containing an encrypted [subject]. + /// + /// Use [PushNotification.fromJson] when the [subject] is not encrypted. + factory PushNotification.fromEncrypted( + Map json, + String accountID, + RSAPrivateKey privateKey, + ) { + final subject = notifications.DecryptedSubject.fromEncrypted(privateKey, json['subject'] as String); + + return PushNotification( + (b) => b + ..accountID = accountID + ..priority = json['priority'] as String + ..type = json['type'] as String + ..subject.replace(subject), + ); + } + + /// Creates a new PushNotification object from the given [json] data. + /// + /// Use [PushNotification.fromEncrypted] when you the [subject] is still encrypted. + factory PushNotification.fromJson(Map json) => _serializers.deserializeWith(serializer, json)!; + + /// Parses this object into a json like map. + Map toJson() => _serializers.serializeWith(serializer, this)! as Map; + + /// The serializer for [PushNotification]. + static Serializer get serializer => _$pushNotificationSerializer; + + /// The account associated with this notification. + String get accountID; + + /// The priority of the notification. + String get priority; + + /// The type of the notification. + String get type; + + /// The subject of this notification. + notifications.DecryptedSubject get subject; +} + +final Serializers _serializers = (Serializers().toBuilder() + ..add(notifications.DecryptedSubject.serializer) + ..add(PushNotification.serializer) + ..addPlugin(StandardJsonPlugin())) + .build(); diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_notification.g.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_notification.g.dart new file mode 100644 index 00000000000..80e9eada48b --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_notification.g.dart @@ -0,0 +1,195 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'push_notification.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +Serializer _$pushNotificationSerializer = _$PushNotificationSerializer(); + +class _$PushNotificationSerializer implements StructuredSerializer { + @override + final Iterable types = const [PushNotification, _$PushNotification]; + @override + final String wireName = 'PushNotification'; + + @override + Iterable serialize(Serializers serializers, PushNotification object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'accountID', + serializers.serialize(object.accountID, specifiedType: const FullType(String)), + 'priority', + serializers.serialize(object.priority, specifiedType: const FullType(String)), + 'type', + serializers.serialize(object.type, specifiedType: const FullType(String)), + 'subject', + serializers.serialize(object.subject, specifiedType: const FullType(notifications.DecryptedSubject)), + ]; + + return result; + } + + @override + PushNotification deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = PushNotificationBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'accountID': + result.accountID = serializers.deserialize(value, specifiedType: const FullType(String))! as String; + break; + case 'priority': + result.priority = serializers.deserialize(value, specifiedType: const FullType(String))! as String; + break; + case 'type': + result.type = serializers.deserialize(value, specifiedType: const FullType(String))! as String; + break; + case 'subject': + result.subject.replace(serializers.deserialize(value, + specifiedType: const FullType(notifications.DecryptedSubject))! as notifications.DecryptedSubject); + break; + } + } + + return result.build(); + } +} + +class _$PushNotification extends PushNotification { + @override + final String accountID; + @override + final String priority; + @override + final String type; + @override + final notifications.DecryptedSubject subject; + + factory _$PushNotification([void Function(PushNotificationBuilder)? updates]) => + (PushNotificationBuilder()..update(updates))._build(); + + _$PushNotification._({required this.accountID, required this.priority, required this.type, required this.subject}) + : super._() { + BuiltValueNullFieldError.checkNotNull(accountID, r'PushNotification', 'accountID'); + BuiltValueNullFieldError.checkNotNull(priority, r'PushNotification', 'priority'); + BuiltValueNullFieldError.checkNotNull(type, r'PushNotification', 'type'); + BuiltValueNullFieldError.checkNotNull(subject, r'PushNotification', 'subject'); + } + + @override + PushNotification rebuild(void Function(PushNotificationBuilder) updates) => (toBuilder()..update(updates)).build(); + + @override + PushNotificationBuilder toBuilder() => PushNotificationBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is PushNotification && + accountID == other.accountID && + priority == other.priority && + type == other.type && + subject == other.subject; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, accountID.hashCode); + _$hash = $jc(_$hash, priority.hashCode); + _$hash = $jc(_$hash, type.hashCode); + _$hash = $jc(_$hash, subject.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'PushNotification') + ..add('accountID', accountID) + ..add('priority', priority) + ..add('type', type) + ..add('subject', subject)) + .toString(); + } +} + +class PushNotificationBuilder implements Builder { + _$PushNotification? _$v; + + String? _accountID; + String? get accountID => _$this._accountID; + set accountID(String? accountID) => _$this._accountID = accountID; + + String? _priority; + String? get priority => _$this._priority; + set priority(String? priority) => _$this._priority = priority; + + String? _type; + String? get type => _$this._type; + set type(String? type) => _$this._type = type; + + notifications.DecryptedSubjectBuilder? _subject; + notifications.DecryptedSubjectBuilder get subject => _$this._subject ??= notifications.DecryptedSubjectBuilder(); + set subject(notifications.DecryptedSubjectBuilder? subject) => _$this._subject = subject; + + PushNotificationBuilder(); + + PushNotificationBuilder get _$this { + final $v = _$v; + if ($v != null) { + _accountID = $v.accountID; + _priority = $v.priority; + _type = $v.type; + _subject = $v.subject.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(PushNotification other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$PushNotification; + } + + @override + void update(void Function(PushNotificationBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + PushNotification build() => _build(); + + _$PushNotification _build() { + _$PushNotification _$result; + try { + _$result = _$v ?? + _$PushNotification._( + accountID: BuiltValueNullFieldError.checkNotNull(accountID, r'PushNotification', 'accountID'), + priority: BuiltValueNullFieldError.checkNotNull(priority, r'PushNotification', 'priority'), + type: BuiltValueNullFieldError.checkNotNull(type, r'PushNotification', 'type'), + subject: subject.build()); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'subject'; + subject.build(); + } catch (e) { + throw BuiltValueNestedFieldError(r'PushNotification', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_subscription.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_subscription.dart new file mode 100644 index 00000000000..b486ac4aacb --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_subscription.dart @@ -0,0 +1,30 @@ +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; +import 'package:built_value/standard_json_plugin.dart'; +import 'package:meta/meta.dart'; +import 'package:nextcloud/notifications.dart' as notifications; + +part 'push_subscription.g.dart'; + +@internal +abstract class PushSubscription implements Built { + factory PushSubscription([void Function(PushSubscriptionBuilder)? updates]) = _$PushSubscription; + + const PushSubscription._(); + + factory PushSubscription.fromJson(Map json) => _serializers.deserializeWith(serializer, json)!; + + Map toJson() => _serializers.serializeWith(serializer, this)! as Map; + + static Serializer get serializer => _$pushSubscriptionSerializer; + + String? get endpoint; + + notifications.PushDevice? get pushDevice; +} + +final Serializers _serializers = (Serializers().toBuilder() + ..add(notifications.PushDevice.serializer) + ..add(PushSubscription.serializer) + ..addPlugin(StandardJsonPlugin())) + .build(); diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_subscription.g.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_subscription.g.dart new file mode 100644 index 00000000000..af2575b47a8 --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_subscription.g.dart @@ -0,0 +1,159 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'push_subscription.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +Serializer _$pushSubscriptionSerializer = _$PushSubscriptionSerializer(); + +class _$PushSubscriptionSerializer implements StructuredSerializer { + @override + final Iterable types = const [PushSubscription, _$PushSubscription]; + @override + final String wireName = 'PushSubscription'; + + @override + Iterable serialize(Serializers serializers, PushSubscription object, + {FullType specifiedType = FullType.unspecified}) { + final result = []; + Object? value; + value = object.endpoint; + if (value != null) { + result + ..add('endpoint') + ..add(serializers.serialize(value, specifiedType: const FullType(String))); + } + value = object.pushDevice; + if (value != null) { + result + ..add('pushDevice') + ..add(serializers.serialize(value, specifiedType: const FullType(notifications.PushDevice))); + } + return result; + } + + @override + PushSubscription deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = PushSubscriptionBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'endpoint': + result.endpoint = serializers.deserialize(value, specifiedType: const FullType(String)) as String?; + break; + case 'pushDevice': + result.pushDevice.replace(serializers.deserialize(value, + specifiedType: const FullType(notifications.PushDevice))! as notifications.PushDevice); + break; + } + } + + return result.build(); + } +} + +class _$PushSubscription extends PushSubscription { + @override + final String? endpoint; + @override + final notifications.PushDevice? pushDevice; + + factory _$PushSubscription([void Function(PushSubscriptionBuilder)? updates]) => + (PushSubscriptionBuilder()..update(updates))._build(); + + _$PushSubscription._({this.endpoint, this.pushDevice}) : super._(); + + @override + PushSubscription rebuild(void Function(PushSubscriptionBuilder) updates) => (toBuilder()..update(updates)).build(); + + @override + PushSubscriptionBuilder toBuilder() => PushSubscriptionBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is PushSubscription && endpoint == other.endpoint && pushDevice == other.pushDevice; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, endpoint.hashCode); + _$hash = $jc(_$hash, pushDevice.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'PushSubscription') + ..add('endpoint', endpoint) + ..add('pushDevice', pushDevice)) + .toString(); + } +} + +class PushSubscriptionBuilder implements Builder { + _$PushSubscription? _$v; + + String? _endpoint; + String? get endpoint => _$this._endpoint; + set endpoint(String? endpoint) => _$this._endpoint = endpoint; + + notifications.PushDeviceBuilder? _pushDevice; + notifications.PushDeviceBuilder get pushDevice => _$this._pushDevice ??= notifications.PushDeviceBuilder(); + set pushDevice(notifications.PushDeviceBuilder? pushDevice) => _$this._pushDevice = pushDevice; + + PushSubscriptionBuilder(); + + PushSubscriptionBuilder get _$this { + final $v = _$v; + if ($v != null) { + _endpoint = $v.endpoint; + _pushDevice = $v.pushDevice?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(PushSubscription other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$PushSubscription; + } + + @override + void update(void Function(PushSubscriptionBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + PushSubscription build() => _build(); + + _$PushSubscription _build() { + _$PushSubscription _$result; + try { + _$result = _$v ?? _$PushSubscription._(endpoint: endpoint, pushDevice: _pushDevice?.build()); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'pushDevice'; + _pushDevice?.build(); + } catch (e) { + throw BuiltValueNestedFieldError(r'PushSubscription', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/notifications_push_repository.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/notifications_push_repository.dart new file mode 100644 index 00000000000..59dd57b8843 --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/notifications_push_repository.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:account_repository/account_repository.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:collection/collection.dart'; +import 'package:crypton/crypton.dart'; +import 'package:logging/logging.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:nextcloud/notifications.dart' as notifications; +import 'package:notifications_push_repository/src/models/models.dart'; +import 'package:notifications_push_repository/src/notifications_push_storage.dart'; +import 'package:notifications_push_repository/src/utils/encryption.dart'; +import 'package:unifiedpush/unifiedpush.dart'; + +/// Signature of the callback triggered by UnifiedPush when a new push notification is received. +typedef OnMessageCallback = void Function(Uint8List message, String accountID); + +final _log = Logger('NotificationsPushRepository'); + +/// A repository for managing push notifications and subscriptions. +final class NotificationsPushRepository { + /// Creates a new [NotificationsPushRepository]. + NotificationsPushRepository({ + required AccountRepository accountRepository, + required NotificationsPushStorage storage, + required OnMessageCallback onMessage, + }) : _accountRepository = accountRepository, + _storage = storage, + _onMessage = onMessage; + + final AccountRepository _accountRepository; + final NotificationsPushStorage _storage; + final OnMessageCallback _onMessage; + var _initialized = false; + StreamSubscription>? _accountsListener; + late final RSAPrivateKey _privateKey; + late BuiltList _accounts; + String? _selectedDistributor; + String? _previousDistributor; + + /// Returns all available distributors. + Future> get distributors async { + final distributors = await UnifiedPush.getDistributors(); + return distributors.toBuiltList(); + } + + /// Closes all open resources of the repository. + void close() { + unawaited(_accountsListener?.cancel()); + } + + /// Changes the used distributor to the new [distributor]. + Future changeDistributor(String? distributor) async { + if (!_initialized) { + throw StateError('Repository not initialized'); + } + + if (distributor == _selectedDistributor) { + return; + } + + _selectedDistributor = distributor; + await _updateSubscriptions(); + } + + /// Initializes the repository. + Future initialize() async { + if (_initialized) { + return; + } + + _accounts = (await _accountRepository.accounts.first).accounts; + _accountsListener = _accountRepository.accounts.skip(1).map((e) => e.accounts).listen((accounts) async { + _accounts = accounts; + await _updateSubscriptions(); + }); + + _selectedDistributor = await UnifiedPush.getDistributor(); + _previousDistributor = _selectedDistributor; + + _privateKey = await getDevicePrivateKey(_storage); + + await UnifiedPush.initialize( + onNewEndpoint: (endpoint, accountID) async { + final account = _accounts.firstWhereOrNull((account) => account.id == accountID); + if (account == null) { + _log.finer('Account $accountID not found'); // coverage:ignore-line + return; + } + + final subscriptions = await _storage.readSubscriptions(); + var subscription = subscriptions[account.id] ?? PushSubscription(); + if (subscription.endpoint == endpoint) { + _log.fine('UnifiedPush endpoint not changed for ${account.id}'); + return; + } + subscription = subscription.rebuild((b) => b.endpoint = endpoint); + + subscription = await _unregisterNextcloud(accountID, account, subscription); + subscription = await _registerNextcloud(account, endpoint, subscription); + await _saveUpdatedSubscription(subscriptions, account.id, subscription); + }, + onUnregistered: (accountID) async { + final account = _accounts.firstWhereOrNull((account) => account.id == accountID); + if (account == null) { + _log.finer('Account $accountID not found'); // coverage:ignore-line + return; + } + + final subscriptions = await _storage.readSubscriptions(); + var subscription = subscriptions[accountID]; + if (subscription == null) { + _log.finer('Subscription for $accountID not found'); // coverage:ignore-line + return; + } + + subscription = subscription.rebuild((b) => b.endpoint = null); + subscription = await _unregisterNextcloud(accountID, account, subscription); + + await _saveUpdatedSubscription(subscriptions, accountID, subscription); + }, + onMessage: _onMessage, + ); + + await _updateSubscriptions(); + + _initialized = true; + } + + Future _updateSubscriptions() async { + if (_selectedDistributor == null) { + _log.fine('Push notifications disabled, removing all subscriptions'); + + await _unregisterUnifiedPush(); + return; + } + + if (_selectedDistributor != _previousDistributor) { + _previousDistributor = _selectedDistributor; + _log.finer('UnifiedPush distributor changed to $_selectedDistributor'); + + await _unregisterUnifiedPush(); + await UnifiedPush.saveDistributor(_selectedDistributor!); + } + + await _registerUnifiedPush(); + } + + Future _registerUnifiedPush() async { + // Notifications will only work on accounts with app password + for (final account in _accounts.where((a) => a.password != null)) { + _log.finer('Registering ${account.id} for UnifiedPush'); + + await UnifiedPush.registerApp(account.id); + } + } + + Future _unregisterUnifiedPush() async { + final subscriptions = await _storage.readSubscriptions(); + for (final entry in subscriptions.entries) { + final accountID = entry.key; + final account = _accounts.firstWhereOrNull((account) => account.id == accountID); + var subscription = entry.value; + + subscription = await _unregisterNextcloud(accountID, account, subscription); + if (subscription.endpoint != null) { + _log.finer('Unregistering $accountID from UnifiedPush'); + + subscription = subscription.rebuild((b) => b.endpoint = null); + await _saveUpdatedSubscription(subscriptions, accountID, subscription); + + await UnifiedPush.unregister(accountID); + } else { + await _saveUpdatedSubscription(subscriptions, accountID, subscription); + } + } + } + + Future _registerNextcloud(Account account, String endpoint, PushSubscription subscription) async { + _log.finer('Registering ${account.id} at Nextcloud'); + + try { + final response = await account.client.notifications.push.registerDevice( + $body: notifications.PushRegisterDeviceRequestApplicationJson( + (b) => b + ..pushTokenHash = notifications.generatePushTokenHash(endpoint) + ..devicePublicKey = _privateKey.publicKey.toFormattedPEM() + ..proxyServer = '$endpoint#', + ), + ); + + return subscription.rebuild( + (b) => b + ..endpoint = endpoint + ..pushDevice.replace(response.body.ocs.data), + ); + } on DynamiteApiException catch (error) { + _log.warning('Failed to register ${account.id} at Nextcloud', error); + } + + return subscription; + } + + Future _unregisterNextcloud( + String accountID, + Account? account, + PushSubscription subscription, + ) async { + if (subscription.pushDevice != null) { + _log.finer('Unregistering $accountID from Nextcloud'); + + try { + await account?.client.notifications.push.removeDevice(); + } on DynamiteApiException catch (error) { + _log.warning('Failed to unregister $accountID at Nextcloud', error); + } + } + + // Remove the push device either way, as we need to register for a new one later + return subscription.rebuild((b) => b.pushDevice = null); + } + + Future _saveUpdatedSubscription( + BuiltMap subscriptions, + String accountID, + PushSubscription subscription, + ) async { + await _storage.saveSubscriptions( + subscriptions.rebuild( + (b) { + if (subscription.endpoint == null && subscription.pushDevice == null) { + _log.finer('Removing subscription for $accountID'); + b.remove(accountID); + } else { + _log.finer('Saving subscription for $accountID'); + b[accountID] = subscription; + } + }, + ), + ); + } +} diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/notifications_push_storage.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/notifications_push_storage.dart new file mode 100644 index 00000000000..795d561fa89 --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/notifications_push_storage.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; + +import 'package:built_collection/built_collection.dart'; +import 'package:crypton/crypton.dart'; +import 'package:neon_framework/storage.dart'; +import 'package:notifications_push_repository/src/models/models.dart'; + +/// A storage for push subscriptions and the device private key. +class NotificationsPushStorage { + /// Creates a new [NotificationsPushStorage]. + NotificationsPushStorage({ + required SingleValueStore devicePrivateKeyPersistence, + required Persistence pushSubscriptionsPersistence, + }) : _pushSubscriptionsPersistence = pushSubscriptionsPersistence, + _devicePrivateKeyPersistence = devicePrivateKeyPersistence; + + final SingleValueStore _devicePrivateKeyPersistence; + final Persistence _pushSubscriptionsPersistence; + + /// Reads the stored device private key. + RSAPrivateKey? readDevicePrivateKey() { + final privateKey = _devicePrivateKeyPersistence.getString(); + if (privateKey != null && privateKey.isNotEmpty) { + return RSAPrivateKey.fromPEM(privateKey); + } + + return null; + } + + /// Saves a new device [privateKey]. + Future saveDevicePrivateKey(RSAPrivateKey privateKey) async { + await _devicePrivateKeyPersistence.setString(privateKey.toPEM()); + } + + /// Reads all stored subscriptions. + Future> readSubscriptions() async { + final builder = MapBuilder(); + + final keys = await _pushSubscriptionsPersistence.keys(); + for (final key in keys) { + final value = await _pushSubscriptionsPersistence.getValue(key); + builder[key] = PushSubscription.fromJson(json.decode(value! as String) as Map); + } + + return builder.build(); + } + + /// Saves the updated [subscriptions]. + /// + /// Removes all stored subscriptions that are no longer present in the new [subscriptions]. + Future saveSubscriptions(BuiltMap subscriptions) async { + for (final entry in subscriptions.entries) { + _pushSubscriptionsPersistence.setValue(entry.key, json.encode(entry.value.toJson())); + } + + final keys = await _pushSubscriptionsPersistence.keys(); + for (final key in keys) { + if (!subscriptions.containsKey(key)) { + await _pushSubscriptionsPersistence.remove(key); + } + } + } +} diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/testing/testing.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/testing/testing.dart new file mode 100644 index 00000000000..4e34f575e96 --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/testing/testing.dart @@ -0,0 +1,2 @@ +export 'testing_push_notification.dart'; +export 'testing_push_subscription.dart'; diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/testing/testing_push_notification.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/testing/testing_push_notification.dart new file mode 100644 index 00000000000..5c02d9a6c0e --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/testing/testing_push_notification.dart @@ -0,0 +1,33 @@ +import 'package:meta/meta.dart'; +import 'package:notifications_push_repository/notifications_push_repository.dart'; + +/// Creates a mocked [PushNotification] object. +@visibleForTesting +PushNotification createPushNotification({ + String accountID = 'accountID', + String priority = 'priority', + String type = 'type', + int nid = 0, + String app = 'app', + String subject = 'subject', + String id = 'id', + bool delete = false, + bool deleteAll = false, +}) { + return PushNotification( + (b) => b + ..accountID = accountID + ..priority = priority + ..type = type + ..subject.update( + (b) => b + ..nid = nid + ..app = app + ..subject = subject + ..type = type + ..id = id + ..delete = delete + ..deleteAll = deleteAll, + ), + ); +} diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/testing/testing_push_subscription.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/testing/testing_push_subscription.dart new file mode 100644 index 00000000000..fb4ae23650b --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/testing/testing_push_subscription.dart @@ -0,0 +1,22 @@ +import 'package:meta/meta.dart'; +import 'package:notifications_push_repository/src/models/models.dart'; + +/// Creates a mocked [PushSubscription] object. +@visibleForTesting +PushSubscription createPushSubscription({ + String endpoint = 'endpoint', + String publicKey = 'publicKey', + String deviceIdentifier = 'deviceIdentifier', + String signature = 'signature', +}) { + return PushSubscription( + (b) => b + ..endpoint = endpoint + ..pushDevice.update( + (b) => b + ..publicKey = publicKey + ..deviceIdentifier = deviceIdentifier + ..signature = signature, + ), + ); +} diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/utils/encryption.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/utils/encryption.dart new file mode 100644 index 00000000000..2167368c5dc --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/utils/encryption.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:built_collection/built_collection.dart'; +import 'package:crypton/crypton.dart'; +import 'package:meta/meta.dart'; +import 'package:notifications_push_repository/notifications_push_repository.dart'; + +/// Reads the device private key from storage or generates a new one if none has been stored. +@internal +Future getDevicePrivateKey(NotificationsPushStorage storage) async { + var privateKey = storage.readDevicePrivateKey(); + + if (privateKey == null) { + // The key size has to be 2048, other sizes are not accepted by Nextcloud (at the moment at least) + // ignore: avoid_redundant_argument_values + privateKey = RSAKeypair.fromRandom(keySize: 2048).privateKey; + await storage.saveDevicePrivateKey(privateKey); + } + + return privateKey; +} + +/// Parses and decrypts the push notifications as sent by the push proxy. +Future> parseEncryptedPushNotifications( + NotificationsPushStorage storage, + Uint8List notifications, + String accountID, +) async { + final privateKey = await getDevicePrivateKey(storage); + + final builder = ListBuilder(); + + for (final notification in Uri(query: utf8.decode(notifications)).queryParameters.values) { + final data = json.decode(notification) as Map; + builder.add( + PushNotification.fromEncrypted( + data, + accountID, + privateKey, + ), + ); + } + + return builder.build(); +} diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/testing.dart b/packages/neon_framework/packages/notifications_push_repository/lib/testing.dart new file mode 100644 index 00000000000..e82b0c21e33 --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/lib/testing.dart @@ -0,0 +1 @@ +export 'src/testing/testing.dart'; diff --git a/packages/neon_framework/packages/notifications_push_repository/pubspec.yaml b/packages/neon_framework/packages/notifications_push_repository/pubspec.yaml new file mode 100644 index 00000000000..c2b457b747c --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/pubspec.yaml @@ -0,0 +1,45 @@ +name: notifications_push_repository +description: Data repository for the neon push notifications +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.0.0 + flutter: ^3.22.0 + +dependencies: + account_repository: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework/packages/account_repository + built_collection: ^5.0.0 + built_value: ^8.9.2 + collection: ^1.0.0 + crypton: ^2.0.0 + logging: ^1.0.0 + meta: ^1.0.0 + neon_framework: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework + nextcloud: ^7.0.0 + rxdart: ^0.28.0 + unifiedpush: ^5.0.0 + unifiedpush_android: ^2.0.0 + +dev_dependencies: + build_runner: ^2.4.12 + built_value_generator: ^8.9.2 + built_value_test: ^8.9.2 + # https://github.com/invertase/melos/issues/755 + flutter: + sdk: flutter + http: ^1.2.2 + mocktail: ^1.0.4 + neon_lints: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_lints + plugin_platform_interface: ^2.1.8 + test: ^1.25.2 + unifiedpush_platform_interface: ^2.0.2 diff --git a/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml b/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml new file mode 100644 index 00000000000..0abe46d2bdd --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml @@ -0,0 +1,20 @@ +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +dependency_overrides: + account_repository: + path: ../account_repository + cookie_store: + path: ../../../cookie_store + dynamite_runtime: + path: ../../../dynamite/packages/dynamite_runtime + interceptor_http_client: + path: ../../../interceptor_http_client + neon_framework: + path: ../.. + neon_http_client: + path: ../neon_http_client + neon_lints: + path: ../../../neon_lints + nextcloud: + path: ../../../nextcloud + sort_box: + path: ../sort_box diff --git a/packages/neon_framework/packages/notifications_push_repository/test/model/push_notification_test.dart b/packages/neon_framework/packages/notifications_push_repository/test/model/push_notification_test.dart new file mode 100644 index 00000000000..cc568dd3bd6 --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/test/model/push_notification_test.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; + +import 'package:built_value_test/matcher.dart'; +import 'package:crypton/crypton.dart'; +import 'package:notifications_push_repository/notifications_push_repository.dart'; +import 'package:notifications_push_repository/testing.dart'; +import 'package:test/test.dart'; + +void main() { + group('PushNotification', () { + group('constructor', () { + test('works correctly', () { + expect( + createPushNotification, + returnsNormally, + ); + }); + }); + + test('supports value equality', () { + expect( + createPushNotification(), + equals(createPushNotification()), + ); + + expect( + createPushNotification().hashCode, + equals(createPushNotification().hashCode), + ); + + expect( + createPushNotification(), + isNot( + equals( + createPushNotification( + accountID: '', + ), + ), + ), + ); + }); + + group('rebuild', () { + test('returns the same object if not attributes are changed', () { + expect( + createPushNotification().rebuild((_) {}), + equals(createPushNotification()), + ); + }); + + test('replaces every attribute', () { + expect( + createPushNotification().rebuild( + (b) => b + ..accountID = 'new-accountID' + ..priority = 'new-priority' + ..type = 'new-type' + ..subject.update( + (b) => b + ..nid = 1 + ..app = 'new-app' + ..subject = 'new-subject' + ..type = 'new-type' + ..id = 'new-id' + ..delete = true + ..deleteAll = true, + ), + ), + equals( + createPushNotification( + accountID: 'new-accountID', + priority: 'new-priority', + type: 'new-type', + nid: 1, + app: 'new-app', + subject: 'new-subject', + id: 'new-id', + delete: true, + deleteAll: true, + ), + ), + ); + }); + }); + + test('toString', () { + expect( + createPushNotification().toString(), + equals(''' +PushNotification { + accountID=accountID, + priority=priority, + type=type, + subject=DecryptedSubject { + nid=0, + app=app, + subject=subject, + type=type, + id=id, + delete=false, + deleteAll=false, + }, +}'''), + ); + }); + + test('toJson', () { + expect( + json.encode(createPushNotification().toJson()), + equals( + '{"accountID":"accountID","priority":"priority","type":"type","subject":{"nid":0,"app":"app","subject":"subject","type":"type","id":"id","delete":false,"delete-all":false}}', + ), + ); + }); + + test('fromJson', () { + expect( + PushNotification.fromJson( + json.decode( + '{"accountID":"accountID","priority":"priority","type":"type","subject":{"nid":0,"app":"app","subject":"subject","type":"type","id":"id","delete":false,"delete-all":false}}', + ) as Map, + ), + equalsBuilt(createPushNotification()), + ); + }); + + test('fromEncrypted', () { + const privateKeyPEM = ''' +-----BEGIN RSA PRIVATE KEY----- +MIICHwIBADANBgkqhkiG9w0BAQEFAASCAgkwggIFAgEAAm4BELTz808T8iAkvBkg +tnWs4a1aNcCFAAX54ePLK40YAL/tQjUGoIe0+zO7yzMT0bydk6BFOdyrIP2iwALN +eOLwDvSi7bfpMZ72TqNz8JlBnEFKDS66XBwFMfbfmWTcXpGyaalFrHfsumcYniM2 +xwIDAQABAm4Agbw0WkoCiSu1ji6+G098QZjA09WU8F/ncwl1vHBRPPoRm2+yiWhG +N0NzUcYo5Zy/sl2K0W4jgRrrgimOQLekZiTX2hpZjMXmuwdxHLcXf9BQlkUWaegg +gqySWUaaSTjRS0twq+S95ETULNPoQQI3HVercaYdMW+1+QCZnyOBkOxdLGUcnHwK +Fhn1DE+uzkTPcwQ/3pTcp3K/QHusFAzgYGuvI9FBtwI3CUs/LPOJQjIrvB2ZnUFL +9tF5raWmVS4MKbd2lM1FZmhLHtva2X9Bnhn0EClhPbmlGV9mo8BzcQI3Gn1xfDK5 +nE37/Qa7qd4GNO4O1+uYvvWErZtVjX3KlNGub2ngt3OxGUMQwohkO928G5BcF3vt +VQI3BH+FwRTRntc3caGF4qVixb+Wu6OLwHg77MjdvKEo8KqTiQjxgAjmUkXPaS8N +4FkEfiY9QA36EQI3AKxizo9goAHnTmY1OVi+4GLp0HroWP64RjW8R/cUemggMqEa +UJYvEQEss8/UoYhOACOm5PEqNg== +-----END RSA PRIVATE KEY-----'''; + final privateKey = RSAPrivateKey.fromPEM(privateKeyPEM); + + const subject = + 'AOXrekPv+79XU82vEXx5WiA9WREus8uYYkfijtKdl4ggWRvvykaY5hQP7OT5P7iKSCzjmO7yNQTuXDJXYtWo/1Pq0AYSVrA3y37pNYr8d/WZklfvQtxIB6o/HTG6pUd1kER7QxVkP7RSHvw/9PU='; + + expect( + PushNotification.fromEncrypted( + json.decode( + '{"priority":"priority","type":"type","subject":"$subject"}', + ) as Map, + 'accountID', + privateKey, + ), + equalsBuilt(createPushNotification()), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/notifications_push_repository/test/model/push_subscription_test.dart b/packages/neon_framework/packages/notifications_push_repository/test/model/push_subscription_test.dart new file mode 100644 index 00000000000..ef1480b45cb --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/test/model/push_subscription_test.dart @@ -0,0 +1,85 @@ +import 'package:notifications_push_repository/testing.dart'; +import 'package:test/test.dart'; + +void main() { + group('PushSubscription', () { + group('constructor', () { + test('works correctly', () { + expect( + createPushSubscription, + returnsNormally, + ); + }); + }); + + test('supports value equality', () { + expect( + createPushSubscription(), + equals(createPushSubscription()), + ); + + expect( + createPushSubscription().hashCode, + equals(createPushSubscription().hashCode), + ); + + expect( + createPushSubscription(), + isNot( + equals( + createPushSubscription( + endpoint: '', + ), + ), + ), + ); + }); + + group('rebuild', () { + test('returns the same object if not attributes are changed', () { + expect( + createPushSubscription().rebuild((_) {}), + equals(createPushSubscription()), + ); + }); + + test('replaces every attribute', () { + expect( + createPushSubscription().rebuild( + (b) => b + ..endpoint = 'new-endpoint' + ..pushDevice.update( + (b) => b + ..publicKey = 'new-publicKey' + ..deviceIdentifier = 'new-deviceIdentifier' + ..signature = 'new-signature', + ), + ), + equals( + createPushSubscription( + endpoint: 'new-endpoint', + publicKey: 'new-publicKey', + deviceIdentifier: 'new-deviceIdentifier', + signature: 'new-signature', + ), + ), + ); + }); + }); + + test('to string', () { + expect( + createPushSubscription().toString(), + equals(''' +PushSubscription { + endpoint=endpoint, + pushDevice=PushDevice { + publicKey=publicKey, + deviceIdentifier=deviceIdentifier, + signature=signature, + }, +}'''), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/notifications_push_repository/test/notifications_push_repository_test.dart b/packages/neon_framework/packages/notifications_push_repository/test/notifications_push_repository_test.dart new file mode 100644 index 00000000000..8758dc3e8dd --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/test/notifications_push_repository_test.dart @@ -0,0 +1,1363 @@ +// ignore_for_file: discarded_futures +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:account_repository/account_repository.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:crypton/crypton.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/testing.dart'; +import 'package:nextcloud/notifications.dart' as notifications; +import 'package:notifications_push_repository/notifications_push_repository.dart'; +import 'package:notifications_push_repository/src/models/models.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; +import 'package:unifiedpush_platform_interface/unifiedpush_platform_interface.dart'; + +class _AccountRepositoryMock extends Mock implements AccountRepository {} + +class _StorageMock extends Mock implements NotificationsPushStorage {} + +class _OnMessageCallbackMock extends Mock { + void call(Uint8List message, String accountID); +} + +class _UnifiedPushPlatformMock extends Mock with MockPlatformInterfaceMixin implements UnifiedPushPlatform {} + +class _HttpRequestCallbackMock extends Mock { + http.StreamedResponse call(String method, Uri url, BuiltMap headers, Uint8List body); +} + +class _HttpClientMock extends Mock implements http.Client { + _HttpClientMock(this._callback); + + final _HttpRequestCallbackMock _callback; + + @override + Future send(http.BaseRequest request) async { + final body = await request.finalize().toBytes(); + return _callback( + request.method.toUpperCase(), + request.url, + BuiltMap(request.headers), + body, + ); + } +} + +void main() { + group('NotificationsPushRepository', () { + late BehaviorSubject> accountsSubject; + late AccountRepository accountRepository; + late NotificationsPushStorage storage; + late OnMessageCallback onMessageCallback; + late UnifiedPushPlatform unifiedPushPlatform; + late NotificationsPushRepository repository; + void Function(String, String)? unifiedPushOnNewEndpoint; + void Function(String)? unifiedPushOnUnregistered; + void Function(Uint8List, String)? unifiedPushOnMessage; + + setUpAll(() { + registerFallbackValue(RSAPrivateKey(BigInt.zero, BigInt.zero, BigInt.zero, BigInt.zero)); + registerFallbackValue(BuiltMap()); + registerFallbackValue(Uri()); + registerFallbackValue(BuiltMap()); + registerFallbackValue(Uint8List(0)); + + FakeNeonStorage.setup(); + }); + + setUp(() { + accountsSubject = BehaviorSubject(); + accountRepository = _AccountRepositoryMock(); + when(() => accountRepository.accounts).thenAnswer((_) => accountsSubject.map((e) => (active: null, accounts: e))); + + storage = _StorageMock(); + + onMessageCallback = _OnMessageCallbackMock().call; + + unifiedPushPlatform = _UnifiedPushPlatformMock(); + when( + () => unifiedPushPlatform.initializeCallback( + onNewEndpoint: any(named: 'onNewEndpoint'), + onRegistrationFailed: any(named: 'onRegistrationFailed'), + onUnregistered: any(named: 'onUnregistered'), + onMessage: any(named: 'onMessage'), + ), + ).thenAnswer((invocation) async { + unifiedPushOnNewEndpoint = invocation.namedArguments[#onNewEndpoint] as void Function(String, String)?; + unifiedPushOnUnregistered = invocation.namedArguments[#onUnregistered] as void Function(String)?; + unifiedPushOnMessage = invocation.namedArguments[#onMessage] as void Function(Uint8List, String)?; + }); + UnifiedPushPlatform.instance = unifiedPushPlatform; + }); + + tearDown(() { + repository.close(); + unawaited(accountsSubject.close()); + }); + + test('Generates new private key', () async { + when(() => unifiedPushPlatform.getDistributor()).thenAnswer((_) async => null); + + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((_) async {}); + when(() => storage.readSubscriptions()).thenAnswer((_) async => BuiltMap()); + + accountsSubject.add(BuiltList([])); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => storage.saveDevicePrivateKey(any())).called(1); + }); + + test('Returns the available distributors', () async { + when(() => unifiedPushPlatform.getDistributor()).thenAnswer((_) async => null); + when(() => unifiedPushPlatform.getDistributors(any())).thenAnswer((_) async => ['1', '2']); + + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((_) async {}); + when(() => storage.readSubscriptions()).thenAnswer((_) async => BuiltMap()); + + accountsSubject.add(BuiltList([])); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + await expectLater(repository.distributors, completion(equals(['1', '2']))); + }); + + test('Changing distributor saves it', () async { + when(() => unifiedPushPlatform.getDistributor()).thenAnswer((_) async => null); + when(() => unifiedPushPlatform.saveDistributor(any())).thenAnswer((_) async {}); + + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((_) async {}); + when(() => storage.readSubscriptions()).thenAnswer((_) async => BuiltMap()); + + accountsSubject.add(BuiltList([])); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + await repository.changeDistributor('test'); + verify(() => unifiedPushPlatform.saveDistributor('test')).called(1); + verifyNever(() => unifiedPushPlatform.saveDistributor(any())); + + await repository.changeDistributor('test'); + verifyNever(() => unifiedPushPlatform.saveDistributor(any())); + }); + + test('onMessage callback is called', () async { + when(() => unifiedPushPlatform.getDistributor()).thenAnswer((_) async => null); + when(() => unifiedPushPlatform.saveDistributor(any())).thenAnswer((_) async {}); + + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((_) async {}); + when(() => storage.readSubscriptions()).thenAnswer((_) async => BuiltMap()); + + accountsSubject.add(BuiltList([])); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + unifiedPushOnMessage!(Uint8List(0), 'test'); + verify(() => onMessageCallback(Uint8List(0), 'test')).called(1); + verifyNever(() => onMessageCallback(any(), any())); + }); + + group('onUnregistered callback removes subscription', () { + late Account account; + late _HttpRequestCallbackMock httpRequest; + + setUp(() { + httpRequest = _HttpRequestCallbackMock(); + + account = createAccount( + credentials: createCredentials( + serverURL: Uri.parse('https://cloud.example.com:8443/nextcloud'), + username: 'user1', + password: 'user1', + ), + httpClient: _HttpClientMock(httpRequest), + ); + + when(() => unifiedPushPlatform.getDistributor()).thenAnswer((_) async => 'test'); + when(() => unifiedPushPlatform.saveDistributor(any())).thenAnswer((_) async {}); + final endpoint = Uri.parse('https://cloud.example.com:8443/nextcloud/unifiedpush'); + when(() => unifiedPushPlatform.registerApp(any(), any())).thenAnswer((invocation) async { + unifiedPushOnNewEndpoint!.call( + endpoint.toString(), + invocation.positionalArguments[0] as String, + ); + }); + when(() => unifiedPushPlatform.unregister(any())).thenAnswer((_) async {}); + + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((invocation) async {}); + final subscription = PushSubscription( + (b) => b + ..endpoint = 'https://cloud.example.com:8443/nextcloud/unifiedpush' + ..pushDevice.update( + (b) => b + ..publicKey = 'publicKey' + ..deviceIdentifier = 'deviceIdentifier' + ..signature = 'signature', + ), + ); + var subscriptions = BuiltMap({account.id: subscription}); + when(() => storage.readSubscriptions()).thenAnswer((_) async => subscriptions); + when(() => storage.saveSubscriptions(any())).thenAnswer((invocation) async { + subscriptions = invocation.positionalArguments[0] as BuiltMap; + }); + + accountsSubject.add(BuiltList([account])); + }); + + test('Success at Nextcloud', () async { + when( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer( + (_) => http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + { + 'ocs': { + 'meta': { + 'status': '', + 'statuscode': 0, + }, + 'data': {}, + }, + }, + ), + ), + ), + 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + ), + ); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + + unifiedPushOnUnregistered!(account.id); + await Future.delayed(const Duration(milliseconds: 1)); + + verifyNever(() => unifiedPushPlatform.unregister(any())); + verify( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'user-agent': 'neon', + }, + ), + Uint8List(0), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + + test('Failure at Nextcloud', () async { + when( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer((_) => http.StreamedResponse(const Stream.empty(), 500)); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + + unifiedPushOnUnregistered!(account.id); + await Future.delayed(const Duration(milliseconds: 1)); + + verifyNever(() => unifiedPushPlatform.unregister(any())); + verify( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'user-agent': 'neon', + }, + ), + Uint8List(0), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + }); + + group('Removes subscriptions for missing accounts', () { + test('Without endpoint and pushDevice', () async { + when(() => unifiedPushPlatform.getDistributor()).thenAnswer((_) async => null); + when(() => unifiedPushPlatform.unregister(any())).thenAnswer((_) async {}); + + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((_) async {}); + final subscription = PushSubscription(); + when(() => storage.readSubscriptions()).thenAnswer((_) async => BuiltMap({'1': subscription})); + when(() => storage.saveSubscriptions(any())).thenAnswer((_) async {}); + + accountsSubject.add(BuiltList([])); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verifyNever(() => unifiedPushPlatform.unregister(any())); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + + test('With endpoint', () async { + when(() => unifiedPushPlatform.getDistributor()).thenAnswer((_) async => null); + when(() => unifiedPushPlatform.unregister(any())).thenAnswer((_) async {}); + + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((_) async {}); + final subscription = PushSubscription( + (b) => b..endpoint = 'endpoint', + ); + when(() => storage.readSubscriptions()).thenAnswer((_) async => BuiltMap({'1': subscription})); + when(() => storage.saveSubscriptions(any())).thenAnswer((_) async {}); + + accountsSubject.add(BuiltList([])); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.unregister('1')).called(1); + verifyNever(() => unifiedPushPlatform.unregister(any())); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + + test('With pushDevice', () async { + when(() => unifiedPushPlatform.getDistributor()).thenAnswer((_) async => null); + when(() => unifiedPushPlatform.unregister(any())).thenAnswer((_) async {}); + + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((_) async {}); + final subscription = PushSubscription( + (b) => b + ..pushDevice.update( + (b) => b + ..publicKey = 'publicKey' + ..deviceIdentifier = 'deviceIdentifier' + ..signature = 'signature', + ), + ); + when(() => storage.readSubscriptions()).thenAnswer((_) async => BuiltMap({'1': subscription})); + when(() => storage.saveSubscriptions(any())).thenAnswer((_) async {}); + + accountsSubject.add(BuiltList([])); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verifyNever(() => unifiedPushPlatform.unregister(any())); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + + test('With endpoint and pushDevice', () async { + when(() => unifiedPushPlatform.getDistributor()).thenAnswer((_) async => null); + when(() => unifiedPushPlatform.unregister(any())).thenAnswer((_) async {}); + + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((_) async {}); + final subscription = PushSubscription( + (b) => b + ..endpoint = 'endpoint' + ..pushDevice.update( + (b) => b + ..publicKey = 'publicKey' + ..deviceIdentifier = 'deviceIdentifier' + ..signature = 'signature', + ), + ); + when(() => storage.readSubscriptions()).thenAnswer((_) async => BuiltMap({'1': subscription})); + when(() => storage.saveSubscriptions(any())).thenAnswer((_) async {}); + + accountsSubject.add(BuiltList([])); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.unregister('1')).called(1); + verifyNever(() => unifiedPushPlatform.unregister(any())); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + }); + + group('Creates, updates and removes subscriptions for accounts', () { + late Account account; + late _HttpRequestCallbackMock httpRequest; + late Uri endpoint; + late RSAPrivateKey privateKey; + + setUp(() { + httpRequest = _HttpRequestCallbackMock(); + + account = createAccount( + credentials: createCredentials( + serverURL: Uri.parse('https://cloud.example.com:8443/nextcloud'), + username: 'user1', + password: 'user1', + ), + httpClient: _HttpClientMock(httpRequest), + ); + + when(() => unifiedPushPlatform.getDistributor()).thenAnswer((_) async => 'test'); + when(() => unifiedPushPlatform.saveDistributor(any())).thenAnswer((_) async {}); + endpoint = Uri.parse('https://cloud.example.com:8443/nextcloud/unifiedpush'); + when(() => unifiedPushPlatform.registerApp(any(), any())).thenAnswer((invocation) async { + unifiedPushOnNewEndpoint!.call( + endpoint.toString(), + invocation.positionalArguments[0] as String, + ); + }); + when(() => unifiedPushPlatform.unregister(any())).thenAnswer((_) async {}); + + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((invocation) async { + privateKey = invocation.positionalArguments[0] as RSAPrivateKey; + }); + + accountsSubject.add(BuiltList([account])); + }); + + group('Creates subscriptions for accounts', () { + group('Existing accounts', () { + setUp(() { + when(() => storage.readSubscriptions()).thenAnswer((_) async => BuiltMap()); + when(() => storage.saveSubscriptions(any())).thenAnswer((invocation) async {}); + }); + + test('Success at Nextcloud', () async { + when( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer( + (_) => http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + { + 'ocs': { + 'meta': { + 'status': '', + 'statuscode': 0, + }, + 'data': { + 'publicKey': 'publicKey', + 'deviceIdentifier': 'deviceIdentifier', + 'signature': 'signature', + }, + }, + }, + ), + ), + ), + 201, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + ), + ); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + await repository.changeDistributor('test'); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + verify( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'Content-Type': 'application/json; charset=utf-8', + 'user-agent': 'neon', + }, + ), + utf8.encode( + json.encode( + notifications.PushRegisterDeviceRequestApplicationJson( + (b) => b + ..pushTokenHash = notifications.generatePushTokenHash(endpoint.toString()) + ..devicePublicKey = privateKey.publicKey.toFormattedPEM() + ..proxyServer = '$endpoint#', + ), + ), + ), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + final expectedSubscription = PushSubscription( + (b) => b + ..endpoint = 'https://cloud.example.com:8443/nextcloud/unifiedpush' + ..pushDevice.update( + (b) => b + ..publicKey = 'publicKey' + ..deviceIdentifier = 'deviceIdentifier' + ..signature = 'signature', + ), + ); + verify(() => storage.saveSubscriptions(BuiltMap({account.id: expectedSubscription}))).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + + test('Failure at Nextcloud', () async { + when( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer((_) => http.StreamedResponse(const Stream.empty(), 500)); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + await repository.changeDistributor('test'); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + verify( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'Content-Type': 'application/json; charset=utf-8', + 'user-agent': 'neon', + }, + ), + utf8.encode( + json.encode( + notifications.PushRegisterDeviceRequestApplicationJson( + (b) => b + ..pushTokenHash = notifications.generatePushTokenHash(endpoint.toString()) + ..devicePublicKey = privateKey.publicKey.toFormattedPEM() + ..proxyServer = '$endpoint#', + ), + ), + ), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + final expectedSubscription = PushSubscription( + (b) => b..endpoint = 'https://cloud.example.com:8443/nextcloud/unifiedpush', + ); + verify(() => storage.saveSubscriptions(BuiltMap({account.id: expectedSubscription}))).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + }); + + group('New accounts', () { + late Account newAccount; + late PushSubscription subscription; + + setUp(() { + newAccount = createAccount( + credentials: createCredentials( + serverURL: Uri.parse('https://cloud.example.com:8443/nextcloud'), + username: 'user2', + password: 'user2', + ), + httpClient: _HttpClientMock(httpRequest), + ); + + subscription = PushSubscription( + (b) => b + ..endpoint = 'https://cloud.example.com:8443/nextcloud/unifiedpush' + ..pushDevice.update( + (b) => b + ..publicKey = 'publicKey' + ..deviceIdentifier = 'deviceIdentifier' + ..signature = 'signature', + ), + ); + var subscriptions = BuiltMap({account.id: subscription}); + when(() => storage.readSubscriptions()).thenAnswer((_) async => subscriptions); + when(() => storage.saveSubscriptions(any())).thenAnswer((invocation) async { + subscriptions = invocation.positionalArguments[0] as BuiltMap; + }); + }); + + test('Success at Nextcloud', () async { + when( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer( + (_) => http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + { + 'ocs': { + 'meta': { + 'status': '', + 'statuscode': 0, + }, + 'data': { + 'publicKey': 'new-publicKey', + 'deviceIdentifier': 'new-deviceIdentifier', + 'signature': 'new-signature', + }, + }, + }, + ), + ), + ), + 201, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + ), + ); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + + accountsSubject.add(BuiltList([account, newAccount])); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verify(() => unifiedPushPlatform.registerApp(newAccount.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + verify( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user2', + 'OCS-APIRequest': 'true', + 'Content-Type': 'application/json; charset=utf-8', + 'user-agent': 'neon', + }, + ), + utf8.encode( + json.encode( + notifications.PushRegisterDeviceRequestApplicationJson( + (b) => b + ..pushTokenHash = notifications.generatePushTokenHash(endpoint.toString()) + ..devicePublicKey = privateKey.publicKey.toFormattedPEM() + ..proxyServer = '$endpoint#', + ), + ), + ), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + final expectedSubscription = PushSubscription( + (b) => b + ..endpoint = 'https://cloud.example.com:8443/nextcloud/unifiedpush' + ..pushDevice.update( + (b) => b + ..publicKey = 'new-publicKey' + ..deviceIdentifier = 'new-deviceIdentifier' + ..signature = 'new-signature', + ), + ); + verify( + () => storage.saveSubscriptions( + BuiltMap({ + account.id: subscription, + newAccount.id: expectedSubscription, + }), + ), + ).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + + test('Failure at Nextcloud', () async { + when( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer((_) => http.StreamedResponse(const Stream.empty(), 500)); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + + accountsSubject.add(BuiltList([account, newAccount])); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verify(() => unifiedPushPlatform.registerApp(newAccount.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + verify( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user2', + 'OCS-APIRequest': 'true', + 'Content-Type': 'application/json; charset=utf-8', + 'user-agent': 'neon', + }, + ), + utf8.encode( + json.encode( + notifications.PushRegisterDeviceRequestApplicationJson( + (b) => b + ..pushTokenHash = notifications.generatePushTokenHash(endpoint.toString()) + ..devicePublicKey = privateKey.publicKey.toFormattedPEM() + ..proxyServer = '$endpoint#', + ), + ), + ), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + final expectedSubscription = PushSubscription( + (b) => b..endpoint = 'https://cloud.example.com:8443/nextcloud/unifiedpush', + ); + verify( + () => storage.saveSubscriptions( + BuiltMap({ + account.id: subscription, + newAccount.id: expectedSubscription, + }), + ), + ).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + }); + }); + + group('Updates and removes subscriptions for accounts', () { + setUp(() { + final subscription = PushSubscription( + (b) => b + ..endpoint = 'https://cloud.example.com:8443/nextcloud/unifiedpush' + ..pushDevice.update( + (b) => b + ..publicKey = 'publicKey' + ..deviceIdentifier = 'deviceIdentifier' + ..signature = 'signature', + ), + ); + var subscriptions = BuiltMap({account.id: subscription}); + when(() => storage.readSubscriptions()).thenAnswer((_) async => subscriptions); + when(() => storage.saveSubscriptions(any())).thenAnswer((invocation) async { + subscriptions = invocation.positionalArguments[0] as BuiltMap; + }); + }); + + group('Removes subscriptions for accounts', () { + test('Success at Nextcloud', () async { + when( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer( + (_) => http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + { + 'ocs': { + 'meta': { + 'status': '', + 'statuscode': 0, + }, + 'data': {}, + }, + }, + ), + ), + ), + 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + ), + ); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + + await repository.changeDistributor(null); + + verify(() => unifiedPushPlatform.unregister(account.id)).called(1); + verifyNever(() => unifiedPushPlatform.unregister(any())); + verify( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'user-agent': 'neon', + }, + ), + Uint8List(0), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + + test('Failure at Nextcloud', () async { + when( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer((_) => http.StreamedResponse(const Stream.empty(), 500)); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + + await repository.changeDistributor(null); + + verify(() => unifiedPushPlatform.unregister(account.id)).called(1); + verifyNever(() => unifiedPushPlatform.unregister(any())); + verify( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'user-agent': 'neon', + }, + ), + Uint8List(0), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + }); + + group('Updates subscriptions when changing distributor', () { + test('Success at Nextcloud', () async { + when( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer( + (_) => http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + { + 'ocs': { + 'meta': { + 'status': '', + 'statuscode': 0, + }, + 'data': {}, + }, + }, + ), + ), + ), + 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + ), + ); + when( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer( + (_) => http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + { + 'ocs': { + 'meta': { + 'status': '', + 'statuscode': 0, + }, + 'data': { + 'publicKey': 'new-publicKey', + 'deviceIdentifier': 'new-deviceIdentifier', + 'signature': 'new-signature', + }, + }, + }, + ), + ), + ), + 201, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + ), + ); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + + endpoint = Uri.parse('https://cloud.example.com:8443/nextcloud/new-unifiedpush'); + + await repository.changeDistributor('other'); + + verify(() => unifiedPushPlatform.unregister(account.id)).called(1); + verifyNever(() => unifiedPushPlatform.unregister(any())); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'user-agent': 'neon', + }, + ), + Uint8List(0), + ), + ).called(1); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + + verify( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'Content-Type': 'application/json; charset=utf-8', + 'user-agent': 'neon', + }, + ), + utf8.encode( + json.encode( + notifications.PushRegisterDeviceRequestApplicationJson( + (b) => b + ..pushTokenHash = notifications.generatePushTokenHash(endpoint.toString()) + ..devicePublicKey = privateKey.publicKey.toFormattedPEM() + ..proxyServer = '$endpoint#', + ), + ), + ), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + final expectedSubscription = PushSubscription( + (b) => b + ..endpoint = 'https://cloud.example.com:8443/nextcloud/new-unifiedpush' + ..pushDevice.update( + (b) => b + ..publicKey = 'new-publicKey' + ..deviceIdentifier = 'new-deviceIdentifier' + ..signature = 'new-signature', + ), + ); + verify(() => storage.saveSubscriptions(BuiltMap({account.id: expectedSubscription}))).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + + group('Failure at Nextcloud', () { + test('Unregister', () async { + when( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer((_) => http.StreamedResponse(const Stream.empty(), 500)); + when( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer( + (_) => http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + { + 'ocs': { + 'meta': { + 'status': '', + 'statuscode': 0, + }, + 'data': { + 'publicKey': 'new-publicKey', + 'deviceIdentifier': 'new-deviceIdentifier', + 'signature': 'new-signature', + }, + }, + }, + ), + ), + ), + 201, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + ), + ); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + + endpoint = Uri.parse('https://cloud.example.com:8443/nextcloud/new-unifiedpush'); + + await repository.changeDistributor('other'); + + verify(() => unifiedPushPlatform.unregister(account.id)).called(1); + verifyNever(() => unifiedPushPlatform.unregister(any())); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'user-agent': 'neon', + }, + ), + Uint8List(0), + ), + ).called(1); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + + verify( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'Content-Type': 'application/json; charset=utf-8', + 'user-agent': 'neon', + }, + ), + utf8.encode( + json.encode( + notifications.PushRegisterDeviceRequestApplicationJson( + (b) => b + ..pushTokenHash = notifications.generatePushTokenHash(endpoint.toString()) + ..devicePublicKey = privateKey.publicKey.toFormattedPEM() + ..proxyServer = '$endpoint#', + ), + ), + ), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + final expectedSubscription = PushSubscription( + (b) => b + ..endpoint = 'https://cloud.example.com:8443/nextcloud/new-unifiedpush' + ..pushDevice.update( + (b) => b + ..publicKey = 'new-publicKey' + ..deviceIdentifier = 'new-deviceIdentifier' + ..signature = 'new-signature', + ), + ); + verify(() => storage.saveSubscriptions(BuiltMap({account.id: expectedSubscription}))).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + + test('Register', () async { + when( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer( + (_) => http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + { + 'ocs': { + 'meta': { + 'status': '', + 'statuscode': 0, + }, + 'data': {}, + }, + }, + ), + ), + ), + 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + ), + ); + when( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + any(), + any(), + ), + ).thenAnswer((_) => http.StreamedResponse(const Stream.empty(), 500)); + + repository = NotificationsPushRepository( + accountRepository: accountRepository, + storage: storage, + onMessage: onMessageCallback, + ); + await repository.initialize(); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + + endpoint = Uri.parse('https://cloud.example.com:8443/nextcloud/new-unifiedpush'); + + await repository.changeDistributor('other'); + + verify(() => unifiedPushPlatform.unregister(account.id)).called(1); + verifyNever(() => unifiedPushPlatform.unregister(any())); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify( + () => httpRequest( + 'DELETE', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'user-agent': 'neon', + }, + ), + Uint8List(0), + ), + ).called(1); + verify(() => storage.saveSubscriptions(BuiltMap())).called(1); + + verify(() => unifiedPushPlatform.registerApp(account.id, [])).called(1); + verifyNever(() => unifiedPushPlatform.registerApp(any(), any())); + + verify( + () => httpRequest( + 'POST', + Uri.parse('https://cloud.example.com:8443/nextcloud/ocs/v2.php/apps/notifications/api/v2/push'), + BuiltMap( + { + 'Accept': 'application/json', + 'Authorization': 'Bearer user1', + 'OCS-APIRequest': 'true', + 'Content-Type': 'application/json; charset=utf-8', + 'user-agent': 'neon', + }, + ), + utf8.encode( + json.encode( + notifications.PushRegisterDeviceRequestApplicationJson( + (b) => b + ..pushTokenHash = notifications.generatePushTokenHash(endpoint.toString()) + ..devicePublicKey = privateKey.publicKey.toFormattedPEM() + ..proxyServer = '$endpoint#', + ), + ), + ), + ), + ).called(1); + verifyNever(() => httpRequest(any(), any(), any(), any())); + final expectedSubscription = PushSubscription( + (b) => b..endpoint = 'https://cloud.example.com:8443/nextcloud/new-unifiedpush', + ); + verify(() => storage.saveSubscriptions(BuiltMap({account.id: expectedSubscription}))).called(1); + verifyNever(() => storage.saveSubscriptions(any())); + }); + }); + }); + }); + }); + }); +} diff --git a/packages/neon_framework/packages/notifications_push_repository/test/notifications_push_storage_test.dart b/packages/neon_framework/packages/notifications_push_repository/test/notifications_push_storage_test.dart new file mode 100644 index 00000000000..aba21779848 --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/test/notifications_push_storage_test.dart @@ -0,0 +1,115 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:crypton/crypton.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/storage.dart'; +import 'package:notifications_push_repository/notifications_push_repository.dart'; +import 'package:notifications_push_repository/src/models/models.dart'; +import 'package:test/test.dart'; + +class _SingleValueStoreMock extends Mock implements SingleValueStore {} + +class _PersistenceMock extends Mock implements Persistence {} + +void main() { + late SingleValueStore devicePrivateKeyPersistence; + late Persistence pushSubscriptionsPersistence; + late NotificationsPushStorage storage; + + setUp(() { + devicePrivateKeyPersistence = _SingleValueStoreMock(); + pushSubscriptionsPersistence = _PersistenceMock(); + + storage = NotificationsPushStorage( + devicePrivateKeyPersistence: devicePrivateKeyPersistence, + pushSubscriptionsPersistence: pushSubscriptionsPersistence, + ); + }); + + group('NotificationsPushStorage', () { + group('Device private key', () { + const privateKeyPEM = ''' +-----BEGIN RSA PRIVATE KEY----- +MDsCAQAwDQYJKoZIhvcNAQEBBQAEJzAlAgEAAgMBchMCAwEAAQICFgECAgHBAgIA0wICAQECAgCtAgIBCg== +-----END RSA PRIVATE KEY-----'''; + final privateKey = RSAPrivateKey.fromPEM(privateKeyPEM); + + test('readDevicePrivateKey', () { + when(() => devicePrivateKeyPersistence.getString()).thenReturn(privateKeyPEM); + + expect(storage.readDevicePrivateKey()!.toPEM(), privateKeyPEM); + verify(() => devicePrivateKeyPersistence.getString()).called(1); + }); + + test('saveDevicePrivateKey', () async { + when(() => devicePrivateKeyPersistence.setString(any())).thenAnswer((_) async => true); + + await storage.saveDevicePrivateKey(privateKey); + verify(() => devicePrivateKeyPersistence.setString(privateKeyPEM)).called(1); + }); + }); + + group('Push subscriptions', () { + final subscriptionsMap = BuiltMap({ + 'a': PushSubscription( + (b) => b + ..endpoint = 'endpoint1' + ..pushDevice.update( + (b) => b + ..publicKey = 'publicKey1' + ..deviceIdentifier = 'deviceIdentifier1' + ..signature = 'signature1', + ), + ), + 'b': PushSubscription( + (b) => b + ..endpoint = 'endpoint2' + ..pushDevice.update( + (b) => b + ..publicKey = 'publicKey2' + ..deviceIdentifier = 'deviceIdentifier2' + ..signature = 'signature2', + ), + ), + }); + final serializedSubscriptions = BuiltMap({ + 'a': + '{"endpoint":"endpoint1","pushDevice":{"publicKey":"publicKey1","deviceIdentifier":"deviceIdentifier1","signature":"signature1"}}', + 'b': + '{"endpoint":"endpoint2","pushDevice":{"publicKey":"publicKey2","deviceIdentifier":"deviceIdentifier2","signature":"signature2"}}', + }); + + test('readSubscriptions', () async { + when(() => pushSubscriptionsPersistence.keys()).thenReturn(['a', 'b']); + when(() => pushSubscriptionsPersistence.getValue(any())).thenAnswer((invocation) { + final key = invocation.positionalArguments.single as String; + return serializedSubscriptions[key]; + }); + + final subscriptions = await storage.readSubscriptions(); + expect(subscriptions, equals(subscriptionsMap)); + + verify(() => pushSubscriptionsPersistence.keys()).called(1); + verify(() => pushSubscriptionsPersistence.getValue('a')).called(1); + verify(() => pushSubscriptionsPersistence.getValue('b')).called(1); + verifyNever(() => pushSubscriptionsPersistence.getValue(any())); + }); + + test('saveSubscriptions', () async { + when(() => pushSubscriptionsPersistence.setValue(any(), any())).thenReturn(true); + when(() => pushSubscriptionsPersistence.keys()).thenReturn(['a', 'b', 'c']); + when(() => pushSubscriptionsPersistence.remove(any())).thenReturn(true); + + await storage.saveSubscriptions(subscriptionsMap); + + verify(() => pushSubscriptionsPersistence.setValue('a', serializedSubscriptions['a']!)).called(1); + verify(() => pushSubscriptionsPersistence.setValue('b', serializedSubscriptions['b']!)).called(1); + verifyNever(() => pushSubscriptionsPersistence.setValue(any(), any())); + + verify(() => pushSubscriptionsPersistence.keys()).called(1); + + verify(() => pushSubscriptionsPersistence.remove('c')).called(1); + verifyNever(() => pushSubscriptionsPersistence.remove(any())); + }); + }); + }); +} diff --git a/packages/neon_framework/packages/notifications_push_repository/test/utils/encryption_test.dart b/packages/neon_framework/packages/notifications_push_repository/test/utils/encryption_test.dart new file mode 100644 index 00000000000..0bd25e8b8e3 --- /dev/null +++ b/packages/neon_framework/packages/notifications_push_repository/test/utils/encryption_test.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:built_value_test/matcher.dart'; +import 'package:crypton/crypton.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:notifications_push_repository/notifications_push_repository.dart'; +import 'package:notifications_push_repository/src/utils/encryption.dart'; +import 'package:notifications_push_repository/testing.dart'; +import 'package:test/test.dart'; + +class _StorageMock extends Mock implements NotificationsPushStorage {} + +void main() { + const privateKeyPEM = ''' +-----BEGIN RSA PRIVATE KEY----- +MIICHwIBADANBgkqhkiG9w0BAQEFAASCAgkwggIFAgEAAm4BELTz808T8iAkvBkg +tnWs4a1aNcCFAAX54ePLK40YAL/tQjUGoIe0+zO7yzMT0bydk6BFOdyrIP2iwALN +eOLwDvSi7bfpMZ72TqNz8JlBnEFKDS66XBwFMfbfmWTcXpGyaalFrHfsumcYniM2 +xwIDAQABAm4Agbw0WkoCiSu1ji6+G098QZjA09WU8F/ncwl1vHBRPPoRm2+yiWhG +N0NzUcYo5Zy/sl2K0W4jgRrrgimOQLekZiTX2hpZjMXmuwdxHLcXf9BQlkUWaegg +gqySWUaaSTjRS0twq+S95ETULNPoQQI3HVercaYdMW+1+QCZnyOBkOxdLGUcnHwK +Fhn1DE+uzkTPcwQ/3pTcp3K/QHusFAzgYGuvI9FBtwI3CUs/LPOJQjIrvB2ZnUFL +9tF5raWmVS4MKbd2lM1FZmhLHtva2X9Bnhn0EClhPbmlGV9mo8BzcQI3Gn1xfDK5 +nE37/Qa7qd4GNO4O1+uYvvWErZtVjX3KlNGub2ngt3OxGUMQwohkO928G5BcF3vt +VQI3BH+FwRTRntc3caGF4qVixb+Wu6OLwHg77MjdvKEo8KqTiQjxgAjmUkXPaS8N +4FkEfiY9QA36EQI3AKxizo9goAHnTmY1OVi+4GLp0HroWP64RjW8R/cUemggMqEa +UJYvEQEss8/UoYhOACOm5PEqNg== +-----END RSA PRIVATE KEY-----'''; + late NotificationsPushStorage storage; + + setUpAll(() { + registerFallbackValue(RSAPrivateKey(BigInt.zero, BigInt.zero, BigInt.zero, BigInt.zero)); + }); + + setUp(() { + storage = _StorageMock(); + }); + + group('getDevicePrivateKey', () { + test('New', () async { + when(() => storage.readDevicePrivateKey()).thenAnswer((_) => null); + when(() => storage.saveDevicePrivateKey(any())).thenAnswer((_) async {}); + + final privateKey = await getDevicePrivateKey(storage); + expect(privateKey.toFormattedPEM(), isNot(privateKeyPEM)); + + verify(() => storage.saveDevicePrivateKey(privateKey)).called(1); + }); + + test('Existing', () async { + when(() => storage.readDevicePrivateKey()).thenAnswer((_) => RSAPrivateKey.fromPEM(privateKeyPEM)); + + final privateKey = await getDevicePrivateKey(storage); + expect(privateKey.toFormattedPEM(), equals(privateKeyPEM)); + + verifyNever(() => storage.saveDevicePrivateKey(any())); + }); + }); + + test('parseEncryptedPushNotifications', () async { + when(() => storage.readDevicePrivateKey()).thenAnswer((_) => RSAPrivateKey.fromPEM(privateKeyPEM)); + + const subject1 = + 'AOXrekPv+79XU82vEXx5WiA9WREus8uYYkfijtKdl4ggWRvvykaY5hQP7OT5P7iKSCzjmO7yNQTuXDJXYtWo/1Pq0AYSVrA3y37pNYr8d/WZklfvQtxIB6o/HTG6pUd1kER7QxVkP7RSHvw/9PU='; + const subject2 = + 'AGcV+V73rhvcT2OMu5AAQNd01zd4BWCJqgZc782MOXlj62yKv4AxfbXLZpKjH2tFn8WiZRg6DJmX25v3652mzaJefC4d/urfbIGYN1a30NNSpPJIxjZ1XWUe2MV+aKuaj+liKYukVvzOpK+scCM='; + final messages = utf8.encode( + Uri( + queryParameters: { + 'message1': '{"priority":"priority","type":"type","subject":"$subject1"}', + 'message2': '{"priority":"priority","type":"type","subject":"$subject2"}', + }, + ).query, + ); + + final notifications = await parseEncryptedPushNotifications(storage, messages, 'accountID'); + expect(notifications, hasLength(2)); + expect(notifications[0], equalsBuilt(createPushNotification())); + expect(notifications[1], equalsBuilt(createPushNotification(nid: 1))); + }); +}