diff --git a/contact/pubspec.lock b/contact/pubspec.lock index ce6c986509..5b1d993b1f 100644 --- a/contact/pubspec.lock +++ b/contact/pubspec.lock @@ -651,11 +651,11 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + ref: "enhancement/web-socket-ticket-capability" + resolved-ref: "06b1288f8e501c38166ec1895d227d4f832e4144" url: "https://github.com/linagora/jmap-dart-client.git" source: git - version: "0.2.2" + version: "0.2.3" js: dependency: transitive description: diff --git a/contact/pubspec.yaml b/contact/pubspec.yaml index b85a3cd0ff..fb813f7030 100644 --- a/contact/pubspec.yaml +++ b/contact/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: jmap_dart_client: git: url: https://github.com/linagora/jmap-dart-client.git - ref: main + ref: enhancement/web-socket-ticket-capability ### Dependencies from pub.dev ### equatable: 2.0.5 diff --git a/core/lib/data/constants/constant.dart b/core/lib/data/constants/constant.dart index 5540b0ac1c..088fed2aa8 100644 --- a/core/lib/data/constants/constant.dart +++ b/core/lib/data/constants/constant.dart @@ -7,4 +7,5 @@ class Constant { static const octetStreamMimeType = 'application/octet-stream'; static const pdfExtension = '.pdf'; static const imageType = 'image'; + static const wsServiceWorkerBroadcastChannel = 'background-message'; } \ No newline at end of file diff --git a/docs/adr/0053-web-socket-data-synchronization.md b/docs/adr/0053-web-socket-data-synchronization.md new file mode 100644 index 0000000000..0e8bb4b0f5 --- /dev/null +++ b/docs/adr/0053-web-socket-data-synchronization.md @@ -0,0 +1,21 @@ +# 53. Web socket data synchronization + +Date: 2024-11-10 + +## Status + +Accepted + +## Context + +- Currently Twake Mail web use Firebase Cloud Messaging to sync data on real time +- JMAP already implemented web socket push, which is more optimized for web + +## Decision + +- Web socket is implemented for real time update data for Twake Mail web +- Service worker is implemented for background tasks, helping web socket working in background + +## Consequences + +- Twake Mail web now no longer depends on Firebase Cloud Messaging, using web socket to update users' latest data diff --git a/email_recovery/pubspec.lock b/email_recovery/pubspec.lock index f95a8c7c70..4fa41903c1 100644 --- a/email_recovery/pubspec.lock +++ b/email_recovery/pubspec.lock @@ -295,11 +295,11 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + ref: "enhancement/web-socket-ticket-capability" + resolved-ref: "06b1288f8e501c38166ec1895d227d4f832e4144" url: "https://github.com/linagora/jmap-dart-client.git" source: git - version: "0.2.2" + version: "0.2.3" js: dependency: transitive description: diff --git a/email_recovery/pubspec.yaml b/email_recovery/pubspec.yaml index 9ffdcbdd12..d524cbfb78 100644 --- a/email_recovery/pubspec.yaml +++ b/email_recovery/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: jmap_dart_client: git: url: https://github.com/linagora/jmap-dart-client.git - ref: main + ref: enhancement/web-socket-ticket-capability ### Dependencies from pub.dev ### equatable: 2.0.5 diff --git a/fcm/lib/model/type_name.dart b/fcm/lib/model/type_name.dart index cae043199a..89879245c0 100644 --- a/fcm/lib/model/type_name.dart +++ b/fcm/lib/model/type_name.dart @@ -2,13 +2,13 @@ import 'package:equatable/equatable.dart'; class TypeName with EquatableMixin { - static final mailboxType = TypeName('Mailbox'); - static final emailType = TypeName('Email'); - static final emailDelivery = TypeName('EmailDelivery'); + static const mailboxType = TypeName('Mailbox'); + static const emailType = TypeName('Email'); + static const emailDelivery = TypeName('EmailDelivery'); final String value; - TypeName(this.value); + const TypeName(this.value); @override List get props => [value]; diff --git a/fcm/pubspec.lock b/fcm/pubspec.lock index bf16ad9212..a93ae78638 100644 --- a/fcm/pubspec.lock +++ b/fcm/pubspec.lock @@ -295,11 +295,11 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + ref: "enhancement/web-socket-ticket-capability" + resolved-ref: "06b1288f8e501c38166ec1895d227d4f832e4144" url: "https://github.com/linagora/jmap-dart-client.git" source: git - version: "0.2.2" + version: "0.2.3" js: dependency: transitive description: diff --git a/fcm/pubspec.yaml b/fcm/pubspec.yaml index e418fae836..f5337a443e 100644 --- a/fcm/pubspec.yaml +++ b/fcm/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: jmap_dart_client: git: url: https://github.com/linagora/jmap-dart-client.git - ref: main + ref: enhancement/web-socket-ticket-capability ### Dependencies from pub.dev ### equatable: 2.0.5 diff --git a/forward/pubspec.lock b/forward/pubspec.lock index bf16ad9212..a93ae78638 100644 --- a/forward/pubspec.lock +++ b/forward/pubspec.lock @@ -295,11 +295,11 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + ref: "enhancement/web-socket-ticket-capability" + resolved-ref: "06b1288f8e501c38166ec1895d227d4f832e4144" url: "https://github.com/linagora/jmap-dart-client.git" source: git - version: "0.2.2" + version: "0.2.3" js: dependency: transitive description: diff --git a/forward/pubspec.yaml b/forward/pubspec.yaml index 6af273b33e..f849524f04 100644 --- a/forward/pubspec.yaml +++ b/forward/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: jmap_dart_client: git: url: https://github.com/linagora/jmap-dart-client.git - ref: main + ref: enhancement/web-socket-ticket-capability ### Dependencies from pub.dev ### equatable: 2.0.5 diff --git a/lib/features/base/action/ui_action.dart b/lib/features/base/action/ui_action.dart index dfe9b368c3..4232b24b25 100644 --- a/lib/features/base/action/ui_action.dart +++ b/lib/features/base/action/ui_action.dart @@ -7,11 +7,9 @@ abstract class Action with EquatableMixin {} abstract class UIAction extends Action {} -abstract class FcmAction extends Action {} - -abstract class FcmStateChangeAction extends FcmAction { +abstract class PushNotificationStateChangeAction extends Action { final TypeName typeName; final jmap.State newState; - FcmStateChangeAction(this.typeName, this.newState); + PushNotificationStateChangeAction(this.typeName, this.newState); } \ No newline at end of file diff --git a/lib/features/base/base_controller.dart b/lib/features/base/base_controller.dart index ecb070a56f..4ec33848a4 100644 --- a/lib/features/base/base_controller.dart +++ b/lib/features/base/base_controller.dart @@ -23,8 +23,9 @@ import 'package:forward/forward/capability_forward.dart'; import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; +import 'package:jmap_dart_client/jmap/core/capability/websocket_capability.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; -import 'package:model/account/authentication_type.dart'; +import 'package:model/model.dart'; import 'package:rule_filter/rule_filter/capability_rule_filter.dart'; import 'package:tmail_ui_user/features/base/before_reconnect_manager.dart'; import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_mixin.dart'; @@ -44,14 +45,17 @@ import 'package:tmail_ui_user/features/manage_account/domain/usecases/log_out_oi import 'package:tmail_ui_user/features/manage_account/presentation/email_rules/bindings/email_rules_interactor_bindings.dart'; import 'package:tmail_ui_user/features/manage_account/presentation/forward/bindings/forwarding_interactors_bindings.dart'; import 'package:tmail_ui_user/features/push_notification/domain/exceptions/fcm_exception.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/exceptions/web_socket_exceptions.dart'; import 'package:tmail_ui_user/features/push_notification/domain/state/destroy_firebase_registration_state.dart'; import 'package:tmail_ui_user/features/push_notification/domain/state/get_stored_firebase_registration_state.dart'; import 'package:tmail_ui_user/features/push_notification/domain/usecases/destroy_firebase_registration_interactor.dart'; import 'package:tmail_ui_user/features/push_notification/domain/usecases/get_stored_firebase_registration_interactor.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/bindings/fcm_interactor_bindings.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/bindings/web_socket_interactor_bindings.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/config/fcm_configuration.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/controller/fcm_message_controller.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/controller/fcm_token_controller.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/controller/web_socket_controller.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/notification/local_notification_manager.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/services/fcm_receiver.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/services/fcm_service.dart'; @@ -372,6 +376,29 @@ abstract class BaseController extends GetxController } } + void injectWebSocket(Session? session, AccountId? accountId) { + try { + requireCapability( + session!, + accountId!, + [ + CapabilityIdentifier.jmapWebSocket, + CapabilityIdentifier.jmapWebSocketTicket + ] + ); + final wsCapability = session.getCapabilityProperties( + accountId, + CapabilityIdentifier.jmapWebSocket); + if (wsCapability?.supportsPush != true) { + throw WebSocketPushNotSupportedException(); + } + WebSocketInteractorBindings().dependencies(); + WebSocketController.instance.initialize(accountId: accountId, session: session); + } catch(e) { + logError('$runtimeType::injectWebSocket(): exception: $e'); + } + } + AuthenticationType get authenticationType => authorizationInterceptors.authenticationType; bool get isAuthenticatedWithOidc => authenticationType == AuthenticationType.oidc; diff --git a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart index f13d240849..f1cdcf7760 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -570,7 +570,11 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo injectAutoCompleteBindings(session, currentAccountId); injectRuleFilterBindings(session, currentAccountId); injectVacationBindings(session, currentAccountId); - injectFCMBindings(session, currentAccountId); + if (PlatformInfo.isWeb) { + injectWebSocket(session, currentAccountId); + } else { + injectFCMBindings(session, currentAccountId); + } _getVacationResponse(); spamReportController.getSpamReportStateAction(); diff --git a/lib/features/push_notification/data/datasource/web_socket_datasource.dart b/lib/features/push_notification/data/datasource/web_socket_datasource.dart new file mode 100644 index 0000000000..6dbfde4218 --- /dev/null +++ b/lib/features/push_notification/data/datasource/web_socket_datasource.dart @@ -0,0 +1,6 @@ +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; + +abstract class WebSocketDatasource { + Stream getWebSocketChannel(Session session, AccountId accountId); +} \ No newline at end of file diff --git a/lib/features/push_notification/data/datasource_impl/web_socket_datasource_impl.dart b/lib/features/push_notification/data/datasource_impl/web_socket_datasource_impl.dart new file mode 100644 index 0000000000..23922f428e --- /dev/null +++ b/lib/features/push_notification/data/datasource_impl/web_socket_datasource_impl.dart @@ -0,0 +1,88 @@ + +import 'package:core/data/constants/constant.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/broadcast_channel/broadcast_channel.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; +import 'package:jmap_dart_client/jmap/core/capability/websocket_capability.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:model/extensions/session_extension.dart'; +import 'package:rxdart/transformers.dart'; +import 'package:tmail_ui_user/features/push_notification/data/datasource/web_socket_datasource.dart'; +import 'package:tmail_ui_user/features/push_notification/data/network/web_socket_api.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/exceptions/web_socket_exceptions.dart'; +import 'package:tmail_ui_user/main/error/capability_validator.dart'; +import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart'; +import 'package:universal_html/html.dart'; + +class WebSocketDatasourceImpl implements WebSocketDatasource { + final WebSocketApi _webSocketApi; + final ExceptionThrower _exceptionThrower; + + const WebSocketDatasourceImpl(this._webSocketApi, this._exceptionThrower); + + static const String _webSocketClosed = 'webSocketClosed'; + + @override + Stream getWebSocketChannel(Session session, AccountId accountId) { + return Stream + .castFrom(_getWebSocketChannel(session, accountId)) + .doOnError(_exceptionThrower.throwException); + } + + Stream _getWebSocketChannel( + Session session, + AccountId accountId, + ) async* { + final broadcastChannel = BroadcastChannel(Constant.wsServiceWorkerBroadcastChannel); + try { + _verifyWebSocketCapabilities(session, accountId); + final webSocketTicket = await _webSocketApi.getWebSocketTicket(session, accountId); + final webSocketUri = _getWebSocketUri(session, accountId); + window.navigator.serviceWorker?.controller?.postMessage({ + 'action': 'connect', + 'url': webSocketUri.toString(), + 'ticket': webSocketTicket, + }); + + yield* _webSocketListener(broadcastChannel); + } catch (e) { + logError('RemoteWebSocketDatasourceImpl::getWebSocketChannel():error: $e'); + rethrow; + } + } + + void _verifyWebSocketCapabilities(Session session, AccountId accountId) { + if (!CapabilityIdentifier.jmapWebSocket.isSupported(session, accountId) + || !CapabilityIdentifier.jmapWebSocketTicket.isSupported(session, accountId) + || session.getCapabilityProperties( + accountId, + CapabilityIdentifier.jmapWebSocket)?.supportsPush != true + ) { + throw WebSocketPushNotSupportedException(); + } + } + + Uri _getWebSocketUri(Session session, AccountId accountId) { + final webSocketCapability = session.getCapabilityProperties( + accountId, + CapabilityIdentifier.jmapWebSocket); + if (webSocketCapability?.supportsPush != true) { + throw WebSocketPushNotSupportedException(); + } + final webSocketUri = webSocketCapability?.url; + if (webSocketUri == null) throw WebSocketUriUnavailableException(); + + return webSocketUri; + } + + Stream _webSocketListener(BroadcastChannel broadcastChannel) { + return broadcastChannel.onMessage.map((event) { + if (event.data == _webSocketClosed) { + throw WebSocketClosedException(); + } + + return event.data; + }); + } +} \ No newline at end of file diff --git a/lib/features/push_notification/data/model/web_socket_echo.dart b/lib/features/push_notification/data/model/web_socket_echo.dart new file mode 100644 index 0000000000..624167ddf6 --- /dev/null +++ b/lib/features/push_notification/data/model/web_socket_echo.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'web_socket_echo.g.dart'; + +@JsonSerializable(includeIfNull: false) +class WebSocketEcho { + @JsonKey(name: '@type') + final String? type; + final String? requestId; + final List>? methodResponses; + + WebSocketEcho({ + this.type, + this.requestId, + this.methodResponses, + }); + + factory WebSocketEcho.fromJson(Map json) => _$WebSocketEchoFromJson(json); + + Map toJson() => _$WebSocketEchoToJson(this); + + static bool isValid(Map json) { + try { + final webSocketEcho = WebSocketEcho.fromJson(json); + final listResponses = webSocketEcho.methodResponses?.firstOrNull; + return listResponses?.contains('Core/echo') ?? false; + } catch (_) { + return false; + } + } +} diff --git a/lib/features/push_notification/data/model/web_socket_ticket.dart b/lib/features/push_notification/data/model/web_socket_ticket.dart new file mode 100644 index 0000000000..e3cb6dfbf6 --- /dev/null +++ b/lib/features/push_notification/data/model/web_socket_ticket.dart @@ -0,0 +1,33 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'web_socket_ticket.g.dart'; + +@JsonSerializable(includeIfNull: false) +class WebSocketTicket with EquatableMixin { + final String? value; + final String? clientAddress; + final DateTime? generatedOn; + final DateTime? validUntil; + final String? username; + + WebSocketTicket({ + required this.value, + required this.clientAddress, + required this.generatedOn, + required this.validUntil, + required this.username, + }); + + factory WebSocketTicket.fromJson(Map json) => _$WebSocketTicketFromJson(json); + Map toJson() => _$WebSocketTicketToJson(this); + + @override + List get props => [ + value, + clientAddress, + generatedOn, + validUntil, + username, + ]; +} \ No newline at end of file diff --git a/lib/features/push_notification/data/network/web_socket_api.dart b/lib/features/push_notification/data/network/web_socket_api.dart new file mode 100644 index 0000000000..181bf1128e --- /dev/null +++ b/lib/features/push_notification/data/network/web_socket_api.dart @@ -0,0 +1,43 @@ + +import 'package:core/data/network/dio_client.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; +import 'package:jmap_dart_client/jmap/core/capability/web_socket_ticket_capability.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:model/extensions/session_extension.dart'; +import 'package:tmail_ui_user/features/push_notification/data/model/web_socket_ticket.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/exceptions/web_socket_exceptions.dart'; +import 'package:tmail_ui_user/main/error/capability_validator.dart'; + +class WebSocketApi { + final DioClient _dioClient; + + WebSocketApi(this._dioClient); + + Future getWebSocketTicket( + Session session, + AccountId accountId + ) async { + requireCapability( + session, + accountId, + [CapabilityIdentifier.jmapWebSocketTicket]); + final webSocketTicketCapability = session.getCapabilityProperties( + accountId, + CapabilityIdentifier.jmapWebSocketTicket); + + final webSocketTicketGenerationUrl = webSocketTicketCapability?.generationEndpoint; + if (webSocketTicketGenerationUrl == null) { + throw WebSocketTicketUnavailableException(); + } + final webSocketTicketGenerationResponse = await _dioClient.post( + '$webSocketTicketGenerationUrl'); + final webSocketTicket = WebSocketTicket.fromJson( + webSocketTicketGenerationResponse); + if (webSocketTicket.value == null) { + throw WebSocketTicketUnavailableException(); + } + + return webSocketTicket.value!; + } +} \ No newline at end of file diff --git a/lib/features/push_notification/data/repository/web_socket_repository_impl.dart b/lib/features/push_notification/data/repository/web_socket_repository_impl.dart new file mode 100644 index 0000000000..0f7ecd93cb --- /dev/null +++ b/lib/features/push_notification/data/repository/web_socket_repository_impl.dart @@ -0,0 +1,14 @@ +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:tmail_ui_user/features/push_notification/data/datasource/web_socket_datasource.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/repository/web_socket_repository.dart'; + +class WebSocketRepositoryImpl implements WebSocketRepository { + final WebSocketDatasource _webSocketDatasource; + + WebSocketRepositoryImpl(this._webSocketDatasource); + + @override + Stream getWebSocketChannel(Session session, AccountId accountId) + => _webSocketDatasource.getWebSocketChannel(session, accountId); +} \ No newline at end of file diff --git a/lib/features/push_notification/domain/exceptions/web_socket_exceptions.dart b/lib/features/push_notification/domain/exceptions/web_socket_exceptions.dart new file mode 100644 index 0000000000..216b4c1265 --- /dev/null +++ b/lib/features/push_notification/domain/exceptions/web_socket_exceptions.dart @@ -0,0 +1,7 @@ +class WebSocketPushNotSupportedException implements Exception {} + +class WebSocketUriUnavailableException implements Exception {} + +class WebSocketTicketUnavailableException implements Exception {} + +class WebSocketClosedException implements Exception {} \ No newline at end of file diff --git a/lib/features/push_notification/domain/repository/web_socket_repository.dart b/lib/features/push_notification/domain/repository/web_socket_repository.dart new file mode 100644 index 0000000000..158272d393 --- /dev/null +++ b/lib/features/push_notification/domain/repository/web_socket_repository.dart @@ -0,0 +1,6 @@ +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; + +abstract class WebSocketRepository { + Stream getWebSocketChannel(Session session, AccountId accountId); +} \ No newline at end of file diff --git a/lib/features/push_notification/domain/state/web_socket_push_state.dart b/lib/features/push_notification/domain/state/web_socket_push_state.dart new file mode 100644 index 0000000000..f6bdba4c94 --- /dev/null +++ b/lib/features/push_notification/domain/state/web_socket_push_state.dart @@ -0,0 +1,19 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:jmap_dart_client/jmap/push/state_change.dart'; + +class InitializingWebSocketPushChannel extends LoadingState {} + +class WebSocketPushStateReceived extends UIState { + final StateChange? stateChange; + + WebSocketPushStateReceived(this.stateChange); + + @override + List get props => [stateChange]; +} + +class WebSocketConnectionFailed extends FeatureFailure { + + WebSocketConnectionFailed({super.exception}); +} \ No newline at end of file diff --git a/lib/features/push_notification/domain/usecases/connect_web_socket_interactor.dart b/lib/features/push_notification/domain/usecases/connect_web_socket_interactor.dart new file mode 100644 index 0000000000..48cf8b725d --- /dev/null +++ b/lib/features/push_notification/domain/usecases/connect_web_socket_interactor.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; + +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:jmap_dart_client/jmap/push/state_change.dart'; +import 'package:tmail_ui_user/features/push_notification/data/model/web_socket_echo.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/repository/web_socket_repository.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/state/web_socket_push_state.dart'; + +class ConnectWebSocketInteractor { + final WebSocketRepository _webSocketRepository; + + ConnectWebSocketInteractor(this._webSocketRepository); + + Stream> execute( + Session session, + AccountId accountId + ) async* { + try { + yield Right(InitializingWebSocketPushChannel()); + yield* _webSocketRepository + .getWebSocketChannel(session, accountId) + .map(_toStateChange); + } catch (e) { + logError('ConnectWebSocketInteractor::execute: $e'); + yield Left(WebSocketConnectionFailed(exception: e)); + } + } + + Either _toStateChange(dynamic data) { + StateChange? possibleStateChange; + try { + if (data is String) { + data = jsonDecode(data); + } + possibleStateChange = StateChange.fromJson(data); + return Right(WebSocketPushStateReceived(possibleStateChange)); + } catch (e) { + logError('ConnectWebSocketInteractor::_toStateChange: ' + 'websocket message is not StateChange: $data'); + final dataIsWebSocketEcho = WebSocketEcho.isValid(data); + if (dataIsWebSocketEcho) { + return Right(WebSocketPushStateReceived(null)); + } + return Left(WebSocketConnectionFailed(exception: e)); + } + } +} \ No newline at end of file diff --git a/lib/features/push_notification/presentation/action/fcm_action.dart b/lib/features/push_notification/presentation/action/push_notification_state_change_action.dart similarity index 82% rename from lib/features/push_notification/presentation/action/fcm_action.dart rename to lib/features/push_notification/presentation/action/push_notification_state_change_action.dart index f04eab4b50..bf84e00c35 100644 --- a/lib/features/push_notification/presentation/action/fcm_action.dart +++ b/lib/features/push_notification/presentation/action/push_notification_state_change_action.dart @@ -6,7 +6,7 @@ import 'package:jmap_dart_client/jmap/core/user_name.dart'; import 'package:tmail_ui_user/features/base/action/ui_action.dart'; import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; -class SynchronizeEmailOnForegroundAction extends FcmStateChangeAction { +class SynchronizeEmailOnForegroundAction extends PushNotificationStateChangeAction { final AccountId accountId; final Session? session; @@ -22,7 +22,7 @@ class SynchronizeEmailOnForegroundAction extends FcmStateChangeAction { List get props => [typeName, newState, accountId, session]; } -class PushNotificationAction extends FcmStateChangeAction { +class PushNotificationAction extends PushNotificationStateChangeAction { final Session? session; final AccountId accountId; @@ -40,7 +40,7 @@ class PushNotificationAction extends FcmStateChangeAction { List get props => [typeName, newState, accountId, session, userName]; } -class StoreEmailStateToRefreshAction extends FcmStateChangeAction { +class StoreEmailStateToRefreshAction extends PushNotificationStateChangeAction { final AccountId accountId; final UserName userName; @@ -58,7 +58,7 @@ class StoreEmailStateToRefreshAction extends FcmStateChangeAction { List get props => [typeName, newState, accountId, session]; } -class SynchronizeMailboxOnForegroundAction extends FcmStateChangeAction { +class SynchronizeMailboxOnForegroundAction extends PushNotificationStateChangeAction { final AccountId accountId; @@ -72,7 +72,7 @@ class SynchronizeMailboxOnForegroundAction extends FcmStateChangeAction { List get props => [typeName, newState, accountId]; } -class StoreMailboxStateToRefreshAction extends FcmStateChangeAction { +class StoreMailboxStateToRefreshAction extends PushNotificationStateChangeAction { final AccountId accountId; final UserName userName; diff --git a/lib/features/push_notification/presentation/bindings/web_socket_interactor_bindings.dart b/lib/features/push_notification/presentation/bindings/web_socket_interactor_bindings.dart new file mode 100644 index 0000000000..dde6040885 --- /dev/null +++ b/lib/features/push_notification/presentation/bindings/web_socket_interactor_bindings.dart @@ -0,0 +1,39 @@ +import 'package:get/get.dart'; +import 'package:tmail_ui_user/features/base/interactors_bindings.dart'; +import 'package:tmail_ui_user/features/push_notification/data/datasource/web_socket_datasource.dart'; +import 'package:tmail_ui_user/features/push_notification/data/datasource_impl/web_socket_datasource_impl.dart'; +import 'package:tmail_ui_user/features/push_notification/data/network/web_socket_api.dart'; +import 'package:tmail_ui_user/features/push_notification/data/repository/web_socket_repository_impl.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/repository/web_socket_repository.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/usecases/connect_web_socket_interactor.dart'; +import 'package:tmail_ui_user/main/exceptions/remote_exception_thrower.dart'; + +class WebSocketInteractorBindings extends InteractorsBindings { + @override + void bindingsDataSource() { + Get.lazyPut(() => Get.find()); + } + + @override + void bindingsDataSourceImpl() { + Get.lazyPut(() => WebSocketDatasourceImpl( + Get.find(), + Get.find(), + )); + } + + @override + void bindingsInteractor() { + Get.lazyPut(() => ConnectWebSocketInteractor(Get.find())); + } + + @override + void bindingsRepository() { + Get.lazyPut(() => Get.find()); + } + + @override + void bindingsRepositoryImpl() { + Get.lazyPut(() => WebSocketRepositoryImpl(Get.find())); + } +} \ No newline at end of file diff --git a/lib/features/push_notification/presentation/controller/fcm_base_controller.dart b/lib/features/push_notification/presentation/controller/fcm_base_controller.dart deleted file mode 100644 index f3c9358a25..0000000000 --- a/lib/features/push_notification/presentation/controller/fcm_base_controller.dart +++ /dev/null @@ -1,25 +0,0 @@ - -import 'package:core/presentation/state/failure.dart'; -import 'package:core/presentation/state/success.dart'; -import 'package:core/utils/app_logger.dart'; -import 'package:dartz/dartz.dart'; - -abstract class FcmBaseController { - - void consumeState(Stream> newStateStream) { - newStateStream.listen( - _handleStateStream, - onError: (error, stackTrace) { - logError('FcmBaseController::consumeState():onError:error: $error | stackTrace: $stackTrace'); - } - ); - } - - void _handleStateStream(Either newState) { - newState.fold(handleFailureViewState, handleSuccessViewState); - } - - void handleFailureViewState(Failure failure); - - void handleSuccessViewState(Success success); -} \ No newline at end of file diff --git a/lib/features/push_notification/presentation/controller/fcm_message_controller.dart b/lib/features/push_notification/presentation/controller/fcm_message_controller.dart index 0636a45363..a290e4dbe7 100644 --- a/lib/features/push_notification/presentation/controller/fcm_message_controller.dart +++ b/lib/features/push_notification/presentation/controller/fcm_message_controller.dart @@ -1,21 +1,17 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:core/data/network/config/dynamic_url_interceptors.dart'; import 'package:core/presentation/state/failure.dart'; import 'package:core/presentation/state/success.dart'; import 'package:core/utils/app_logger.dart'; import 'package:core/utils/platform_info.dart'; -import 'package:fcm/model/type_name.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; -import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; import 'package:jmap_dart_client/jmap/core/user_name.dart'; import 'package:jmap_dart_client/jmap/push/state_change.dart'; import 'package:model/model.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:tmail_ui_user/features/base/action/ui_action.dart'; import 'package:tmail_ui_user/features/caching/config/hive_cache_config.dart'; import 'package:tmail_ui_user/features/home/domain/extensions/session_extensions.dart'; import 'package:tmail_ui_user/features/home/domain/state/get_session_state.dart'; @@ -30,10 +26,9 @@ import 'package:tmail_ui_user/features/login/domain/state/get_stored_token_oidc_ import 'package:tmail_ui_user/features/login/domain/usecases/get_authenticated_account_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/data/local/state_cache_manager.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart'; -import 'package:tmail_ui_user/features/push_notification/presentation/action/fcm_action.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/bindings/fcm_interactor_bindings.dart'; -import 'package:tmail_ui_user/features/push_notification/presentation/controller/fcm_base_controller.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/controller/fcm_token_controller.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/controller/push_base_controller.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/extensions/state_change_extension.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/listener/email_change_listener.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/listener/mailbox_change_listener.dart'; @@ -42,12 +37,7 @@ import 'package:tmail_ui_user/features/push_notification/presentation/utils/fcm_ import 'package:tmail_ui_user/main/bindings/main_bindings.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; -class FcmMessageController extends FcmBaseController { - - AccountId? _currentAccountId; - Session? _currentSession; - UserName? _userName; - +class FcmMessageController extends PushBaseController { GetAuthenticatedAccountInteractor? _getAuthenticatedAccountInteractor; DynamicUrlInterceptors? _dynamicUrlInterceptors; AuthorizationInterceptors? _authorizationInterceptors; @@ -64,10 +54,9 @@ class FcmMessageController extends FcmBaseController { static FcmMessageController get instance => _instance; + @override void initialize({AccountId? accountId, Session? session}) { - _currentAccountId = accountId; - _currentSession = session; - _userName = session?.username; + super.initialize(accountId: accountId, session: session); _listenTokenStream(); _listenForegroundMessageStream(); @@ -96,15 +85,17 @@ class FcmMessageController extends FcmBaseController { } void _handleForegroundMessageAction(Map payloadData) { - log('FcmMessageController::_handleForegroundMessageAction():payloadData: $payloadData | _currentAccountId: $_currentAccountId'); - if (_currentAccountId != null && _userName != null) { + log('FcmMessageController::_handleForegroundMessageAction():payloadData: $payloadData | accountId: $accountId'); + if (accountId != null && session?.username != null) { final stateChange = FcmUtils.instance.convertFirebaseDataMessageToStateChange(payloadData); - final mapTypeState = stateChange.getMapTypeState(_currentAccountId!); - _mappingTypeStateToAction( + final mapTypeState = stateChange.getMapTypeState(accountId!); + mappingTypeStateToAction( mapTypeState, - _currentAccountId!, - _userName!, - session: _currentSession); + accountId!, + emailChangeListener: EmailChangeListener.instance, + mailboxChangeListener: MailboxChangeListener.instance, + session!.username, + session: session); } } @@ -115,74 +106,6 @@ class FcmMessageController extends FcmBaseController { _getAuthenticatedAccount(stateChange: stateChange); } - void _mappingTypeStateToAction( - Map mapTypeState, - AccountId accountId, - UserName userName, { - bool isForeground = true, - Session? session - }) { - log('FcmMessageController::_mappingTypeStateToAction():mapTypeState: $mapTypeState'); - final listTypeName = mapTypeState.keys - .map((value) => TypeName(value)) - .toList(); - - final listEmailActions = listTypeName - .where((typeName) => typeName == TypeName.emailType || typeName == TypeName.emailDelivery) - .map((typeName) => toFcmAction(typeName, accountId, userName, mapTypeState, isForeground, session: session)) - .whereNotNull() - .toList(); - - log('FcmMessageController::_mappingTypeStateToAction():listEmailActions: $listEmailActions'); - - if (listEmailActions.isNotEmpty) { - EmailChangeListener.instance.dispatchActions(listEmailActions); - } - - final listMailboxActions = listTypeName - .where((typeName) => typeName == TypeName.mailboxType) - .map((typeName) => toFcmAction(typeName, accountId, userName, mapTypeState, isForeground)) - .whereNotNull() - .toList(); - - log('FcmMessageController::_mappingTypeStateToAction():listMailboxActions: $listEmailActions'); - - if (listMailboxActions.isNotEmpty) { - MailboxChangeListener.instance.dispatchActions(listMailboxActions); - } - } - - FcmAction? toFcmAction( - TypeName typeName, - AccountId accountId, - UserName userName, - Map mapTypeState, - isForeground, - { - Session? session - } - ) { - final newState = jmap.State(mapTypeState[typeName.value]); - if (typeName == TypeName.emailType) { - if (isForeground) { - return SynchronizeEmailOnForegroundAction(typeName, newState, accountId, session); - } else { - return StoreEmailStateToRefreshAction(typeName, newState, accountId, userName, session); - } - } else if (typeName == TypeName.emailDelivery) { - if (!isForeground) { - return PushNotificationAction(typeName, newState, session, accountId, userName); - } - } else if (typeName == TypeName.mailboxType) { - if (isForeground) { - return SynchronizeMailboxOnForegroundAction(typeName, newState, accountId); - } else { - return StoreMailboxStateToRefreshAction(typeName, newState, accountId, userName); - } - } - return null; - } - Future _initialAppConfig() async { await Future.wait([ MainBindings().dependencies(), @@ -312,10 +235,12 @@ class FcmMessageController extends FcmBaseController { }) { final mapTypeState = stateChange.getMapTypeState(accountId); - _mappingTypeStateToAction( + mappingTypeStateToAction( mapTypeState, accountId, userName, + emailChangeListener: EmailChangeListener.instance, + mailboxChangeListener: MailboxChangeListener.instance, isForeground: false, session: session); } diff --git a/lib/features/push_notification/presentation/controller/fcm_token_controller.dart b/lib/features/push_notification/presentation/controller/fcm_token_controller.dart index ecb70032a4..e85e598603 100644 --- a/lib/features/push_notification/presentation/controller/fcm_token_controller.dart +++ b/lib/features/push_notification/presentation/controller/fcm_token_controller.dart @@ -20,12 +20,12 @@ import 'package:tmail_ui_user/features/push_notification/domain/usecases/get_sto import 'package:tmail_ui_user/features/push_notification/domain/usecases/register_new_firebase_registration_token_interactor.dart'; import 'package:tmail_ui_user/features/push_notification/domain/usecases/store_firebase_registration_interator.dart'; import 'package:tmail_ui_user/features/push_notification/domain/usecases/update_firebase_registration_token_interactor.dart'; -import 'package:tmail_ui_user/features/push_notification/presentation/controller/fcm_base_controller.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/controller/push_base_controller.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/utils/fcm_utils.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; import 'package:uuid/uuid.dart'; -class FcmTokenController extends FcmBaseController { +class FcmTokenController extends PushBaseController { FcmTokenController._internal(); diff --git a/lib/features/push_notification/presentation/controller/push_base_controller.dart b/lib/features/push_notification/presentation/controller/push_base_controller.dart new file mode 100644 index 0000000000..14ccdc7355 --- /dev/null +++ b/lib/features/push_notification/presentation/controller/push_base_controller.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:dartz/dartz.dart'; +import 'package:fcm/model/type_name.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; +import 'package:jmap_dart_client/jmap/core/user_name.dart'; +import 'package:tmail_ui_user/features/base/action/ui_action.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/action/push_notification_state_change_action.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/listener/email_change_listener.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/listener/mailbox_change_listener.dart'; + +abstract class PushBaseController { + Session? session; + AccountId? accountId; + + StreamSubscription>? _stateStreamSubscription; + + void consumeState(Stream> newStateStream) { + _stateStreamSubscription = newStateStream.listen( + _handleStateStream, + onError: handleErrorViewState, + ); + } + + void _handleStateStream(Either newState) { + newState.fold(handleFailureViewState, handleSuccessViewState); + } + + void handleFailureViewState(Failure failure); + + void handleSuccessViewState(Success success); + + void handleErrorViewState(Object error, StackTrace stackTrace) { + logError('PushBaseController::handleErrorViewState():error: $error | stackTrace: $stackTrace'); + } + + void cancelStateStreamSubscription() { + _stateStreamSubscription?.cancel(); + _stateStreamSubscription = null; + } + + void initialize({AccountId? accountId, Session? session}) { + this.accountId = accountId; + this.session = session; + } + + void mappingTypeStateToAction( + Map mapTypeState, + AccountId accountId, + UserName userName, { + bool isForeground = true, + Session? session, + required EmailChangeListener emailChangeListener, + required MailboxChangeListener mailboxChangeListener + }) { + log('PushBaseController::mappingTypeStateToAction():mapTypeState: $mapTypeState'); + final listTypeName = mapTypeState.keys + .map((value) => TypeName(value)) + .toList(); + + final listEmailActions = listTypeName + .where((typeName) => typeName == TypeName.emailType || typeName == TypeName.emailDelivery) + .map((typeName) => _toPushNotificationAction(typeName, accountId, userName, mapTypeState, isForeground, session: session)) + .whereNotNull() + .toList(); + + log('PushBaseController::mappingTypeStateToAction():listEmailActions: $listEmailActions'); + + if (listEmailActions.isNotEmpty) { + emailChangeListener.dispatchActions(listEmailActions); + } + + final listMailboxActions = listTypeName + .where((typeName) => typeName == TypeName.mailboxType) + .map((typeName) => _toPushNotificationAction(typeName, accountId, userName, mapTypeState, isForeground)) + .whereNotNull() + .toList(); + + log('PushBaseController::mappingTypeStateToAction():listMailboxActions: $listEmailActions'); + + if (listMailboxActions.isNotEmpty) { + mailboxChangeListener.dispatchActions(listMailboxActions); + } + } + + PushNotificationStateChangeAction? _toPushNotificationAction( + TypeName typeName, + AccountId accountId, + UserName userName, + Map mapTypeState, + isForeground, + {Session? session} + ) { + final newState = jmap.State(mapTypeState[typeName.value]); + switch (typeName) { + case TypeName.emailType: + return isForeground + ? SynchronizeEmailOnForegroundAction(typeName, newState, accountId, session) + : StoreEmailStateToRefreshAction(typeName, newState, accountId, userName, session); + case TypeName.emailDelivery: + if (!isForeground) { + return PushNotificationAction(typeName, newState, session, accountId, userName); + } + break; + case TypeName.mailboxType: + return isForeground + ? SynchronizeMailboxOnForegroundAction(typeName, newState, accountId) + : StoreMailboxStateToRefreshAction(typeName, newState, accountId, userName); + } + return null; + } +} \ No newline at end of file diff --git a/lib/features/push_notification/presentation/controller/web_socket_controller.dart b/lib/features/push_notification/presentation/controller/web_socket_controller.dart new file mode 100644 index 0000000000..6632c78a52 --- /dev/null +++ b/lib/features/push_notification/presentation/controller/web_socket_controller.dart @@ -0,0 +1,90 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/exceptions/web_socket_exceptions.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/state/web_socket_push_state.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/usecases/connect_web_socket_interactor.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/controller/push_base_controller.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/extensions/state_change_extension.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/listener/email_change_listener.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/listener/mailbox_change_listener.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; + +class WebSocketController extends PushBaseController { + WebSocketController._internal(); + + static final WebSocketController _instance = WebSocketController._internal(); + + static WebSocketController get instance => _instance; + + ConnectWebSocketInteractor? _connectWebSocketInteractor; + + int _retryRemained = 3; + + @override + void handleFailureViewState(Failure failure) { + logError('WebSocketController::handleFailureViewState():Failure $failure'); + cancelStateStreamSubscription(); + if (failure is WebSocketConnectionFailed) { + _handleWebSocketConnectionRetry(); + } + } + + @override + void handleSuccessViewState(Success success) { + log('WebSocketController::handleSuccessViewState():Success $success'); + if (success is WebSocketPushStateReceived) { + _handleWebSocketPushStateReceived(success); + } + } + + @override + void handleErrorViewState(Object error, StackTrace stackTrace) { + super.handleErrorViewState(error, stackTrace); + cancelStateStreamSubscription(); + if (error is WebSocketClosedException) { + _handleWebSocketConnectionRetry(); + } + } + + @override + void initialize({AccountId? accountId, Session? session}) { + super.initialize(accountId: accountId, session: session); + + _connectWebSocket(accountId, session); + } + + void _connectWebSocket(AccountId? accountId, Session? session) { + _connectWebSocketInteractor = getBinding(); + if (_connectWebSocketInteractor == null || accountId == null || session == null) { + return; + } + + consumeState(_connectWebSocketInteractor!.execute(session, accountId)); + } + + void _handleWebSocketPushStateReceived(WebSocketPushStateReceived success) { + log('WebSocketController::_handleWebSocketPushStateReceived(): $success'); + _retryRemained = 3; + if (accountId == null || session == null) return; + final stateChange = success.stateChange; + if (stateChange == null) return; + final mapTypeState = stateChange.getMapTypeState(accountId!); + mappingTypeStateToAction( + mapTypeState, + accountId!, + emailChangeListener: EmailChangeListener.instance, + mailboxChangeListener: MailboxChangeListener.instance, + session!.username, + session: session); + } + + void _handleWebSocketConnectionRetry() { + if (_retryRemained > 0) { + _retryRemained--; + _connectWebSocket(accountId, session); + } + } +} \ No newline at end of file diff --git a/lib/features/push_notification/presentation/listener/email_change_listener.dart b/lib/features/push_notification/presentation/listener/email_change_listener.dart index 4839b0a05e..5d3ef11759 100644 --- a/lib/features/push_notification/presentation/listener/email_change_listener.dart +++ b/lib/features/push_notification/presentation/listener/email_change_listener.dart @@ -39,7 +39,7 @@ import 'package:tmail_ui_user/features/push_notification/domain/usecases/get_new import 'package:tmail_ui_user/features/push_notification/domain/usecases/get_stored_email_delivery_state_interactor.dart'; import 'package:tmail_ui_user/features/push_notification/domain/usecases/store_email_delivery_state_interactor.dart'; import 'package:tmail_ui_user/features/push_notification/domain/usecases/store_email_state_to_refresh_interactor.dart'; -import 'package:tmail_ui_user/features/push_notification/presentation/action/fcm_action.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/action/push_notification_state_change_action.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/listener/change_listener.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/notification/local_notification_manager.dart'; import 'package:tmail_ui_user/features/thread/domain/constants/thread_constants.dart'; diff --git a/lib/features/push_notification/presentation/listener/mailbox_change_listener.dart b/lib/features/push_notification/presentation/listener/mailbox_change_listener.dart index 1ff676f91c..2502f6e0a5 100644 --- a/lib/features/push_notification/presentation/listener/mailbox_change_listener.dart +++ b/lib/features/push_notification/presentation/listener/mailbox_change_listener.dart @@ -9,7 +9,7 @@ import 'package:tmail_ui_user/features/base/action/ui_action.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/action/mailbox_ui_action.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; import 'package:tmail_ui_user/features/push_notification/domain/usecases/store_mailbox_state_to_refresh_interactor.dart'; -import 'package:tmail_ui_user/features/push_notification/presentation/action/fcm_action.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/action/push_notification_state_change_action.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/listener/change_listener.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; diff --git a/lib/main/bindings/network/network_bindings.dart b/lib/main/bindings/network/network_bindings.dart index 343a6e8293..ce115afe95 100644 --- a/lib/main/bindings/network/network_bindings.dart +++ b/lib/main/bindings/network/network_bindings.dart @@ -33,6 +33,7 @@ import 'package:tmail_ui_user/features/manage_account/data/network/rule_filter_a import 'package:tmail_ui_user/features/manage_account/data/network/vacation_api.dart'; import 'package:tmail_ui_user/features/push_notification/data/keychain/keychain_sharing_manager.dart'; import 'package:tmail_ui_user/features/push_notification/data/network/fcm_api.dart'; +import 'package:tmail_ui_user/features/push_notification/data/network/web_socket_api.dart'; import 'package:tmail_ui_user/features/quotas/data/network/quotas_api.dart'; import 'package:tmail_ui_user/features/server_settings/data/network/server_settings_api.dart'; import 'package:tmail_ui_user/features/thread/data/network/thread_api.dart'; @@ -124,6 +125,7 @@ class NetworkBindings extends Bindings { Get.put(FcmApi(Get.find())); Get.put(SpamReportApi(Get.find())); Get.put(ServerSettingsAPI(Get.find())); + Get.put(WebSocketApi(Get.find())); } void _bindingConnection() { diff --git a/model/pubspec.lock b/model/pubspec.lock index d93d870fac..edeac91a6d 100644 --- a/model/pubspec.lock +++ b/model/pubspec.lock @@ -643,11 +643,11 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + ref: "enhancement/web-socket-ticket-capability" + resolved-ref: "06b1288f8e501c38166ec1895d227d4f832e4144" url: "https://github.com/linagora/jmap-dart-client.git" source: git - version: "0.2.2" + version: "0.2.3" js: dependency: transitive description: diff --git a/model/pubspec.yaml b/model/pubspec.yaml index 5c0a75a1e8..45662b13ff 100644 --- a/model/pubspec.yaml +++ b/model/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: jmap_dart_client: git: url: https://github.com/linagora/jmap-dart-client.git - ref: main + ref: enhancement/web-socket-ticket-capability ### Dependencies from pub.dev ### cupertino_icons: 1.0.6 diff --git a/pubspec.lock b/pubspec.lock index 801f9dc384..24fe636d55 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1235,11 +1235,11 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + ref: "enhancement/web-socket-ticket-capability" + resolved-ref: "06b1288f8e501c38166ec1895d227d4f832e4144" url: "https://github.com/linagora/jmap-dart-client.git" source: git - version: "0.2.2" + version: "0.2.3" js: dependency: transitive description: @@ -2135,13 +2135,13 @@ packages: source: hosted version: "0.4.2" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "939ab60734a4f8fa95feacb55804fa278de28bdeef38e616dc08e44a84adea23" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.3" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 41d36b3d71..e2d3363f79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,7 +65,7 @@ dependencies: jmap_dart_client: git: url: https://github.com/linagora/jmap-dart-client.git - ref: main + ref: enhancement/web-socket-ticket-capability contacts_service: git: @@ -248,6 +248,8 @@ dependencies: app_settings: 5.1.1 + web_socket_channel: 2.4.3 + dev_dependencies: flutter_test: sdk: flutter diff --git a/rule_filter/pubspec.lock b/rule_filter/pubspec.lock index bf16ad9212..a93ae78638 100644 --- a/rule_filter/pubspec.lock +++ b/rule_filter/pubspec.lock @@ -295,11 +295,11 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + ref: "enhancement/web-socket-ticket-capability" + resolved-ref: "06b1288f8e501c38166ec1895d227d4f832e4144" url: "https://github.com/linagora/jmap-dart-client.git" source: git - version: "0.2.2" + version: "0.2.3" js: dependency: transitive description: diff --git a/rule_filter/pubspec.yaml b/rule_filter/pubspec.yaml index bb8c5c7e0f..30ea25bbdc 100644 --- a/rule_filter/pubspec.yaml +++ b/rule_filter/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: jmap_dart_client: git: url: https://github.com/linagora/jmap-dart-client.git - ref: main + ref: enhancement/web-socket-ticket-capability ### Dependencies from pub.dev ### equatable: 2.0.5 diff --git a/server_settings/pubspec.lock b/server_settings/pubspec.lock index bc63780517..2d58b4abee 100644 --- a/server_settings/pubspec.lock +++ b/server_settings/pubspec.lock @@ -287,11 +287,11 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + ref: "enhancement/web-socket-ticket-capability" + resolved-ref: "06b1288f8e501c38166ec1895d227d4f832e4144" url: "https://github.com/linagora/jmap-dart-client.git" source: git - version: "0.2.2" + version: "0.2.3" js: dependency: transitive description: diff --git a/server_settings/pubspec.yaml b/server_settings/pubspec.yaml index 4e70d55723..29a5dd61f4 100644 --- a/server_settings/pubspec.yaml +++ b/server_settings/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: jmap_dart_client: git: url: https://github.com/linagora/jmap-dart-client.git - ref: main + ref: enhancement/web-socket-ticket-capability ### Dependencies from pub.dev ### equatable: 2.0.5 diff --git a/test/features/push_notification/data/model/web_socket_echo_test.dart b/test/features/push_notification/data/model/web_socket_echo_test.dart new file mode 100644 index 0000000000..cd91450448 --- /dev/null +++ b/test/features/push_notification/data/model/web_socket_echo_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tmail_ui_user/features/push_notification/data/model/web_socket_echo.dart'; + +void main() { + group('web socket echo test:', () { + group('isValid():', () { + test( + 'should return true ' + 'when json is web socket echo', + () { + // arrange + final json = { + "@type": "Response", + "requestId": "R1", + "methodResponses": [["Core/echo", {}, "c0"]] + }; + + // act + final result = WebSocketEcho.isValid(json); + + // assert + expect(result, true); + }); + + test( + 'should return false ' + 'when json is not web socket echo', + () { + // arrange + final json = { + "@type": "Response", + "requestId": "R1", + "methodResponses": [["Core/not-echo", {}, "c0"]] + }; + + // act + final result = WebSocketEcho.isValid(json); + + // assert + expect(result, false); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/push_notification/domain/usecases/connect_web_socket_interactor_test.dart b/test/features/push_notification/domain/usecases/connect_web_socket_interactor_test.dart new file mode 100644 index 0000000000..06f1a609aa --- /dev/null +++ b/test/features/push_notification/domain/usecases/connect_web_socket_interactor_test.dart @@ -0,0 +1,121 @@ +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/push/state_change.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/repository/web_socket_repository.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/state/web_socket_push_state.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/usecases/connect_web_socket_interactor.dart'; + +import '../../../../fixtures/account_fixtures.dart'; +import '../../../../fixtures/session_fixtures.dart'; +import 'connect_web_socket_interactor_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockWebSocketRepository webSocketRepository; + late ConnectWebSocketInteractor connectWebSocketInteractor; + + setUp(() { + webSocketRepository = MockWebSocketRepository(); + connectWebSocketInteractor = ConnectWebSocketInteractor(webSocketRepository); + }); + + group('connect web socket interactor test:', () { + test( + 'should yield WebSocketPushStateReceived with StateChange ' + 'when web socket repository yield StateChange', + () { + // arrange + final stateChangeJson = { + "@type": "StateChange", + "changed": {} + }; + when(webSocketRepository.getWebSocketChannel(any, any)) + .thenAnswer((_) => Stream.value(stateChangeJson)); + + // assert + expect( + connectWebSocketInteractor.execute( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId), + emitsInOrder([ + Right(InitializingWebSocketPushChannel()), + Right(WebSocketPushStateReceived(StateChange.fromJson(stateChangeJson))), + ])); + }); + + test( + 'should yield WebSocketPushStateReceived with null ' + 'when web socket repository yield WebSocketEcho', + () { + // arrange + final webSocketEchoJson = { + "@type": "Response", + "requestId": "R1", + "methodResponses": [["Core/echo", {}, "c0"]] + }; + when(webSocketRepository.getWebSocketChannel(any, any)) + .thenAnswer((_) => Stream.value(webSocketEchoJson)); + + // assert + expect( + connectWebSocketInteractor.execute( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId), + emitsInOrder([ + Right(InitializingWebSocketPushChannel()), + Right(WebSocketPushStateReceived(null)), + ])); + }); + + test( + 'should yield WebSocketConnectionFailed ' + 'when web socket repository throws exception', + () { + // arrange + final exception = Exception(); + when(webSocketRepository.getWebSocketChannel(any, any)) + .thenThrow(exception); + + // assert + expect( + connectWebSocketInteractor.execute( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId), + emitsInOrder([ + Right(InitializingWebSocketPushChannel()), + Left(WebSocketConnectionFailed(exception: exception)), + ])); + }); + + test( + 'should yield WebSocketConnectionFailed ' + 'when web socket repository yield data ' + 'and data is not web socket echo', + () async { + // arrange + final notWebSocketEchoJson = { + "@type": "Response", + "requestId": "R1", + "methodResponses": [["Core/not-echo", {}, "c0"]] + }; + when(webSocketRepository.getWebSocketChannel(any, any)) + .thenAnswer((_) => Stream.value(notWebSocketEchoJson)); + + // act + final lastState = await connectWebSocketInteractor.execute( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId).last; + + // assert + expect( + lastState.fold( + (failure) => failure is WebSocketConnectionFailed, + (success) => false, + ), + true + ); + }); + }); +} \ No newline at end of file diff --git a/test/features/push_notification/presentation/controller/push_base_controller_test.dart b/test/features/push_notification/presentation/controller/push_base_controller_test.dart new file mode 100644 index 0000000000..dfa32c14bc --- /dev/null +++ b/test/features/push_notification/presentation/controller/push_base_controller_test.dart @@ -0,0 +1,230 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:fcm/model/type_name.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart'; +import 'package:jmap_dart_client/jmap/core/user_name.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/action/push_notification_state_change_action.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/controller/push_base_controller.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/listener/email_change_listener.dart'; +import 'package:tmail_ui_user/features/push_notification/presentation/listener/mailbox_change_listener.dart'; + +import 'push_base_controller_test.mocks.dart'; + +class TestPushController extends PushBaseController { + @override + void handleFailureViewState(Failure failure) {} + + @override + void handleSuccessViewState(Success success) {} +} + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + final accountId = AccountId(Id('accountId')); + final userName = UserName('userName'); + + group('push base controller test:', () { + group('mappingTypeStateToAction:', () { + final emailChangeListener = MockEmailChangeListener(); + final mailboxChangeListener = MockMailboxChangeListener(); + + test( + 'should call emailChangeListener.dispatchActions with SynchronizeEmailOnForegroundAction ' + 'when mapTypeState contains emailType ' + 'and isForeground is true', + () { + // arrange + final state = State('some-state'); + final mapTypeState = {TypeName.emailType.value: state.value}; + + // act + final pushBaseController = TestPushController(); + pushBaseController.mappingTypeStateToAction( + mapTypeState, + accountId, + userName, + isForeground: true, + emailChangeListener: emailChangeListener, + mailboxChangeListener: mailboxChangeListener + ); + + // assert + verify( + emailChangeListener.dispatchActions([ + SynchronizeEmailOnForegroundAction( + TypeName.emailType, + state, + accountId, + null, + ), + ]), + ).called(1); + }); + + test( + 'should call emailChangeListener.dispatchActions with StoreEmailStateToRefreshAction ' + 'when mapTypeState contains emailType ' + 'and isForeground is false', + () { + // arrange + final state = State('some-state'); + final mapTypeState = {TypeName.emailType.value: state.value}; + + // act + final pushBaseController = TestPushController(); + pushBaseController.mappingTypeStateToAction( + mapTypeState, + accountId, + userName, + isForeground: false, + emailChangeListener: emailChangeListener, + mailboxChangeListener: mailboxChangeListener + ); + + // assert + verify( + emailChangeListener.dispatchActions([ + StoreEmailStateToRefreshAction( + TypeName.emailType, + state, + accountId, + userName, + null, + ), + ]), + ).called(1); + }); + + test( + 'should call emailChangeListener.dispatchActions with nothing ' + 'when mapTypeState contains emailDelivery ' + 'and isForeground is true', + () { + // arrange + final state = State('some-state'); + final mapTypeState = {TypeName.emailDelivery.value: state.value}; + + // act + final pushBaseController = TestPushController(); + pushBaseController.mappingTypeStateToAction( + mapTypeState, + accountId, + userName, + isForeground: true, + emailChangeListener: emailChangeListener, + mailboxChangeListener: mailboxChangeListener + ); + + // assert + verifyNever(emailChangeListener.dispatchActions(any)); + }); + + test( + 'should call emailChangeListener.dispatchActions with PushNotificationAction ' + 'when mapTypeState contains emailDelivery ' + 'and isForeground is false', + () { + // arrange + final state = State('some-state'); + final mapTypeState = {TypeName.emailDelivery.value: state.value}; + + // act + final pushBaseController = TestPushController(); + pushBaseController.mappingTypeStateToAction( + mapTypeState, + accountId, + userName, + isForeground: false, + emailChangeListener: emailChangeListener, + mailboxChangeListener: mailboxChangeListener + ); + + // assert + verify( + emailChangeListener.dispatchActions([ + PushNotificationAction( + TypeName.emailDelivery, + state, + null, + accountId, + userName, + ), + ]), + ).called(1); + }); + + test( + 'should call mailboxChangeListener.dispatchActions with SynchronizeMailboxOnForegroundAction ' + 'when mapTypeState contains mailboxType ' + 'and isForeground is true', + () { + // arrange + final state = State('some-state'); + final mapTypeState = {TypeName.mailboxType.value: state.value}; + + // act + final pushBaseController = TestPushController(); + pushBaseController.mappingTypeStateToAction( + mapTypeState, + accountId, + userName, + isForeground: true, + emailChangeListener: emailChangeListener, + mailboxChangeListener: mailboxChangeListener + ); + + // assert + verify( + mailboxChangeListener.dispatchActions([ + SynchronizeMailboxOnForegroundAction( + TypeName.mailboxType, + state, + accountId, + ), + ]), + ).called(1); + }); + + test( + 'should call mailboxChangeListener.dispatchActions with StoreMailboxStateToRefreshAction ' + 'when mapTypeState contains mailboxType ' + 'and isForeground is false', + () { + // arrange + final state = State('some-state'); + final mapTypeState = {TypeName.mailboxType.value: state.value}; + + // act + final pushBaseController = TestPushController(); + pushBaseController.mappingTypeStateToAction( + mapTypeState, + accountId, + userName, + isForeground: false, + emailChangeListener: emailChangeListener, + mailboxChangeListener: mailboxChangeListener + ); + + // assert + verify( + mailboxChangeListener.dispatchActions([ + StoreMailboxStateToRefreshAction( + TypeName.mailboxType, + state, + accountId, + userName, + ), + ]), + ).called(1); + }); + }); + }); +} \ No newline at end of file diff --git a/web/web-sockets-worker.js b/web/web-sockets-worker.js new file mode 100644 index 0000000000..5aa1184882 --- /dev/null +++ b/web/web-sockets-worker.js @@ -0,0 +1,72 @@ +var webSocket; +const broadcast = new BroadcastChannel("background-message"); +var intervalId; +const pingIntervalInMs = 10000; + +function connect(url, ticket) { + webSocket = new WebSocket(`${url}?ticket=${ticket}`, "jmap"); + + webSocket.onopen = () => { + console.log("websocket open"); + webSocket.send( + JSON.stringify({ + "@type": "WebSocketPushEnable", + dataTypes: ["Mailbox", "Email"], + }) + ); + intervalId = setInterval(() => { + webSocket.send( + JSON.stringify({ + "@type": "Request", + id: "R1", + using: ["urn:ietf:params:jmap:core"], + methodCalls: [["Core/echo", {}, "c0"]], + }) + ); + }, pingIntervalInMs); + }; + + webSocket.onmessage = (event) => { + console.log(`websocket received message: ${event.data}`); + broadcast.postMessage(event.data); + }; + + webSocket.onclose = (event) => { + console.log( + `websocket connection closed with code: "${event.code}" reason: "${event.reason}" and cleanly: "${event.wasClean}"` + ); + broadcast.postMessage("webSocketClosed"); + webSocket = null; + clearInterval(intervalId); + }; +} + +function disconnect() { + if (webSocket == null) { + return; + } + webSocket.close(); +} + +self.addEventListener("install", (event) => { + self.skipWaiting().then(() => { + console.log("Service worker installed"); + }); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + self.clients.claim().then(() => { + console.log("Service worker activated"); + }) + ); +}); + +self.addEventListener("message", (event) => { + console.log(`web socket worker received message: ${event.data}`); + if (event.data.action === "connect") { + connect(event.data.url, event.data.ticket); + } else if (event.data.action === "disconnect") { + disconnect(); + } +}); diff --git a/web/worker_service/worker_service.js b/web/worker_service/worker_service.js index eabbda0a47..4be7087b3a 100644 --- a/web/worker_service/worker_service.js +++ b/web/worker_service/worker_service.js @@ -24,11 +24,11 @@ function fetchServiceWorker() { // Wait for registration to finish before dropping the