diff --git a/contact/pubspec.lock b/contact/pubspec.lock index 09e768f070..9cf2644136 100644 --- a/contact/pubspec.lock +++ b/contact/pubspec.lock @@ -652,10 +652,10 @@ packages: description: path: "." ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + resolved-ref: b75666ba1ac351c7e7be7a1a8f95e58a860505fc 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/docs/adr/0053-web-socket-data-synchronization.md b/docs/adr/0053-web-socket-data-synchronization.md new file mode 100644 index 0000000000..0bf693e773 --- /dev/null +++ b/docs/adr/0053-web-socket-data-synchronization.md @@ -0,0 +1,20 @@ +# 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 + +## 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..bbe1cb845b 100644 --- a/email_recovery/pubspec.lock +++ b/email_recovery/pubspec.lock @@ -296,10 +296,10 @@ packages: description: path: "." ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + resolved-ref: b75666ba1ac351c7e7be7a1a8f95e58a860505fc 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/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..cef6ac7756 100644 --- a/fcm/pubspec.lock +++ b/fcm/pubspec.lock @@ -296,10 +296,10 @@ packages: description: path: "." ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + resolved-ref: b75666ba1ac351c7e7be7a1a8f95e58a860505fc 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.lock b/forward/pubspec.lock index bf16ad9212..cef6ac7756 100644 --- a/forward/pubspec.lock +++ b/forward/pubspec.lock @@ -296,10 +296,10 @@ packages: description: path: "." ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + resolved-ref: b75666ba1ac351c7e7be7a1a8f95e58a860505fc 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/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 23191f91cf..edef20674f 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -574,7 +574,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..9e15463114 --- /dev/null +++ b/lib/features/push_notification/data/datasource/web_socket_datasource.dart @@ -0,0 +1,9 @@ +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +abstract class WebSocketDatasource { + Future 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..7ec0c650d1 --- /dev/null +++ b/lib/features/push_notification/data/datasource_impl/web_socket_datasource_impl.dart @@ -0,0 +1,62 @@ + +import 'dart:async'; + +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: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:web_socket_channel/web_socket_channel.dart'; + +class WebSocketDatasourceImpl implements WebSocketDatasource { + final WebSocketApi _webSocketApi; + final ExceptionThrower _exceptionThrower; + + const WebSocketDatasourceImpl(this._webSocketApi, this._exceptionThrower); + + @override + Future getWebSocketChannel(Session session, AccountId accountId) { + return Future.sync(() async { + _verifyWebSocketCapabilities(session, accountId); + final webSocketTicket = await _webSocketApi.getWebSocketTicket(session, accountId); + final webSocketUri = _getWebSocketUri(session, accountId); + final webSocketChannel = WebSocketChannel.connect( + Uri.parse('$webSocketUri?ticket=$webSocketTicket'), + protocols: ["jmap"], + ); + + await webSocketChannel.ready; + + return webSocketChannel; + }).catchError(_exceptionThrower.throwException); + } + + 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; + } +} \ No newline at end of file diff --git a/lib/features/push_notification/data/model/connect_web_socket_message.dart b/lib/features/push_notification/data/model/connect_web_socket_message.dart new file mode 100644 index 0000000000..b793ebcb95 --- /dev/null +++ b/lib/features/push_notification/data/model/connect_web_socket_message.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:tmail_ui_user/features/push_notification/domain/model/web_socket_action.dart'; + +part 'connect_web_socket_message.g.dart'; + +@JsonSerializable() +class ConnectWebSocketMessage with EquatableMixin { + @JsonKey(name: 'action') + final WebSocketAction webSocketAction; + @JsonKey(name: 'url') + final String webSocketUrl; + @JsonKey(name: 'ticket') + final String webSocketTicket; + + ConnectWebSocketMessage({ + required this.webSocketAction, + required this.webSocketUrl, + required this.webSocketTicket, + }); + + factory ConnectWebSocketMessage.fromJson(Map json) + => _$ConnectWebSocketMessageFromJson(json); + Map toJson() => _$ConnectWebSocketMessageToJson(this); + + @override + List get props => [webSocketAction, webSocketUrl, webSocketTicket]; +} \ No newline at end of file diff --git a/lib/features/push_notification/data/model/web_socket_echo_request.dart b/lib/features/push_notification/data/model/web_socket_echo_request.dart new file mode 100644 index 0000000000..1d55c9d861 --- /dev/null +++ b/lib/features/push_notification/data/model/web_socket_echo_request.dart @@ -0,0 +1,19 @@ +import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; +import 'package:tmail_ui_user/features/push_notification/data/model/web_socket_request.dart'; + +class WebSocketEchoRequest extends WebSocketRequest { + static const String type = 'Request'; + static const String id = 'R1'; + static final CapabilityIdentifier usingCapability = CapabilityIdentifier.jmapCore; + static const String method = 'Core/echo'; + + @override + Map toJson() { + return { + '@type': type, + 'id': id, + 'using': [usingCapability.value.toString()], + 'methodCalls': [[method, {}, 'c0']], + }; + } +} \ No newline at end of file diff --git a/lib/features/push_notification/data/model/web_socket_push_enable_request.dart b/lib/features/push_notification/data/model/web_socket_push_enable_request.dart new file mode 100644 index 0000000000..6d4b289b3a --- /dev/null +++ b/lib/features/push_notification/data/model/web_socket_push_enable_request.dart @@ -0,0 +1,14 @@ +import 'package:fcm/model/type_name.dart'; + +class WebSocketPushEnableRequest { + static const String type = 'WebSocketPushEnable'; + + static Map toJson({ + required List dataTypes, + }) { + return { + '@type': type, + 'dataTypes': dataTypes.map((typeName) => typeName.value).toList(), + }; + } +} \ No newline at end of file diff --git a/lib/features/push_notification/data/model/web_socket_request.dart b/lib/features/push_notification/data/model/web_socket_request.dart new file mode 100644 index 0000000000..729c9eec56 --- /dev/null +++ b/lib/features/push_notification/data/model/web_socket_request.dart @@ -0,0 +1,5 @@ +abstract class WebSocketRequest { + const WebSocketRequest(); + + Map toJson(); +} \ No newline at end of file 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..9d6cc1b403 --- /dev/null +++ b/lib/features/push_notification/data/repository/web_socket_repository_impl.dart @@ -0,0 +1,17 @@ +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'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class WebSocketRepositoryImpl implements WebSocketRepository { + final WebSocketDatasource _webSocketDatasource; + + WebSocketRepositoryImpl(this._webSocketDatasource); + + @override + Future 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/model/web_socket_action.dart b/lib/features/push_notification/domain/model/web_socket_action.dart new file mode 100644 index 0000000000..a9c2163d78 --- /dev/null +++ b/lib/features/push_notification/domain/model/web_socket_action.dart @@ -0,0 +1 @@ +enum WebSocketAction {connect, disconnect} \ 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..b524c4e930 --- /dev/null +++ b/lib/features/push_notification/domain/repository/web_socket_repository.dart @@ -0,0 +1,9 @@ +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +abstract class WebSocketRepository { + Future 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..d51a1890f9 --- /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:web_socket_channel/web_socket_channel.dart'; + +class InitializingWebSocketPushChannel extends LoadingState {} + +class WebSocketConnectionSuccess extends UIState { + final WebSocketChannel webSocketChannel; + + WebSocketConnectionSuccess(this.webSocketChannel); + + @override + List get props => [webSocketChannel]; +} + +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..5e6dbc02bd --- /dev/null +++ b/lib/features/push_notification/domain/usecases/connect_web_socket_interactor.dart @@ -0,0 +1,30 @@ +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: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()); + final webSocketChannel = await _webSocketRepository.getWebSocketChannel( + session, + accountId); + yield Right(WebSocketConnectionSuccess(webSocketChannel)); + } catch (e) { + logError('ConnectWebSocketInteractor::execute: $e'); + yield 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..a9c25f16ff --- /dev/null +++ b/lib/features/push_notification/presentation/controller/push_base_controller.dart @@ -0,0 +1,111 @@ +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; + + void consumeState(Stream> newStateStream) { + 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 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..a262e7508f --- /dev/null +++ b/lib/features/push_notification/presentation/controller/web_socket_controller.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +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: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/push/state_change.dart'; +import 'package:tmail_ui_user/features/push_notification/data/model/web_socket_echo_request.dart'; +import 'package:tmail_ui_user/features/push_notification/data/model/web_socket_push_enable_request.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'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class WebSocketController extends PushBaseController { + WebSocketController._internal(); + + static final WebSocketController _instance = WebSocketController._internal(); + + static WebSocketController get instance => _instance; + + ConnectWebSocketInteractor? _connectWebSocketInteractor; + + int _retryRemained = 3; + WebSocketChannel? _webSocketChannel; + Timer? _webSocketPingTimer; + StreamSubscription? _webSocketSubscription; + + @override + void handleFailureViewState(Failure failure) { + logError('WebSocketController::handleFailureViewState():Failure $failure'); + _webSocketSubscription?.cancel(); + _webSocketChannel = null; + _webSocketPingTimer?.cancel(); + if (failure is WebSocketConnectionFailed) { + _handleWebSocketConnectionRetry(); + } + } + + @override + void handleSuccessViewState(Success success) { + log('WebSocketController::handleSuccessViewState():Success $success'); + if (success is WebSocketConnectionSuccess) { + _handleWebSocketConnectionSuccess(success); + } + } + + @override + void handleErrorViewState(Object error, StackTrace stackTrace) { + super.handleErrorViewState(error, stackTrace); + handleFailureViewState(WebSocketConnectionFailed()); + } + + @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 _handleWebSocketConnectionSuccess(WebSocketConnectionSuccess success) { + log('WebSocketController::_handleWebSocketConnectionSuccess(): $success'); + _retryRemained = 3; + _webSocketChannel = success.webSocketChannel; + _enableWebSocketPush(); + _pingWebSocket(); + _listenToWebSocket(); + } + + void _handleWebSocketConnectionRetry() { + _webSocketSubscription?.cancel(); + _webSocketChannel = null; + _webSocketPingTimer?.cancel(); + if (_retryRemained > 0) { + _retryRemained--; + _connectWebSocket(accountId, session); + } + } + + void _enableWebSocketPush() { + _webSocketChannel?.sink.add(jsonEncode(WebSocketPushEnableRequest.toJson( + dataTypes: [TypeName.emailType, TypeName.mailboxType] + ))); + } + + void _pingWebSocket() { + _webSocketPingTimer = Timer.periodic(const Duration(seconds: 10), (_) { + _webSocketChannel?.sink.add(jsonEncode(WebSocketEchoRequest().toJson())); + }); + } + + void _listenToWebSocket() { + _webSocketSubscription = _webSocketChannel?.stream.listen( + (data) { + log('WebSocketController::_listenToWebSocket(): $data'); + if (session == null || accountId == null) return; + if (data is String) { + data = jsonDecode(data); + } + + try { + final stateChange = StateChange.fromJson(data); + final mapTypeState = stateChange.getMapTypeState(accountId!); + mappingTypeStateToAction( + mapTypeState, + accountId!, + emailChangeListener: EmailChangeListener.instance, + mailboxChangeListener: MailboxChangeListener.instance, + session!.username, + session: session); + } catch (e) { + logError('WebSocketController::_listenToWebSocket(): Data is not StateChange'); + } + }, + cancelOnError: true, + onError: (error) { + logError('WebSocketController::_listenToWebSocket():Error: $error'); + handleFailureViewState(WebSocketConnectionFailed(exception: error)); + }, + onDone: () { + log('WebSocketController::_listenToWebSocket():onDone'); + _handleWebSocketConnectionRetry(); + }, + ); + } +} \ 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/features/push_notification/presentation/services/fcm_receiver.dart b/lib/features/push_notification/presentation/services/fcm_receiver.dart index 51fa43e66f..0cb6efd145 100644 --- a/lib/features/push_notification/presentation/services/fcm_receiver.dart +++ b/lib/features/push_notification/presentation/services/fcm_receiver.dart @@ -1,11 +1,7 @@ import 'package:core/utils/app_logger.dart'; -import 'package:core/utils/broadcast_channel/broadcast_channel.dart'; -import 'package:core/utils/platform_info.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/controller/fcm_message_controller.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/services/fcm_service.dart'; -import 'package:tmail_ui_user/main/utils/app_config.dart'; -import 'package:universal_html/html.dart' as html show MessageEvent, DomException; @pragma('vm:entry-point') Future handleFirebaseBackgroundMessage(RemoteMessage message) async { @@ -23,32 +19,11 @@ class FcmReceiver { static const int MAX_COUNT_RETRY_TO_GET_FCM_TOKEN = 3; - int _countRetryToGetFcmToken = 0; - Future onInitialFcmListener() async { - _countRetryToGetFcmToken = 0; _onForegroundMessage(); _onBackgroundMessage(); - if (PlatformInfo.isWeb) { - _onMessageBroadcastChannel(); - await _requestNotificationPermissionOnWeb(); - } else { - await _onHandleFcmToken(); - } - } - - Future _requestNotificationPermissionOnWeb() async { - NotificationSettings notificationSetting = await FirebaseMessaging.instance.getNotificationSettings(); - log('FcmReceiver::_requestNotificationPermissionOnWeb: authorizationStatus = ${notificationSetting.authorizationStatus}'); - if (notificationSetting.authorizationStatus != AuthorizationStatus.authorized) { - notificationSetting = await FirebaseMessaging.instance.requestPermission(); - if (notificationSetting.authorizationStatus == AuthorizationStatus.authorized) { - await _onHandleFcmToken(); - } - } else { - await _onHandleFcmToken(); - } + await _onHandleFcmToken(); } void _onForegroundMessage() { @@ -59,38 +34,17 @@ class FcmReceiver { FirebaseMessaging.onBackgroundMessage(handleFirebaseBackgroundMessage); } - void _onMessageBroadcastChannel() { - final broadcast = BroadcastChannel('background-message'); - broadcast.onMessage.listen((event) { - if (event is html.MessageEvent) { - FcmService.instance.handleMessageEventBroadcastChannel(event); - } - }); - } - Future _getInitialToken() async { try { - final vapidKey = PlatformInfo.isWeb ? AppConfig.fcmVapidPublicKeyWeb : null; - final token = await FirebaseMessaging.instance.getToken(vapidKey: vapidKey); + final token = await FirebaseMessaging.instance.getToken(); log('FcmReceiver::_getInitialToken:token: $token'); return token; } catch (e) { logError('FcmReceiver::_getInitialToken: TYPE = ${e.runtimeType} | Exception = $e'); - if (PlatformInfo.isWeb - && e is html.DomException - && _countRetryToGetFcmToken < MAX_COUNT_RETRY_TO_GET_FCM_TOKEN) { - return await _retryGetToken(); - } return null; } } - Future _retryGetToken() async { - _countRetryToGetFcmToken++; - log('FcmReceiver::_retryGetToken: CountRetry = $_countRetryToGetFcmToken'); - return await _getInitialToken(); - } - Future _onHandleFcmToken() async { final token = await _getInitialToken(); FcmService.instance.handleToken(token); 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/lib/main/utils/app_config.dart b/lib/main/utils/app_config.dart index c03faff1f0..2abac88f85 100644 --- a/lib/main/utils/app_config.dart +++ b/lib/main/utils/app_config.dart @@ -36,7 +36,6 @@ class AppConfig { return supportedOtherPlatform == 'supported'; } } - static String get fcmVapidPublicKeyWeb => dotenv.get('FIREBASE_WEB_VAPID_PUBLIC_KEY', fallback: ''); static List get oidcScopes { try { final envScopes = dotenv.get('OIDC_SCOPES', fallback: ''); diff --git a/model/pubspec.lock b/model/pubspec.lock index 9f33346d4d..195b271e43 100644 --- a/model/pubspec.lock +++ b/model/pubspec.lock @@ -644,10 +644,10 @@ packages: description: path: "." ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + resolved-ref: b75666ba1ac351c7e7be7a1a8f95e58a860505fc 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/pubspec.lock b/pubspec.lock index bde3028dab..613d0ce91a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1236,10 +1236,10 @@ packages: description: path: "." ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + resolved-ref: b75666ba1ac351c7e7be7a1a8f95e58a860505fc url: "https://github.com/linagora/jmap-dart-client.git" source: git - version: "0.2.2" + version: "0.2.3" js: dependency: transitive description: @@ -2160,13 +2160,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 0d8c50fb59..7f51fc0ba6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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..cef6ac7756 100644 --- a/rule_filter/pubspec.lock +++ b/rule_filter/pubspec.lock @@ -296,10 +296,10 @@ packages: description: path: "." ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + resolved-ref: b75666ba1ac351c7e7be7a1a8f95e58a860505fc 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.lock b/server_settings/pubspec.lock index bc63780517..59b5687278 100644 --- a/server_settings/pubspec.lock +++ b/server_settings/pubspec.lock @@ -288,10 +288,10 @@ packages: description: path: "." ref: main - resolved-ref: f55a1862c1486197ea6a79feac31776ebeddc66c + resolved-ref: b75666ba1ac351c7e7be7a1a8f95e58a860505fc 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/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..72e414c639 --- /dev/null +++ b/test/features/push_notification/presentation/controller/push_base_controller_test.dart @@ -0,0 +1,236 @@ +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); + verifyNever(mailboxChangeListener.dispatchActions(any)); + }); + + 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); + verifyNever(mailboxChangeListener.dispatchActions(any)); + }); + + 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)); + verifyNever(mailboxChangeListener.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); + verifyNever(mailboxChangeListener.dispatchActions(any)); + }); + + 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); + verifyNever(emailChangeListener.dispatchActions(any)); + }); + + 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); + verifyNever(emailChangeListener.dispatchActions(any)); + }); + }); + }); +} \ No newline at end of file diff --git a/web/firebase-messaging-sw.js b/web/firebase-messaging-sw.js deleted file mode 100644 index 5b27455fd2..0000000000 --- a/web/firebase-messaging-sw.js +++ /dev/null @@ -1,20 +0,0 @@ -importScripts("https://www.gstatic.com/firebasejs/9.10.0/firebase-app-compat.js"); -importScripts("https://www.gstatic.com/firebasejs/9.10.0/firebase-messaging-compat.js"); -// Initialize the Firebase app in the service worker by passing in the messagingSenderId. -firebase.initializeApp({ - apiKey: "...", - authDomain: "...", - databaseURL: "...", - projectId: "...", - storageBucket: "...", - messagingSenderId: "...", - appId: "...", -}); -// Retrieve an instance of Firebase Messaging so that it can handle background messages. -const messaging = firebase.messaging(); - -const broadcast = new BroadcastChannel('background-message'); - -messaging.onBackgroundMessage((message) => { - broadcast.postMessage(message); -}); \ No newline at end of file diff --git a/web/worker_service/worker_service.js b/web/worker_service/worker_service.js index eabbda0a47..ec6a1a7ee9 100644 --- a/web/worker_service/worker_service.js +++ b/web/worker_service/worker_service.js @@ -4,7 +4,6 @@ const iosPlatform = 'iOS'; const androidPlatform = 'android'; const otherPlatform = 'other'; const timeoutDuration = 4000; -var serviceWorkerVersion = null; var scriptLoaded = false; function loadMainDartJs() { @@ -24,16 +23,7 @@ function fetchServiceWorker() { // Wait for registration to finish before dropping the