diff --git a/CHANGELOG.md b/CHANGELOG.md index 989fe0a498..3f9c1e0f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## [0.14.0] - 2024-11-09 +### Added +- #2387 TWP Registration from mobile +- #3157 Web socket to replace FCM in web app +- #2953 Mobile integration test with Patrol + +### Fixed +- #3244 `To` filter should apply also for `cc` `bcc` +- #3243 some tags still be in sanitizing +- #3178 Only Space in Name verification for Identity, Rule Filter, Vacation +- 3D links not work on mobile +- Focus problem in `tab` in Basic Auth login form +- #3225 Print button blink blink +- #3222 Hide reply calendar event action button +- #3247 Cc is lost if open email from Quick search result +- #3200 Update option menu for personal folders +- Support German + +## [0.13.6] - 2024-11-07 +### Fixed +- Remove app grid in mobile + ## [0.13.5] - 2024-10-24 ### Fixed - Sanitize html diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 07027d3fc6..e6ef49d68a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -79,12 +79,6 @@ - - - - - - @@ -98,6 +92,18 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend-docker/docker-compose.yaml b/backend-docker/docker-compose.yaml index 2ce61d4f9d..36f851616e 100644 --- a/backend-docker/docker-compose.yaml +++ b/backend-docker/docker-compose.yaml @@ -10,6 +10,8 @@ services: - ./mailetcontainer.xml:/root/conf/mailetcontainer.xml - ./imapserver.xml:/root/conf/imapserver.xml - ./jmap.properties:/root/conf/jmap.properties + - ../provisioning/integration_test/search_email_with_sort_order/provisioning.sh:/root/conf/integration_test/search_email_with_sort_order/provisioning.sh + - ../provisioning/integration_test/search_email_with_sort_order/eml:/root/conf/integration_test/search_email_with_sort_order/eml ports: - "80:80" environment: diff --git a/core/lib/core.dart b/core/lib/core.dart index f887e6f075..bfe0664e63 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -8,6 +8,7 @@ export 'presentation/extensions/capitalize_extension.dart'; export 'presentation/extensions/list_extensions.dart'; export 'presentation/extensions/list_nullable_extensions.dart'; export 'domain/extensions/datetime_extension.dart'; +export 'domain/extensions/list_datetime_extension.dart'; export 'presentation/extensions/html_extension.dart'; export 'presentation/extensions/compare_string_extension.dart'; export 'presentation/extensions/compare_list_extensions.dart'; diff --git a/core/lib/data/constants/constant.dart b/core/lib/data/constants/constant.dart index 5540b0ac1c..06b5d45eae 100644 --- a/core/lib/data/constants/constant.dart +++ b/core/lib/data/constants/constant.dart @@ -7,4 +7,6 @@ class Constant { static const octetStreamMimeType = 'application/octet-stream'; static const pdfExtension = '.pdf'; static const imageType = 'image'; + static const textVCardMimeType = 'text/x-vcard'; + static const textPlainMimeType = 'text/plain'; } \ No newline at end of file diff --git a/core/lib/domain/extensions/list_datetime_extension.dart b/core/lib/domain/extensions/list_datetime_extension.dart new file mode 100644 index 0000000000..a5af81996b --- /dev/null +++ b/core/lib/domain/extensions/list_datetime_extension.dart @@ -0,0 +1,21 @@ + +extension ListDateTimeExtension on List { + + bool isSortedByMostRecent() { + for (int i = 0; i < length - 1; i++) { + if (this[i].isBefore(this[i + 1])) { + return false; + } + } + return true; + } + + bool isSortedByOldestFirst() { + for (int i = 0; i < length - 1; i++) { + if (this[i].isAfter(this[i + 1])) { + return false; + } + } + return true; + } +} \ No newline at end of file diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index ce2516630c..52b6013b14 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -220,6 +220,7 @@ class ImagePaths { String get icGoodSignature => _getImagePath('ic_good_signature.svg'); String get icBadSignature => _getImagePath('ic_bad_signature.svg'); String get icDeleteSelection => _getImagePath('ic_delete_selection.svg'); + String get icLogoTwakeWelcome => _getImagePath('ic_logo_twake_welcome.svg'); String _getImagePath(String imageName) { return AssetsPaths.images + imageName; diff --git a/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart b/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart index 474b14dac5..e9e5b8c76b 100644 --- a/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart +++ b/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart @@ -19,6 +19,7 @@ class StandardizeHtmlSanitizingTransformers extends TextTransformer { 'center', 'style', 'body', + 'section', ]; const StandardizeHtmlSanitizingTransformers(); diff --git a/core/lib/presentation/views/bottom_popup/cupertino_action_sheet_builder.dart b/core/lib/presentation/views/bottom_popup/cupertino_action_sheet_builder.dart index 3f8ff78456..16cb45a939 100644 --- a/core/lib/presentation/views/bottom_popup/cupertino_action_sheet_builder.dart +++ b/core/lib/presentation/views/bottom_popup/cupertino_action_sheet_builder.dart @@ -6,13 +6,14 @@ import 'package:pointer_interceptor/pointer_interceptor.dart'; class CupertinoActionSheetBuilder { final BuildContext _context; + final Key? key; final List _actionTiles = []; Widget? _titleWidget; Widget? _messageWidget; Widget? _cancelWidget; - CupertinoActionSheetBuilder(this._context); + CupertinoActionSheetBuilder(this._context, {this.key}); void title(Widget? titleWidget) { _titleWidget = titleWidget; @@ -35,6 +36,7 @@ class CupertinoActionSheetBuilder { context: _context, barrierColor: AppColor.colorDefaultCupertinoActionSheet, builder: (context) => PointerInterceptor(child: CupertinoActionSheet( + key: key, title: _titleWidget, message: _messageWidget, actions: _actionTiles, diff --git a/core/test/utils/standardize_html_sanitizing_transformers_test.dart b/core/test/utils/standardize_html_sanitizing_transformers_test.dart index 2249cac95e..5910914dc4 100644 --- a/core/test/utils/standardize_html_sanitizing_transformers_test.dart +++ b/core/test/utils/standardize_html_sanitizing_transformers_test.dart @@ -89,7 +89,7 @@ void main() { ]; const listHTMLTags = [ - 'div', 'span', 'p', 'a', 'u', 'i', 'table' + 'div', 'span', 'p', 'a', 'u', 'i', 'table', 'section' ]; for (var tag in listHTMLTags) { diff --git a/docs/adr/0036-mailto-uri-chemes-to-interact-twake-mail.md b/docs/adr/0036-mailto-uri-chemes-to-interact-twake-mail.md index 1f75e80813..2faa805420 100644 --- a/docs/adr/0036-mailto-uri-chemes-to-interact-twake-mail.md +++ b/docs/adr/0036-mailto-uri-chemes-to-interact-twake-mail.md @@ -18,13 +18,13 @@ Summary of URI schemes that can interact with Twake Mail: - `/mailto?uri=user@example.com` - `/mailto/?uri=mailto:user@example.com&subject=TwakeMail&body=HelloWorld` - - `/mailto/?uri=mailto:user1@example.com,user2@example.com,user3@example.com&subject=TwakeMail&body=HelloWorld` + - `/mailto/?uri=mailto:user1@example.com,user2@example.com,user3@example.com&to=user1@example.com,user2@example.com,user3@example.com&cc=user1@example.com,user2@example.com,user3@example.com&bcc=user1@example.com,user2@example.com,user3@example.com&subject=TwakeMail&body=HelloWorld` 2. URI scheme encoded - `%2Fmailto%3Furi%3Duser%40example.com` - `%2Fmailto%2F%3Furi%3Dmailto%3Auser%40example.com%26subject%3DTwakeMail%26body%3DHelloWorld` - - `%2Fmailto%2F%3Furi%3Dmailto%3Auser1%40example.com%2Cuser2%40example.com%2Cuser3%40example.com%26subject%3DTwakeMail%26body%3DHelloWorld` + - `%2Fmailto%2F%3Furi%3Dmailto%3Auser1%40example.com%2Cuser2%40example.com%2Cuser3%40example.com%26to=user1%40example.com%2Cuser2%40example.com%2Cuser3%40example.com%26cc=user1%40example.com%2Cuser2%40example.com%2Cuser3%40example.com%26bcc=user1%40example.com%2Cuser2%40example.com%2Cuser3%40example.com%26subject%3DTwakeMail%26body%3DHelloWorld` ## Consequences diff --git a/integration_test/base/base_scenario.dart b/integration_test/base/base_scenario.dart index a9445902e2..1bd8e923f5 100644 --- a/integration_test/base/base_scenario.dart +++ b/integration_test/base/base_scenario.dart @@ -1,3 +1,4 @@ +import 'package:flutter_test/flutter_test.dart'; import 'package:patrol/patrol.dart'; abstract class BaseScenario { @@ -6,4 +7,9 @@ abstract class BaseScenario { const BaseScenario(this.$); Future execute(); + + Future expectViewVisible(PatrolFinder patrolFinder) async { + await $.waitUntilVisible(patrolFinder); + expect(patrolFinder, findsWidgets); + } } \ No newline at end of file diff --git a/integration_test/base/core_robot.dart b/integration_test/base/core_robot.dart index a116b25539..7d586037b8 100644 --- a/integration_test/base/core_robot.dart +++ b/integration_test/base/core_robot.dart @@ -1,4 +1,3 @@ -import 'package:flutter_test/flutter_test.dart'; import 'package:patrol/patrol.dart'; abstract class CoreRobot { @@ -6,10 +5,5 @@ abstract class CoreRobot { CoreRobot(this.$); - Future ensureViewVisible(PatrolFinder patrolFinder) async { - await $.waitUntilVisible(patrolFinder); - expect(patrolFinder, findsWidgets); - } - dynamic ignoreException() => $.tester.takeException(); } \ No newline at end of file diff --git a/integration_test/robots/composer_robot.dart b/integration_test/robots/composer_robot.dart index e8cd076a79..02786f2fc7 100644 --- a/integration_test/robots/composer_robot.dart +++ b/integration_test/robots/composer_robot.dart @@ -50,10 +50,6 @@ class ComposerRobot extends CoreRobot { .tap(); } - Future expectSendEmailSuccessToast() async { - expect($('Message has been sent successfully'), findsOneWidget); - } - Future grantContactPermission() async { if (await $.native.isPermissionDialogVisible(timeout: const Duration(seconds: 5))) { await $.native.grantPermissionWhenInUse(); diff --git a/integration_test/robots/login_robot.dart b/integration_test/robots/login_robot.dart index b079396fea..25edc9aef0 100644 --- a/integration_test/robots/login_robot.dart +++ b/integration_test/robots/login_robot.dart @@ -1,15 +1,26 @@ import 'package:core/presentation/views/text/type_ahead_form_field_builder.dart'; import 'package:flutter/material.dart'; +import 'package:patrol/patrol.dart'; import 'package:tmail_ui_user/features/login/domain/model/recent_login_username.dart'; import 'package:tmail_ui_user/features/login/presentation/login_view.dart'; import 'package:tmail_ui_user/features/login/presentation/widgets/login_text_input_builder.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import '../base/core_robot.dart'; class LoginRobot extends CoreRobot { LoginRobot(super.$); - Future expectLoginViewVisible() => ensureViewVisible($(LoginView)); + Future grantNotificationPermission(NativeAutomator nativeAutomator) async { + if (await nativeAutomator.isPermissionDialogVisible(timeout: const Duration(seconds: 5))) { + await nativeAutomator.grantPermissionWhenInUse(); + } + } + + Future tapOnUseCompanyServer() async { + await $.pumpAndSettle(); + await $(AppLocalizations().useCompanyServer).tap(); + } Future enterEmail(String email) async { final finder = $(LoginView).$(TextField); diff --git a/integration_test/robots/search_robot.dart b/integration_test/robots/search_robot.dart new file mode 100644 index 0000000000..8923b5091b --- /dev/null +++ b/integration_test/robots/search_robot.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../base/core_robot.dart'; + +class SearchRobot extends CoreRobot { + SearchRobot(super.$); + + Future enterQueryString(String queryString) async { + await $(#search_email_text_field).enterText(queryString); + } + + Future scrollToEndListSearchFilter() async { + await $.scrollUntilVisible( + finder: $(#mobile_sortBy_search_filter_button), + view: $(#search_filter_list_view), + scrollDirection: AxisDirection.right, + delta: 300, + ); + } + + Future openSortOrderBottomDialog() async { + await $(#mobile_sortBy_search_filter_button).tap(); + } + + Future selectSortOrder(String sortOrderName) async { + await $(find.text(sortOrderName)).tap(); + await $.pump(const Duration(seconds: 2)); + } +} \ No newline at end of file diff --git a/integration_test/robots/thread_robot.dart b/integration_test/robots/thread_robot.dart index d2dd8ee3c4..6ff0767b1e 100644 --- a/integration_test/robots/thread_robot.dart +++ b/integration_test/robots/thread_robot.dart @@ -1,18 +1,17 @@ +import 'package:core/presentation/views/search/search_bar_view.dart'; import 'package:flutter/material.dart'; import 'package:tmail_ui_user/features/base/widget/compose_floating_button.dart'; -import 'package:tmail_ui_user/features/composer/presentation/composer_view.dart'; -import 'package:tmail_ui_user/features/thread/presentation/thread_view.dart'; import '../base/core_robot.dart'; class ThreadRobot extends CoreRobot { ThreadRobot(super.$); - Future expectThreadViewVisible() => ensureViewVisible($(ThreadView)); - Future openComposer() async { await $(ComposeFloatingButton).$(InkWell).tap(); } - Future expectComposerViewVisible() => ensureViewVisible($(ComposerView)); + Future openSearchView() async { + await $(SearchBarView).$(InkWell).tap(); + } } \ No newline at end of file diff --git a/integration_test/scenarios/login_with_basic_auth_scenario.dart b/integration_test/scenarios/login_with_basic_auth_scenario.dart index f83ebd081d..a11320d881 100644 --- a/integration_test/scenarios/login_with_basic_auth_scenario.dart +++ b/integration_test/scenarios/login_with_basic_auth_scenario.dart @@ -1,9 +1,11 @@ +import 'package:tmail_ui_user/features/login/presentation/login_view.dart'; +import 'package:tmail_ui_user/features/starting_page/presentation/twake_welcome/twake_welcome_view.dart'; +import 'package:tmail_ui_user/features/thread/presentation/thread_view.dart'; + import '../base/base_scenario.dart'; import '../robots/login_robot.dart'; -import '../robots/thread_robot.dart'; -import '../utils/scenario_utils_mixin.dart'; -class LoginWithBasicAuthScenario extends BaseScenario with ScenarioUtilsMixin { +class LoginWithBasicAuthScenario extends BaseScenario { const LoginWithBasicAuthScenario( super.$, { @@ -22,9 +24,12 @@ class LoginWithBasicAuthScenario extends BaseScenario with ScenarioUtilsMixin { @override Future execute() async { final loginRobot = LoginRobot($); - final threadRobot = ThreadRobot($); - await loginRobot.expectLoginViewVisible(); + await _expectWelcomeViewVisible(); + + await loginRobot.tapOnUseCompanyServer(); + await _expectLoginViewVisible(); + await loginRobot.enterEmail(username); await loginRobot.enterHostUrl(hostUrl); @@ -32,8 +37,14 @@ class LoginWithBasicAuthScenario extends BaseScenario with ScenarioUtilsMixin { await loginRobot.enterBasicAuthPassword(password); await loginRobot.loginBasicAuth(); - await grantNotificationPermission($.native); + await loginRobot.grantNotificationPermission($.native); - await threadRobot.expectThreadViewVisible(); + await _expectThreadViewVisible(); } + + Future _expectWelcomeViewVisible() => expectViewVisible($(TwakeWelcomeView)); + + Future _expectLoginViewVisible() => expectViewVisible($(LoginView)); + + Future _expectThreadViewVisible() => expectViewVisible($(ThreadView)); } \ No newline at end of file diff --git a/integration_test/scenarios/search_email_with_sort_order_scenario.dart b/integration_test/scenarios/search_email_with_sort_order_scenario.dart new file mode 100644 index 0000000000..bac755a993 --- /dev/null +++ b/integration_test/scenarios/search_email_with_sort_order_scenario.dart @@ -0,0 +1,198 @@ + +import 'package:collection/collection.dart'; +import 'package:core/domain/extensions/list_datetime_extension.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/email_sort_order_type.dart'; +import 'package:tmail_ui_user/features/search/email/presentation/search_email_view.dart'; +import 'package:tmail_ui_user/features/thread/presentation/widgets/email_tile_builder.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; + +import '../base/base_scenario.dart'; +import '../robots/search_robot.dart'; +import '../robots/thread_robot.dart'; +import 'login_with_basic_auth_scenario.dart'; + +class SearchEmailWithSortOrderScenario extends BaseScenario { + const SearchEmailWithSortOrderScenario( + super.$, + { + required this.loginWithBasicAuthScenario, + required this.queryString, + required this.listUsername, + } + ); + + final LoginWithBasicAuthScenario loginWithBasicAuthScenario; + final String queryString; + final List listUsername; + + @override + Future execute() async { + await loginWithBasicAuthScenario.execute(); + + final threadRobot = ThreadRobot($); + await threadRobot.openSearchView(); + await _expectSearchViewVisible(); + + final searchRobot = SearchRobot($); + await searchRobot.enterQueryString(queryString); + await _expectSuggestionSearchListViewVisible(); + + await searchRobot.scrollToEndListSearchFilter(); + await _expectSortBySearchFilterButtonVisible(); + + final appLocalizations = AppLocalizations(); + + for (var sortOrderType in EmailSortOrderType.values) { + await _performSelectSortOrderAndValidateSearchResult( + searchRobot: searchRobot, + sortOrderType: sortOrderType, + appLocalizations: appLocalizations, + ); + } + } + + Future _performSelectSortOrderAndValidateSearchResult({ + required SearchRobot searchRobot, + required EmailSortOrderType sortOrderType, + required AppLocalizations appLocalizations, + }) async { + await Future.delayed(const Duration(seconds: 2)); + + await searchRobot.openSortOrderBottomDialog(); + await _expectSortFilterContextMenuVisible(); + + await searchRobot.selectSortOrder(sortOrderType.getTitleByAppLocalizations(appLocalizations)); + await _expectSearchResultEmailListVisible(); + await _expectEmailListDisplayedCorrectly(listUsername: listUsername); + + switch (sortOrderType) { + case EmailSortOrderType.mostRecent: + await _expectEmailListSortedCorrectByMostRecent(); + break; + case EmailSortOrderType.oldest: + await _expectEmailListSortedCorrectByOldest(); + break; + case EmailSortOrderType.senderAscending: + await _expectEmailListSortedCorrectBySenderAscending(listUsername: listUsername); + break; + case EmailSortOrderType.senderDescending: + await _expectEmailListSortedCorrectBySenderDescending(listUsername: listUsername); + break; + case EmailSortOrderType.subjectAscending: + await _expectEmailListSortedCorrectBySubjectAscending(listUsername: listUsername); + break; + case EmailSortOrderType.subjectDescending: + await _expectEmailListSortedCorrectBySubjectDescending(listUsername: listUsername); + break; + case EmailSortOrderType.relevance: + break; + } + } + + Future _expectSearchViewVisible() async { + await expectViewVisible($(SearchEmailView)); + } + + Future _expectSuggestionSearchListViewVisible() async { + await expectViewVisible($(#suggestion_search_list_view)); + } + + Future _expectSortBySearchFilterButtonVisible() async { + await expectViewVisible($(#mobile_sortBy_search_filter_button)); + } + + Future _expectSortFilterContextMenuVisible() async { + await expectViewVisible($(#sort_filter_context_menu)); + } + + Future _expectSearchResultEmailListVisible() async { + await expectViewVisible($(#search_email_list_notification_listener)); + } + + Future _expectEmailListDisplayedCorrectly({ + required List listUsername, + }) async { + expect(find.byType(EmailTileBuilder), findsNWidgets(listUsername.length)); + } + + Future _expectEmailListSortedCorrectBySenderAscending({ + required List listUsername, + }) async { + final listEmailTile = $.tester + .widgetList(find.byType(EmailTileBuilder)); + + for (int i = 0; i < listUsername.length; i++) { + EmailTileBuilder emailTile = listEmailTile.elementAt(i); + final senderName = emailTile.presentationEmail.firstEmailAddressInFrom; + expect(senderName, equals('${listUsername[i].toLowerCase()}@example.com')); + } + } + + Future _expectEmailListSortedCorrectBySenderDescending({ + required List listUsername, + }) async { + final listEmailTile = $.tester + .widgetList(find.byType(EmailTileBuilder)); + + final reversedListUsername = listUsername.reversed.toList(); + + for (int i = 0; i < reversedListUsername.length; i++) { + EmailTileBuilder emailTile = listEmailTile.elementAt(i); + final senderName = emailTile.presentationEmail.firstEmailAddressInFrom; + expect(senderName, equals('${reversedListUsername[i].toLowerCase()}@example.com')); + } + } + + Future _expectEmailListSortedCorrectBySubjectAscending({ + required List listUsername, + }) async { + final listEmailTile = $.tester + .widgetList(find.byType(EmailTileBuilder)); + + for (int i = 0; i < listUsername.length; i++) { + EmailTileBuilder emailTile = listEmailTile.elementAt(i); + final subject = emailTile.presentationEmail.subject; + expect(subject, equals('${listUsername[i]} send Bob')); + } + } + + Future _expectEmailListSortedCorrectBySubjectDescending({ + required List listUsername, + }) async { + final listEmailTile = $.tester + .widgetList(find.byType(EmailTileBuilder)); + + final reversedListUsername = listUsername.reversed.toList(); + + for (int i = 0; i < reversedListUsername.length; i++) { + EmailTileBuilder emailTile = listEmailTile.elementAt(i); + final subject = emailTile.presentationEmail.subject; + expect(subject, equals('${reversedListUsername[i]} send Bob')); + } + } + + Future _expectEmailListSortedCorrectByMostRecent() async { + final listEmailTile = $.tester + .widgetList(find.byType(EmailTileBuilder)); + + final listReceiveAtTime = listEmailTile + .map((emailTile) => emailTile.presentationEmail.receivedAt?.value) + .whereNotNull() + .toList(); + + expect(listReceiveAtTime.isSortedByMostRecent(), isTrue); + } + + Future _expectEmailListSortedCorrectByOldest() async { + final listEmailTile = $.tester + .widgetList(find.byType(EmailTileBuilder)); + + final listReceiveAtTime = listEmailTile + .map((emailTile) => emailTile.presentationEmail.receivedAt?.value) + .whereNotNull() + .toList(); + + expect(listReceiveAtTime.isSortedByOldestFirst(), isTrue); + } +} \ No newline at end of file diff --git a/integration_test/scenarios/send_email_scenario.dart b/integration_test/scenarios/send_email_scenario.dart index 8d9184fa85..e0d5f6b8a0 100644 --- a/integration_test/scenarios/send_email_scenario.dart +++ b/integration_test/scenarios/send_email_scenario.dart @@ -1,3 +1,6 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tmail_ui_user/features/composer/presentation/composer_view.dart'; + import '../base/base_scenario.dart'; import '../robots/composer_robot.dart'; import '../robots/thread_robot.dart'; @@ -27,7 +30,7 @@ class SendEmailScenario extends BaseScenario { await loginWithBasicAuthScenario.execute(); await threadRobot.openComposer(); - await threadRobot.expectComposerViewVisible(); + await _expectComposerViewVisible(); await composerRobot.grantContactPermission(); @@ -36,6 +39,13 @@ class SendEmailScenario extends BaseScenario { await composerRobot.addSubject(subject); await composerRobot.addContent(content); await composerRobot.sendEmail(); - await composerRobot.expectSendEmailSuccessToast(); + + await _expectSendEmailSuccessToast(); + } + + Future _expectComposerViewVisible() => expectViewVisible($(ComposerView)); + + Future _expectSendEmailSuccessToast() async { + expect($('Message has been sent successfully'), findsOneWidget); } } \ No newline at end of file diff --git a/integration_test/tests/search/search_email_with_sort_order_test.dart b/integration_test/tests/search/search_email_with_sort_order_test.dart new file mode 100644 index 0000000000..1f6001e87b --- /dev/null +++ b/integration_test/tests/search/search_email_with_sort_order_test.dart @@ -0,0 +1,35 @@ +import '../../base/test_base.dart'; +import '../../scenarios/login_with_basic_auth_scenario.dart'; +import '../../scenarios/search_email_with_sort_order_scenario.dart'; + +void main() { + TestBase().runPatrolTest( + description: 'Should see list email displayed by sort order selected when search email successfully', + test: ($) async { + const username = String.fromEnvironment('USERNAME'); + const password = String.fromEnvironment('PASSWORD'); + const hostUrl = String.fromEnvironment('BASIC_AUTH_URL'); + const email = String.fromEnvironment('BASIC_AUTH_EMAIL'); + + final loginWithBasicAuthScenario = LoginWithBasicAuthScenario( + $, + username: username, + password: password, + hostUrl: hostUrl, + email: email, + ); + + const queryString = 'hello'; + const listUsername = ['Alice', 'Brian', 'Charlotte', 'David', 'Emma']; + + final searchEmailWithSortOrderScenario = SearchEmailWithSortOrderScenario( + $, + loginWithBasicAuthScenario: loginWithBasicAuthScenario, + queryString: queryString, + listUsername: listUsername + ); + + await searchEmailWithSortOrderScenario.execute(); + }, + ); +} \ No newline at end of file diff --git a/integration_test/utils/scenario_utils_mixin.dart b/integration_test/utils/scenario_utils_mixin.dart deleted file mode 100644 index 2b119c5101..0000000000 --- a/integration_test/utils/scenario_utils_mixin.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:patrol/patrol.dart'; - -mixin ScenarioUtilsMixin { - Future grantNotificationPermission(NativeAutomator nativeAutomator) async { - if (await nativeAutomator.isPermissionDialogVisible(timeout: const Duration(seconds: 5))) { - await nativeAutomator.grantPermissionWhenInUse(); - } - } -} \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile index d1c2687646..8148d12783 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -32,6 +32,10 @@ target 'Runner' do use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + target 'TeamMailShareExtension' do + inherit! :search_paths + end end post_install do |installer| diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d1d2b04611..cd8c1e4fa6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -9,6 +9,7 @@ PODS: - AppAuth/Core - better_open_file (0.0.1): - Flutter + - CocoaAsyncSocket (7.6.5) - connectivity_plus (0.0.1): - Flutter - ReachabilitySwift @@ -116,6 +117,8 @@ PODS: - Flutter - flutter_secure_storage (6.0.0): - Flutter + - flutter_web_auth_2 (3.0.0): + - Flutter - GoogleDataTransport (9.3.0): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30910.0, >= 2.30908.0) @@ -163,17 +166,24 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - patrol (0.0.1): + - CocoaAsyncSocket (~> 7.6) + - Flutter + - FlutterMacOS - pdf_render (0.0.1): - Flutter - permission_handler_apple (9.1.1): - Flutter + - photo_manager (2.0.0): + - Flutter + - FlutterMacOS - pointer_interceptor_ios (0.0.1): - Flutter - printing (1.0.0): - Flutter - PromisesObjC (2.3.1) - ReachabilitySwift (5.0.0) - - receive_sharing_intent (0.0.1): + - receive_sharing_intent (1.8.1): - Flutter - SDWebImage (5.18.10): - SDWebImage/Core (= 5.18.10) @@ -214,10 +224,13 @@ DEPENDENCIES: - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - patrol (from `.symlinks/plugins/patrol/darwin`) - pdf_render (from `.symlinks/plugins/pdf_render/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - photo_manager (from `.symlinks/plugins/photo_manager/ios`) - pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`) - printing (from `.symlinks/plugins/printing/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) @@ -229,6 +242,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - AppAuth + - CocoaAsyncSocket - DKImagePickerController - DKPhotoGallery - Firebase @@ -290,14 +304,20 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" + flutter_web_auth_2: + :path: ".symlinks/plugins/flutter_web_auth_2/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + patrol: + :path: ".symlinks/plugins/patrol/darwin" pdf_render: :path: ".symlinks/plugins/pdf_render/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + photo_manager: + :path: ".symlinks/plugins/photo_manager/ios" pointer_interceptor_ios: :path: ".symlinks/plugins/pointer_interceptor_ios/ios" printing: @@ -317,6 +337,7 @@ SPEC CHECKSUMS: app_settings: 017320c6a680cdc94c799949d95b84cb69389ebc AppAuth: 182c5b88630569df5acb672720534756c29b3358 better_open_file: 03cf320415d4d3f46b6e00adc4a567d76c1a399d + CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e contacts_service: 849e1f84281804c8bfbec1b4c3eedcb23c5d3eca device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed @@ -342,6 +363,7 @@ SPEC CHECKSUMS: flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be + flutter_web_auth_2: 051cf9f5dc366f31b5dcc4e2952c2b954767be8a GoogleDataTransport: 57c22343ab29bc686febbf7cbb13bad167c2d8fe GoogleUtilities: 0759d1a57ebb953965c2dfe0ba4c82e95ccc2e34 libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 @@ -350,13 +372,15 @@ SPEC CHECKSUMS: OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + patrol: 0564cee315ff6c86fb802b3647db05cc2d3d0624 pdf_render: 0b4e1a615aab83ce88b26c57753049424908a755 permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a pointer_interceptor_ios: 9280618c0b2eeb80081a343924aa8ad756c21375 printing: 233e1b73bd1f4a05615548e9b5a324c98588640b PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 - receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 + receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1 SDWebImage: fc8f2d48bbfd72ef39d70e981bd24a3f3be53fec SDWebImageWebPCoder: 633b813fca24f1de5e076bcd7f720c038b23892b share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 @@ -366,6 +390,6 @@ SPEC CHECKSUMS: url_launcher_ios: 68d46cc9766d0c41dbdc884310529557e3cd7a86 workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 -PODFILE CHECKSUM: ded4724b53568389542fa0f7d7a64c05ffc03971 +PODFILE CHECKSUM: f2eb8f5a17c320a935d112236893d073c572e17e COCOAPODS: 1.14.3 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 38f84fc654..fe3b934ce1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 756BBC4C51FCB44047AB39E4 /* Pods_TeamMailShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 143BD7B2CF181BA69CDE351A /* Pods_TeamMailShareExtension.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -48,7 +49,6 @@ F5BBBF512B2EEC37007930E1 /* NetworkExceptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5BBBF502B2EEC37007930E1 /* NetworkExceptions.swift */; }; F5BBBF532B2EECAA007930E1 /* JmapExceptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5BBBF522B2EECAA007930E1 /* JmapExceptions.swift */; }; F5BBBF552B2EEF3D007930E1 /* BundleExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5BBBF542B2EEF3D007930E1 /* BundleExtension.swift */; }; - F5CFE0302B335098005A90A9 /* TwakeLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5EFC07C2B328B9F00829056 /* TwakeLogger.swift */; }; F5CFE0352B335322005A90A9 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; F5D4EA032B2ABF090090DDFC /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D4EA022B2ABF090090DDFC /* NotificationService.swift */; }; F5D4EA072B2ABF090090DDFC /* TwakeMailNSE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = F5D4EA002B2ABF090090DDFC /* TwakeMailNSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -118,8 +118,11 @@ /* Begin PBXFileReference section */ 06C73A54FA0478D3C89AB5E5 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 07753111B751BFDB3187FE6F /* Pods-TeamMailShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TeamMailShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-TeamMailShareExtension/Pods-TeamMailShareExtension.debug.xcconfig"; sourceTree = ""; }; + 143BD7B2CF181BA69CDE351A /* Pods_TeamMailShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TeamMailShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1FB76FA91BBCB2BF7B08705B /* Pods-TeamMailShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TeamMailShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-TeamMailShareExtension/Pods-TeamMailShareExtension.profile.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 4D124E74293A67D900BA5186 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; 5810EDDA99BEFEACD742F507 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -135,6 +138,7 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B2EAFF659572E6B9F5AFAAF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + BC08294F5AE09BF8CC592AD1 /* Pods-TeamMailShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TeamMailShareExtension.release.xcconfig"; path = "Target Support Files/Pods-TeamMailShareExtension/Pods-TeamMailShareExtension.release.xcconfig"; sourceTree = ""; }; F522E87E2C0EE23400DDA35B /* AuthenticationSSOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationSSOTests.swift; sourceTree = ""; }; F522E8852C0EE8B600DDA35B /* CoreUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreUtils.swift; sourceTree = ""; }; F52F992D27FD6EB900346091 /* TeamMailShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TeamMailShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -197,6 +201,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 756BBC4C51FCB44047AB39E4 /* Pods_TeamMailShareExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -223,6 +228,7 @@ isa = PBXGroup; children = ( 5810EDDA99BEFEACD742F507 /* Pods_Runner.framework */, + 143BD7B2CF181BA69CDE351A /* Pods_TeamMailShareExtension.framework */, ); name = Frameworks; sourceTree = ""; @@ -482,6 +488,9 @@ B2EAFF659572E6B9F5AFAAF8 /* Pods-Runner.debug.xcconfig */, 631346BB444C71671599207F /* Pods-Runner.release.xcconfig */, 06C73A54FA0478D3C89AB5E5 /* Pods-Runner.profile.xcconfig */, + 07753111B751BFDB3187FE6F /* Pods-TeamMailShareExtension.debug.xcconfig */, + BC08294F5AE09BF8CC592AD1 /* Pods-TeamMailShareExtension.release.xcconfig */, + 1FB76FA91BBCB2BF7B08705B /* Pods-TeamMailShareExtension.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -510,8 +519,6 @@ F5D4EA062B2ABF090090DDFC /* PBXTargetDependency */, ); name = Runner; - packageProductDependencies = ( - ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -520,6 +527,7 @@ isa = PBXNativeTarget; buildConfigurationList = F52F993827FD6EB900346091 /* Build configuration list for PBXNativeTarget "TeamMailShareExtension" */; buildPhases = ( + E791D44F3C4EDE784D610B9E /* [CP] Check Pods Manifest.lock */, F52F992927FD6EB900346091 /* Sources */, F52F992A27FD6EB900346091 /* Frameworks */, F52F992B27FD6EB900346091 /* Resources */, @@ -696,7 +704,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n"; }; 3D8E7FEF3D91E77326493F6D /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -735,6 +743,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; + E791D44F3C4EDE784D610B9E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-TeamMailShareExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -759,7 +789,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F5CFE0302B335098005A90A9 /* TwakeLogger.swift in Sources */, F52F993027FD6EB900346091 /* ShareViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1120,7 +1149,9 @@ }; F52F993927FD6EB900346091 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 07753111B751BFDB3187FE6F /* Pods-TeamMailShareExtension.debug.xcconfig */; buildSettings = { + APP_GROUP_ID = group.com.linagora.teammail; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_OBJC_WEAK = YES; @@ -1160,7 +1191,9 @@ }; F52F993A27FD6EB900346091 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = BC08294F5AE09BF8CC592AD1 /* Pods-TeamMailShareExtension.release.xcconfig */; buildSettings = { + APP_GROUP_ID = group.com.linagora.teammail; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_OBJC_WEAK = YES; @@ -1197,7 +1230,9 @@ }; F52F993B27FD6EB900346091 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1FB76FA91BBCB2BF7B08705B /* Pods-TeamMailShareExtension.profile.xcconfig */; buildSettings = { + APP_GROUP_ID = group.com.linagora.teammail; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_OBJC_WEAK = YES; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/TeamMailShareExtension.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/TeamMailShareExtension.xcscheme new file mode 100644 index 0000000000..5758ae89aa --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/TeamMailShareExtension.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/TeamMailShareExtension/Info.plist b/ios/TeamMailShareExtension/Info.plist index 400268a436..adb158d904 100644 --- a/ios/TeamMailShareExtension/Info.plist +++ b/ios/TeamMailShareExtension/Info.plist @@ -2,6 +2,8 @@ + AppGroupId + ${APP_GROUP_ID} CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/ios/TeamMailShareExtension/ShareViewController.swift b/ios/TeamMailShareExtension/ShareViewController.swift index 5ece90a089..0e45452f8b 100644 --- a/ios/TeamMailShareExtension/ShareViewController.swift +++ b/ios/TeamMailShareExtension/ShareViewController.swift @@ -1,354 +1,4 @@ -import UIKit -import Social -import MobileCoreServices -import Photos +import receive_sharing_intent -class ShareViewController: SLComposeServiceViewController { - var hostAppBundleIdentifier = "" - let appGroupId = "group.com.linagora.teammail" - let sharedKey = "ShareKey" - var sharedMedia: [SharedMediaFile] = [] - var sharedText: [String] = [] - let imageContentType = kUTTypeImage as String - let textContentType = kUTTypeText as String - let urlContentType = kUTTypeURL as String - let videoContentType = kUTTypeMovie as String - let fileURLType = kUTTypeFileURL as String - - override func isContentValid() -> Bool { - return true - } - - private func loadIds() { - // loading Share extension App Id - let shareExtensionAppBundleIdentifier = Bundle.main.bundleIdentifier!; - - - // convert ShareExtension id to host app id - // By default it is remove last part of id after last point - // For example: com.test.ShareExtension -> com.test - let lastIndexOfPoint = shareExtensionAppBundleIdentifier.lastIndex(of: "."); - hostAppBundleIdentifier = String(shareExtensionAppBundleIdentifier[.. [Any]! { - // To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here. - return [] - } - - private func handleText (content: NSExtensionItem, attachment: NSItemProvider, index: Int) { - attachment.loadItem(forTypeIdentifier: textContentType, options: nil) { [weak self] data, error in - - if error == nil, let item = data as? String, let this = self { - - this.sharedText.append(item) - - // If this is the last item, save imagesData in userDefaults and redirect to host app - if index == (content.attachments?.count)! - 1 { - let userDefaults = UserDefaults(suiteName: this.appGroupId) - userDefaults?.set(this.sharedText, forKey: this.sharedKey) - userDefaults?.synchronize() - this.redirectToHostApp(type: .text) - } - - } else { - self?.dismissWithError() - } - } - } - - private func handleUrl (content: NSExtensionItem, attachment: NSItemProvider, index: Int) { - attachment.loadItem(forTypeIdentifier: urlContentType, options: nil) { [weak self] data, error in - - if error == nil, let item = data as? URL, let this = self { - - this.sharedText.append(item.absoluteString) - - // If this is the last item, save imagesData in userDefaults and redirect to host app - if index == (content.attachments?.count)! - 1 { - let userDefaults = UserDefaults(suiteName: this.appGroupId) - userDefaults?.set(this.sharedText, forKey: this.sharedKey) - userDefaults?.synchronize() - this.redirectToHostApp(type: .text) - } - - } else { - self?.dismissWithError() - } - } - } - - private func handleImages (content: NSExtensionItem, attachment: NSItemProvider, index: Int) { - attachment.loadItem(forTypeIdentifier: imageContentType, options: nil) { [weak self] data, error in - - if error == nil, let url = data as? URL, let this = self { - - // Always copy - let fileName = this.getFileName(from: url, type: .image) - let newPath = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: this.appGroupId)! - .appendingPathComponent(fileName) - let copied = this.copyFile(at: url, to: newPath) - if(copied) { - this.sharedMedia.append(SharedMediaFile(path: newPath.absoluteString, thumbnail: nil, duration: nil, type: .image)) - } - - // If this is the last item, save imagesData in userDefaults and redirect to host app - if index == (content.attachments?.count)! - 1 { - let userDefaults = UserDefaults(suiteName: this.appGroupId) - userDefaults?.set(this.toData(data: this.sharedMedia), forKey: this.sharedKey) - userDefaults?.synchronize() - this.redirectToHostApp(type: .media) - } - - } else { - self?.dismissWithError() - } - } - } - - private func handleVideos (content: NSExtensionItem, attachment: NSItemProvider, index: Int) { - attachment.loadItem(forTypeIdentifier: videoContentType, options: nil) { [weak self] data, error in - - if error == nil, let url = data as? URL, let this = self { - - // Always copy - let fileName = this.getFileName(from: url, type: .video) - let newPath = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: this.appGroupId)! - .appendingPathComponent(fileName) - let copied = this.copyFile(at: url, to: newPath) - if(copied) { - guard let sharedFile = this.getSharedMediaFile(forVideo: newPath) else { - return - } - this.sharedMedia.append(sharedFile) - } - - // If this is the last item, save imagesData in userDefaults and redirect to host app - if index == (content.attachments?.count)! - 1 { - let userDefaults = UserDefaults(suiteName: this.appGroupId) - userDefaults?.set(this.toData(data: this.sharedMedia), forKey: this.sharedKey) - userDefaults?.synchronize() - this.redirectToHostApp(type: .media) - } - - } else { - self?.dismissWithError() - } - } - } - - private func handleFiles (content: NSExtensionItem, attachment: NSItemProvider, index: Int) { - attachment.loadItem(forTypeIdentifier: fileURLType, options: nil) { [weak self] data, error in - - if error == nil, let url = data as? URL, let this = self { - - // Always copy - let fileName = this.getFileName(from :url, type: .file) - let newPath = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: this.appGroupId)! - .appendingPathComponent(fileName) - let copied = this.copyFile(at: url, to: newPath) - if (copied) { - this.sharedMedia.append(SharedMediaFile(path: newPath.absoluteString, thumbnail: nil, duration: nil, type: .file)) - } - - if index == (content.attachments?.count)! - 1 { - let userDefaults = UserDefaults(suiteName: this.appGroupId) - userDefaults?.set(this.toData(data: this.sharedMedia), forKey: this.sharedKey) - userDefaults?.synchronize() - this.redirectToHostApp(type: .file) - } - - } else { - self?.dismissWithError() - } - } - } - - - private func dismissWithError() { - TwakeLogger.shared.log(message: "[ERROR] Error loading data!") - let alert = UIAlertController(title: "Error", message: "Error loading data", preferredStyle: .alert) - - let action = UIAlertAction(title: "Error", style: .cancel) { _ in - self.dismiss(animated: true, completion: nil) - } - - alert.addAction(action) - present(alert, animated: true, completion: nil) - extensionContext!.completeRequest(returningItems: [], completionHandler: nil) - } - - private func redirectToHostApp(type: RedirectType) { - // ids may not loaded yet so we need loadIds here too - loadIds(); - let url = URL(string: "ShareMedia-\(hostAppBundleIdentifier)://dataUrl=\(sharedKey)#\(type)") - var responder = self as UIResponder? - let selectorOpenURL = sel_registerName("openURL:") - - while (responder != nil) { - if (responder?.responds(to: selectorOpenURL))! { - let _ = responder?.perform(selectorOpenURL, with: url) - } - responder = responder!.next - } - extensionContext!.completeRequest(returningItems: [], completionHandler: nil) - } - - enum RedirectType { - case media - case text - case file - } - - func getExtension(from url: URL, type: SharedMediaType) -> String { - let parts = url.lastPathComponent.components(separatedBy: ".") - var ex: String? = nil - if (parts.count > 1) { - ex = parts.last - } - - if (ex == nil) { - switch type { - case .image: - ex = "PNG" - case .video: - ex = "MP4" - case .file: - ex = "TXT" - } - } - return ex ?? "Unknown" - } - - func getFileName(from url: URL, type: SharedMediaType) -> String { - var name = url.lastPathComponent - - if (name.isEmpty) { - name = UUID().uuidString + "." + getExtension(from: url, type: type) - } - - return name - } - - func copyFile(at srcURL: URL, to dstURL: URL) -> Bool { - do { - if FileManager.default.fileExists(atPath: dstURL.path) { - try FileManager.default.removeItem(at: dstURL) - } - try FileManager.default.copyItem(at: srcURL, to: dstURL) - } catch (let error) { - TwakeLogger.shared.log(message: "Cannot copy item at \(srcURL) to \(dstURL): \(error)") - return false - } - return true - } - - private func getSharedMediaFile(forVideo: URL) -> SharedMediaFile? { - let asset = AVAsset(url: forVideo) - let duration = (CMTimeGetSeconds(asset.duration) * 1000).rounded() - let thumbnailPath = getThumbnailPath(for: forVideo) - - if FileManager.default.fileExists(atPath: thumbnailPath.path) { - return SharedMediaFile(path: forVideo.absoluteString, thumbnail: thumbnailPath.absoluteString, duration: duration, type: .video) - } - - var saved = false - let assetImgGenerate = AVAssetImageGenerator(asset: asset) - assetImgGenerate.appliesPreferredTrackTransform = true - // let scale = UIScreen.main.scale - assetImgGenerate.maximumSize = CGSize(width: 360, height: 360) - do { - let img = try assetImgGenerate.copyCGImage(at: CMTimeMakeWithSeconds(600, preferredTimescale: Int32(1.0)), actualTime: nil) - try UIImage.pngData(UIImage(cgImage: img))()?.write(to: thumbnailPath) - saved = true - } catch { - saved = false - } - - return saved ? SharedMediaFile(path: forVideo.absoluteString, thumbnail: thumbnailPath.absoluteString, duration: duration, type: .video) : nil - - } - - private func getThumbnailPath(for url: URL) -> URL { - let fileName = Data(url.lastPathComponent.utf8).base64EncodedString().replacingOccurrences(of: "==", with: "") - let path = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroupId)! - .appendingPathComponent("\(fileName).jpg") - return path - } - - class SharedMediaFile: Codable { - var path: String; // can be image, video or url path. It can also be text content - var thumbnail: String?; // video thumbnail - var duration: Double?; // video duration in milliseconds - var type: SharedMediaType; - - - init(path: String, thumbnail: String?, duration: Double?, type: SharedMediaType) { - self.path = path - self.thumbnail = thumbnail - self.duration = duration - self.type = type - } - - // Debug method to print out SharedMediaFile details in the console - func toString() { - TwakeLogger.shared.log(message: "[SharedMediaFile] \n\tpath: \(self.path)\n\tthumbnail: \(self.thumbnail)\n\tduration: \(self.duration)\n\ttype: \(self.type)") - } - } - - enum SharedMediaType: Int, Codable { - case image - case video - case file - } - - func toData(data: [SharedMediaFile]) -> Data { - let encodedData = try? JSONEncoder().encode(data) - return encodedData! - } -} - -extension Array { - subscript (safe index: UInt) -> Element? { - return Int(index) < count ? self[Int(index)] : nil - } +class ShareViewController: RSIShareViewController { } diff --git a/lib/features/base/base_controller.dart b/lib/features/base/base_controller.dart index 4ec33848a4..852b63873c 100644 --- a/lib/features/base/base_controller.dart +++ b/lib/features/base/base_controller.dart @@ -407,14 +407,32 @@ abstract class BaseController extends GetxController FirebaseCapability.fcmIdentifier.isSupported(session, accountId) && AppConfig.fcmAvailable; void goToLogin() { - if (Get.currentRoute != AppRoutes.login) { - pushAndPopAll( - AppRoutes.login, - arguments: LoginArguments( - PlatformInfo.isMobile ? LoginFormType.dnsLookupForm : LoginFormType.none - ) - ); + if (PlatformInfo.isMobile) { + navigateToTwakeWelcomePage(); + } else { + navigateToLoginPage(); + } + } + + void removeAllPageAndGoToLogin() { + if (PlatformInfo.isMobile) { + pushAndPopAll(AppRoutes.twakeWelcome); + } else { + navigateToLoginPage(); + } + } + + void navigateToTwakeWelcomePage() { + popAndPush(AppRoutes.twakeWelcome); + } + + void navigateToLoginPage() { + if (Get.currentRoute == AppRoutes.login) { + return; } + pushAndPopAll( + AppRoutes.login, + arguments: LoginArguments(LoginFormType.none)); } void logout(Session? session, AccountId? accountId) async { @@ -460,7 +478,7 @@ abstract class BaseController extends GetxController Future clearDataAndGoToLoginPage() async { log('$runtimeType::clearDataAndGoToLoginPage:'); await clearAllData(); - goToLogin(); + removeAllPageAndGoToLogin(); } Future clearAllData() async { diff --git a/lib/features/base/mixin/popup_context_menu_action_mixin.dart b/lib/features/base/mixin/popup_context_menu_action_mixin.dart index 9acdf7e97a..5060baf553 100644 --- a/lib/features/base/mixin/popup_context_menu_action_mixin.dart +++ b/lib/features/base/mixin/popup_context_menu_action_mixin.dart @@ -9,8 +9,15 @@ import 'package:tmail_ui_user/main/routes/route_navigation.dart'; mixin PopupContextMenuActionMixin { - void openContextMenuAction(BuildContext context, List actionTiles, {Widget? cancelButton}) async { - await (CupertinoActionSheetBuilder(context) + void openContextMenuAction( + BuildContext context, + List actionTiles, + { + Widget? cancelButton, + Key? key, + } + ) async { + await (CupertinoActionSheetBuilder(context, key: key) ..addTiles(actionTiles) ..addCancelButton(cancelButton ?? buildCancelButton(context))) .show(); diff --git a/lib/features/base/reloadable/reloadable_controller.dart b/lib/features/base/reloadable/reloadable_controller.dart index fbfa118421..f0750cec70 100644 --- a/lib/features/base/reloadable/reloadable_controller.dart +++ b/lib/features/base/reloadable/reloadable_controller.dart @@ -36,7 +36,7 @@ abstract class ReloadableController extends BaseController { goToLogin(); } else if (failure is GetSessionFailure) { logError('$runtimeType::handleFailureViewState():Failure = $failure'); - _handleGetSessionFailure(failure.exception); + handleGetSessionFailure(failure.exception); } else if (failure is UpdateAccountCacheFailure) { logError('$runtimeType::handleFailureViewState():Failure = $failure'); _handleUpdateAccountCacheCompleted( @@ -127,7 +127,7 @@ abstract class ReloadableController extends BaseController { consumeState(_getSessionInteractor.execute()); } - void _handleGetSessionFailure(GetSessionFailure failure) { + void handleGetSessionFailure(GetSessionFailure failure) { if (failure.exception is! BadCredentialsException) { toastManager.showMessageFailure(failure); } diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index b3ec9a78df..4e843153dd 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -629,6 +629,16 @@ class ComposerController extends BaseController isInitialRecipient.value = true; toAddressExpandMode.value = ExpandMode.COLLAPSE; } + if (arguments.cc?.isNotEmpty == true) { + listCcEmailAddress = arguments.cc!; + ccRecipientState.value = PrefixRecipientState.enabled; + ccAddressExpandMode.value = ExpandMode.COLLAPSE; + } + if (arguments.bcc?.isNotEmpty == true) { + bccRecipientState.value = PrefixRecipientState.enabled; + bccAddressExpandMode.value = ExpandMode.COLLAPSE; + listBccEmailAddress = arguments.bcc!; + } _getEmailContentFromMailtoUri(arguments.body ?? ''); _updateStatusEmailSendButton(); break; @@ -955,12 +965,15 @@ class ComposerController extends BaseController } Future _getContentInEditor() async { - final htmlTextEditor = PlatformInfo.isWeb - ? _textEditorWeb - : await htmlEditorApi?.getText(); - if (htmlTextEditor?.isNotEmpty == true) { - return htmlTextEditor!.removeEditorStartTag(); - } else { + try { + final htmlTextEditor = PlatformInfo.isWeb + ? _textEditorWeb + : await htmlEditorApi?.getText(); + return htmlTextEditor?.isNotEmpty == true + ? htmlTextEditor!.removeEditorStartTag() + : ''; + } catch (e) { + logError('ComposerController::_getContentInEditor:Exception = $e'); return ''; } } @@ -1726,10 +1739,15 @@ class ComposerController extends BaseController if (bccRecipientState.value == PrefixRecipientState.disabled) { bccRecipientState.value = PrefixRecipientState.enabled; } - listBccEmailAddress = listEmailAddress.toList(); + if (composerArguments.value?.emailActionType == EmailActionType.composeFromMailtoUri) { + listBccEmailAddress = {...listEmailAddress, ...?composerArguments.value?.bcc}.toList(); + } else { + listBccEmailAddress = listEmailAddress.toList(); + } toAddressExpandMode.value = ExpandMode.COLLAPSE; ccAddressExpandMode.value = ExpandMode.COLLAPSE; bccAddressExpandMode.value = ExpandMode.COLLAPSE; + bccAddressExpandMode.refresh(); _updateStatusEmailSendButton(); } diff --git a/lib/features/composer/presentation/extensions/shared_media_file_extension.dart b/lib/features/composer/presentation/extensions/shared_media_file_extension.dart index 93f6caa37f..bdf157a56f 100644 --- a/lib/features/composer/presentation/extensions/shared_media_file_extension.dart +++ b/lib/features/composer/presentation/extensions/shared_media_file_extension.dart @@ -1,22 +1,16 @@ import 'dart:io'; -import 'package:core/utils/platform_info.dart'; import 'package:model/upload/file_info.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/file_extension.dart'; extension SharedMediaFileExtension on SharedMediaFile { - File toFile() { - if (PlatformInfo.isIOS) { - final pathFile = type == SharedMediaType.FILE - ? path.toString().replaceAll('file:/', '').replaceAll('%20', ' ') - : path.toString().replaceAll('%20', ' '); - return File(pathFile); - } else { - return File(path); - } - } + File toFile() => File(path); - FileInfo toFileInfo({bool? isShared}) => toFile().toFileInfo(isInline: type == SharedMediaType.IMAGE, isShared: isShared); + FileInfo toFileInfo({bool? isShared}) => + toFile().toFileInfo( + isInline: type == SharedMediaType.image, + isShared: isShared, + ); } \ No newline at end of file diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index 2b9e09d765..e40e78fca3 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -1159,13 +1159,21 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } Future openMailToLink(Uri? uri) async { + if (uri == null) return; + + final navigationRouter = RouteUtils.generateNavigationRouterFromMailtoLink(uri.toString()); log('SingleEmailController::openMailToLink(): ${uri.toString()}'); - String address = uri?.path ?? ''; - log('SingleEmailController::openMailToLink(): address: $address'); - if (address.isNotEmpty) { - final emailAddress = EmailAddress(null, address); - mailboxDashBoardController.goToComposer(ComposerArguments.fromEmailAddress(emailAddress)); - } + if (!RouteUtils.canOpenComposerFromNavigationRouter(navigationRouter)) return; + + mailboxDashBoardController.goToComposer( + ComposerArguments.fromMailtoUri( + listEmailAddress: navigationRouter.listEmailAddress, + cc: navigationRouter.cc, + bcc: navigationRouter.bcc, + subject: navigationRouter.subject, + body: navigationRouter.body + ) + ); } void deleteEmailPermanently(BuildContext context, PresentationEmail email) { diff --git a/lib/features/email/presentation/model/composer_arguments.dart b/lib/features/email/presentation/model/composer_arguments.dart index fc9b9f2811..479d0e617a 100644 --- a/lib/features/email/presentation/model/composer_arguments.dart +++ b/lib/features/email/presentation/model/composer_arguments.dart @@ -30,6 +30,8 @@ class ComposerArguments extends RouterArguments { final List? inlineImages; final bool? hasRequestReadReceipt; final ScreenDisplayMode displayMode; + final List? cc; + final List? bcc; ComposerArguments({ this.emailActionType = EmailActionType.compose, @@ -49,7 +51,9 @@ class ComposerArguments extends RouterArguments { this.selectedIdentityId, this.inlineImages, this.hasRequestReadReceipt, - this.displayMode = ScreenDisplayMode.normal + this.displayMode = ScreenDisplayMode.normal, + this.cc, + this.bcc, }); factory ComposerArguments.fromSendingEmail(SendingEmail sendingEmail) => @@ -76,13 +80,20 @@ class ComposerArguments extends RouterArguments { listEmailAddress: [emailAddress] ); - factory ComposerArguments.fromMailtoUri({List? listEmailAddress, String? subject, String? body}) => - ComposerArguments( - emailActionType: EmailActionType.composeFromMailtoUri, - listEmailAddress: listEmailAddress, - subject: subject, - body: body, - ); + factory ComposerArguments.fromMailtoUri({ + List? listEmailAddress, + String? subject, + String? body, + List? cc, + List? bcc + }) => ComposerArguments( + emailActionType: EmailActionType.composeFromMailtoUri, + listEmailAddress: listEmailAddress, + subject: subject, + body: body, + cc: cc, + bcc: bcc, + ); factory ComposerArguments.editDraftEmail(PresentationEmail presentationEmail) => ComposerArguments( @@ -193,6 +204,8 @@ class ComposerArguments extends RouterArguments { inlineImages, hasRequestReadReceipt, displayMode, + cc, + bcc, ]; ComposerArguments copyWith({ @@ -214,6 +227,8 @@ class ComposerArguments extends RouterArguments { List? inlineImages, bool? hasRequestReadReceipt, ScreenDisplayMode? displayMode, + List? cc, + List? bcc, }) { return ComposerArguments( emailActionType: emailActionType ?? this.emailActionType, @@ -234,6 +249,8 @@ class ComposerArguments extends RouterArguments { inlineImages: inlineImages ?? this.inlineImages, hasRequestReadReceipt: hasRequestReadReceipt ?? this.hasRequestReadReceipt, displayMode: displayMode ?? this.displayMode, + cc: cc ?? this.cc, + bcc: bcc ?? this.bcc, ); } } diff --git a/lib/features/home/presentation/home_controller.dart b/lib/features/home/presentation/home_controller.dart index 4f6758cddd..b08595cab5 100644 --- a/lib/features/home/presentation/home_controller.dart +++ b/lib/features/home/presentation/home_controller.dart @@ -1,12 +1,7 @@ import 'package:core/utils/platform_info.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; -import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; -import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; -import 'package:model/email/email_content.dart'; -import 'package:model/email/email_content_type.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:tmail_ui_user/features/base/reloadable/reloadable_controller.dart'; import 'package:tmail_ui_user/features/caching/config/hive_cache_config.dart'; import 'package:tmail_ui_user/features/cleanup/domain/model/cleanup_rule.dart'; @@ -45,7 +40,7 @@ class HomeController extends ReloadableController { void onInit() { if (PlatformInfo.isMobile) { _initFlutterDownloader(); - _registerReceivingSharingIntent(); + _registerReceivingFileSharing(); } if (PlatformInfo.isIOS) { _registerNotificationClickOnIOS(); @@ -85,20 +80,8 @@ class HomeController extends ReloadableController { ], eagerError: true).then((_) => getAuthenticatedAccountAction()); } - void _registerReceivingSharingIntent() { - _emailReceiveManager.receivingSharingStream.listen((uri) { - if (uri != null) { - if (GetUtils.isEmail(uri.path)) { - _emailReceiveManager.setPendingEmailAddress(EmailAddress(null, uri.path)); - } else if (uri.scheme == "file") { - _emailReceiveManager.setPendingFileInfo([SharedMediaFile(uri.path, null, null, SharedMediaType.FILE)]); - } else { - _emailReceiveManager.setPendingEmailContent(EmailContent(EmailContentType.textPlain, Uri.decodeComponent(uri.path))); - } - } - }); - - _emailReceiveManager.receivingFileSharingStream.listen(_emailReceiveManager.setPendingFileInfo); + void _registerReceivingFileSharing() { + _emailReceiveManager.registerReceivingFileSharingStreamWhileAppClosed(); } void _registerNotificationClickOnIOS() { diff --git a/lib/features/login/data/network/authentication_client/authentication_client_base.dart b/lib/features/login/data/network/authentication_client/authentication_client_base.dart index a1a097644b..e3f9c70d4a 100644 --- a/lib/features/login/data/network/authentication_client/authentication_client_base.dart +++ b/lib/features/login/data/network/authentication_client/authentication_client_base.dart @@ -30,5 +30,9 @@ abstract class AuthenticationClientBase { Future logoutOidc(TokenId tokenId, OIDCConfiguration config, OIDCDiscoveryResponse oidcRescovery); + Future signInTwakeWorkplace(OIDCConfiguration oidcConfiguration); + + Future signUpTwakeWorkplace(OIDCConfiguration oidcConfiguration); + factory AuthenticationClientBase({String? tag}) => getAuthenticationClientImplementation(tag: tag); } \ No newline at end of file diff --git a/lib/features/login/data/network/authentication_client/authentication_client_mobile.dart b/lib/features/login/data/network/authentication_client/authentication_client_mobile.dart index f22968b8a1..e307810b54 100644 --- a/lib/features/login/data/network/authentication_client/authentication_client_mobile.dart +++ b/lib/features/login/data/network/authentication_client/authentication_client_mobile.dart @@ -1,6 +1,7 @@ import 'package:core/utils/app_logger.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:get/get.dart'; import 'package:model/oidc/oidc_configuration.dart'; import 'package:model/oidc/response/oidc_discovery_response.dart'; @@ -9,6 +10,7 @@ import 'package:model/oidc/token_oidc.dart'; import 'package:tmail_ui_user/features/login/data/extensions/authentication_token_extension.dart'; import 'package:tmail_ui_user/features/login/data/extensions/token_response_extension.dart'; import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart'; +import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart'; import 'package:tmail_ui_user/features/login/domain/exceptions/authentication_exception.dart'; import 'package:tmail_ui_user/features/login/domain/extensions/oidc_configuration_extensions.dart'; @@ -96,6 +98,32 @@ class AuthenticationClientMobile implements AuthenticationClientBase { Future getAuthenticationInfo() { return Future.value(''); } + + @override + Future signInTwakeWorkplace(OIDCConfiguration oidcConfiguration) async { + final uri = await FlutterWebAuth2.authenticate( + url: oidcConfiguration.signInTWPUrl, + callbackUrlScheme: OIDCConstant.twakeWorkplaceUrlScheme, + options: const FlutterWebAuth2Options( + intentFlags: ephemeralIntentFlags, + ), + ); + log('AuthenticationClientMobile::signInTwakeWorkplace():Uri = $uri'); + return TokenOIDC.fromUri(uri); + } + + @override + Future signUpTwakeWorkplace(OIDCConfiguration oidcConfiguration) async { + final uri = await FlutterWebAuth2.authenticate( + url: oidcConfiguration.signUpTWPUrl, + callbackUrlScheme: OIDCConstant.twakeWorkplaceUrlScheme, + options: const FlutterWebAuth2Options( + intentFlags: ephemeralIntentFlags, + ), + ); + log('AuthenticationClientMobile::signUpTwakeWorkplace():Uri = $uri'); + return TokenOIDC.fromUri(uri); + } } AuthenticationClientBase getAuthenticationClientImplementation({String? tag}) => diff --git a/lib/features/login/data/network/authentication_client/authentication_client_web.dart b/lib/features/login/data/network/authentication_client/authentication_client_web.dart index 463f79a11e..1e3146a00c 100644 --- a/lib/features/login/data/network/authentication_client/authentication_client_web.dart +++ b/lib/features/login/data/network/authentication_client/authentication_client_web.dart @@ -106,6 +106,16 @@ class AuthenticationClientWeb implements AuthenticationClientBase { throw CanNotAuthenticationInfoOnWeb(); } } + + @override + Future signUpTwakeWorkplace(OIDCConfiguration oidcConfiguration) { + throw UnimplementedError(); + } + + @override + Future signInTwakeWorkplace(OIDCConfiguration oidcConfiguration) { + throw UnimplementedError(); + } } AuthenticationClientBase getAuthenticationClientImplementation({String? tag}) => diff --git a/lib/features/login/data/network/config/oidc_constant.dart b/lib/features/login/data/network/config/oidc_constant.dart index 8940791367..2e4790c58b 100644 --- a/lib/features/login/data/network/config/oidc_constant.dart +++ b/lib/features/login/data/network/config/oidc_constant.dart @@ -1,4 +1,4 @@ -import 'package:core/core.dart'; +import 'package:core/utils/platform_info.dart'; import 'package:tmail_ui_user/main/utils/app_config.dart'; class OIDCConstant { @@ -6,6 +6,11 @@ class OIDCConstant { static List get oidcScope => ['openid', 'profile', 'email', 'offline_access']; static const keyAuthorityOidc = 'KEY_AUTHORITY_OIDC'; static const authResponseKey = "auth_info"; + static const String twakeWorkplaceUrlScheme = 'twakemail.mobile'; + static const String twakeWorkplaceRedirectUrl = '$twakeWorkplaceUrlScheme://redirect'; + static const String appParameter = 'tmail'; + static const String postRegisteredRedirectUrlPathParams = 'post_registered_redirect_url'; + static const String postLoginRedirectUrlPathParams = 'post_login_redirect_url'; static String get clientId => PlatformInfo.isWeb ? AppConfig.webOidcClientId : mobileOidcClientId; } \ No newline at end of file diff --git a/lib/features/login/domain/exceptions/authentication_exception.dart b/lib/features/login/domain/exceptions/authentication_exception.dart index 2fd97c29e8..170b5e6d29 100644 --- a/lib/features/login/domain/exceptions/authentication_exception.dart +++ b/lib/features/login/domain/exceptions/authentication_exception.dart @@ -44,4 +44,8 @@ class CanNotFoundPassword implements Exception {} class CanNotAuthenticationInfoOnWeb implements Exception {} -class NotFoundAuthenticationInfoCache implements Exception {} \ No newline at end of file +class NotFoundAuthenticationInfoCache implements Exception {} + +class CanNotFoundSaasServerUrl implements Exception {} + +class SaasServerUriIsNull implements Exception {} \ No newline at end of file diff --git a/lib/features/login/domain/extensions/oidc_configuration_extensions.dart b/lib/features/login/domain/extensions/oidc_configuration_extensions.dart index fef03e30f7..9e4b60e79d 100644 --- a/lib/features/login/domain/extensions/oidc_configuration_extensions.dart +++ b/lib/features/login/domain/extensions/oidc_configuration_extensions.dart @@ -1,19 +1,23 @@ +import 'package:core/data/model/query/query_parameter.dart'; +import 'package:core/data/network/config/service_path.dart'; import 'package:core/utils/platform_info.dart'; import 'package:model/oidc/oidc_configuration.dart'; +import 'package:tmail_ui_user/features/login/data/extensions/service_path_extension.dart'; +import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart'; import 'package:tmail_ui_user/main/utils/app_config.dart'; extension OidcConfigurationExtensions on OIDCConfiguration { String get redirectUrl { if (PlatformInfo.isWeb) { - if (AppConfig.domainRedirectUrl.endsWith('/')) { - return AppConfig.domainRedirectUrl + loginRedirectOidcWeb; - } else { - return '${AppConfig.domainRedirectUrl}/$loginRedirectOidcWeb'; - } + return AppConfig.domainRedirectUrl.endsWith('/') + ? AppConfig.domainRedirectUrl + loginRedirectOidcWeb + : '${AppConfig.domainRedirectUrl}/$loginRedirectOidcWeb'; } else { - return redirectOidcMobile; + return _isSaasAuthority(authority) + ? OIDCConstant.twakeWorkplaceRedirectUrl + : redirectOidcMobile; } } @@ -25,7 +29,32 @@ extension OidcConfigurationExtensions on OIDCConfiguration { return '${AppConfig.domainRedirectUrl}/$logoutRedirectOidcWeb'; } } else { - return redirectOidcMobile; + return _isSaasAuthority(authority) + ? OIDCConstant.twakeWorkplaceRedirectUrl + : redirectOidcMobile; } } + + bool _isSaasAuthority(String authority) => + authority == AppConfig.saasRegistrationUrl; + + String get signInTWPUrl => ServicePath(authority) + .withQueryParameters([ + StringQueryParameter( + OIDCConstant.postLoginRedirectUrlPathParams, + OIDCConstant.twakeWorkplaceRedirectUrl, + ), + StringQueryParameter('app', OIDCConstant.appParameter), + ]) + .generateEndpointPath(); + + String get signUpTWPUrl => ServicePath(authority) + .withQueryParameters([ + StringQueryParameter( + OIDCConstant.postRegisteredRedirectUrlPathParams, + OIDCConstant.twakeWorkplaceRedirectUrl, + ), + StringQueryParameter('app', OIDCConstant.appParameter), + ]) + .generateEndpointPath(); } \ No newline at end of file diff --git a/lib/features/login/presentation/base_login_view.dart b/lib/features/login/presentation/base_login_view.dart index 9032bd5134..cc5c092f11 100644 --- a/lib/features/login/presentation/base_login_view.dart +++ b/lib/features/login/presentation/base_login_view.dart @@ -27,7 +27,7 @@ abstract class BaseLoginView extends GetWidget { side: const BorderSide(width: 0, color: AppColor.primaryColor) ) ), - onPressed: controller.handleLoginPressed, + onPressed: () => controller.handleLoginPressed(context), child: Text( AppLocalizations.of(context).signIn, style: const TextStyle(fontSize: 16, color: Colors.white) @@ -89,7 +89,7 @@ abstract class BaseLoginView extends GetWidget { hintText: AppLocalizations.of(context).password, focusNode: controller.passFocusNode, onTextChange: controller.onPasswordChange, - onSubmitted: (_) => controller.handleLoginPressed(), + onSubmitted: (_) => controller.handleLoginPressed(context), ); } diff --git a/lib/features/login/presentation/login_bindings.dart b/lib/features/login/presentation/login_bindings.dart index 980cc98e7e..2dd140624d 100644 --- a/lib/features/login/presentation/login_bindings.dart +++ b/lib/features/login/presentation/login_bindings.dart @@ -6,6 +6,7 @@ import 'package:tmail_ui_user/features/caching/clients/recent_login_username_cac import 'package:tmail_ui_user/features/login/data/datasource/login_datasource.dart'; import 'package:tmail_ui_user/features/login/data/datasource_impl/hive_login_datasource_impl.dart'; import 'package:tmail_ui_user/features/login/data/datasource_impl/login_datasource_impl.dart'; +import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart'; import 'package:tmail_ui_user/features/login/data/network/dns_service.dart'; import 'package:tmail_ui_user/features/login/data/repository/login_repository_impl.dart'; import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart'; @@ -26,6 +27,11 @@ import 'package:tmail_ui_user/features/login/domain/usecases/get_token_oidc_inte import 'package:tmail_ui_user/features/login/domain/usecases/save_login_url_on_mobile_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/save_login_username_on_mobile_interactor.dart'; import 'package:tmail_ui_user/features/login/presentation/login_controller.dart'; +import 'package:tmail_ui_user/features/starting_page/data/datasource/saas_authentication_datasource.dart'; +import 'package:tmail_ui_user/features/starting_page/data/datasource_impl/saas_authentication_datasource_impl.dart'; +import 'package:tmail_ui_user/features/starting_page/data/repository/saas_authentication_repository_impl.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/repository/saas_authentication_repository.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_in_twake_workplace_interactor.dart'; import 'package:tmail_ui_user/main/exceptions/cache_exception_thrower.dart'; import 'package:tmail_ui_user/main/exceptions/remote_exception_thrower.dart'; @@ -47,12 +53,15 @@ class LoginBindings extends BaseBindings { Get.find(), Get.find(), Get.find(), + Get.find(), )); } @override void bindingsDataSource() { Get.lazyPut(() => Get.find()); + Get.lazyPut( + () => Get.find()); } @override @@ -66,6 +75,10 @@ class LoginBindings extends BaseBindings { Get.find(), Get.find(), )); + Get.lazyPut(() => SaasAuthenticationDataSourceImpl( + Get.find(), + Get.find(), + )); } @override @@ -98,11 +111,19 @@ class LoginBindings extends BaseBindings { Get.lazyPut(() => SaveLoginUsernameOnMobileInteractor(Get.find(),)); Get.lazyPut(() => GetAllRecentLoginUsernameOnMobileInteractor(Get.find())); Get.lazyPut(() => DNSLookupToGetJmapUrlInteractor(Get.find())); + Get.lazyPut(() => SignInTwakeWorkplaceInteractor( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + )); } @override void bindingsRepository() { Get.lazyPut(() => Get.find()); + Get.lazyPut( + () => Get.find()); } @override @@ -113,5 +134,7 @@ class LoginBindings extends BaseBindings { DataSourceType.network: Get.find(), } )); + Get.lazyPut(() => SaasAuthenticationRepositoryImpl( + Get.find())); } } \ No newline at end of file diff --git a/lib/features/login/presentation/login_controller.dart b/lib/features/login/presentation/login_controller.dart index 425e0f0b6e..7946426e30 100644 --- a/lib/features/login/presentation/login_controller.dart +++ b/lib/features/login/presentation/login_controller.dart @@ -1,6 +1,7 @@ import 'package:core/presentation/extensions/url_extension.dart'; import 'package:core/presentation/state/failure.dart'; import 'package:core/presentation/state/success.dart'; +import 'package:core/presentation/utils/keyboard_utils.dart'; import 'package:core/utils/app_logger.dart'; import 'package:core/utils/platform_info.dart'; import 'package:dartz/dartz.dart'; @@ -13,6 +14,7 @@ import 'package:model/account/password.dart'; import 'package:model/oidc/oidc_configuration.dart'; import 'package:model/oidc/request/oidc_request.dart'; import 'package:model/oidc/response/oidc_response.dart'; +import 'package:model/oidc/token_oidc.dart'; import 'package:tmail_ui_user/features/base/reloadable/reloadable_controller.dart'; import 'package:tmail_ui_user/features/home/domain/state/get_session_state.dart'; import 'package:tmail_ui_user/features/login/data/network/oidc_error.dart'; @@ -48,6 +50,8 @@ import 'package:tmail_ui_user/features/login/domain/usecases/save_login_url_on_m import 'package:tmail_ui_user/features/login/domain/usecases/save_login_username_on_mobile_interactor.dart'; import 'package:tmail_ui_user/features/login/presentation/login_form_type.dart'; import 'package:tmail_ui_user/features/login/presentation/model/login_arguments.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/state/sign_in_twake_workplace_state.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_in_twake_workplace_interactor.dart'; import 'package:tmail_ui_user/main/routes/app_routes.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; import 'package:tmail_ui_user/main/routes/route_utils.dart'; @@ -69,6 +73,7 @@ class LoginController extends ReloadableController { final SaveLoginUsernameOnMobileInteractor _saveLoginUsernameOnMobileInteractor; final GetAllRecentLoginUsernameOnMobileInteractor _getAllRecentLoginUsernameOnMobileInteractor; final DNSLookupToGetJmapUrlInteractor _dnsLookupToGetJmapUrlInteractor; + final SignInTwakeWorkplaceInteractor _signInTwakeWorkplaceInteractor; final TextEditingController urlInputController = TextEditingController(); final TextEditingController usernameInputController = TextEditingController(); @@ -98,6 +103,7 @@ class LoginController extends ReloadableController { this._saveLoginUsernameOnMobileInteractor, this._getAllRecentLoginUsernameOnMobileInteractor, this._dnsLookupToGetJmapUrlInteractor, + this._signInTwakeWorkplaceInteractor, ); @override @@ -129,7 +135,8 @@ class LoginController extends ReloadableController { _handleCheckOIDCIsAvailableFailure(failure); } else if (failure is GetStoredOidcConfigurationFailure || failure is GetOIDCIsAvailableFailure || - failure is GetOIDCConfigurationFailure + failure is GetOIDCConfigurationFailure || + failure is SignInTwakeWorkplaceFailure ) { _handleCommonOIDCFailure(); } else if (failure is GetTokenOIDCFailure) { @@ -167,6 +174,12 @@ class LoginController extends ReloadableController { _loginSuccessAction(success); } else if (success is DNSLookupToGetJmapUrlSuccess) { _handleDNSLookupToGetJmapUrlSuccess(success); + } else if (success is SignInTwakeWorkplaceSuccess) { + _synchronizeTokenAndGetSession( + baseUri: success.baseUri, + tokenOIDC: success.tokenOIDC, + oidcConfiguration: success.oidcConfiguration, + ); } else { super.handleSuccessViewState(success); } @@ -179,7 +192,9 @@ class LoginController extends ReloadableController { _handleCheckOIDCIsAvailableFailure(failure); } else if (failure is GetStoredOidcConfigurationFailure || failure is GetOIDCConfigurationFailure || - failure is GetOIDCIsAvailableFailure) { + failure is GetOIDCIsAvailableFailure || + failure is SignInTwakeWorkplaceFailure + ) { _handleCommonOIDCFailure(); } else if (failure is GetTokenOIDCFailure) { _handleNoSuitableBrowserOIDC(failure) @@ -254,20 +269,30 @@ class LoginController extends ReloadableController { )); } - void handleBackButtonAction() { + void handleBackButtonAction(BuildContext context) { + KeyboardUtils.hideKeyboard(context); clearState(); - if (loginFormType.value == LoginFormType.credentialForm) { - _password = null; - _username = null; - usernameInputController.clear(); - passwordInputController.clear(); - loginFormType.value = LoginFormType.baseUrlForm; - } else if (loginFormType.value == LoginFormType.passwordForm) { - _password = null; - _baseUri = null; - urlInputController.clear(); - passwordInputController.clear(); - loginFormType.value = LoginFormType.dnsLookupForm; + switch(loginFormType.value) { + case LoginFormType.dnsLookupForm: + case LoginFormType.baseUrlForm: + navigateToTwakeWelcomePage(); + break; + case LoginFormType.passwordForm: + _password = null; + _baseUri = null; + urlInputController.clear(); + passwordInputController.clear(); + loginFormType.value = LoginFormType.dnsLookupForm; + break; + case LoginFormType.credentialForm: + _password = null; + _username = null; + usernameInputController.clear(); + passwordInputController.clear(); + loginFormType.value = LoginFormType.baseUrlForm; + break; + default: + break; } } @@ -277,7 +302,8 @@ class LoginController extends ReloadableController { userNameFocusNode.requestFocus(); } - void handleLoginPressed() { + void handleLoginPressed(BuildContext context) { + KeyboardUtils.hideKeyboard(context); log('LoginController::handleLoginPressed:_currentBaseUrl: $_currentBaseUrl | _username: $_username | _password: $_password'); if (_currentBaseUrl == null) { consumeState(Stream.value(Left(AuthenticationUserFailure(CanNotFoundBaseUrl())))); @@ -314,11 +340,41 @@ class LoginController extends ReloadableController { void _getOIDCConfigurationSuccess(GetOIDCConfigurationSuccess success) { if (PlatformInfo.isWeb) { _authenticateOidcOnBrowserAction(success.oidcConfiguration); + } else if (success.oidcConfiguration.authority == AppConfig.saasRegistrationUrl) { + _getTokenOIDCOnSaaSPlatform(success.oidcConfiguration); } else { _getTokenOIDCAction(success.oidcConfiguration); } } + void _getTokenOIDCOnSaaSPlatform(OIDCConfiguration oidcConfiguration) { + if (_currentBaseUrl != null) { + consumeState(_signInTwakeWorkplaceInteractor.execute( + baseUri: _currentBaseUrl!, + oidcConfiguration: oidcConfiguration, + )); + } else { + dispatchState(Left(GetTokenOIDCFailure(CanNotFoundBaseUrl()))); + } + } + + void _synchronizeTokenAndGetSession({ + required Uri baseUri, + required TokenOIDC tokenOIDC, + required OIDCConfiguration oidcConfiguration, + }) { + dynamicUrlInterceptors.setJmapUrl(baseUri.toString()); + dynamicUrlInterceptors.changeBaseUrl(baseUri.toString()); + authorizationInterceptors.setTokenAndAuthorityOidc( + newToken: tokenOIDC, + newConfig: oidcConfiguration); + authorizationIsolateInterceptors.setTokenAndAuthorityOidc( + newToken: tokenOIDC, + newConfig: oidcConfiguration); + + getSessionAction(); + } + void _getTokenOIDCAction(OIDCConfiguration config) { if (_currentBaseUrl != null) { consumeState(_getTokenOIDCInteractor.execute(_currentBaseUrl!, config)); @@ -345,15 +401,11 @@ class LoginController extends ReloadableController { } void _getTokenOIDCSuccess(GetTokenOIDCSuccess success) { - dynamicUrlInterceptors.setJmapUrl(_currentBaseUrl?.toString()); - dynamicUrlInterceptors.changeBaseUrl(_currentBaseUrl?.toString()); - authorizationInterceptors.setTokenAndAuthorityOidc( - newToken: success.tokenOIDC, - newConfig: success.configuration); - authorizationIsolateInterceptors.setTokenAndAuthorityOidc( - newToken: success.tokenOIDC, - newConfig: success.configuration); - getSessionAction(); + _synchronizeTokenAndGetSession( + baseUri: _currentBaseUrl!, + tokenOIDC: success.tokenOIDC, + oidcConfiguration: success.configuration, + ); } void _loginSuccessAction(AuthenticationUserSuccess success) { @@ -419,6 +471,8 @@ class LoginController extends ReloadableController { void invokeDNSLookupToGetJmapUrl() { log('LoginController::invokeDNSLookupToGetJmapUrl:_username $_username'); + FocusManager.instance.primaryFocus?.unfocus(); + if (_username == null) { consumeState(Stream.value(Left(AuthenticationUserFailure(CanNotFoundUserName())))); } else { @@ -503,6 +557,11 @@ class LoginController extends ReloadableController { passwordInputController.clear(); } + bool get isBackButtonActivated => + loginFormType.value == LoginFormType.dnsLookupForm || + loginFormType.value == LoginFormType.passwordForm || + loginFormType.value == LoginFormType.credentialForm; + @override void onClose() { passFocusNode.dispose(); diff --git a/lib/features/login/presentation/login_view.dart b/lib/features/login/presentation/login_view.dart index a09e6cbe9a..bb051da49c 100644 --- a/lib/features/login/presentation/login_view.dart +++ b/lib/features/login/presentation/login_view.dart @@ -26,42 +26,50 @@ class LoginView extends BaseLoginView { Widget build(BuildContext context) { ThemeUtils.setSystemDarkUIStyle(); - return Scaffold( - backgroundColor: AppColor.primaryLightColor, - body: GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - child: Container( - color: Colors.white, - child: SafeArea( - child: _supportScrollForm(context) - ? Stack(children: [ - Center( - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: _buildCenterForm(context) - ) - ), - Obx(() { - if (controller.loginFormType.value == LoginFormType.passwordForm || - controller.loginFormType.value == LoginFormType.credentialForm) { - return LoginBackButton(onBackAction: controller.handleBackButtonAction); - } - return const SizedBox.shrink(); - }) - ]) - : Stack(children: [ - _buildCenterForm(context), - Obx(() { - if (controller.loginFormType.value == LoginFormType.passwordForm || - controller.loginFormType.value == LoginFormType.credentialForm) { - return LoginBackButton(onBackAction: controller.handleBackButtonAction); - } - return const SizedBox.shrink(); - }) - ]), + return PopScope( + canPop: false, + onPopInvoked: (didPop) => !didPop + ? controller.handleBackButtonAction(context) + : null, + child: Scaffold( + backgroundColor: AppColor.primaryLightColor, + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + color: Colors.white, + child: SafeArea( + child: _supportScrollForm(context) + ? Stack(children: [ + Center( + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: _buildCenterForm(context) + ) + ), + Obx(() { + if (controller.isBackButtonActivated) { + return LoginBackButton( + onBackAction: () => controller.handleBackButtonAction(context) + ); + } + return const SizedBox.shrink(); + }) + ]) + : Stack(children: [ + _buildCenterForm(context), + Obx(() { + if (controller.isBackButtonActivated) { + return LoginBackButton( + onBackAction: () => controller.handleBackButtonAction(context) + ); + } + return const SizedBox.shrink(); + }) + ]), + ), ), - ), - )); + )), + ); } Widget _buildCenterForm(BuildContext context) { @@ -103,7 +111,7 @@ class LoginView extends BaseLoginView { textEditingController: controller.passwordInputController, focusNode: controller.passFocusNode, onTextChange: controller.onPasswordChange, - onTextSubmitted: (_) => controller.handleLoginPressed(), + onTextSubmitted: (_) => controller.handleLoginPressed(context), ); case LoginFormType.baseUrlForm: return _buildUrlInput(context); diff --git a/lib/features/login/presentation/privacy_link_widget.dart b/lib/features/login/presentation/privacy_link_widget.dart index 5fe0b3c97e..9e6205140f 100644 --- a/lib/features/login/presentation/privacy_link_widget.dart +++ b/lib/features/login/presentation/privacy_link_widget.dart @@ -2,14 +2,13 @@ import 'package:core/presentation/extensions/color_extension.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; +import 'package:tmail_ui_user/main/utils/app_config.dart'; import 'package:tmail_ui_user/main/utils/app_utils.dart'; class PrivacyLinkWidget extends StatelessWidget { - static const linagoraPrivacy = 'https://www.linagora.com/en/legal/privacy'; - final String privacyUrlString; - const PrivacyLinkWidget({Key? key, this.privacyUrlString = linagoraPrivacy}) : super(key: key); + const PrivacyLinkWidget({Key? key, this.privacyUrlString = AppConfig.linagoraPrivacyUrl}) : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/features/mailbox/presentation/mailbox_controller.dart b/lib/features/mailbox/presentation/mailbox_controller.dart index 62b9027108..b0501879e4 100644 --- a/lib/features/mailbox/presentation/mailbox_controller.dart +++ b/lib/features/mailbox/presentation/mailbox_controller.dart @@ -460,6 +460,8 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM mailboxDashBoardController.goToComposer( ComposerArguments.fromMailtoUri( listEmailAddress: _navigationRouter?.listEmailAddress, + cc: _navigationRouter?.cc, + bcc: _navigationRouter?.bcc, subject: _navigationRouter?.subject, body: _navigationRouter?.body ) diff --git a/lib/features/mailbox/presentation/mailbox_view.dart b/lib/features/mailbox/presentation/mailbox_view.dart index 8c63db888f..c0fbf84c4b 100644 --- a/lib/features/mailbox/presentation/mailbox_view.dart +++ b/lib/features/mailbox/presentation/mailbox_view.dart @@ -192,7 +192,7 @@ class MailboxView extends BaseMailboxView { ); }), Obx(() => MailboxLoadingBarWidget(viewState: controller.viewState.value)), - AppConfig.appGridDashboardAvailable + AppConfig.appGridDashboardAvailable && !PlatformInfo.isMobile ? buildAppGridDashboard(context, controller.responsiveUtils, controller.imagePaths, controller) : const SizedBox.shrink(), const SizedBox(height: 8), 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 edef20674f..bd5ca0ee12 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -25,6 +25,7 @@ import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:jmap_dart_client/jmap/mail/vacation/vacation_response.dart'; import 'package:model/model.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:rxdart/transformers.dart'; import 'package:tmail_ui_user/features/base/action/ui_action.dart'; import 'package:tmail_ui_user/features/base/reloadable/reloadable_controller.dart'; @@ -241,11 +242,8 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo ComposerArguments? composerArguments; List? _identities; ScrollController? listSearchFilterScrollController; - - late StreamSubscription _emailAddressStreamSubscription; - late StreamSubscription _emailContentStreamSubscription; - late StreamSubscription _fileReceiveManagerStreamSubscription; - + StreamSubscription? _pendingSharedFileInfoSubscription; + StreamSubscription? _receivingFileSharingStreamSubscription; StreamSubscription? _currentEmailIdInNotificationIOSStreamSubscription; final StreamController> _progressStateController = @@ -288,6 +286,9 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo @override void onInit() { + if (PlatformInfo.isMobile) { + _registerReceivingFileSharingStream(); + } _registerStreamListener(); BackButtonInterceptor.add(_onBackButtonInterceptor, name: AppRoutes.dashboard); WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -301,9 +302,6 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo if (PlatformInfo.isWeb) { listSearchFilterScrollController = ScrollController(); } - _registerPendingEmailAddress(); - _registerPendingEmailContents(); - _registerPendingFileInfo(); if (PlatformInfo.isIOS) { _registerPendingCurrentEmailIdInNotification(); } @@ -454,31 +452,85 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo } } - void _registerPendingEmailAddress() { - _emailAddressStreamSubscription = - _emailReceiveManager.pendingEmailAddressInfo.stream.listen((emailAddress) { - if (emailAddress?.email?.isNotEmpty == true) { - goToComposer(ComposerArguments.fromEmailAddress(emailAddress!)); - } - }); - } + void _registerReceivingFileSharingStream() { + _receivingFileSharingStreamSubscription = _emailReceiveManager + .receivingFileSharingStream + .listen( + _emailReceiveManager.setPendingFileInfo, + onError: (err) { + logError('MailboxDashBoardController::_registerReceivingFileSharingStream::receivingFileSharingStream:Exception = $err'); + }, + ); - void _registerPendingEmailContents() { - _emailContentStreamSubscription = - _emailReceiveManager.pendingEmailContentInfo.stream.listen((emailContent) { - if (emailContent?.content.isNotEmpty == true) { - goToComposer(ComposerArguments.fromContentShared([emailContent!].asHtmlString)); - } - }); + _pendingSharedFileInfoSubscription = _emailReceiveManager + .pendingSharedFileInfo + .listen( + _handleReceivingFileSharing, + onError: (err) { + logError('MailboxDashBoardController::_registerReceivingFileSharingStream::pendingSharedFileInfo:Exception = $err'); + }, + ); } - void _registerPendingFileInfo() { - _fileReceiveManagerStreamSubscription = - _emailReceiveManager.pendingFileInfo.stream.listen((listFile) { - if (listFile.isNotEmpty) { - goToComposer(ComposerArguments.fromFileShared(listFile)); - } - }); + void _handleReceivingFileSharing(List listSharedMediaFile) { + log('MailboxDashBoardController::_handleReceivingFileSharing: LIST_LENGTH = ${listSharedMediaFile.length}'); + if (listSharedMediaFile.isEmpty) return; + + for (var file in listSharedMediaFile) { + log('MailboxDashBoardController::_handleReceivingFileSharing:SharedMediaFile = ${file.toMap()}'); + } + + if (listSharedMediaFile.length == 1) { + final sharedMediaFile = listSharedMediaFile.first; + if (sharedMediaFile.path.trim().isEmpty) return; + + switch (sharedMediaFile.type) { + case SharedMediaType.image: + case SharedMediaType.video: + case SharedMediaType.file: + goToComposer( + ComposerArguments.fromFileShared([sharedMediaFile]), + ); + break; + case SharedMediaType.text: + if (sharedMediaFile.mimeType == Constant.textVCardMimeType) { + goToComposer( + ComposerArguments.fromFileShared([sharedMediaFile]), + ); + } else if (sharedMediaFile.mimeType == Constant.textPlainMimeType) { + goToComposer( + ComposerArguments.fromContentShared(sharedMediaFile.path.trim()), + ); + } + break; + case SharedMediaType.url: + if (sharedMediaFile.path.startsWith(RouteUtils.mailtoPrefix)) { + final navigationRouter = RouteUtils.generateNavigationRouterFromMailtoLink(sharedMediaFile.path); + goToComposer( + ComposerArguments.fromMailtoUri( + listEmailAddress: navigationRouter.listEmailAddress, + subject: navigationRouter.subject, + body: navigationRouter.body, + ), + ); + } + break; + case SharedMediaType.mailto: + if (EmailUtils.isEmailAddressValid(sharedMediaFile.path)) { + goToComposer( + ComposerArguments.fromEmailAddress( + EmailAddress(null, sharedMediaFile.path), + ), + ); + } + break; + } + return; + } + + goToComposer( + ComposerArguments.fromFileShared(listSharedMediaFile), + ); } void _registerPendingCurrentEmailIdInNotification() { @@ -2861,14 +2913,15 @@ class MailboxDashBoardController extends ReloadableController with UserSettingPo if (PlatformInfo.isWeb) { listSearchFilterScrollController?.dispose(); } - _emailReceiveManager.closeEmailReceiveManagerStream(); if (PlatformInfo.isIOS) { _iosNotificationManager?.dispose(); _currentEmailIdInNotificationIOSStreamSubscription?.cancel(); } - _emailAddressStreamSubscription.cancel(); - _emailContentStreamSubscription.cancel(); - _fileReceiveManagerStreamSubscription.cancel(); + if (PlatformInfo.isMobile) { + _pendingSharedFileInfoSubscription?.cancel(); + _receivingFileSharingStreamSubscription?.cancel(); + _emailReceiveManager.closeEmailReceiveManagerStream(); + } _progressStateController.close(); _refreshActionEventController.close(); _notificationManager.closeStream(); diff --git a/lib/features/mailbox_dashboard/presentation/model/search/email_sort_order_type.dart b/lib/features/mailbox_dashboard/presentation/model/search/email_sort_order_type.dart index a781452b2b..23172312b2 100644 --- a/lib/features/mailbox_dashboard/presentation/model/search/email_sort_order_type.dart +++ b/lib/features/mailbox_dashboard/presentation/model/search/email_sort_order_type.dart @@ -15,21 +15,25 @@ enum EmailSortOrderType { subjectDescending; String getTitle(BuildContext context) { + return getTitleByAppLocalizations(AppLocalizations.of(context)); + } + + String getTitleByAppLocalizations(AppLocalizations appLocalizations) { switch (this) { case EmailSortOrderType.mostRecent: - return AppLocalizations.of(context).mostRecent; + return appLocalizations.mostRecent; case EmailSortOrderType.oldest: - return AppLocalizations.of(context).oldest; + return appLocalizations.oldest; case EmailSortOrderType.relevance: - return AppLocalizations.of(context).relevance; + return appLocalizations.relevance; case EmailSortOrderType.senderAscending: - return AppLocalizations.of(context).senderAscending; + return appLocalizations.senderAscending; case EmailSortOrderType.senderDescending: - return AppLocalizations.of(context).senderDescending; + return appLocalizations.senderDescending; case EmailSortOrderType.subjectAscending: - return AppLocalizations.of(context).subjectAscending; + return appLocalizations.subjectAscending; case EmailSortOrderType.subjectDescending: - return AppLocalizations.of(context).subjectDescending; + return appLocalizations.subjectDescending; } } diff --git a/lib/features/mailto/presentation/mailto_url_controller.dart b/lib/features/mailto/presentation/mailto_url_controller.dart index a999b7e19f..29d3e1f134 100644 --- a/lib/features/mailto/presentation/mailto_url_controller.dart +++ b/lib/features/mailto/presentation/mailto_url_controller.dart @@ -28,7 +28,7 @@ class MailtoUrlController extends ReloadableController { if (parameters.containsKey('uri')) { final mailtoArgument = MailtoArguments( session: session, - mailtoUri: parameters['uri'] + mailtoUri: Uri.base.toString(), ); popAndPush( RouteUtils.generateNavigationRoute(AppRoutes.dashboard), diff --git a/lib/features/search/email/presentation/search_email_view.dart b/lib/features/search/email/presentation/search_email_view.dart index 15d72ce66d..d90cdc3a4b 100644 --- a/lib/features/search/email/presentation/search_email_view.dart +++ b/lib/features/search/email/presentation/search_email_view.dart @@ -137,6 +137,7 @@ class SearchEmailView extends GetWidget onTap: () => controller.closeSearchView(context: context) ), Expanded(child: TextFieldBuilder( + key: const Key('search_email_text_field'), onTextChange: controller.onTextSearchChange, textInputAction: TextInputAction.search, controller: controller.textInputSearchController, @@ -190,6 +191,7 @@ class SearchEmailView extends GetWidget ), scrollController: controller.listSearchFilterScrollController, child: ListView( + key: const Key('search_filter_list_view'), scrollDirection: Axis.horizontal, shrinkWrap: true, controller: controller.listSearchFilterScrollController, @@ -375,7 +377,8 @@ class SearchEmailView extends GetWidget context, controller.emailSortOrderType.value, controller.selectSortOrderQuickSearchFilter - ) + ), + key: const Key('sort_filter_context_menu') ); } @@ -562,6 +565,7 @@ class SearchEmailView extends GetWidget List listSuggestionSearch ) { return ListView.builder( + key: const Key('suggestion_search_list_view'), shrinkWrap: true, primary: false, itemCount: listSuggestionSearch.length, @@ -618,6 +622,7 @@ class SearchEmailView extends GetWidget Widget _buildListEmailBody(BuildContext context, List listPresentationEmail) { return NotificationListener( + key: const Key('search_email_list_notification_listener'), onNotification: (ScrollNotification scrollInfo) { if (scrollInfo is ScrollEndNotification && controller.searchMoreState != SearchMoreState.waiting diff --git a/lib/features/starting_page/data/datasource/saas_authentication_datasource.dart b/lib/features/starting_page/data/datasource/saas_authentication_datasource.dart new file mode 100644 index 0000000000..93b31654b4 --- /dev/null +++ b/lib/features/starting_page/data/datasource/saas_authentication_datasource.dart @@ -0,0 +1,9 @@ + +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token_oidc.dart'; + +abstract class SaasAuthenticationDataSource { + Future signInTwakeWorkplace(OIDCConfiguration oidcConfiguration); + + Future signUpTwakeWorkplace(OIDCConfiguration oidcConfiguration); +} \ No newline at end of file diff --git a/lib/features/starting_page/data/datasource_impl/saas_authentication_datasource_impl.dart b/lib/features/starting_page/data/datasource_impl/saas_authentication_datasource_impl.dart new file mode 100644 index 0000000000..1d59ec59ec --- /dev/null +++ b/lib/features/starting_page/data/datasource_impl/saas_authentication_datasource_impl.dart @@ -0,0 +1,30 @@ +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token_oidc.dart'; +import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart'; +import 'package:tmail_ui_user/features/starting_page/data/datasource/saas_authentication_datasource.dart'; +import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart'; + +class SaasAuthenticationDataSourceImpl extends SaasAuthenticationDataSource { + + final AuthenticationClientBase _authenticationClient; + final ExceptionThrower _exceptionThrower; + + SaasAuthenticationDataSourceImpl( + this._authenticationClient, + this._exceptionThrower, + ); + + @override + Future signInTwakeWorkplace(OIDCConfiguration oidcConfiguration) { + return Future.sync(() async { + return await _authenticationClient.signInTwakeWorkplace(oidcConfiguration); + }).catchError(_exceptionThrower.throwException); + } + + @override + Future signUpTwakeWorkplace(OIDCConfiguration oidcConfiguration) { + return Future.sync(() async { + return await _authenticationClient.signUpTwakeWorkplace(oidcConfiguration); + }).catchError(_exceptionThrower.throwException); + } +} \ No newline at end of file diff --git a/lib/features/starting_page/data/repository/saas_authentication_repository_impl.dart b/lib/features/starting_page/data/repository/saas_authentication_repository_impl.dart new file mode 100644 index 0000000000..6ce779c8ac --- /dev/null +++ b/lib/features/starting_page/data/repository/saas_authentication_repository_impl.dart @@ -0,0 +1,21 @@ +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token_oidc.dart'; +import 'package:tmail_ui_user/features/starting_page/data/datasource/saas_authentication_datasource.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/repository/saas_authentication_repository.dart'; + +class SaasAuthenticationRepositoryImpl extends SaasAuthenticationRepository { + + final SaasAuthenticationDataSource _saasAuthenticationDataSource; + + SaasAuthenticationRepositoryImpl(this._saasAuthenticationDataSource); + + @override + Future signInTwakeWorkplace(OIDCConfiguration oidcConfiguration) { + return _saasAuthenticationDataSource.signInTwakeWorkplace(oidcConfiguration); + } + + @override + Future signUpTwakeWorkplace(OIDCConfiguration oidcConfiguration) { + return _saasAuthenticationDataSource.signUpTwakeWorkplace(oidcConfiguration); + } +} \ No newline at end of file diff --git a/lib/features/starting_page/domain/repository/saas_authentication_repository.dart b/lib/features/starting_page/domain/repository/saas_authentication_repository.dart new file mode 100644 index 0000000000..a59a1b0ff1 --- /dev/null +++ b/lib/features/starting_page/domain/repository/saas_authentication_repository.dart @@ -0,0 +1,8 @@ +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token_oidc.dart'; + +abstract class SaasAuthenticationRepository { + Future signInTwakeWorkplace(OIDCConfiguration oidcConfiguration); + + Future signUpTwakeWorkplace(OIDCConfiguration oidcConfiguration); +} \ No newline at end of file diff --git a/lib/features/starting_page/domain/state/sign_in_twake_workplace_state.dart b/lib/features/starting_page/domain/state/sign_in_twake_workplace_state.dart new file mode 100644 index 0000000000..eb8397575b --- /dev/null +++ b/lib/features/starting_page/domain/state/sign_in_twake_workplace_state.dart @@ -0,0 +1,21 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token_oidc.dart'; + +class SignInTwakeWorkplaceLoading extends LoadingState {} + +class SignInTwakeWorkplaceSuccess extends Success { + final TokenOIDC tokenOIDC; + final Uri baseUri; + final OIDCConfiguration oidcConfiguration; + + SignInTwakeWorkplaceSuccess(this.tokenOIDC, this.baseUri, this.oidcConfiguration); + + @override + List get props => [tokenOIDC, baseUri, oidcConfiguration]; +} + +class SignInTwakeWorkplaceFailure extends FeatureFailure { + SignInTwakeWorkplaceFailure(dynamic exception) : super(exception: exception); +} \ No newline at end of file diff --git a/lib/features/starting_page/domain/state/sign_up_twake_workplace_state.dart b/lib/features/starting_page/domain/state/sign_up_twake_workplace_state.dart new file mode 100644 index 0000000000..d1cce187a9 --- /dev/null +++ b/lib/features/starting_page/domain/state/sign_up_twake_workplace_state.dart @@ -0,0 +1,21 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token_oidc.dart'; + +class SignUpTwakeWorkplaceLoading extends LoadingState {} + +class SignUpTwakeWorkplaceSuccess extends Success { + final TokenOIDC tokenOIDC; + final Uri baseUri; + final OIDCConfiguration oidcConfiguration; + + SignUpTwakeWorkplaceSuccess(this.tokenOIDC, this.baseUri, this.oidcConfiguration); + + @override + List get props => [tokenOIDC, baseUri, oidcConfiguration]; +} + +class SignUpTwakeWorkplaceFailure extends FeatureFailure { + SignUpTwakeWorkplaceFailure(dynamic exception) : super(exception: exception); +} \ No newline at end of file diff --git a/lib/features/starting_page/domain/usecase/sign_in_twake_workplace_interactor.dart b/lib/features/starting_page/domain/usecase/sign_in_twake_workplace_interactor.dart new file mode 100644 index 0000000000..5c47df90d2 --- /dev/null +++ b/lib/features/starting_page/domain/usecase/sign_in_twake_workplace_interactor.dart @@ -0,0 +1,55 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:model/account/authentication_type.dart'; +import 'package:model/account/personal_account.dart'; +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token_oidc.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/credential_repository.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/repository/saas_authentication_repository.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/state/sign_in_twake_workplace_state.dart'; + +class SignInTwakeWorkplaceInteractor { + final SaasAuthenticationRepository _saasRepository; + final AuthenticationOIDCRepository _authenticationOIDCRepository; + final AccountRepository _accountRepository; + final CredentialRepository _credentialRepository; + + const SignInTwakeWorkplaceInteractor( + this._saasRepository, + this._authenticationOIDCRepository, + this._accountRepository, + this._credentialRepository + ); + + Stream> execute({ + required Uri baseUri, + required OIDCConfiguration oidcConfiguration + }) async* { + try { + yield Right(SignInTwakeWorkplaceLoading()); + + final tokenOIDC = await _saasRepository.signInTwakeWorkplace(oidcConfiguration); + + await Future.wait([ + _credentialRepository.saveBaseUrl(baseUri), + _authenticationOIDCRepository.persistTokenOIDC(tokenOIDC), + _authenticationOIDCRepository.persistAuthorityOidc(oidcConfiguration.authority), + ]); + + await _accountRepository.setCurrentAccount( + PersonalAccount( + tokenOIDC.tokenIdHash, + AuthenticationType.oidc, + isSelected: true + ) + ); + + yield Right(SignInTwakeWorkplaceSuccess(tokenOIDC, baseUri, oidcConfiguration)); + } catch (e) { + yield Left(SignInTwakeWorkplaceFailure(e)); + } + } +} \ No newline at end of file diff --git a/lib/features/starting_page/domain/usecase/sign_up_twake_workplace_interactor.dart b/lib/features/starting_page/domain/usecase/sign_up_twake_workplace_interactor.dart new file mode 100644 index 0000000000..1460fdf595 --- /dev/null +++ b/lib/features/starting_page/domain/usecase/sign_up_twake_workplace_interactor.dart @@ -0,0 +1,55 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:model/account/authentication_type.dart'; +import 'package:model/account/personal_account.dart'; +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token_oidc.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/credential_repository.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/repository/saas_authentication_repository.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/state/sign_up_twake_workplace_state.dart'; + +class SignUpTwakeWorkplaceInteractor { + final SaasAuthenticationRepository _saasRepository; + final AuthenticationOIDCRepository _authenticationOIDCRepository; + final AccountRepository _accountRepository; + final CredentialRepository _credentialRepository; + + const SignUpTwakeWorkplaceInteractor( + this._saasRepository, + this._authenticationOIDCRepository, + this._accountRepository, + this._credentialRepository + ); + + Stream> execute({ + required Uri baseUri, + required OIDCConfiguration oidcConfiguration + }) async* { + try { + yield Right(SignUpTwakeWorkplaceLoading()); + + final tokenOIDC = await _saasRepository.signUpTwakeWorkplace(oidcConfiguration); + + await Future.wait([ + _credentialRepository.saveBaseUrl(baseUri), + _authenticationOIDCRepository.persistTokenOIDC(tokenOIDC), + _authenticationOIDCRepository.persistAuthorityOidc(oidcConfiguration.authority), + ]); + + await _accountRepository.setCurrentAccount( + PersonalAccount( + tokenOIDC.tokenIdHash, + AuthenticationType.oidc, + isSelected: true + ) + ); + + yield Right(SignUpTwakeWorkplaceSuccess(tokenOIDC, baseUri, oidcConfiguration)); + } catch (e) { + yield Left(SignUpTwakeWorkplaceFailure(e)); + } + } +} \ No newline at end of file diff --git a/lib/features/starting_page/presentation/twake_welcome/twake_welcome_bindings.dart b/lib/features/starting_page/presentation/twake_welcome/twake_welcome_bindings.dart new file mode 100644 index 0000000000..e8f4955f40 --- /dev/null +++ b/lib/features/starting_page/presentation/twake_welcome/twake_welcome_bindings.dart @@ -0,0 +1,68 @@ +import 'package:get/get.dart'; +import 'package:tmail_ui_user/features/base/base_bindings.dart'; +import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/credential_repository.dart'; +import 'package:tmail_ui_user/features/starting_page/data/datasource/saas_authentication_datasource.dart'; +import 'package:tmail_ui_user/features/starting_page/data/datasource_impl/saas_authentication_datasource_impl.dart'; +import 'package:tmail_ui_user/features/starting_page/data/repository/saas_authentication_repository_impl.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/repository/saas_authentication_repository.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_in_twake_workplace_interactor.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_up_twake_workplace_interactor.dart'; +import 'package:tmail_ui_user/features/starting_page/presentation/twake_welcome/twake_welcome_controller.dart'; +import 'package:tmail_ui_user/main/exceptions/remote_exception_thrower.dart'; + +class TwakeWelcomeBindings extends BaseBindings { + + @override + void bindingsController() { + Get.lazyPut(() => TwakeWelcomeController( + Get.find(), + Get.find(), + )); + } + + @override + void bindingsInteractor() { + Get.lazyPut(() => SignInTwakeWorkplaceInteractor( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + )); + Get.lazyPut(() => SignUpTwakeWorkplaceInteractor( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + )); + } + + @override + void bindingsDataSourceImpl() { + Get.lazyPut(() => SaasAuthenticationDataSourceImpl( + Get.find(), + Get.find(), + )); + } + + @override + void bindingsDataSource() { + Get.lazyPut( + () => Get.find()); + } + + @override + void bindingsRepository() { + Get.lazyPut( + () => Get.find()); + } + + @override + void bindingsRepositoryImpl() { + Get.lazyPut(() => SaasAuthenticationRepositoryImpl( + Get.find(), + )); + } +} \ No newline at end of file diff --git a/lib/features/starting_page/presentation/twake_welcome/twake_welcome_controller.dart b/lib/features/starting_page/presentation/twake_welcome/twake_welcome_controller.dart new file mode 100644 index 0000000000..64b2e85958 --- /dev/null +++ b/lib/features/starting_page/presentation/twake_welcome/twake_welcome_controller.dart @@ -0,0 +1,160 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token_oidc.dart'; +import 'package:tip_dialog/tip_dialog.dart'; +import 'package:tmail_ui_user/features/base/reloadable/reloadable_controller.dart'; +import 'package:tmail_ui_user/features/home/domain/state/get_session_state.dart'; +import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart'; +import 'package:tmail_ui_user/features/login/domain/exceptions/authentication_exception.dart'; +import 'package:tmail_ui_user/features/login/presentation/login_form_type.dart'; +import 'package:tmail_ui_user/features/login/presentation/model/login_arguments.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/state/sign_in_twake_workplace_state.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/state/sign_up_twake_workplace_state.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_in_twake_workplace_interactor.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_up_twake_workplace_interactor.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; +import 'package:tmail_ui_user/main/routes/app_routes.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; +import 'package:tmail_ui_user/main/routes/route_utils.dart'; +import 'package:tmail_ui_user/main/utils/app_config.dart'; +import 'package:tmail_ui_user/main/utils/app_utils.dart'; + +class TwakeWelcomeController extends ReloadableController { + + final SignInTwakeWorkplaceInteractor _signInTwakeWorkplaceInteractor; + final SignUpTwakeWorkplaceInteractor _signUpTwakeWorkplaceInteractor; + + TwakeWelcomeController( + this._signInTwakeWorkplaceInteractor, + this._signUpTwakeWorkplaceInteractor, + ); + + void handleUseCompanyServer() { + popAndPush( + AppRoutes.login, + arguments: LoginArguments(LoginFormType.dnsLookupForm)); + } + + void onClickPrivacyPolicy() { + AppUtils.launchLink(AppConfig.linagoraPrivacyUrl); + } + + void onClickSignIn(BuildContext context) { + TipDialogHelper.loading(AppLocalizations.of(context).loadingPleaseWait); + + final baseUri = Uri.tryParse(AppConfig.saasJmapServerUrl); + + if (baseUri == null) { + consumeState(Stream.value(Left(SignInTwakeWorkplaceFailure(SaasServerUriIsNull())))); + return; + } + + consumeState(_signInTwakeWorkplaceInteractor.execute( + baseUri: baseUri, + oidcConfiguration: OIDCConfiguration( + authority: AppConfig.saasRegistrationUrl, + clientId: OIDCConstant.clientId, + scopes: AppConfig.oidcScopes + ) + )); + } + + void onSignUpTwakeWorkplace(BuildContext context) { + TipDialogHelper.loading(AppLocalizations.of(context).loadingPleaseWait); + + final baseUri = Uri.tryParse(AppConfig.saasJmapServerUrl); + + if (baseUri == null) { + consumeState(Stream.value(Left(SignUpTwakeWorkplaceFailure(SaasServerUriIsNull())))); + return; + } + + consumeState(_signUpTwakeWorkplaceInteractor.execute( + baseUri: baseUri, + oidcConfiguration: OIDCConfiguration( + authority: AppConfig.saasRegistrationUrl, + clientId: OIDCConstant.clientId, + scopes: AppConfig.oidcScopes + ) + )); + } + + @override + void handleSuccessViewState(Success success) { + if (success is SignInTwakeWorkplaceSuccess) { + _synchronizeTokenAndGetSession( + baseUri: success.baseUri, + tokenOIDC: success.tokenOIDC, + oidcConfiguration: success.oidcConfiguration, + ); + } else if (success is SignUpTwakeWorkplaceSuccess) { + _synchronizeTokenAndGetSession( + baseUri: success.baseUri, + tokenOIDC: success.tokenOIDC, + oidcConfiguration: success.oidcConfiguration, + ); + } else { + super.handleSuccessViewState(success); + } + } + + @override + void handleFailureViewState(Failure failure) { + if (failure is SignInTwakeWorkplaceFailure) { + _handleSignInTwakeWorkplaceFailure(failure); + } else if (failure is SignUpTwakeWorkplaceFailure) { + _handleSignUpTwakeWorkplaceFailure(failure); + } else { + super.handleFailureViewState(failure); + } + } + + @override + void handleReloaded(Session session) { + TipDialogHelper.dismiss(); + + popAndPush( + RouteUtils.generateNavigationRoute(AppRoutes.dashboard), + arguments: session); + } + + @override + void handleGetSessionFailure(GetSessionFailure failure) { + TipDialogHelper.dismiss(); + + toastManager.showMessageFailure(failure); + } + + void _synchronizeTokenAndGetSession({ + required Uri baseUri, + required TokenOIDC tokenOIDC, + required OIDCConfiguration oidcConfiguration, + }) { + dynamicUrlInterceptors.setJmapUrl(baseUri.toString()); + dynamicUrlInterceptors.changeBaseUrl(baseUri.toString()); + authorizationInterceptors.setTokenAndAuthorityOidc( + newToken: tokenOIDC, + newConfig: oidcConfiguration); + authorizationIsolateInterceptors.setTokenAndAuthorityOidc( + newToken: tokenOIDC, + newConfig: oidcConfiguration); + + getSessionAction(); + } + + void _handleSignInTwakeWorkplaceFailure(SignInTwakeWorkplaceFailure failure) { + TipDialogHelper.dismiss(); + + toastManager.showMessageFailure(failure); + } + + void _handleSignUpTwakeWorkplaceFailure(SignUpTwakeWorkplaceFailure failure) { + TipDialogHelper.dismiss(); + + toastManager.showMessageFailure(failure); + } +} \ No newline at end of file diff --git a/lib/features/starting_page/presentation/twake_welcome/twake_welcome_view.dart b/lib/features/starting_page/presentation/twake_welcome/twake_welcome_view.dart new file mode 100644 index 0000000000..c16bc67a94 --- /dev/null +++ b/lib/features/starting_page/presentation/twake_welcome/twake_welcome_view.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:linagora_design_flutter/linagora_design_flutter.dart'; +import 'package:tip_dialog/tip_dialog.dart'; +import 'package:tmail_ui_user/features/starting_page/presentation/twake_welcome/twake_welcome_controller.dart'; +import 'package:tmail_ui_user/features/starting_page/presentation/twake_welcome/twake_welcome_view_style.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; + +class TwakeWelcomeView extends GetWidget { + + const TwakeWelcomeView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown + ]); + + return Stack( + children: [ + TwakeWelcomeScreen( + logo: Padding( + padding: const EdgeInsetsDirectional.only(bottom: 16), + child: SvgPicture.asset( + controller.imagePaths.icLogoTwakeWelcome, + fit: BoxFit.fill, + ), + ), + focusColor: Colors.transparent, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + overlayColor: WidgetStateProperty.all(Colors.transparent), + signInTitle: AppLocalizations.of(context).signIn.capitalizeFirst ?? '', + onSignInOnTap: () => controller.onClickSignIn(context), + onCreateTwakeIdOnTap: () => controller.onSignUpTwakeWorkplace(context), + createTwakeIdTitle: AppLocalizations.of(context).createTwakeId, + useCompanyServerTitle: AppLocalizations.of(context).useCompanyServer, + description: AppLocalizations.of(context).descriptionWelcomeTo, + descriptionTextStyle: TwakeWelcomeViewStyle.descriptionTextStyle, + privacyPolicy: AppLocalizations.of(context).privacyPolicy, + descriptionPrivacyPolicy: AppLocalizations.of(context).byContinuingYouAreAgreeingToOur, + onPrivacyPolicyOnTap: controller.onClickPrivacyPolicy, + onUseCompanyServerOnTap: controller.handleUseCompanyServer, + ), + TipDialogContainer( + duration: const Duration(seconds: 2), + outsideTouchable: true, + onOutsideTouch: (tipDialog) { + if (tipDialog is TipDialog && tipDialog.type == TipDialogType.LOADING) { + TipDialogHelper.dismiss(); + } + } + ) + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/starting_page/presentation/twake_welcome/twake_welcome_view_style.dart b/lib/features/starting_page/presentation/twake_welcome/twake_welcome_view_style.dart new file mode 100644 index 0000000000..8de175e138 --- /dev/null +++ b/lib/features/starting_page/presentation/twake_welcome/twake_welcome_view_style.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:linagora_design_flutter/linagora_design_flutter.dart'; + +class TwakeWelcomeViewStyle { + static final TextStyle descriptionTextStyle = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: LinagoraSysColors.material().onSurface + ); +} diff --git a/lib/features/thread/data/extensions/list_email_extension.dart b/lib/features/thread/data/extensions/list_email_extension.dart index 9640dffee6..12aebbbb7f 100644 --- a/lib/features/thread/data/extensions/list_email_extension.dart +++ b/lib/features/thread/data/extensions/list_email_extension.dart @@ -1,5 +1,6 @@ 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/user_name.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:model/extensions/account_id_extensions.dart'; @@ -15,4 +16,26 @@ extension ListEmailExtension on List { TupleKey(email.id!.asString, accountId.asString, userName.value).encodeKey : email.toEmailCache() }; } + + List sortingByOrderOfIdList(List ids) { + if (ids.length != length) { + return this; + } + + sort((email1, email2) { + final id1 = email1.id?.id; + final id2 = email2.id?.id; + + if (id1 == null || id2 == null) { + return 0; + } + + final index1 = ids.indexWhere((id) => id == id1); + final index2 = ids.indexWhere((id) => id == id2); + + return index1.compareTo(index2); + }); + + return this; + } } \ No newline at end of file diff --git a/lib/features/thread/data/network/thread_api.dart b/lib/features/thread/data/network/thread_api.dart index 93cc0ff5c3..eb1573fa47 100644 --- a/lib/features/thread/data/network/thread_api.dart +++ b/lib/features/thread/data/network/thread_api.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:core/utils/app_logger.dart'; import 'package:jmap_dart_client/http/http_client.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/filter/filter.dart'; @@ -16,6 +17,9 @@ import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/get/get_email_method.dart'; import 'package:jmap_dart_client/jmap/mail/email/get/get_email_response.dart'; import 'package:jmap_dart_client/jmap/mail/email/query/query_email_method.dart'; +import 'package:jmap_dart_client/jmap/mail/email/query/query_email_response.dart'; +import 'package:model/extensions/list_email_extension.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/list_email_extension.dart'; import 'package:tmail_ui_user/features/thread/data/model/email_change_response.dart'; import 'package:tmail_ui_user/features/thread/domain/model/email_response.dart'; import 'package:tmail_ui_user/main/error/capability_validator.dart'; @@ -71,16 +75,32 @@ class ThreadAPI { .build() .execute(); - final resultList = result.parse( - getEmailInvocation.methodCallId, GetEmailResponse.deserialize); + final responseOfGetEmailMethod = result.parse( + getEmailInvocation.methodCallId, + GetEmailResponse.deserialize, + ); - if (sort != null && resultList != null) { - for (var comparator in sort) { - resultList.sortEmails(comparator); - } + final responseOfQueryEmailMethod = result.parse( + queryEmailInvocation.methodCallId, + QueryEmailResponse.deserialize, + ); + + List? emailList; + + if (responseOfGetEmailMethod?.list.isNotEmpty == true && + responseOfQueryEmailMethod?.ids.isNotEmpty == true) { + log('ThreadAPI::getAllEmail: QUERY_EMAIL_IDS = ${responseOfQueryEmailMethod?.ids}'); + final listSortedEmail = responseOfGetEmailMethod!.list + .sortingByOrderOfIdList(responseOfQueryEmailMethod!.ids.toList()); + emailList = listSortedEmail; + } else { + emailList = responseOfGetEmailMethod?.list; } - - return EmailsResponse(emailList: resultList?.list, state: resultList?.state); + log('ThreadAPI::getAllEmail: EMAIL_DISPLAYED_IDS = ${emailList?.listEmailIds}'); + return EmailsResponse( + emailList: emailList, + state: responseOfGetEmailMethod?.state, + ); } Future getChanges( diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index ea19f96ad1..8d54fea6f0 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -4047,5 +4047,35 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "descriptionWelcomeTo": "The new Open Source standard for\n secure professional e-mail", + "@descriptionWelcomeTo": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "createTwakeId": "Create Twake ID", + "@createTwakeId": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "useCompanyServer": "Use company server", + "@useCompanyServer": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "sigInSaasFailed": "Login failed. Please check again.", + "@sigInSaasFailed": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "createTwakeIdFailed": "Create Twake Id failed. Please check again.", + "@createTwakeIdFailed": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 2016d9272a..bf48809f06 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -4252,4 +4252,39 @@ class AppLocalizations { name: 'thisFieldCannotContainOnlySpaces', ); } + + String get descriptionWelcomeTo { + return Intl.message( + 'The new Open Source standard for\n secure professional e-mail', + name: 'descriptionWelcomeTo', + ); + } + + String get createTwakeId { + return Intl.message( + 'Create Twake ID', + name: 'createTwakeId', + ); + } + + String get useCompanyServer { + return Intl.message( + 'Use company server', + name: 'useCompanyServer', + ); + } + + String get sigInSaasFailed { + return Intl.message( + 'Login failed. Please check again.', + name: 'sigInSaasFailed', + ); + } + + String get createTwakeIdFailed { + return Intl.message( + 'Create Twake Id failed. Please check again.', + name: 'createTwakeIdFailed', + ); + } } diff --git a/lib/main/pages/app_pages.dart b/lib/main/pages/app_pages.dart index 83e23838f0..61cb38e514 100644 --- a/lib/main/pages/app_pages.dart +++ b/lib/main/pages/app_pages.dart @@ -28,6 +28,8 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/mailbox_da import 'package:tmail_ui_user/features/manage_account/presentation/manage_account_dashboard_bindings.dart'; import 'package:tmail_ui_user/features/manage_account/presentation/manage_account_dashboard_view.dart' deferred as manage_account_dashboard; import 'package:tmail_ui_user/features/search/mailbox/presentation/search_mailbox_bindings.dart'; +import 'package:tmail_ui_user/features/starting_page/presentation/twake_welcome/twake_welcome_bindings.dart'; +import 'package:tmail_ui_user/features/starting_page/presentation/twake_welcome/twake_welcome_view.dart'; import 'package:tmail_ui_user/features/unknown_route_page/unknown_route_page_view.dart'; import 'package:tmail_ui_user/main/pages/deferred_widget.dart'; import 'package:tmail_ui_user/main/routes/app_routes.dart'; @@ -83,6 +85,10 @@ class AppPages { unknownRoutePage, if (PlatformInfo.isMobile) ...[ + GetPage( + name: AppRoutes.twakeWelcome, + page: () => const TwakeWelcomeView(), + binding: TwakeWelcomeBindings()), GetPage( name: AppRoutes.composer, page: () => DeferredWidget( diff --git a/lib/main/routes/app_routes.dart b/lib/main/routes/app_routes.dart index 0b682d202c..9ae2e98ee8 100644 --- a/lib/main/routes/app_routes.dart +++ b/lib/main/routes/app_routes.dart @@ -1,5 +1,6 @@ abstract class AppRoutes { static const home = '/'; + static const twakeWelcome = '/twake_welcome'; static const login = '/login'; static const dashboard = '/dashboard'; static const dashboardWithParameter = '/dashboard/:id'; diff --git a/lib/main/routes/navigation_router.dart b/lib/main/routes/navigation_router.dart index 50c120b6b6..9d43beeaff 100644 --- a/lib/main/routes/navigation_router.dart +++ b/lib/main/routes/navigation_router.dart @@ -21,6 +21,8 @@ class NavigationRouter with EquatableMixin { final String? subject; final String? body; final AccountMenuItem accountMenuItem; + final List? cc; + final List? bcc; NavigationRouter({ this.emailId, @@ -32,6 +34,8 @@ class NavigationRouter with EquatableMixin { this.subject, this.body, this.accountMenuItem = AccountMenuItem.none, + this.cc, + this.bcc, }); factory NavigationRouter.initial() => NavigationRouter(); @@ -47,5 +51,7 @@ class NavigationRouter with EquatableMixin { subject, body, accountMenuItem, + cc, + bcc, ]; } \ No newline at end of file diff --git a/lib/main/routes/route_utils.dart b/lib/main/routes/route_utils.dart index 3f4e03f1e3..28e25356ea 100644 --- a/lib/main/routes/route_utils.dart +++ b/lib/main/routes/route_utils.dart @@ -25,8 +25,12 @@ abstract class RouteUtils { static const String paramMailtoAddress = 'mailtoAddress'; static const String paramSubject = 'subject'; static const String paramBody = 'body'; + static const String paramTo = 'to'; + static const String paramCc = 'cc'; + static const String paramBcc = 'bcc'; - static const String mailtoPrefix = 'mailto:'; + static const String mailtoPrefix = 'mailto'; + static const String uriPrefix = 'uri'; static const String ADDRESS_SEPARATOR = ','; static const String INVALID_VALUE = 'invalid'; @@ -107,6 +111,8 @@ abstract class RouteUtils { final queryParam = parameters[paramQuery]; final routeName = parameters[paramRouteName]; final mailtoAddress = parameters[paramMailtoAddress]; + final mailtoCc = parameters[paramCc]; + final mailtoBcc = parameters[paramBcc]; final subject = parameters[paramSubject]; final body = parameters[paramBody]; @@ -116,18 +122,11 @@ abstract class RouteUtils { final dashboardType = DashboardType.values.firstWhereOrNull((type) => type.name == typeParam) ?? DashboardType.normal; final settingType = AccountMenuItem.values.firstWhereOrNull((type) => type.getAliasBrowser() == typeParam) ?? AccountMenuItem.none; List? listEmailAddress; - if (mailtoAddress is List) { - listEmailAddress = mailtoAddress - .map((address) => EmailAddress( - null, - GetUtils.isEmail(address) ? address : INVALID_VALUE - )) - .toList(); - } else if (mailtoAddress is String) { - listEmailAddress = [ - EmailAddress(null, GetUtils.isEmail(mailtoAddress) ? mailtoAddress : INVALID_VALUE) - ]; - } + List? cc; + List? bcc; + listEmailAddress = _emailAddressesFromMailtoAddress(mailtoAddress); + cc = _emailAddressesFromMailtoAddress(mailtoCc); + bcc = _emailAddressesFromMailtoAddress(mailtoBcc); log('RouteUtils::parsingRouteParametersToNavigationRouter:listEmailAddress = $listEmailAddress'); return NavigationRouter( emailId: emailId, @@ -136,12 +135,27 @@ abstract class RouteUtils { dashboardType: dashboardType, routeName: routeName, listEmailAddress: listEmailAddress, + cc: cc, + bcc: bcc, subject: subject, body: body, accountMenuItem: settingType, ); } + static List? _emailAddressesFromMailtoAddress(dynamic mailtoAddress) { + if (mailtoAddress is List) { + return mailtoAddress + .map((address) => EmailAddress(null, address)) + .toList(); + } else if (mailtoAddress is String) { + return [ + EmailAddress(null, mailtoAddress) + ]; + } + return null; + } + static void replaceBrowserHistory({required String title, required Uri url}) { log('RouteUtils::replaceBrowserHistory(): title: $title | url: $url'); html.window.history.replaceState(null, title, url.toString()); @@ -152,37 +166,59 @@ abstract class RouteUtils { final mapMailto = { RouteUtils.paramRouteName: AppRoutes.mailtoURL, }; - if (mailtoUri?.startsWith(mailtoPrefix) == true) { - final mailtoUrlDecoded = Uri.decodeFull(mailtoUri!); - log('RouteUtils::parseMapMailtoFromUri:mailtoUrlDecoded = $mailtoUrlDecoded'); - final uri = Uri.tryParse(mailtoUrlDecoded); - if (uri == null) return mapMailto; - - final mailtoAddress = uri.path; - final mapQueryParam = uri.queryParameters; - - if (mailtoAddress.contains(ADDRESS_SEPARATOR)) { - final listAddress = mailtoAddress.split(ADDRESS_SEPARATOR); - log('RouteUtils::parseMapMailtoFromUri:listAddress = $listAddress'); - mapMailto[paramMailtoAddress] = listAddress; - } else { - log('RouteUtils::parseMapMailtoFromUri:mailtoAddress = $mailtoAddress'); - mapMailto[paramMailtoAddress] = mailtoAddress; - } - if (mapQueryParam.containsKey(paramSubject)) { - mapMailto[paramSubject] = mapQueryParam[paramSubject]; - } - if (mapQueryParam.containsKey(paramBody)) { - mapMailto[paramBody] = mapQueryParam[paramBody]; - } - } else if (mailtoUri != null) { - final mailtoUrlDecoded = Uri.decodeFull(mailtoUri); - log('RouteUtils::parseMapMailtoFromUri:mailtoUrlDecoded = $mailtoUrlDecoded'); - mapMailto[paramMailtoAddress] = mailtoUrlDecoded; + mailtoUri = mailtoUri == null ? null : Uri.decodeFull(mailtoUri); + final parsedMailToUri = Uri.tryParse(mailtoUri ?? ''); + + if (parsedMailToUri?.scheme == mailtoPrefix) { + final to = { + ...?parsedMailToUri?.path.split(ADDRESS_SEPARATOR), + ...?parsedMailToUri?.queryParameters[paramTo]?.split(ADDRESS_SEPARATOR) + }.toList(); + final cc = { + ...?parsedMailToUri?.queryParameters[paramCc]?.split(ADDRESS_SEPARATOR), + }.toList(); + final bcc = { + ...?parsedMailToUri?.queryParameters[paramBcc]?.split(ADDRESS_SEPARATOR), + }.toList(); + final subject = parsedMailToUri?.queryParameters[paramSubject]; + final body = parsedMailToUri?.queryParameters[paramBody]; + mapMailto[paramMailtoAddress] = to; + mapMailto[paramCc] = cc; + mapMailto[paramBcc] = bcc; + mapMailto[paramSubject] = subject; + mapMailto[paramBody] = body; + } else if (parsedMailToUri?.path == "/$mailtoPrefix" || parsedMailToUri?.path == "/$mailtoPrefix/") { + final to = { + ...?parsedMailToUri?.queryParameters[uriPrefix]?.split('$mailtoPrefix:').last.split(ADDRESS_SEPARATOR), + ...?parsedMailToUri?.queryParameters[paramTo]?.split(ADDRESS_SEPARATOR) + }.toList(); + final cc = { + ...?parsedMailToUri?.queryParameters[paramCc]?.split(ADDRESS_SEPARATOR), + }.toList(); + final bcc = { + ...?parsedMailToUri?.queryParameters[paramBcc]?.split(ADDRESS_SEPARATOR), + }.toList(); + final subject = parsedMailToUri?.queryParameters[paramSubject]; + final body = parsedMailToUri?.queryParameters[paramBody]; + mapMailto[paramMailtoAddress] = to; + mapMailto[paramCc] = cc; + mapMailto[paramBcc] = bcc; + mapMailto[paramSubject] = subject; + mapMailto[paramBody] = body; } else { - mapMailto[paramMailtoAddress] = mailtoUri; + mapMailto[paramMailtoAddress] = mailtoUri?.split(ADDRESS_SEPARATOR); } - log('RouteUtils::parseMapMailtoFromUri:mapMailto: $mapMailto'); + + if (mapMailto[paramMailtoAddress]?.length == 1) { + mapMailto[paramMailtoAddress] = mapMailto[paramMailtoAddress].first; + } + + log('RouteUtils::parseMapMailtoFromUri:paramMailtoAddress = ${mapMailto[paramMailtoAddress]}'); + log('RouteUtils::parseMapMailtoFromUri:paramCc = ${mapMailto[paramCc]}'); + log('RouteUtils::parseMapMailtoFromUri:paramBcc = ${mapMailto[paramBcc]}'); + log('RouteUtils::parseMapMailtoFromUri:paramSubject = ${mapMailto[paramSubject]}'); + log('RouteUtils::parseMapMailtoFromUri:paramBody = ${mapMailto[paramBody]}'); + return mapMailto; } @@ -192,4 +228,12 @@ abstract class RouteUtils { log('RouteUtils::generateNavigationRouterFromMailtoLink:navigationRouter: $navigationRouter'); return navigationRouter; } + + static bool canOpenComposerFromNavigationRouter(NavigationRouter navigationRouter) { + return navigationRouter.listEmailAddress?.isNotEmpty == true + || navigationRouter.cc?.isNotEmpty == true + || navigationRouter.bcc?.isNotEmpty == true + || navigationRouter.subject?.isNotEmpty == true + || navigationRouter.body?.isNotEmpty == true; + } } \ No newline at end of file diff --git a/lib/main/utils/app_config.dart b/lib/main/utils/app_config.dart index 2abac88f85..ceb41963ad 100644 --- a/lib/main/utils/app_config.dart +++ b/lib/main/utils/app_config.dart @@ -15,6 +15,9 @@ class AppConfig { static const String iOSKeychainSharingGroupId = 'KUT463DS29.com.linagora.ios.teammail.shared'; static const String iOSKeychainSharingService = 'com.linagora.ios.teammail.sessions'; static const String saasPlatform = 'saas'; + static const String linagoraPrivacyUrl = 'https://www.linagora.com/en/legal/privacy'; + static const String saasRegistrationUrl = 'https://sign-up.stg.lin-saas.com'; + static const String saasJmapServerUrl = 'https://jmap.stg.lin-saas.com'; static String get baseUrl => dotenv.get('SERVER_URL', fallback: ''); static String get domainRedirectUrl => dotenv.get('DOMAIN_REDIRECT_URL', fallback: ''); diff --git a/lib/main/utils/email_receive_manager.dart b/lib/main/utils/email_receive_manager.dart index 2d2c84c6c2..32beb62929 100644 --- a/lib/main/utils/email_receive_manager.dart +++ b/lib/main/utils/email_receive_manager.dart @@ -1,75 +1,38 @@ -import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; -import 'package:model/email/email_content.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:rxdart/rxdart.dart'; class EmailReceiveManager { + BehaviorSubject> _pendingSharedFileInfo = + BehaviorSubject.seeded(List.empty(growable: true)); + BehaviorSubject> get pendingSharedFileInfo => + _pendingSharedFileInfo; - BehaviorSubject _pendingEmailAddressInfo = BehaviorSubject.seeded(null); - BehaviorSubject get pendingEmailAddressInfo => _pendingEmailAddressInfo; + Stream> get receivingFileSharingStream => + ReceiveSharingIntent.instance.getMediaStream(); - BehaviorSubject _pendingEmailContentInfo = BehaviorSubject.seeded(null); - BehaviorSubject get pendingEmailContentInfo => _pendingEmailContentInfo; + void registerReceivingFileSharingStreamWhileAppClosed() { + ReceiveSharingIntent.instance.getInitialMedia().then((value) { + setPendingFileInfo(value); - BehaviorSubject> _pendingFileInfo = BehaviorSubject.seeded(List.empty(growable: true)); - BehaviorSubject> get pendingFileInfo => _pendingFileInfo; - - Stream get receivingSharingStream { - return Rx.merge([ - Stream.fromFuture(ReceiveSharingIntent.getInitialTextAsUri()), - ReceiveSharingIntent.getTextStreamAsUri() - ]); - } - Stream> get receivingFileSharingStream { - return Rx.merge([ - Stream.fromFuture(ReceiveSharingIntent.getInitialMedia()), - ReceiveSharingIntent.getMediaStream() - ]); - } - - void setPendingEmailAddress(EmailAddress emailAddress) async { - _clearPendingEmailAddress(); - _pendingEmailAddressInfo.add(emailAddress); - } - - void setPendingEmailContent(EmailContent emailContent) async { - _clearPendingEmailContent(); - _pendingEmailContentInfo.add(emailContent); - } - - void _clearPendingEmailContent() { - if (_pendingEmailContentInfo.isClosed) { - _pendingEmailContentInfo = BehaviorSubject.seeded(null); - } else { - _pendingEmailContentInfo.add(null); - } - } - - void _clearPendingEmailAddress() { - if(_pendingEmailAddressInfo.isClosed) { - _pendingEmailAddressInfo = BehaviorSubject.seeded(null); - } else { - _pendingEmailAddressInfo.add(null); - } + ReceiveSharingIntent.instance.reset(); + }); } void closeEmailReceiveManagerStream() { - _pendingEmailAddressInfo.close(); - _pendingEmailContentInfo.close(); - _pendingFileInfo.close(); + _pendingSharedFileInfo.close(); } - void setPendingFileInfo(List list) async { + void setPendingFileInfo(List list) { _clearPendingFileInfo(); - _pendingFileInfo.add(list); + _pendingSharedFileInfo.add(list); } void _clearPendingFileInfo() { - if(_pendingFileInfo.isClosed) { - _pendingFileInfo = BehaviorSubject.seeded(List.empty(growable: true)); + if(_pendingSharedFileInfo.isClosed) { + _pendingSharedFileInfo = BehaviorSubject.seeded(List.empty(growable: true)); } else { - _pendingFileInfo.add(List.empty(growable: true)); + _pendingSharedFileInfo.add(List.empty(growable: true)); } } } \ No newline at end of file diff --git a/lib/main/utils/toast_manager.dart b/lib/main/utils/toast_manager.dart index 03a6317c9a..595b20f6a4 100644 --- a/lib/main/utils/toast_manager.dart +++ b/lib/main/utils/toast_manager.dart @@ -2,12 +2,15 @@ import 'package:core/presentation/state/failure.dart'; import 'package:core/presentation/utils/app_toast.dart'; import 'package:core/utils/app_logger.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:model/email/email_action_type.dart'; import 'package:tmail_ui_user/features/email/domain/model/move_action.dart'; import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; import 'package:tmail_ui_user/features/home/domain/state/get_session_state.dart'; import 'package:tmail_ui_user/features/login/data/network/oidc_error.dart'; import 'package:tmail_ui_user/features/login/domain/exceptions/authentication_exception.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/state/sign_in_twake_workplace_state.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/state/sign_up_twake_workplace_state.dart'; import 'package:tmail_ui_user/features/thread/domain/state/empty_spam_folder_state.dart'; import 'package:tmail_ui_user/features/thread/domain/state/move_multiple_email_to_mailbox_state.dart'; import 'package:tmail_ui_user/main/exceptions/remote_exception.dart'; @@ -64,6 +67,20 @@ class ToastManager { && failure.emailActionType == EmailActionType.moveToSpam && failure.moveAction == MoveAction.moving) { message = AppLocalizations.of(currentContext!).markAsSpamFailed; + } else if (failure is SignInTwakeWorkplaceFailure) { + final exception = failure.exception; + if (exception is PlatformException && exception.message?.isNotEmpty == true) { + message = exception.message; + } else { + message = AppLocalizations.of(currentContext!).sigInSaasFailed; + } + } else if (failure is SignUpTwakeWorkplaceFailure) { + final exception = failure.exception; + if (exception is PlatformException && exception.message?.isNotEmpty == true) { + message = exception.message; + } else { + message = AppLocalizations.of(currentContext!).createTwakeIdFailed; + } } if (message?.isNotEmpty == true) { diff --git a/model/lib/exceptions/token_oidc_exceptions.dart b/model/lib/exceptions/token_oidc_exceptions.dart new file mode 100644 index 0000000000..cbef3761e9 --- /dev/null +++ b/model/lib/exceptions/token_oidc_exceptions.dart @@ -0,0 +1,7 @@ +class AccessTokenIsNullException implements Exception {} + +class RefreshTokenIsNullException implements Exception {} + +class TokenIdIsNullException implements Exception {} + +class ExpiresTimeIsNullException implements Exception {} diff --git a/model/lib/oidc/oidc_configuration.dart b/model/lib/oidc/oidc_configuration.dart index ebec65a3dc..37732695f0 100644 --- a/model/lib/oidc/oidc_configuration.dart +++ b/model/lib/oidc/oidc_configuration.dart @@ -25,8 +25,6 @@ class OIDCConfiguration with EquatableMixin { } } - String get clientIdHash => clientId.hashCode.toString(); - @override List get props => [ authority, diff --git a/model/lib/oidc/token_oidc.dart b/model/lib/oidc/token_oidc.dart index 05afa3448e..dd853dcea2 100644 --- a/model/lib/oidc/token_oidc.dart +++ b/model/lib/oidc/token_oidc.dart @@ -2,6 +2,7 @@ import 'package:core/utils/app_logger.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:model/exceptions/token_oidc_exceptions.dart'; import 'package:model/oidc/converter/token_id_converter.dart'; import 'package:model/oidc/token_id.dart'; @@ -27,6 +28,31 @@ class TokenOIDC with EquatableMixin { Map toJson() => _$TokenOIDCToJson(this); + factory TokenOIDC.fromUri(String uriString) { + final uri = Uri.parse(uriString); + final queryParams = uri.queryParameters; + + final accessToken = queryParams['access_token']; + if (accessToken == null || accessToken.isEmpty) { + throw AccessTokenIsNullException(); + } + + final refreshToken = queryParams['refresh_token'] ?? ''; + final idToken = queryParams['id_token'] ?? ''; + final expiresIn = queryParams['expires_in']; + + final expiredTime = expiresIn == null + ? null + : DateTime.now().add(Duration(seconds: int.parse(expiresIn))); + + return TokenOIDC( + accessToken, + TokenId(idToken), + refreshToken, + expiredTime: expiredTime, + ); + } + @override List get props => [token, tokenId, expiredTime, refreshToken]; } diff --git a/model/test/oidc/token_oidc_test.dart b/model/test/oidc/token_oidc_test.dart new file mode 100644 index 0000000000..ce41efaa50 --- /dev/null +++ b/model/test/oidc/token_oidc_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:model/exceptions/token_oidc_exceptions.dart'; +import 'package:model/oidc/token_oidc.dart'; + +void main() { + group('TokenOIDC.fromUrl', () { + test('returns TokenOIDC instance on successful parsing', () { + const uriString = 'https://example.com/callback?access_token=valid_access&refresh_token=valid_refresh&id_token=valid_id&expires_in=3600'; + final token = TokenOIDC.fromUri(uriString); + + expect(token.token, 'valid_access'); + expect(token.refreshToken, 'valid_refresh'); + expect(token.tokenId.uuid, 'valid_id'); + expect(token.expiredTime?.isAfter(DateTime.now()), isTrue); + }); + + test('throws AccessTokenIsNullException if access_token is missing', () { + const uriString = 'https://example.com/callback?refresh_token=valid_refresh&id_token=valid_id&expires_in=3600'; + + expect(() => TokenOIDC.fromUri(uriString), throwsA(isA())); + }); + + test('throws FormatException on invalid URL format', () { + const uriString = '::Not valid URI::'; + + expect(() => TokenOIDC.fromUri(uriString), throwsA(isA())); + }); + }); +} + diff --git a/provisioning/integration_test/search_email_with_sort_order/eml/0.eml b/provisioning/integration_test/search_email_with_sort_order/eml/0.eml new file mode 100644 index 0000000000..f9061eec4f --- /dev/null +++ b/provisioning/integration_test/search_email_with_sort_order/eml/0.eml @@ -0,0 +1,40 @@ +Return-Path: +MIME-Version: 1.0 +References: +Subject: Alice send Bob +From: alice@example.com +To: "bob@example.com" +Reply-To: alice@example.com +Date: Tue, 29 Oct 2024 04:13:50 +0000 +Message-ID: +User-Agent: Twake-Mail/0.13.2 Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; + rv:131.0) Gecko/20100101 Firefox/131.0 +Content-Type: multipart/alternative; + boundary="-=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=-" + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=- +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Accept-Language: fr-FR, en-US, vi-VN, ru-RU, ar-TN, it-IT +Content-Language: en-US + +Hello Bob. + +Invite you in to a meeting: Event OPEN TECH TALK: INNOVATION FOR THE TWAKE WORKPLACE + +Our 3rd Open Tech Talk in 2024! + + ► Topic: "Enhancing Multi-Tenancy Security" + ► Topic: "Bringing Desktop Synchronization into TDrive" + ► Topic: "Websocket: Real-time Update + +We are waiting for you! + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=- +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Accept-Language: fr-FR, en-US, vi-VN, ru-RU, ar-TN, it-IT +Content-Language: en-US + + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=--- diff --git a/provisioning/integration_test/search_email_with_sort_order/eml/1.eml b/provisioning/integration_test/search_email_with_sort_order/eml/1.eml new file mode 100644 index 0000000000..4586e5425b --- /dev/null +++ b/provisioning/integration_test/search_email_with_sort_order/eml/1.eml @@ -0,0 +1,40 @@ +Return-Path: +MIME-Version: 1.0 +References: +Subject: Brian send Bob +From: brian@example.com +To: "bob@example.com" +Reply-To: brian@example.com +Date: Wed, 30 Oct 2024 08:13:50 +0000 +Message-ID: +User-Agent: Twake-Mail/0.13.2 Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; + rv:131.0) Gecko/20100101 Firefox/131.0 +Content-Type: multipart/alternative; + boundary="-=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=-" + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=- +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Accept-Language: fr-FR, en-US, vi-VN, ru-RU, ar-TN, it-IT +Content-Language: en-US + +Hello Bob. + +Invite you in to a meeting: Event OPEN TECH TALK: INNOVATION FOR THE TWAKE WORKPLACE + +Our 3rd Open Tech Talk in 2024! + + ► Topic: "Enhancing Multi-Tenancy Security" + ► Topic: "Bringing Desktop Synchronization into TDrive" + ► Topic: "Websocket: Real-time Update + +We are waiting for you! + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=- +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Accept-Language: fr-FR, en-US, vi-VN, ru-RU, ar-TN, it-IT +Content-Language: en-US + + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=--- diff --git a/provisioning/integration_test/search_email_with_sort_order/eml/2.eml b/provisioning/integration_test/search_email_with_sort_order/eml/2.eml new file mode 100644 index 0000000000..f71f64a248 --- /dev/null +++ b/provisioning/integration_test/search_email_with_sort_order/eml/2.eml @@ -0,0 +1,40 @@ +Return-Path: +MIME-Version: 1.0 +References: +Subject: Charlotte send Bob +From: charlotte@example.com +To: "bob@example.com" +Reply-To: charlotte@example.com +Date: Thu, 31 Oct 2024 12:13:50 +0000 +Message-ID: +User-Agent: Twake-Mail/0.13.2 Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; + rv:131.0) Gecko/20100101 Firefox/131.0 +Content-Type: multipart/alternative; + boundary="-=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=-" + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=- +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Accept-Language: fr-FR, en-US, vi-VN, ru-RU, ar-TN, it-IT +Content-Language: en-US + +Hello Bob. + +Invite you in to a meeting: Event OPEN TECH TALK: INNOVATION FOR THE TWAKE WORKPLACE + +Our 3rd Open Tech Talk in 2024! + + ► Topic: "Enhancing Multi-Tenancy Security" + ► Topic: "Bringing Desktop Synchronization into TDrive" + ► Topic: "Websocket: Real-time Update + +We are waiting for you! + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=- +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Accept-Language: fr-FR, en-US, vi-VN, ru-RU, ar-TN, it-IT +Content-Language: en-US + + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=--- diff --git a/provisioning/integration_test/search_email_with_sort_order/eml/3.eml b/provisioning/integration_test/search_email_with_sort_order/eml/3.eml new file mode 100644 index 0000000000..23282777d0 --- /dev/null +++ b/provisioning/integration_test/search_email_with_sort_order/eml/3.eml @@ -0,0 +1,40 @@ +Return-Path: +MIME-Version: 1.0 +References: +Subject: David send Bob +From: david@example.com +To: "bob@example.com" +Reply-To: david@example.com +Date: Fri, 1 Nov 2024 07:13:50 +0000 +Message-ID: +User-Agent: Twake-Mail/0.13.2 Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; + rv:131.0) Gecko/20100101 Firefox/131.0 +Content-Type: multipart/alternative; + boundary="-=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=-" + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=- +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Accept-Language: fr-FR, en-US, vi-VN, ru-RU, ar-TN, it-IT +Content-Language: en-US + +Hello Bob. + +Invite you in to a meeting: Event OPEN TECH TALK: INNOVATION FOR THE TWAKE WORKPLACE + +Our 3rd Open Tech Talk in 2024! + + ► Topic: "Enhancing Multi-Tenancy Security" + ► Topic: "Bringing Desktop Synchronization into TDrive" + ► Topic: "Websocket: Real-time Update + +We are waiting for you! + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=- +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Accept-Language: fr-FR, en-US, vi-VN, ru-RU, ar-TN, it-IT +Content-Language: en-US + + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=--- diff --git a/provisioning/integration_test/search_email_with_sort_order/eml/4.eml b/provisioning/integration_test/search_email_with_sort_order/eml/4.eml new file mode 100644 index 0000000000..815535ee8e --- /dev/null +++ b/provisioning/integration_test/search_email_with_sort_order/eml/4.eml @@ -0,0 +1,40 @@ +Return-Path: +MIME-Version: 1.0 +References: +Subject: Emma send Bob +From: emma@example.com +To: "bob@example.com" +Reply-To: emma@example.com +Date: Sat, 2 Nov 2024 13:13:50 +0000 +Message-ID: +User-Agent: Twake-Mail/0.13.2 Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; + rv:131.0) Gecko/20100101 Firefox/131.0 +Content-Type: multipart/alternative; + boundary="-=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=-" + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=- +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Accept-Language: fr-FR, en-US, vi-VN, ru-RU, ar-TN, it-IT +Content-Language: en-US + +Hello Bob. + +Invite you in to a meeting: Event OPEN TECH TALK: INNOVATION FOR THE TWAKE WORKPLACE + +Our 3rd Open Tech Talk in 2024! + + ► Topic: "Enhancing Multi-Tenancy Security" + ► Topic: "Bringing Desktop Synchronization into TDrive" + ► Topic: "Websocket: Real-time Update + +We are waiting for you! + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=- +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Accept-Language: fr-FR, en-US, vi-VN, ru-RU, ar-TN, it-IT +Content-Language: en-US + + +---=Part.4f.450e1cfbcd355387.192d67ae03d.c367d8042fea24dd=--- diff --git a/provisioning/integration_test/search_email_with_sort_order/provisioning.sh b/provisioning/integration_test/search_email_with_sort_order/provisioning.sh new file mode 100755 index 0000000000..e09ff2200f --- /dev/null +++ b/provisioning/integration_test/search_email_with_sort_order/provisioning.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Define users and folders +users=("alice" "bob" "brian" "charlotte" "david" "emma") + +# Add users +for user in "${users[@]}"; do + james-cli AddUser "$user@example.com" "$user" +done + +# Create search folder for user Bob +james-cli CreateMailbox \#private "bob@example.com" "search" + +# Import emails into search folder for user Bob +for eml in {0..4}; do + echo "Importing $eml.eml into search folder for user bob" + james-cli ImportEml \#private "bob@example.com" "search" "/root/conf/integration_test/search_email_with_sort_order/eml/$eml.eml" & +done diff --git a/pubspec.lock b/pubspec.lock index 613d0ce91a..a5ca08fdd8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + auto_size_text: + dependency: transitive + description: + name: auto_size_text + sha256: "3f5261cd3fb5f2a9ab4e2fc3fba84fd9fcaac8821f20a1d4e71f557521b22599" + url: "https://pub.dev" + source: hosted + version: "3.0.0" back_button_interceptor: dependency: "direct main" description: @@ -1042,6 +1050,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" + flutter_web_auth_2: + dependency: "direct main" + description: + name: flutter_web_auth_2 + sha256: "3ea3a0cc539ca74319f4f2f7484f62742fe5b2ff9a0fca37575426d6e6f07901" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: e8669e262005a8354389ba2971f0fc1c36188481234ff50d013aaf993f30f739 + url: "https://pub.dev" + source: hosted + version: "3.1.0" flutter_web_plugins: dependency: transitive description: flutter @@ -1297,6 +1321,15 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + linagora_design_flutter: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: "0f666ca14fd4f0710775ca3de03cde73b21de7d6" + url: "https://github.com/linagora/linagora-design-flutter.git" + source: git + version: "0.0.1" linkify: dependency: transitive description: @@ -1313,6 +1346,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + loading_view: + dependency: transitive + description: + name: loading_view + sha256: "3d31c2c5293c2e3518b7330ffdc2fab5c83daaa9218cc45d4406c875f08f0795" + url: "https://pub.dev" + source: hosted + version: "1.1.0" logging: dependency: transitive description: @@ -1585,6 +1626,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.0" + photo_manager: + dependency: transitive + description: + name: photo_manager + sha256: e29619443803c40385ee509abc7937835d9b5122f899940080d28b2dceed59c1 + url: "https://pub.dev" + source: hosted + version: "3.3.0" + photo_manager_image_provider: + dependency: transitive + description: + name: photo_manager_image_provider + sha256: "38ef1023dc11de3a8669f16e7c981673b3c5cfee715d17120f4b87daa2cdd0af" + url: "https://pub.dev" + source: hosted + version: "2.1.1" platform: dependency: transitive description: @@ -1701,11 +1758,11 @@ packages: dependency: "direct main" description: path: "." - ref: master - resolved-ref: f23f7fb0fad25ae80a88350ad8654851f1ee682f + ref: main + resolved-ref: b388eb837bd1256a9623a8cd0a62cfc21e9f51cf url: "https://github.com/linagora/receive_sharing_intent.git" source: git - version: "1.4.5" + version: "1.8.1" rich_text_composer: dependency: "direct main" description: @@ -1983,6 +2040,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + tip_dialog: + dependency: "direct main" + description: + name: tip_dialog + sha256: edc1ebbb4b9f9575220c85206cf6986921c2b63d173744be0babac00c2e48bd0 + url: "https://pub.dev" + source: hosted + version: "4.0.0" typed_data: dependency: transitive description: @@ -2175,6 +2240,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.4" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + url: "https://pub.dev" + source: hosted + version: "0.0.3" worker_manager: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7f51fc0ba6..6f87d9b5c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.13.5 +version: 0.14.0 environment: sdk: ">=3.0.0 <4.0.0" @@ -75,7 +75,7 @@ dependencies: receive_sharing_intent: git: url: https://github.com/linagora/receive_sharing_intent.git - ref: master + ref: main flutter_appauth_web: git: @@ -111,6 +111,11 @@ dependencies: url: https://github.com/dab246/languagetool_textfield.git ref: twake-supported + linagora_design_flutter: + git: + url: https://github.com/linagora/linagora-design-flutter.git + ref: master + ### Dependencies from pub.dev ### cupertino_icons: 1.0.6 @@ -250,6 +255,10 @@ dependencies: web_socket_channel: 2.4.3 + tip_dialog: 4.0.0 + + flutter_web_auth_2: 3.1.1 + dev_dependencies: flutter_test: sdk: flutter diff --git a/scripts/patrol-integration-test-with-docker.sh b/scripts/patrol-integration-test-with-docker.sh index 5f00659308..244248cbc6 100755 --- a/scripts/patrol-integration-test-with-docker.sh +++ b/scripts/patrol-integration-test-with-docker.sh @@ -40,8 +40,8 @@ done export BOB="bob" export ALICE="alice" export DOMAIN="example.com" -docker exec tmail-backend james-cli AddUser "$BOB@$DOMAIN" "$BOB" -docker exec tmail-backend james-cli AddUser "$ALICE@$DOMAIN" "$ALICE" + +docker exec tmail-backend ./root/conf/integration_test/search_email_with_sort_order/provisioning.sh cd .. diff --git a/scripts/patrol-local-integration-test-with-docker.sh b/scripts/patrol-local-integration-test-with-docker.sh index 90f331d83a..72b8c589d1 100755 --- a/scripts/patrol-local-integration-test-with-docker.sh +++ b/scripts/patrol-local-integration-test-with-docker.sh @@ -40,8 +40,8 @@ done export BOB="bob" export ALICE="alice" export DOMAIN="example.com" -docker exec tmail-backend james-cli AddUser "$BOB@$DOMAIN" "$BOB" -docker exec tmail-backend james-cli AddUser "$ALICE@$DOMAIN" "$ALICE" + +docker exec tmail-backend ./root/conf/integration_test/search_email_with_sort_order/provisioning.sh cd .. diff --git a/test/features/email/sorting_list_email_by_order_id_list_test.dart b/test/features/email/sorting_list_email_by_order_id_list_test.dart new file mode 100644 index 0000000000..ab17742d2e --- /dev/null +++ b/test/features/email/sorting_list_email_by_order_id_list_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/list_email_extension.dart'; + +void main() { + group('sorting_list_email_by_order_id_list test', () { + test('sortingByOrderOfIdList method should return an ordered list of ids when of the same length', () { + List ids = [ + Id('a'), + Id('b'), + Id('c'), + Id('d'), + Id('e') + ]; + List emails = [ + Email(id: EmailId(Id('a'))), + Email(id: EmailId(Id('c'))), + Email(id: EmailId(Id('e'))), + Email(id: EmailId(Id('d'))), + Email(id: EmailId(Id('b'))) + ]; + + List sortedEmails = emails.sortingByOrderOfIdList(ids); + + expect( + sortedEmails.map((e) => e.id?.id.value), + equals(['a', 'b', 'c', 'd', 'e']) + ); + }); + + test('sortingByOrderOfIdList method should return the original list when the length of the two lists is different', () { + List ids = [ + Id('a'), + Id('b'), + Id('c'), + ]; + List emails = [ + Email(id: EmailId(Id('a'))), + Email(id: EmailId(Id('c'))), + Email(id: EmailId(Id('e'))), + Email(id: EmailId(Id('d'))), + Email(id: EmailId(Id('b'))) + ]; + + List sortedEmails = emails.sortingByOrderOfIdList(ids); + + expect( + sortedEmails.map((e) => e.id?.id.value), + equals(['a', 'c', 'e', 'd', 'b']) + ); + }); + }); +} \ No newline at end of file diff --git a/test/features/interceptor/authorization_interceptor_test.dart b/test/features/interceptor/authorization_interceptor_test.dart index fcc70b1cc5..63c4c1e2a2 100644 --- a/test/features/interceptor/authorization_interceptor_test.dart +++ b/test/features/interceptor/authorization_interceptor_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:core/data/constants/constant.dart'; import 'package:core/data/network/dio_client.dart'; import 'package:dio/dio.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:mockito/annotations.dart'; @@ -76,6 +77,10 @@ void main() { dio.interceptors.add(authorizationInterceptors); dioAdapter = DioAdapter(dio: dio); + + dotenv.testLoad(mergeWith: { + 'PLATFORM': 'other' + }); }); group('AuthorizationInterceptor test', () { diff --git a/test/features/login/domain/extensions/oidc_configuration_extensions_test.dart b/test/features/login/domain/extensions/oidc_configuration_extensions_test.dart new file mode 100644 index 0000000000..fcae78b94c --- /dev/null +++ b/test/features/login/domain/extensions/oidc_configuration_extensions_test.dart @@ -0,0 +1,50 @@ +import 'package:core/data/model/query/query_parameter.dart'; +import 'package:core/data/network/config/service_path.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tmail_ui_user/features/login/data/extensions/service_path_extension.dart'; +import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart'; + + +void main() { + test('signInTWPUrl should return the correct URL with query parameters', () { + // Arrange + const authority = 'https://authority.example.com'; + const expectedUrl = '$authority?${OIDCConstant.postLoginRedirectUrlPathParams}=${OIDCConstant.twakeWorkplaceRedirectUrl}&app=${OIDCConstant.appParameter}'; + final service = ServicePath(authority); + + // Act + final actualUrl = service + .withQueryParameters([ + StringQueryParameter( + OIDCConstant.postLoginRedirectUrlPathParams, + OIDCConstant.twakeWorkplaceRedirectUrl, + ), + StringQueryParameter('app', OIDCConstant.appParameter), + ]) + .generateEndpointPath(); + + // Assert + expect(actualUrl, equals(expectedUrl)); + }); + + test('signUpTWPUrl should return the correct URL with query parameters', () { + // Arrange + const authority = 'https://authority.example.com'; + const expectedUrl = '$authority?${OIDCConstant.postRegisteredRedirectUrlPathParams}=${OIDCConstant.twakeWorkplaceRedirectUrl}&app=${OIDCConstant.appParameter}'; + final service = ServicePath(authority); + + // Act + final actualUrl = service + .withQueryParameters([ + StringQueryParameter( + OIDCConstant.postRegisteredRedirectUrlPathParams, + OIDCConstant.twakeWorkplaceRedirectUrl, + ), + StringQueryParameter('app', OIDCConstant.appParameter), + ]) + .generateEndpointPath(); + + // Assert + expect(actualUrl, equals(expectedUrl)); + }); +} diff --git a/test/features/login/presentation/login_controller_test.dart b/test/features/login/presentation/login_controller_test.dart index 9442b782c5..f30e210f2e 100644 --- a/test/features/login/presentation/login_controller_test.dart +++ b/test/features/login/presentation/login_controller_test.dart @@ -33,6 +33,7 @@ import 'package:tmail_ui_user/features/login/presentation/login_controller.dart' import 'package:tmail_ui_user/features/login/presentation/login_form_type.dart'; import 'package:tmail_ui_user/features/manage_account/data/local/language_cache_manager.dart'; import 'package:tmail_ui_user/features/manage_account/domain/usecases/log_out_oidc_interactor.dart'; +import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_in_twake_workplace_interactor.dart'; import 'package:tmail_ui_user/main/bindings/network/binding_tag.dart'; import 'package:tmail_ui_user/main/utils/toast_manager.dart'; import 'package:uuid/uuid.dart'; @@ -62,6 +63,7 @@ import 'login_controller_test.mocks.dart'; MockSpec(), MockSpec(), MockSpec(), + MockSpec(), MockSpec(), MockSpec(), MockSpec(), @@ -84,6 +86,7 @@ void main() { late MockSaveLoginUsernameOnMobileInteractor mockSaveLoginUsernameOnMobileInteractor; late MockGetAllRecentLoginUsernameOnMobileInteractor mockGetAllRecentLoginUsernameOnMobileInteractor; late MockDNSLookupToGetJmapUrlInteractor mockDNSLookupToGetJmapUrlInteractor; + late MockSignInTwakeWorkplaceInteractor mockSignInTwakeWorkplaceInteractor; late MockGetSessionInteractor mockGetSessionInteractor; late MockGetAuthenticatedAccountInteractor mockGetAuthenticatedAccountInteractor; late MockUpdateAccountCacheInteractor mockUpdateAccountCacheInteractor; @@ -118,6 +121,7 @@ void main() { mockSaveLoginUsernameOnMobileInteractor = MockSaveLoginUsernameOnMobileInteractor(); mockGetAllRecentLoginUsernameOnMobileInteractor = MockGetAllRecentLoginUsernameOnMobileInteractor(); mockDNSLookupToGetJmapUrlInteractor = MockDNSLookupToGetJmapUrlInteractor(); + mockSignInTwakeWorkplaceInteractor = MockSignInTwakeWorkplaceInteractor(); // mock reloadable controller mockGetSessionInteractor = MockGetSessionInteractor(); @@ -175,6 +179,7 @@ void main() { mockSaveLoginUsernameOnMobileInteractor, mockGetAllRecentLoginUsernameOnMobileInteractor, mockDNSLookupToGetJmapUrlInteractor, + mockSignInTwakeWorkplaceInteractor, ); diff --git a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart index cd0e127d55..a6045ae75a 100644 --- a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart +++ b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart @@ -347,9 +347,7 @@ void main() { setUp(() { getEmailsInMailboxInteractor = MockGetEmailsInMailboxInteractor(); - when(emailReceiveManager.pendingEmailAddressInfo).thenAnswer((_) => BehaviorSubject.seeded(null)); - when(emailReceiveManager.pendingEmailContentInfo).thenAnswer((_) => BehaviorSubject.seeded(null)); - when(emailReceiveManager.pendingFileInfo).thenAnswer((_) => BehaviorSubject.seeded([])); + when(emailReceiveManager.pendingSharedFileInfo).thenAnswer((_) => BehaviorSubject.seeded([])); Get.put(mailboxDashboardController); mailboxDashboardController.onReady(); diff --git a/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart b/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart index 85e4756086..910f68bc9f 100644 --- a/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart +++ b/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart @@ -306,9 +306,7 @@ void main() { Get.put(getAllIdentitiesInteractor); Get.put(removeComposerCacheOnWebInteractor); - when(emailReceiveManager.pendingEmailAddressInfo).thenAnswer((_) => BehaviorSubject.seeded(null)); - when(emailReceiveManager.pendingEmailContentInfo).thenAnswer((_) => BehaviorSubject.seeded(null)); - when(emailReceiveManager.pendingFileInfo).thenAnswer((_) => BehaviorSubject.seeded([])); + when(emailReceiveManager.pendingSharedFileInfo).thenAnswer((_) => BehaviorSubject.seeded([])); searchController = SearchController( quickSearchEmailInteractor, diff --git a/test/main/routes/route_utils_test.dart b/test/main/routes/route_utils_test.dart index 7e0f9ebd1b..5227bbaa18 100644 --- a/test/main/routes/route_utils_test.dart +++ b/test/main/routes/route_utils_test.dart @@ -83,5 +83,146 @@ void main() { expect(result[RouteUtils.paramMailtoAddress], containsAll(['test@example.com', 'test2@example.com', 'test3@example.com'])); expect(result[RouteUtils.paramSubject], equals('Hello')); }); + + test( + 'should parse a valid mailto URI encoded contains every possible parameters', + () { + // arrange + const to1 = 'to1@example.com'; + const to2 = 'to2@example.com'; + const to3 = 'to3@example.com'; + const cc1 = 'cc1@example.com'; + const cc2 = 'cc2@example.com'; + const bcc1 = 'bcc1@example.com'; + const bcc2 = 'bcc2@example.com'; + const subject = 'Hello'; + const body = 'Bye'; + const mailtoSchemeUri = 'mailto:$to1,$to2' + '?to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + const mailtoPathUri = 'https://example.com/mailto' + '?uri=$to1,$to2' + '&to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + const mailtoPathWithNestedMailtoUri = 'https://example.com/mailto/' + '?uri=mailto:$to1,$to2' + '&to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + // act + final mailtoSchemeResult = RouteUtils.parseMapMailtoFromUri( + Uri.encodeFull(mailtoSchemeUri)); + final mailtoPathResult = RouteUtils.parseMapMailtoFromUri( + Uri.encodeFull(mailtoPathUri)); + final mailtoPathWithNestedMailtoResult = RouteUtils.parseMapMailtoFromUri( + Uri.encodeFull(mailtoPathWithNestedMailtoUri)); + + // assert + expect(mailtoSchemeResult, equals(mailtoPathResult)); + expect(mailtoSchemeResult, equals(mailtoPathWithNestedMailtoResult)); + expect(mailtoPathResult, equals(mailtoPathWithNestedMailtoResult)); + + expect( + mailtoSchemeResult[RouteUtils.paramMailtoAddress], + containsAll([to1, to2, to3,]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramCc], + containsAll([cc1, cc2]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramBcc], + containsAll([bcc1, bcc2]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramSubject], + equals(subject) + ); + expect( + mailtoSchemeResult[RouteUtils.paramBody], + equals(body) + ); + }); + + test( + 'should parse a valid mailto URI contains every possible parameters', + () { + // arrange + const to1 = 'to1@example.com'; + const to2 = 'to2@example.com'; + const to3 = 'to3@example.com'; + const cc1 = 'cc1@example.com'; + const cc2 = 'cc2@example.com'; + const bcc1 = 'bcc1@example.com'; + const bcc2 = 'bcc2@example.com'; + const subject = 'Hello'; + const body = 'Bye'; + const mailtoSchemeUri = 'mailto:$to1,$to2' + '?to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + const mailtoPathUri = 'https://example.com/mailto' + '?uri=$to1,$to2' + '&to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + const mailtoPathWithNestedMailtoUri = 'https://example.com/mailto/' + '?uri=mailto:$to1,$to2' + '&to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + // act + final mailtoSchemeResult = RouteUtils.parseMapMailtoFromUri( + mailtoSchemeUri); + final mailtoPathResult = RouteUtils.parseMapMailtoFromUri(mailtoPathUri); + final mailtoPathWithNestedMailtoResult = RouteUtils.parseMapMailtoFromUri( + mailtoPathWithNestedMailtoUri); + + // assert + expect(mailtoSchemeResult, equals(mailtoPathResult)); + expect(mailtoSchemeResult, equals(mailtoPathWithNestedMailtoResult)); + expect(mailtoPathResult, equals(mailtoPathWithNestedMailtoResult)); + + expect( + mailtoSchemeResult[RouteUtils.paramMailtoAddress], + containsAll([to1, to2, to3,]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramCc], + containsAll([cc1, cc2]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramBcc], + containsAll([bcc1, bcc2]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramSubject], + equals(subject) + ); + expect( + mailtoSchemeResult[RouteUtils.paramBody], + equals(body) + ); + }); }); } \ No newline at end of file