TF-3278 Handle open app via deep link at MailboxDashboard screen

This commit is contained in:
dab246
2024-11-22 23:30:37 +07:00
committed by Dat H. Pham
parent 8022559bc2
commit 223fdbf1fb
59 changed files with 1058 additions and 429 deletions
+1
View File
@@ -52,6 +52,7 @@ export 'utils/broadcast_channel/broadcast_channel.dart';
export 'utils/list_utils.dart';
export 'utils/mail/domain.dart';
export 'utils/mail/mail_address.dart';
export 'utils/application_manager.dart';
export 'utils/preview_eml_file_utils.dart';
// Views
@@ -2,7 +2,18 @@ import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:dartz/dartz.dart';
typedef OnFailureCallback = void Function(Failure? failure);
typedef OnSuccessCallback<T> = void Function(T success);
extension EitherViewStateExtension on Either<Failure, Success> {
void foldSuccess<T>({
required OnSuccessCallback<T> onSuccess,
required OnFailureCallback onFailure,
}) {
fold(onFailure,
(success) => success is T ? onSuccess(success as T) : onFailure(null));
}
dynamic foldSuccessWithResult<T>() {
return fold(
(failure) => failure,
+10 -2
View File
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'app_logger.dart';
import 'package:core/utils/app_logger.dart';
import 'package:core/domain/exceptions/string_exception.dart';
class StringConvert {
@@ -17,6 +16,15 @@ class StringConvert {
return text ?? '';
}
static String decodeBase64ToString(String text) {
try {
return utf8.decode(base64Decode(text));
} catch (e) {
logError('StringConvert::decodeBase64ToString:Exception = $e');
return text;
}
}
static List<String> extractStrings(String input) {
try {
// Check if the input is URL encoded
@@ -0,0 +1,79 @@
import 'package:core/presentation/extensions/either_view_state_extension.dart';
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:dartz/dartz.dart';
class MockFailure extends Failure {
final String message;
MockFailure(this.message);
@override
List<Object?> get props => [message];
}
class MockSuccess extends Success {
final String data;
MockSuccess(this.data);
@override
List<Object?> get props => [data];
}
class AnotherSuccess extends Success {
@override
List<Object?> get props => [];
}
void main() {
group('EitherViewStateExtension::foldSuccess::test', () {
test('Should calls onFailure when Either is Left', () {
final either = Left<Failure, Success>(MockFailure('Error occurred'));
bool failureCalled = false;
either.foldSuccess<Success>(
onSuccess: (_) => fail('onSuccess should not be called'),
onFailure: (failure) {
failureCalled = true;
expect(failure, isNotNull);
expect(failure, isA<MockFailure>());
},
);
expect(failureCalled, isTrue);
});
test('Should calls onSuccess when Either is Right with matching type', () {
final either = Right<Failure, Success>(MockSuccess('Successful'));
bool successCalled = false;
either.foldSuccess<Success>(
onSuccess: (success) {
successCalled = true;
expect(success, isNotNull);
expect(success, isA<MockSuccess>());
},
onFailure: (_) => fail('onFailure should not be called'),
);
expect(successCalled, isTrue);
});
test('Should calls onFailure when Either is Right with non-matching type', () {
final either = Right<Failure, Success>(MockSuccess('Successful'));
bool failureCalled = false;
either.foldSuccess<AnotherSuccess>(
onSuccess: (_) => fail('onSuccess should not be called'),
onFailure: (failure) {
failureCalled = true;
expect(failure, isNull);
},
);
expect(failureCalled, isTrue);
});
});
}
+36
View File
@@ -5,6 +5,42 @@ import 'package:core/utils/string_convert.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('StringConvert::decodeBase64ToString::test', () {
test('should decode a valid Base64 string to a normal string', () {
// Arrange
const base64Encoded = 'SGVsbG8gV29ybGQh';
const expectedDecoded = 'Hello World!';
// Act
final result = StringConvert.decodeBase64ToString(base64Encoded);
// Assert
expect(result, expectedDecoded);
});
test('should return the original string for invalid Base64 input', () {
// Arrange
const invalidBase64 = 'InvalidBase64@@';
// Act
final result = StringConvert.decodeBase64ToString(invalidBase64);
// Assert
expect(result, invalidBase64);
});
test('should return the original string for empty input', () {
// Arrange
const emptyInput = '';
// Act
final result = StringConvert.decodeBase64ToString(emptyInput);
// Assert
expect(result, emptyInput);
});
});
group('StringConvert::extractStrings::', () {
group('Basic Functionality', () {
test('should extract strings separated by spaces', () {
+6
View File
@@ -1,4 +1,6 @@
PODS:
- app_links (0.0.2):
- Flutter
- app_settings (5.1.1):
- Flutter
- AppAuth (1.7.4):
@@ -204,6 +206,7 @@ PODS:
- Flutter
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
- app_settings (from `.symlinks/plugins/app_settings/ios`)
- better_open_file (from `.symlinks/plugins/better_open_file/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
@@ -264,6 +267,8 @@ SPEC REPOS:
- UniversalDetector2
EXTERNAL SOURCES:
app_links:
:path: ".symlinks/plugins/app_links/ios"
app_settings:
:path: ".symlinks/plugins/app_settings/ios"
better_open_file:
@@ -334,6 +339,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/workmanager/ios"
SPEC CHECKSUMS:
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
app_settings: 017320c6a680cdc94c799949d95b84cb69389ebc
AppAuth: 182c5b88630569df5acb672720534756c29b3358
better_open_file: 03cf320415d4d3f46b6e00adc4a567d76c1a399d
+1
View File
@@ -33,6 +33,7 @@
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>teammail.mobile</string>
<string>twakemail.mobile</string>
<string>mailto</string>
</array>
</dict>
+3 -1
View File
@@ -24,6 +24,7 @@ class JmapClient {
basicAuth: String?,
tokenEndpointUrl: String?,
oidcScopes: [String]?,
isTWP: Bool?,
onComplete: @escaping ([Email], [Error]) -> Void
) {
if (authenticationType == AuthenticationType.basic) {
@@ -42,7 +43,8 @@ class JmapClient {
tokenRefreshManager = TokenRefreshManager(
refreshToken: tokenOidc?.refreshToken ?? "",
tokenEndpoint: tokenEndpointUrl ?? "",
scopes: oidcScopes
scopes: oidcScopes,
isTWP: isTWP
)
}
@@ -5,6 +5,7 @@ class TokenRefreshManager {
private let MOBIE_CLIENT_ID = "teammail-mobile"
private let MOBIE_REDIRECT_URL = "teammail.mobile://oauthredirect"
private let OIDC_SCOPES = ["openid", "profile", "email", "offline_access"]
private let TWP_MOBIE_REDIRECT_URL = "twakemail.mobile://redirect"
private let GRANT_TYPE = "grant_type"
private let REFRESH_TOKEN = "refresh_token"
@@ -15,11 +16,13 @@ class TokenRefreshManager {
let refreshToken: String
let tokenEndpoint: String
let scopes: [String]?
let isTWP: Bool?
init(refreshToken: String, tokenEndpoint: String, scopes: [String]?) {
init(refreshToken: String, tokenEndpoint: String, scopes: [String]?, isTWP: Bool?) {
self.refreshToken = refreshToken
self.tokenEndpoint = tokenEndpoint
self.scopes = scopes
self.isTWP = isTWP
}
private func getScopes() -> String {
@@ -28,6 +31,14 @@ class TokenRefreshManager {
}
return OIDC_SCOPES.joined(separator: " ")
}
private func getRedirectUrl() -> String {
if (isTWP == true) {
return TWP_MOBIE_REDIRECT_URL
} else {
return MOBIE_REDIRECT_URL
}
}
func handleRefreshAccessToken(
onSuccess: @escaping (TokenResponse) -> Void,
@@ -41,7 +52,7 @@ class TokenRefreshManager {
let params = [
CLIENT_ID: MOBIE_CLIENT_ID,
GRANT_TYPE: REFRESH_TOKEN,
REDIRECT_URI: MOBIE_REDIRECT_URL,
REDIRECT_URI: getRedirectUrl(),
REFRESH_TOKEN: refreshToken,
SCOPES: getScopes(),
]
@@ -12,6 +12,7 @@ struct KeychainSharingSession: Codable {
let tokenEndpoint: String?
let oidcScopes: [String]?
let mailboxIdsBlockNotification: [String]?
let isTWP: Bool?
}
extension KeychainSharingSession {
@@ -27,7 +28,8 @@ extension KeychainSharingSession {
basicAuth: self.basicAuth,
tokenEndpoint: self.tokenEndpoint,
oidcScopes: self.oidcScopes,
mailboxIdsBlockNotification: self.mailboxIdsBlockNotification
mailboxIdsBlockNotification: self.mailboxIdsBlockNotification,
isTWP: self.isTWP
)
}
@@ -48,7 +50,8 @@ extension KeychainSharingSession {
basicAuth: self.basicAuth,
tokenEndpoint: self.tokenEndpoint,
oidcScopes: self.oidcScopes,
mailboxIdsBlockNotification: self.mailboxIdsBlockNotification
mailboxIdsBlockNotification: self.mailboxIdsBlockNotification,
isTWP: self.isTWP
)
}
@@ -82,6 +82,7 @@ class NotificationService: UNNotificationServiceExtension {
basicAuth: keychainSharingSession.basicAuth,
tokenEndpointUrl: keychainSharingSession.tokenEndpoint,
oidcScopes: keychainSharingSession.oidcScopes,
isTWP: keychainSharingSession.isTWP,
onComplete: { (emails, errors) in
do {
if emails.isEmpty {
+31 -30
View File
@@ -1,19 +1,7 @@
import 'dart:async';
import 'package:core/core.dart';
import 'package:flutter/services.dart' as services;
import 'package:contact/contact/model/capability_contact.dart';
import 'package:core/data/network/config/dynamic_url_interceptors.dart';
import 'package:core/domain/exceptions/platform_exception.dart';
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:core/presentation/utils/app_toast.dart';
import 'package:core/presentation/utils/responsive_utils.dart';
import 'package:core/presentation/views/toast/tmail_toast.dart';
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/application_manager.dart';
import 'package:core/utils/fps_manager.dart';
import 'package:core/utils/platform_info.dart';
import 'package:dartz/dartz.dart';
import 'package:fcm/model/firebase_capability.dart';
import 'package:fcm/model/firebase_registration_id.dart';
@@ -34,7 +22,9 @@ import 'package:tmail_ui_user/features/base/mixin/popup_context_menu_action_mixi
import 'package:tmail_ui_user/features/caching/caching_manager.dart';
import 'package:tmail_ui_user/features/email/presentation/bindings/mdn_interactor_bindings.dart';
import 'package:tmail_ui_user/features/home/domain/extensions/session_extensions.dart';
import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart';
import 'package:tmail_ui_user/features/login/data/network/interceptors/authorization_interceptors.dart';
import 'package:tmail_ui_user/features/login/domain/exceptions/logout_exception.dart';
import 'package:tmail_ui_user/features/login/domain/usecases/delete_authority_oidc_interactor.dart';
import 'package:tmail_ui_user/features/login/domain/usecases/delete_credential_interactor.dart';
import 'package:tmail_ui_user/features/login/presentation/login_form_type.dart';
@@ -203,6 +193,8 @@ abstract class BaseController extends GetxController
clearDataAndGoToLoginPage();
}
void onCancelReconnectWhenSessionExpired() {}
void _handleConnectionErrorException() {
if (currentOverlayContext != null && currentContext != null) {
appToast.showToastErrorMessage(
@@ -241,7 +233,9 @@ abstract class BaseController extends GetxController
outsideDismissible: false,
titleActionButtonMaxLines: 1,
icon: SvgPicture.asset(imagePaths.icTMailLogo, width: 64, height: 64),
onConfirmAction: _executeBeforeReconnectAndLogOut);
onConfirmAction: _executeBeforeReconnectAndLogOut,
onCancelAction: onCancelReconnectWhenSessionExpired
);
} else if (PlatformInfo.isMobile) {
if (currentContext == null) {
clearDataAndGoToLoginPage();
@@ -257,7 +251,9 @@ abstract class BaseController extends GetxController
outsideDismissible: false,
titleActionButtonMaxLines: 1,
icon: SvgPicture.asset(imagePaths.icTMailLogo, width: 64, height: 64),
onConfirmAction: clearDataAndGoToLoginPage);
onConfirmAction: clearDataAndGoToLoginPage,
onCancelAction: onCancelReconnectWhenSessionExpired
);
}
}
@@ -477,20 +473,22 @@ abstract class BaseController extends GetxController
required Session session,
required AccountId accountId,
required Function onSuccessCallback,
required Function onFailureCallback,
required Function({Object? exception}) onFailureCallback,
}) async {
try {
_isFcmEnabled = _isFcmActivated(session, accountId);
if (isAuthenticatedWithOidc) {
final logoutViewState = await logoutOidcInteractor.execute().last;
logoutViewState.fold(
(failure) => onFailureCallback(),
(success) async {
if (success is LogoutOidcSuccess) {
logoutViewState.foldSuccess<LogoutOidcSuccess>(
onSuccess: (success) async {
await _handleDeleteFCMAndClearData();
onSuccessCallback();
},
onFailure: (failure) async {
if (failure is LogoutOidcFailure && _validateUserCancelledLogoutOidcFlow(failure.exception)) {
await _handleDeleteFCMAndClearData();
onSuccessCallback();
onFailureCallback(exception: UserCancelledLogoutOIDCFlowException());
} else {
onFailureCallback();
}
@@ -506,6 +504,11 @@ abstract class BaseController extends GetxController
}
}
bool _validateUserCancelledLogoutOidcFlow(dynamic exception) {
return exception is services.PlatformException &&
exception.code == OIDCConstant.endSessionFailedCode;
}
Future<void> _handleDeleteFCMAndClearData() async {
await Future.wait([
if (_isFcmEnabled)
@@ -519,14 +522,12 @@ abstract class BaseController extends GetxController
_getStoredFirebaseRegistrationInteractor = getBinding<GetStoredFirebaseRegistrationInteractor>();
final fcmRegistration = await _getStoredFirebaseRegistrationInteractor?.execute().last;
fcmRegistration?.fold(
(failure) => null,
(success) async {
if (success is GetStoredFirebaseRegistrationSuccess) {
_destroyFirebaseRegistrationInteractor = getBinding<DestroyFirebaseRegistrationInteractor>();
await _destroyFirebaseRegistrationInteractor?.execute(success.firebaseRegistration.id!).last;
}
fcmRegistration?.foldSuccess<GetStoredFirebaseRegistrationSuccess>(
onSuccess: (success) async {
_destroyFirebaseRegistrationInteractor = getBinding<DestroyFirebaseRegistrationInteractor>();
await _destroyFirebaseRegistrationInteractor?.execute(success.firebaseRegistration.id!).last;
},
onFailure: (failure) {},
);
} catch (e) {
logError('BaseController::_handleDeleteFCMRegistration:Exception = $e');
@@ -34,7 +34,7 @@ abstract class ReloadableController extends BaseController {
goToLogin();
} else if (failure is GetSessionFailure) {
logError('$runtimeType::handleFailureViewState():Failure = $failure');
handleGetSessionFailure(failure.exception);
handleGetSessionFailure(failure);
} else if (failure is UpdateAccountCacheFailure) {
logError('$runtimeType::handleFailureViewState():Failure = $failure');
_handleUpdateAccountCacheCompleted(
@@ -0,0 +1,12 @@
import 'package:tmail_ui_user/features/caching/config/hive_cache_client.dart';
import 'package:tmail_ui_user/features/caching/utils/caching_constants.dart';
import 'package:tmail_ui_user/features/login/data/model/oidc_configuration_cache.dart';
class OidcConfigurationCacheClient extends HiveCacheClient<OidcConfigurationCache> {
@override
String get tableName => CachingConstants.oidcConfigurationCacheBoxName;
@override
bool get encryption => true;
}
@@ -18,6 +18,7 @@ import 'package:tmail_ui_user/features/login/data/local/encryption_key_cache_man
import 'package:tmail_ui_user/features/login/data/model/account_cache.dart';
import 'package:tmail_ui_user/features/login/data/model/authentication_info_cache.dart';
import 'package:tmail_ui_user/features/login/data/model/encryption_key_cache.dart';
import 'package:tmail_ui_user/features/login/data/model/oidc_configuration_cache.dart';
import 'package:tmail_ui_user/features/login/data/model/recent_login_url_cache.dart';
import 'package:tmail_ui_user/features/login/data/model/recent_login_username_cache.dart';
import 'package:tmail_ui_user/features/login/data/model/token_oidc_cache.dart';
@@ -185,6 +186,10 @@ class HiveCacheConfig {
SessionHiveObjAdapter(),
CachingConstants.SESSION_HIVE_CACHE_ID
);
registerCacheAdapter<OidcConfigurationCache>(
OidcConfigurationCacheAdapter(),
CachingConstants.OIDC_CONFIGURATION_CACHE_ID,
);
}
void registerCacheAdapter<T>(TypeAdapter<T> typeAdapter, int typeId) {
@@ -19,6 +19,7 @@ class CachingConstants {
static const int DETAILED_EMAIL_HIVE_CACHE_ID = 17;
static const int SENDING_EMAIL_HIVE_CACHE_ID = 18;
static const int SESSION_HIVE_CACHE_ID = 19;
static const int OIDC_CONFIGURATION_CACHE_ID = 20;
static const String fcmCacheBoxName = 'fcm_cache_box';
static const String newEmailCacheBoxName = 'new_email_cache_box';
@@ -26,6 +27,9 @@ class CachingConstants {
static const String sendingEmailCacheBoxName = 'sending_email_cache_box';
static const String sessionCacheBoxName = 'session_cache_box';
static const String firebaseRegistrationCacheBoxName = 'firebase_registration_cache_box';
static const String oidcConfigurationCacheBoxName = 'oidc_configuration_cache_box';
static const String oidcConfigurationCacheKeyName = 'oidc_configuration_cache_key';
static const String newEmailsContentFolderName = 'new_emails';
static const String openedEmailContentFolderName = 'opened_email';
@@ -32,7 +32,7 @@ class AutoSignInViaDeepLinkInteractor {
await Future.wait([
_credentialRepository.saveBaseUrl(baseUri),
_authenticationOIDCRepository.persistTokenOIDC(tokenOIDC),
_authenticationOIDCRepository.persistAuthorityOidc(oidcConfiguration.authority),
_authenticationOIDCRepository.persistOidcConfiguration(oidcConfiguration),
]);
await _accountRepository.setCurrentAccount(
@@ -6,6 +6,7 @@ import 'package:core/utils/app_logger.dart';
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:model/account/personal_account.dart';
import 'package:tmail_ui_user/features/base/reloadable/reloadable_controller.dart';
@@ -21,9 +22,12 @@ import 'package:tmail_ui_user/features/cleanup/domain/usecases/cleanup_recent_lo
import 'package:tmail_ui_user/features/cleanup/domain/usecases/cleanup_recent_search_cache_interactor.dart';
import 'package:tmail_ui_user/features/home/domain/state/auto_sign_in_via_deep_link_state.dart';
import 'package:tmail_ui_user/features/home/domain/state/get_session_state.dart';
import 'package:tmail_ui_user/features/login/domain/exceptions/logout_exception.dart';
import 'package:tmail_ui_user/features/login/domain/state/get_credential_state.dart';
import 'package:tmail_ui_user/features/login/domain/state/get_stored_token_oidc_state.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_data.dart';
import 'package:tmail_ui_user/features/login/presentation/model/login_navigate_arguments.dart';
import 'package:tmail_ui_user/features/login/presentation/model/login_navigate_type.dart';
import 'package:tmail_ui_user/main/deep_links/open_app_deep_link_data.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';
@@ -65,7 +69,7 @@ class HomeController extends ReloadableController {
@override
void onReady() {
_cleanupCache();
_handleNavigateToScreen();
super.onReady();
}
@@ -76,6 +80,11 @@ class HomeController extends ReloadableController {
arguments: session);
}
@override
void onCancelReconnectWhenSessionExpired() {
clearDataAndGoToLoginPage();
}
void _initFlutterDownloader() {
FlutterDownloader
.initialize(debug: kDebugMode)
@@ -84,6 +93,15 @@ class HomeController extends ReloadableController {
static void downloadCallback(String id, DownloadTaskStatus status, int progress) {}
void _handleNavigateToScreen() {
final arguments = Get.arguments;
if (arguments is LoginNavigateArguments) {
_handleLoginNavigateArguments(arguments);
} else {
_cleanupCache();
}
}
Future<void> _cleanupCache() async {
await HiveCacheConfig.instance.onUpgradeDatabase(cachingManager);
@@ -95,6 +113,17 @@ class HomeController extends ReloadableController {
], eagerError: true).then((_) => getAuthenticatedAccountAction());
}
void _handleLoginNavigateArguments(LoginNavigateArguments arguments) {
switch (arguments.navigateType) {
case LoginNavigateType.autoSignIn:
_handleAutoSignInViaDeepLinkSuccess(arguments.autoSignInViaDeepLinkSuccess!);
break;
default:
_cleanupCache();
break;
}
}
void _registerReceivingFileSharing() {
_emailReceiveManager.registerReceivingFileSharingStreamWhileAppClosed();
}
@@ -124,7 +153,7 @@ class HomeController extends ReloadableController {
void _handleLogOutAndSignInNewAccount({
required Success authenticationViewStateSuccess,
required PersonalAccount personalAccount,
required DeepLinkData deepLinkData,
required OpenAppDeepLinkData openAppDeepLinkData,
}) {
if (authenticationViewStateSuccess is GetCredentialViewState) {
setDataToInterceptors(
@@ -143,14 +172,14 @@ class HomeController extends ReloadableController {
_getSessionActionToLogOut(
authenticationViewStateSuccess: authenticationViewStateSuccess,
personalAccount: personalAccount,
deepLinkData: deepLinkData,
openAppDeepLinkData: openAppDeepLinkData,
);
}
Future<void> _getSessionActionToLogOut({
required Success authenticationViewStateSuccess,
required PersonalAccount personalAccount,
required DeepLinkData deepLinkData,
required OpenAppDeepLinkData openAppDeepLinkData,
}) async {
try {
final sessionViewState = await getSessionInteractor.execute().last;
@@ -161,7 +190,7 @@ class HomeController extends ReloadableController {
sessionViewStateSuccess: success,
authenticationViewStateSuccess: authenticationViewStateSuccess,
personalAccount: personalAccount,
deepLinkData: deepLinkData,
openAppDeepLinkData: openAppDeepLinkData,
),
);
} catch (e) {
@@ -174,18 +203,27 @@ class HomeController extends ReloadableController {
required Success sessionViewStateSuccess,
required Success authenticationViewStateSuccess,
required PersonalAccount personalAccount,
required DeepLinkData deepLinkData,
required OpenAppDeepLinkData openAppDeepLinkData,
}) {
if (sessionViewStateSuccess is GetSessionSuccess) {
logoutToSignInNewAccount(
session: sessionViewStateSuccess.session,
accountId: personalAccount.accountId!,
onFailureCallback: () =>
_continueUsingTheApp(authenticationViewStateSuccess),
onFailureCallback: ({exception}) {
if (exception is UserCancelledLogoutOIDCFlowException) {
_deepLinksManager?.autoSignInViaDeepLink(
openAppDeepLinkData: openAppDeepLinkData,
onFailureCallback: () => _continueUsingTheApp(authenticationViewStateSuccess),
onAutoSignInSuccessCallback: _handleAutoSignInViaDeepLinkSuccess,
);
} else {
_continueUsingTheApp(authenticationViewStateSuccess);
}
},
onSuccessCallback: () => _deepLinksManager?.autoSignInViaDeepLink(
deepLinkData: deepLinkData,
openAppDeepLinkData: openAppDeepLinkData,
onFailureCallback: () => _continueUsingTheApp(authenticationViewStateSuccess),
onSuccessCallback: _handleAutoSignInViaDeepLinkSuccess,
onAutoSignInSuccessCallback: _handleAutoSignInViaDeepLinkSuccess,
),
);
} else {
@@ -234,8 +272,19 @@ class HomeController extends ReloadableController {
@override
void handleFailureViewState(Failure failure) {
if (_validateToHandleDeepLinksNotSignedIn(failure)) {
_deepLinksManager!.handleDeepLinksWhenAppTerminatedNotSignedIn(
onSuccessCallback: _handleAutoSignInViaDeepLinkSuccess,
_deepLinksManager!.handleDeepLinksWhenAppTerminated(
onSuccessCallback: (deepLinkData) {
if (deepLinkData is OpenAppDeepLinkData) {
_deepLinksManager!.handleOpenAppDeepLinks(
openAppDeepLinkData: deepLinkData,
isSignedIn: false,
onFailureCallback: goToLogin,
onAutoSignInSuccessCallback: _handleAutoSignInViaDeepLinkSuccess
);
} else {
goToLogin();
}
},
onFailureCallback: goToLogin,
);
} else {
@@ -248,14 +297,25 @@ class HomeController extends ReloadableController {
if (_validateToHandleDeepLinksSignedIn(success)) {
final personalAccount = _getPersonalAccountFromViewStateSuccess(success);
_deepLinksManager!.handleDeepLinksWhenAppTerminatedSignedIn(
username: personalAccount?.userName?.value,
_deepLinksManager!.handleDeepLinksWhenAppTerminated(
onSuccessCallback: (deepLinkData) {
if (deepLinkData is OpenAppDeepLinkData) {
_deepLinksManager!.handleOpenAppDeepLinks(
openAppDeepLinkData: deepLinkData,
isSignedIn: true,
username: personalAccount?.userName,
onConfirmLogoutCallback: (openAppDeepLinkData) => _handleLogOutAndSignInNewAccount(
authenticationViewStateSuccess: success,
personalAccount: personalAccount!,
openAppDeepLinkData: openAppDeepLinkData,
),
onFailureCallback: () => _continueUsingTheApp(success),
);
} else {
_continueUsingTheApp(success);
}
},
onFailureCallback: () => _continueUsingTheApp(success),
onConfirmCallback: (deepLinkData) => _handleLogOutAndSignInNewAccount(
authenticationViewStateSuccess: success,
personalAccount: personalAccount!,
deepLinkData: deepLinkData,
),
);
} else {
super.handleSuccessViewState(success);
@@ -16,9 +16,9 @@ abstract class AuthenticationOIDCDataSource {
Future<TokenOIDC> getStoredTokenOIDC(String tokenIdHash);
Future<void> persistAuthorityOidc(String authority);
Future<void> persistOidcConfiguration(OIDCConfiguration oidcConfiguration);
Future<void> deleteAuthorityOidc();
Future<void> deleteOidcConfiguration();
Future<OIDCConfiguration> getStoredOidcConfiguration();
@@ -74,9 +74,9 @@ class AuthenticationOIDCDataSourceImpl extends AuthenticationOIDCDataSource {
}
@override
Future<void> persistAuthorityOidc(String authority) {
Future<void> persistOidcConfiguration(OIDCConfiguration oidcConfiguration) {
return Future.sync(() async {
return await _oidcConfigurationCacheManager.persistAuthorityOidc(authority);
return await _oidcConfigurationCacheManager.persistOidcConfiguration(oidcConfiguration);
}).catchError(_cacheExceptionThrower.throwException);
}
@@ -106,9 +106,9 @@ class AuthenticationOIDCDataSourceImpl extends AuthenticationOIDCDataSource {
}
@override
Future<void> deleteAuthorityOidc() {
Future<void> deleteOidcConfiguration() {
return Future.sync(() async {
return await _oidcConfigurationCacheManager.deleteAuthorityOidc();
return await _oidcConfigurationCacheManager.deleteOidcConfiguration();
}).catchError(_exceptionThrower.throwException);
}
@@ -0,0 +1,16 @@
import 'package:model/oidc/oidc_configuration.dart';
import 'package:tmail_ui_user/features/login/data/model/oidc_configuration_cache.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 OidcConfigutationCacheExtension on OidcConfigurationCache {
OIDCConfiguration toOIDCConfiguration() {
return OIDCConfiguration(
authority: authority,
isTWP: isTWP,
clientId: OIDCConstant.clientId,
scopes: AppConfig.oidcScopes,
);
}
}
@@ -1,34 +1,54 @@
import 'package:core/utils/app_logger.dart';
import 'package:model/oidc/oidc_configuration.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tmail_ui_user/features/caching/clients/oidc_configuration_cache_client.dart';
import 'package:tmail_ui_user/features/caching/utils/caching_constants.dart';
import 'package:tmail_ui_user/features/login/data/extensions/oidc_configutation_cache_extension.dart';
import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart';
import 'package:tmail_ui_user/features/login/data/network/oidc_error.dart';
import 'package:tmail_ui_user/features/login/domain/extensions/oidc_configuration_extensions.dart';
import 'package:tmail_ui_user/main/utils/app_config.dart';
class OidcConfigurationCacheManager {
final SharedPreferences _sharedPreferences;
final OidcConfigurationCacheClient _oidcConfigurationCacheClient;
OidcConfigurationCacheManager(this._sharedPreferences);
OidcConfigurationCacheManager(this._sharedPreferences, this._oidcConfigurationCacheClient);
Future<OIDCConfiguration> getOidcConfiguration() async {
final authority = _sharedPreferences.getString(OIDCConstant.keyAuthorityOidc);
if (authority == null || authority.isEmpty) {
throw CanNotFoundOIDCAuthority();
} else {
final oidcConfigurationCache = await _oidcConfigurationCacheClient.getItem(CachingConstants.oidcConfigurationCacheKeyName);
if (oidcConfigurationCache == null) {
final authority = _sharedPreferences.getString(OIDCConstant.keyAuthorityOidc);
if (authority == null || authority.isEmpty) {
throw CanNotFoundOIDCAuthority();
}
return OIDCConfiguration(
authority: authority,
clientId: OIDCConstant.clientId,
scopes: AppConfig.oidcScopes);
authority: authority,
clientId: OIDCConstant.clientId,
scopes: AppConfig.oidcScopes,
);
} else {
return oidcConfigurationCache.toOIDCConfiguration();
}
}
Future<void> persistAuthorityOidc(String authority) async {
log('OidcConfigurationCacheManager::persistAuthorityOidc(): $authority');
await _sharedPreferences.setString(OIDCConstant.keyAuthorityOidc, authority);
Future<void> persistOidcConfiguration(OIDCConfiguration oidcConfiguration) async {
log('OidcConfigurationCacheManager::persistOidcConfiguration(): $oidcConfiguration');
await _oidcConfigurationCacheClient.insertItem(
CachingConstants.oidcConfigurationCacheKeyName,
oidcConfiguration.toOidcConfigurationCache(),
);
}
Future<void> deleteAuthorityOidc() async {
log('OidcConfigurationCacheManager::deleteAuthorityOidc()');
await _sharedPreferences.remove(OIDCConstant.keyAuthorityOidc);
Future<void> deleteOidcConfiguration() async {
log('OidcConfigurationCacheManager::deleteOidcConfiguration()');
await Future.wait([
_oidcConfigurationCacheClient.deleteItem(
CachingConstants.oidcConfigurationCacheKeyName,
),
_sharedPreferences.remove(OIDCConstant.keyAuthorityOidc),
]);
}
}
@@ -0,0 +1,20 @@
import 'package:equatable/equatable.dart';
import 'package:hive/hive.dart';
import 'package:tmail_ui_user/features/caching/utils/caching_constants.dart';
part 'oidc_configuration_cache.g.dart';
@HiveType(typeId: CachingConstants.OIDC_CONFIGURATION_CACHE_ID)
class OidcConfigurationCache extends HiveObject with EquatableMixin {
@HiveField(0)
final String authority;
@HiveField(1)
final bool isTWP;
OidcConfigurationCache(this.authority, this.isTWP);
@override
List<Object?> get props => [authority, isTWP];
}
@@ -4,13 +4,17 @@ import 'package:tmail_ui_user/main/utils/app_config.dart';
class OIDCConstant {
static String get mobileOidcClientId => 'teammail-mobile';
static List<String> get oidcScope => ['openid', 'profile', 'email', 'offline_access'];
static const keyAuthorityOidc = 'KEY_AUTHORITY_OIDC';
static const authResponseKey = "auth_info";
static const String keyAuthorityOidc = 'KEY_AUTHORITY_OIDC';
static const String 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 const String endSessionFailedCode = 'end_session_failed';
static const String redirectOidcMobile = 'teammail.mobile://oauthredirect';
static const String loginRedirectOidcWeb = 'login-callback.html';
static const String logoutRedirectOidcWeb = 'logout-callback.html';
static String get clientId => PlatformInfo.isWeb ? AppConfig.webOidcClientId : mobileOidcClientId;
}
@@ -52,11 +52,14 @@ class OIDCHttpClient {
if (oidcResponse.links.isEmpty) {
throw CanNotFoundOIDCAuthority();
}
log('OIDCHttpClient::getOIDCConfiguration(): href: ${oidcResponse.links[0].href}');
final authority = oidcResponse.links[0].href.toString();
final isTWP = authority == AppConfig.saasRegistrationUrl;
log('OIDCHttpClient::getOIDCConfiguration():authority: $authority');
return OIDCConfiguration(
authority: oidcResponse.links[0].href.toString(),
authority: authority,
clientId: OIDCConstant.clientId,
scopes: AppConfig.oidcScopes
scopes: AppConfig.oidcScopes,
isTWP: isTWP,
);
}
@@ -22,7 +22,6 @@ class AuthenticationOIDCRepositoryImpl extends AuthenticationOIDCRepository {
return _oidcDataSource.getOIDCConfiguration(oidcResponse);
}
@override
Future<OIDCDiscoveryResponse> discoverOIDC(OIDCConfiguration oidcConfiguration) {
return _oidcDataSource.discoverOIDC(oidcConfiguration);
@@ -49,8 +48,8 @@ class AuthenticationOIDCRepositoryImpl extends AuthenticationOIDCRepository {
}
@override
Future<void> persistAuthorityOidc(String authority) {
return _oidcDataSource.persistAuthorityOidc(authority);
Future<void> persistOidcConfiguration(OIDCConfiguration oidcConfiguration) {
return _oidcDataSource.persistOidcConfiguration(oidcConfiguration);
}
@override
@@ -75,8 +74,8 @@ class AuthenticationOIDCRepositoryImpl extends AuthenticationOIDCRepository {
}
@override
Future<void> deleteAuthorityOidc() {
return _oidcDataSource.deleteAuthorityOidc();
Future<void> deleteOidcConfiguration() {
return _oidcDataSource.deleteOidcConfiguration();
}
@override
@@ -0,0 +1 @@
class UserCancelledLogoutOIDCFlowException implements Exception {}
@@ -4,6 +4,7 @@ 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/model/oidc_configuration_cache.dart';
import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart';
import 'package:tmail_ui_user/main/utils/app_config.dart';
@@ -12,32 +13,29 @@ extension OidcConfigurationExtensions on OIDCConfiguration {
String get redirectUrl {
if (PlatformInfo.isWeb) {
return AppConfig.domainRedirectUrl.endsWith('/')
? AppConfig.domainRedirectUrl + loginRedirectOidcWeb
: '${AppConfig.domainRedirectUrl}/$loginRedirectOidcWeb';
? AppConfig.domainRedirectUrl + OIDCConstant.loginRedirectOidcWeb
: '${AppConfig.domainRedirectUrl}/${OIDCConstant.loginRedirectOidcWeb}';
} else {
return _isSaasAuthority(authority)
return isTWP
? OIDCConstant.twakeWorkplaceRedirectUrl
: redirectOidcMobile;
: OIDCConstant.redirectOidcMobile;
}
}
String get logoutRedirectUrl {
if (PlatformInfo.isWeb) {
if (AppConfig.domainRedirectUrl.endsWith('/')) {
return AppConfig.domainRedirectUrl + logoutRedirectOidcWeb;
return AppConfig.domainRedirectUrl + OIDCConstant.logoutRedirectOidcWeb;
} else {
return '${AppConfig.domainRedirectUrl}/$logoutRedirectOidcWeb';
return '${AppConfig.domainRedirectUrl}/${OIDCConstant.logoutRedirectOidcWeb}';
}
} else {
return _isSaasAuthority(authority)
return isTWP
? OIDCConstant.twakeWorkplaceRedirectUrl
: redirectOidcMobile;
: OIDCConstant.redirectOidcMobile;
}
}
bool _isSaasAuthority(String authority) =>
authority == AppConfig.saasRegistrationUrl;
String get signInTWPUrl => ServicePath(authority)
.withQueryParameters([
StringQueryParameter(
@@ -57,4 +55,8 @@ extension OidcConfigurationExtensions on OIDCConfiguration {
StringQueryParameter('app', OIDCConstant.appParameter),
])
.generateEndpointPath();
OidcConfigurationCache toOidcConfigurationCache() {
return OidcConfigurationCache(authority, isTWP);
}
}
@@ -16,9 +16,9 @@ abstract class AuthenticationOIDCRepository {
Future<TokenOIDC> getStoredTokenOIDC(String tokenIdHash);
Future<void> persistAuthorityOidc(String authority);
Future<void> persistOidcConfiguration(OIDCConfiguration oidcConfiguration);
Future<void> deleteAuthorityOidc();
Future<void> deleteOidcConfiguration();
Future<OIDCConfiguration> getStoredOidcConfiguration();
@@ -17,7 +17,7 @@ class DeleteAuthorityOidcInteractor {
try {
await Future.wait([
_credentialRepository.removeBaseUrl(),
_authenticationOIDCRepository.deleteAuthorityOidc(),
_authenticationOIDCRepository.deleteOidcConfiguration(),
_authenticationOIDCRepository.deleteTokenOIDC(),
]);
return Right(DeleteAuthorityOidcSuccess());
@@ -15,7 +15,7 @@ class GetOIDCConfigurationInteractor {
try {
yield Right<Failure, Success>(GetOIDCConfigurationLoading());
final oidcConfiguration = await _oidcRepository.getOIDCConfiguration(oidcResponse);
await _oidcRepository.persistAuthorityOidc(oidcConfiguration.authority);
await _oidcRepository.persistOidcConfiguration(oidcConfiguration);
yield Right<Failure, Success>(GetOIDCConfigurationSuccess(oidcConfiguration));
} catch (e) {
log('GetOIDCConfigurationInteractor::execute(): ERROR: $e');
@@ -35,7 +35,7 @@ class GetTokenOIDCInteractor {
await Future.wait([
_credentialRepository.saveBaseUrl(baseUrl),
authenticationOIDCRepository.persistTokenOIDC(tokenOIDC),
authenticationOIDCRepository.persistAuthorityOidc(config.authority),
authenticationOIDCRepository.persistOidcConfiguration(config),
]);
await _accountRepository.setCurrentAccount(
@@ -58,6 +58,7 @@ import 'package:tmail_ui_user/features/starting_page/domain/state/sign_in_twake_
import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_in_twake_workplace_interactor.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_data.dart';
import 'package:tmail_ui_user/main/deep_links/deep_links_manager.dart';
import 'package:tmail_ui_user/main/deep_links/open_app_deep_link_data.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';
@@ -163,6 +164,7 @@ class LoginController extends ReloadableController {
} else if (failure is GetAuthenticatedAccountFailure) {
_checkOIDCIsAvailable();
} else if (failure is GetSessionFailure) {
SmartDialog.dismiss();
clearAllData();
} else if (failure is DNSLookupToGetJmapUrlFailure) {
_username = null;
@@ -218,6 +220,7 @@ class LoginController extends ReloadableController {
_handleNoSuitableBrowserOIDC(failure)
.map((stillFailed) => _handleCommonOIDCFailure());
} else if (failure is GetSessionFailure) {
SmartDialog.dismiss();
clearAllData();
} else {
super.handleUrgentException(failure: failure, exception: exception);
@@ -234,12 +237,6 @@ class LoginController extends ReloadableController {
);
}
@override
void handleGetSessionFailure(GetSessionFailure failure) {
SmartDialog.dismiss();
super.handleGetSessionFailure(failure);
}
void _registerDeepLinks() {
_deepLinksManager = getBinding<DeepLinksManager>();
_deepLinksManager?.clearPendingDeepLinkData();
@@ -250,16 +247,21 @@ class LoginController extends ReloadableController {
void _handlePendingDeepLinkDataStream(DeepLinkData? deepLinkData) {
log('LoginController::_handlePendingDeepLinkDataStream:DeepLinkData = $deepLinkData');
if (deepLinkData == null) return;
if (currentContext != null) {
SmartDialog.showLoading(msg: AppLocalizations.of(currentContext!).loadingPleaseWait);
}
_deepLinksManager?.handleDeepLinksWhenAppOnForegroundNotSignedIn(
_deepLinksManager?.handleDeepLinksWhenAppRunning(
deepLinkData: deepLinkData,
onSuccessCallback: _handleAutoSignInViaDeepLinkSuccess,
onFailureCallback: SmartDialog.dismiss,
onSuccessCallback: (deepLinkData) {
if (deepLinkData is! OpenAppDeepLinkData) return;
if (currentContext != null) {
SmartDialog.showLoading(msg: AppLocalizations.of(currentContext!).loadingPleaseWait);
}
_deepLinksManager?.autoSignInViaDeepLink(
openAppDeepLinkData: deepLinkData,
onAutoSignInSuccessCallback: _handleAutoSignInViaDeepLinkSuccess,
onFailureCallback: SmartDialog.dismiss,
);
},
);
}
@@ -0,0 +1,29 @@
import 'package:tmail_ui_user/features/home/domain/state/auto_sign_in_via_deep_link_state.dart';
import 'package:tmail_ui_user/features/login/presentation/model/login_navigate_type.dart';
import 'package:tmail_ui_user/main/routes/router_arguments.dart';
class LoginNavigateArguments extends RouterArguments {
final LoginNavigateType navigateType;
final AutoSignInViaDeepLinkSuccess? autoSignInViaDeepLinkSuccess;
LoginNavigateArguments({
required this.navigateType,
this.autoSignInViaDeepLinkSuccess,
});
factory LoginNavigateArguments.autoSignIn({
required AutoSignInViaDeepLinkSuccess autoSignInViaDeepLinkSuccess,
}) {
return LoginNavigateArguments(
navigateType: LoginNavigateType.autoSignIn,
autoSignInViaDeepLinkSuccess: autoSignInViaDeepLinkSuccess,
);
}
@override
List<Object?> get props => [
navigateType,
autoSignInViaDeepLinkSuccess,
];
}
@@ -0,0 +1,3 @@
enum LoginNavigateType {
autoSignIn
}
@@ -8,6 +8,7 @@ import 'package:email_recovery/email_recovery/email_recovery_action.dart';
import 'package:email_recovery/email_recovery/email_recovery_action_id.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:jmap_dart_client/jmap/account_id.dart';
@@ -74,9 +75,12 @@ import 'package:tmail_ui_user/features/email/presentation/model/composer_argumen
import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart';
import 'package:tmail_ui_user/features/email_recovery/presentation/model/email_recovery_arguments.dart';
import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart';
import 'package:tmail_ui_user/features/home/domain/state/auto_sign_in_via_deep_link_state.dart';
import 'package:tmail_ui_user/features/home/domain/usecases/store_session_interactor.dart';
import 'package:tmail_ui_user/features/identity_creator/domain/state/get_identity_cache_on_web_state.dart';
import 'package:tmail_ui_user/features/identity_creator/domain/usecase/get_identity_cache_on_web_interactor.dart';
import 'package:tmail_ui_user/features/login/domain/exceptions/logout_exception.dart';
import 'package:tmail_ui_user/features/login/presentation/model/login_navigate_arguments.dart';
import 'package:tmail_ui_user/features/mailbox/domain/exceptions/mailbox_exception.dart';
import 'package:tmail_ui_user/features/mailbox/domain/model/create_new_mailbox_request.dart';
import 'package:tmail_ui_user/features/mailbox/domain/state/mark_as_mailbox_read_state.dart';
@@ -157,6 +161,9 @@ import 'package:tmail_ui_user/features/thread/domain/usecases/mark_as_multiple_e
import 'package:tmail_ui_user/features/thread/domain/usecases/mark_as_star_multiple_email_interactor.dart';
import 'package:tmail_ui_user/features/thread/domain/usecases/move_multiple_email_to_mailbox_interactor.dart';
import 'package:tmail_ui_user/features/thread/presentation/model/delete_action_type.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_data.dart';
import 'package:tmail_ui_user/main/deep_links/deep_links_manager.dart';
import 'package:tmail_ui_user/main/deep_links/open_app_deep_link_data.dart';
import 'package:tmail_ui_user/main/exceptions/remote_exception.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
import 'package:tmail_ui_user/main/routes/app_routes.dart';
@@ -254,6 +261,8 @@ class MailboxDashBoardController extends ReloadableController
StreamSubscription? _receivingFileSharingStreamSubscription;
StreamSubscription? _currentEmailIdInNotificationIOSStreamSubscription;
bool _isFirstSessionLoad = false;
DeepLinksManager? _deepLinksManager;
StreamSubscription<DeepLinkData?>? _deepLinkDataStreamSubscription;
final StreamController<Either<Failure, Success>> _progressStateController =
StreamController<Either<Failure, Success>>.broadcast();
@@ -301,6 +310,7 @@ class MailboxDashBoardController extends ReloadableController
void onInit() {
if (PlatformInfo.isMobile) {
_registerReceivingFileSharingStream();
_registerDeepLinks();
}
_registerStreamListener();
BackButtonInterceptor.add(_onBackButtonInterceptor, name: AppRoutes.dashboard);
@@ -467,6 +477,8 @@ class MailboxDashBoardController extends ReloadableController
@override
void handleUrgentExceptionOnMobile({Failure? failure, Exception? exception}) {
SmartDialog.dismiss();
if (failure is SendEmailFailure && exception is NoNetworkError) {
_storeSendingEmailInCaseOfSendingFailureInMobile(failure);
} else {
@@ -563,6 +575,71 @@ class MailboxDashBoardController extends ReloadableController
);
}
void _registerDeepLinks() {
_deepLinksManager = getBinding<DeepLinksManager>();
_deepLinksManager?.clearPendingDeepLinkData();
_deepLinkDataStreamSubscription = _deepLinksManager
?.pendingDeepLinkData.stream
.listen(_handlePendingDeepLinkDataStream);
}
void _handlePendingDeepLinkDataStream(DeepLinkData? deepLinkData) {
log('MailboxDashBoardController::_handlePendingDeepLinkDataStream:DeepLinkData = $deepLinkData');
_deepLinksManager?.handleDeepLinksWhenAppRunning(
deepLinkData: deepLinkData,
onSuccessCallback: (deepLinkData) {
if (deepLinkData is! OpenAppDeepLinkData) return;
_deepLinksManager?.handleOpenAppDeepLinks(
openAppDeepLinkData: deepLinkData,
isSignedIn: true,
username: sessionCurrent?.username,
onConfirmLogoutCallback: _handleLogOutAndSignInNewAccount,
);
},
);
}
void _handleLogOutAndSignInNewAccount(OpenAppDeepLinkData openAppDeepLinkData) {
if (sessionCurrent == null || accountId.value == null) return;
if (currentContext != null) {
SmartDialog.showLoading(msg: AppLocalizations.of(currentContext!).loadingPleaseWait);
}
logoutToSignInNewAccount(
session: sessionCurrent!,
accountId: accountId.value!,
onFailureCallback: ({exception}) {
if (exception is UserCancelledLogoutOIDCFlowException) {
_deepLinksManager?.autoSignInViaDeepLink(
openAppDeepLinkData: openAppDeepLinkData,
onFailureCallback: SmartDialog.dismiss,
onAutoSignInSuccessCallback: _handleAutoSignInViaDeepLinkSuccess,
);
} else {
SmartDialog.dismiss();
}
},
onSuccessCallback: () => _deepLinksManager?.autoSignInViaDeepLink(
openAppDeepLinkData: openAppDeepLinkData,
onFailureCallback: SmartDialog.dismiss,
onAutoSignInSuccessCallback: _handleAutoSignInViaDeepLinkSuccess,
),
);
}
void _handleAutoSignInViaDeepLinkSuccess(AutoSignInViaDeepLinkSuccess success) {
SmartDialog.dismiss();
pushAndPopAll(
AppRoutes.home,
arguments: LoginNavigateArguments.autoSignIn(
autoSignInViaDeepLinkSuccess: success,
),
);
}
void _registerPendingCurrentEmailIdInNotification() {
_iosNotificationManager = getBinding<IOSNotificationManager>();
_currentEmailIdInNotificationIOSStreamSubscription = _iosNotificationManager
@@ -1625,6 +1702,8 @@ class MailboxDashBoardController extends ReloadableController
@override
void handleReloaded(Session session) {
log('MailboxDashBoardController::handleReloaded():');
SmartDialog.dismiss();
_getRouteParameters();
_setUpComponentsFromSession(session);
if (PlatformInfo.isWeb) {
@@ -3144,6 +3223,7 @@ class MailboxDashBoardController extends ReloadableController
_pendingSharedFileInfoSubscription?.cancel();
_receivingFileSharingStreamSubscription?.cancel();
_emailReceiveManager.closeEmailReceiveManagerStream();
_deepLinkDataStreamSubscription?.cancel();
}
_progressStateController.close();
_refreshActionEventController.close();
@@ -17,6 +17,7 @@ extension KeychainSharingSessionExtension on KeychainSharingSession {
tokenEndpoint: tokenEndpoint,
oidcScopes: oidcScopes,
mailboxIdsBlockNotification: mailboxIdsBlockNotification ?? this.mailboxIdsBlockNotification,
isTWP: isTWP,
);
}
}
@@ -27,6 +27,7 @@ class KeychainSharingSession with EquatableMixin {
String? tokenEndpoint;
List<String>? oidcScopes;
List<MailboxId>? mailboxIdsBlockNotification;
bool isTWP;
KeychainSharingSession({
required this.accountId,
@@ -40,6 +41,7 @@ class KeychainSharingSession with EquatableMixin {
this.tokenEndpoint,
this.oidcScopes,
this.mailboxIdsBlockNotification,
this.isTWP = false,
});
factory KeychainSharingSession.fromJson(Map<String, dynamic> json) => _$KeychainSharingSessionFromJson(json);
@@ -59,5 +61,6 @@ class KeychainSharingSession with EquatableMixin {
tokenEndpoint,
oidcScopes,
mailboxIdsBlockNotification,
isTWP,
];
}
@@ -36,7 +36,7 @@ class SignInTwakeWorkplaceInteractor {
await Future.wait([
_credentialRepository.saveBaseUrl(baseUri),
_authenticationOIDCRepository.persistTokenOIDC(tokenOIDC),
_authenticationOIDCRepository.persistAuthorityOidc(oidcConfiguration.authority),
_authenticationOIDCRepository.persistOidcConfiguration(oidcConfiguration),
]);
await _accountRepository.setCurrentAccount(
@@ -36,7 +36,7 @@ class SignUpTwakeWorkplaceInteractor {
await Future.wait([
_credentialRepository.saveBaseUrl(baseUri),
_authenticationOIDCRepository.persistTokenOIDC(tokenOIDC),
_authenticationOIDCRepository.persistAuthorityOidc(oidcConfiguration.authority),
_authenticationOIDCRepository.persistOidcConfiguration(oidcConfiguration),
]);
await _accountRepository.setCurrentAccount(
@@ -1,4 +1,3 @@
import 'package:core/utils/platform_info.dart';
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';
@@ -38,9 +37,6 @@ class TwakeWelcomeBindings extends BaseBindings {
Get.find<AccountRepository>(),
Get.find<CredentialRepository>(),
));
if (PlatformInfo.isMobile) {
}
}
@override
@@ -23,6 +23,7 @@ import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_in_twak
import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_up_twake_workplace_interactor.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_data.dart';
import 'package:tmail_ui_user/main/deep_links/deep_links_manager.dart';
import 'package:tmail_ui_user/main/deep_links/open_app_deep_link_data.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';
@@ -61,16 +62,21 @@ class TwakeWelcomeController extends ReloadableController {
void _handlePendingDeepLinkDataStream(DeepLinkData? deepLinkData) {
log('TwakeWelcomeController::_handlePendingDeepLinkDataStream:DeepLinkData = $deepLinkData');
if (deepLinkData == null) return;
if (currentContext != null) {
SmartDialog.showLoading(msg: AppLocalizations.of(currentContext!).loadingPleaseWait);
}
_deepLinksManager?.handleDeepLinksWhenAppOnForegroundNotSignedIn(
_deepLinksManager?.handleDeepLinksWhenAppRunning(
deepLinkData: deepLinkData,
onSuccessCallback: _handleAutoSignInViaDeepLinkSuccess,
onFailureCallback: SmartDialog.dismiss,
onSuccessCallback: (deepLinkData) {
if (deepLinkData is! OpenAppDeepLinkData) return;
if (currentContext != null) {
SmartDialog.showLoading(msg: AppLocalizations.of(currentContext!).loadingPleaseWait);
}
_deepLinksManager?.autoSignInViaDeepLink(
openAppDeepLinkData: deepLinkData,
onAutoSignInSuccessCallback: _handleAutoSignInViaDeepLinkSuccess,
onFailureCallback: SmartDialog.dismiss,
);
},
);
}
@@ -107,7 +113,8 @@ class TwakeWelcomeController extends ReloadableController {
oidcConfiguration: OIDCConfiguration(
authority: AppConfig.saasRegistrationUrl,
clientId: OIDCConstant.clientId,
scopes: AppConfig.oidcScopes
scopes: AppConfig.oidcScopes,
isTWP: true,
)
));
}
@@ -127,7 +134,8 @@ class TwakeWelcomeController extends ReloadableController {
oidcConfiguration: OIDCConfiguration(
authority: AppConfig.saasRegistrationUrl,
clientId: OIDCConstant.clientId,
scopes: AppConfig.oidcScopes
scopes: AppConfig.oidcScopes,
isTWP: true,
)
));
}
@@ -178,6 +186,12 @@ class TwakeWelcomeController extends ReloadableController {
toastManager.showMessageFailure(failure);
}
@override
void handleUrgentExceptionOnMobile({Failure? failure, Exception? exception}) {
SmartDialog.dismiss();
super.handleUrgentExceptionOnMobile(failure: failure, exception: exception);
}
void _synchronizeTokenAndGetSession({
required Uri baseUri,
required TokenOIDC tokenOIDC,
+19 -1
View File
@@ -1,5 +1,5 @@
{
"@@last_modified": "2024-12-05T18:34:48.830922",
"@@last_modified": "2024-12-09T13:59:58.460897",
"initializing_data": "Initializing data...",
"@initializing_data": {
"type": "text",
@@ -4118,6 +4118,24 @@
"placeholders_order": [],
"placeholders": {}
},
"switchAccountConfirmation": "Switch Account Confirmation",
"@switchAccountConfirmation": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"youAreCurrentlyLoggedInWith": "You are currently logged in with",
"@youAreCurrentlyLoggedInWith": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"doYouWantToLogOutAndSwitchTo": "Do you want to log out and switch to",
"@doYouWantToLogOutAndSwitchTo": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"getHelpOrReportABug": "Get help or report a bug",
"@getHelpOrReportABug": {
"type": "text",
@@ -15,6 +15,6 @@ class DeepLinkBindings extends Bindings {
Get.find<AccountRepository>(),
Get.find<CredentialRepository>(),
));
Get.put(DeepLinksManager(Get.find<AutoSignInViaDeepLinkInteractor>()));
Get.put(DeepLinksManager());
}
}
+3 -1
View File
@@ -13,6 +13,7 @@ import 'package:tmail_ui_user/features/caching/clients/firebase_registration_cac
import 'package:tmail_ui_user/features/caching/clients/hive_cache_version_client.dart';
import 'package:tmail_ui_user/features/caching/clients/mailbox_cache_client.dart';
import 'package:tmail_ui_user/features/caching/clients/new_email_hive_cache_client.dart';
import 'package:tmail_ui_user/features/caching/clients/oidc_configuration_cache_client.dart';
import 'package:tmail_ui_user/features/caching/clients/opened_email_hive_cache_client.dart';
import 'package:tmail_ui_user/features/caching/clients/recent_login_url_cache_client.dart';
import 'package:tmail_ui_user/features/caching/clients/recent_login_username_cache_client.dart';
@@ -70,7 +71,8 @@ class LocalBindings extends Bindings {
Get.put(EncryptionKeyCacheManager(Get.find<EncryptionKeyCacheClient>()));
Get.put(AuthenticationInfoCacheClient());
Get.put(AuthenticationInfoCacheManager(Get.find<AuthenticationInfoCacheClient>()));
Get.put(OidcConfigurationCacheManager(Get.find<SharedPreferences>()));
Get.put(OidcConfigurationCacheClient());
Get.put(OidcConfigurationCacheManager(Get.find<SharedPreferences>(), Get.find<OidcConfigurationCacheClient>()));
Get.put(LanguageCacheManager(Get.find<SharedPreferences>()));
Get.put(RecentLoginUrlCacheClient());
Get.put(RecentLoginUrlCacheManager((Get.find<RecentLoginUrlCacheClient>())));
@@ -1,9 +0,0 @@
import 'package:tmail_ui_user/features/home/domain/state/auto_sign_in_via_deep_link_state.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_data.dart';
typedef OnDeepLinkSuccessCallback = Function(AutoSignInViaDeepLinkSuccess success);
typedef OnDeepLinkFailureCallback = Function();
typedef OnDeepLinkConfirmLogoutCallback = Function(DeepLinkData deepLinkData);
@@ -0,0 +1,5 @@
enum DeepLinkActionType {
openApp,
unknown;
}
@@ -0,0 +1,11 @@
import 'package:tmail_ui_user/features/home/domain/state/auto_sign_in_via_deep_link_state.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_data.dart';
typedef OnAutoSignInViaDeepLinkSuccessCallback = Function(AutoSignInViaDeepLinkSuccess success);
typedef OnDeepLinkSuccessCallback = Function(DeepLinkData deepLinkData);
typedef OnDeepLinkFailureCallback = Function();
typedef OnDeepLinkConfirmLogoutCallback<T> = Function(T t);
+4 -38
View File
@@ -1,45 +1,11 @@
import 'package:equatable/equatable.dart';
import 'package:model/model.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_action_type.dart';
class DeepLinkData with EquatableMixin {
final String action;
final String? accessToken;
final String? refreshToken;
final String? idToken;
final int? expiresIn;
final String? username;
final DeepLinkActionType actionType;
DeepLinkData({
required this.action,
this.accessToken,
this.refreshToken,
this.idToken,
this.expiresIn,
this.username,
});
bool isValidToken() => accessToken?.isNotEmpty == true && username?.isNotEmpty == true;
TokenOIDC getTokenOIDC() {
final expiredTime = expiresIn == null
? null
: DateTime.now().add(Duration(seconds: expiresIn!));
return TokenOIDC(
accessToken!,
TokenId(idToken ?? ''),
refreshToken ?? '',
expiredTime: expiredTime,
);
}
DeepLinkData({required this.actionType});
@override
List<Object?> get props => [
action,
accessToken,
refreshToken,
idToken,
expiresIn,
username,
];
List<Object?> get props => [actionType];
}
+20 -178
View File
@@ -1,24 +1,15 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/utils/app_logger.dart';
import 'package:flutter/material.dart';
import 'package:model/oidc/oidc_configuration.dart';
import 'package:rxdart/subjects.dart';
import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_mixin.dart';
import 'package:tmail_ui_user/features/home/domain/state/auto_sign_in_via_deep_link_state.dart';
import 'package:tmail_ui_user/features/home/domain/usecases/auto_sign_in_via_deep_link_interactor.dart';
import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_action_define.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_action_type.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_callback_action_define.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_data.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
import 'package:tmail_ui_user/main/utils/app_config.dart';
import 'package:tmail_ui_user/main/deep_links/open_app_deep_link_handler_mixin.dart';
class DeepLinksManager with MessageDialogActionMixin {
final AutoSignInViaDeepLinkInteractor _autoSignInViaDeepLinkInteractor;
class DeepLinksManager with MessageDialogActionMixin, OpenAppDeepLinkHandlerMixin {
BehaviorSubject<DeepLinkData?> _pendingDeepLinkData = BehaviorSubject.seeded(null);
@@ -26,8 +17,6 @@ class DeepLinksManager with MessageDialogActionMixin {
StreamSubscription<Uri>? _deepLinkStreamSubscription;
DeepLinksManager(this._autoSignInViaDeepLinkInteractor);
Future<DeepLinkData?> getDeepLinkData() async {
final uriLink = await AppLinks().getInitialLink();
log('DeepLinksManager::getDeepLinkData:uriLink = $uriLink');
@@ -63,194 +52,47 @@ class DeepLinksManager with MessageDialogActionMixin {
DeepLinkData? parseDeepLink(String url) {
try {
final updatedUrl = url.replaceFirst(
OIDCConstant.twakeWorkplaceUrlScheme,
'https',
);
final uri = Uri.parse(updatedUrl);
final decodedUrl = Uri.decodeFull(url);
final uri = Uri.parse(decodedUrl);
log('DeepLinksManager::parseDeepLink:uri = $uri');
final action = uri.host;
final accessToken = uri.queryParameters['access_token'];
final refreshToken = uri.queryParameters['refresh_token'];
final idToken = uri.queryParameters['id_token'];
final expiresInStr = uri.queryParameters['expires_in'];
final username = uri.queryParameters['username'];
final expiresIn = expiresInStr != null
? int.tryParse(expiresInStr)
: null;
return DeepLinkData(
action: action,
accessToken: accessToken,
refreshToken: refreshToken,
idToken: idToken,
expiresIn: expiresIn,
username: username,
);
if (action == DeepLinkActionType.openApp.name.toLowerCase()) {
return parseOpenAppDeepLink(uri);
} else {
return DeepLinkData(actionType: DeepLinkActionType.unknown);
}
} catch (e) {
logError('DeepLinksManager::parseDeepLink:Exception = $e');
return null;
}
}
Future<void> handleDeepLinksWhenAppOnForegroundNotSignedIn({
required DeepLinkData deepLinkData,
required OnDeepLinkSuccessCallback onSuccessCallback,
OnDeepLinkFailureCallback? onFailureCallback,
}) async {
log('DeepLinksManager::handleDeepLinksWhenAppOnForegroundNotSignedIn:DeepLinkData = $deepLinkData');
if (deepLinkData.action.toLowerCase() == AppConfig.openAppHostDeepLink.toLowerCase()) {
_handleOpenApp(
deepLinkData: deepLinkData,
onFailureCallback: onFailureCallback,
onSuccessCallback: onSuccessCallback,
);
} else {
onFailureCallback?.call();
}
}
Future<void> handleDeepLinksWhenAppTerminatedNotSignedIn({
Future<void> handleDeepLinksWhenAppTerminated({
required OnDeepLinkSuccessCallback onSuccessCallback,
required OnDeepLinkFailureCallback onFailureCallback,
}) async {
final deepLinkData = await getDeepLinkData();
log('DeepLinksManager::handleDeepLinksWhenAppTerminatedNotSignedIn:DeepLinkData = $deepLinkData');
if (deepLinkData == null) {
onFailureCallback();
return;
}
if (deepLinkData.action.toLowerCase() == AppConfig.openAppHostDeepLink.toLowerCase()) {
_handleOpenApp(
deepLinkData: deepLinkData,
onFailureCallback: onFailureCallback,
onSuccessCallback: onSuccessCallback,
);
} else {
onFailureCallback();
}
onSuccessCallback(deepLinkData);
}
Future<void> handleDeepLinksWhenAppTerminatedSignedIn({
required String? username,
required OnDeepLinkConfirmLogoutCallback onConfirmCallback,
required OnDeepLinkFailureCallback onFailureCallback,
Future<void> handleDeepLinksWhenAppRunning({
required DeepLinkData? deepLinkData,
required OnDeepLinkSuccessCallback onSuccessCallback,
OnDeepLinkFailureCallback? onFailureCallback,
}) async {
final deepLinkData = await getDeepLinkData();
log('DeepLinksManager::handleDeepLinksWhenAppTerminatedSignedIn:DeepLinkData = $deepLinkData');
if (deepLinkData == null) {
onFailureCallback();
onFailureCallback?.call();
return;
}
if (deepLinkData.action.toLowerCase() == AppConfig.openAppHostDeepLink.toLowerCase()) {
if (deepLinkData.username?.isNotEmpty != true || username?.isNotEmpty != true) {
onFailureCallback();
return;
}
if (deepLinkData.username == username || currentContext == null) {
onFailureCallback();
} else {
_showConfirmDialogSwitchAccount(
context: currentContext!,
username: username!,
onConfirmAction: () => onConfirmCallback(deepLinkData),
onCancelAction: onFailureCallback,
);
}
} else {
onFailureCallback();
}
}
void _handleOpenApp({
required DeepLinkData deepLinkData,
required OnDeepLinkSuccessCallback onSuccessCallback,
OnDeepLinkFailureCallback? onFailureCallback,
}) {
autoSignInViaDeepLink(
deepLinkData: deepLinkData,
onFailureCallback: onFailureCallback,
onSuccessCallback: onSuccessCallback,
);
}
Future<void> autoSignInViaDeepLink({
required DeepLinkData deepLinkData,
required OnDeepLinkSuccessCallback onSuccessCallback,
OnDeepLinkFailureCallback? onFailureCallback,
}) async {
try {
if (deepLinkData.isValidToken()) {
final autoSignInViewState = await _autoSignInViaDeepLinkInteractor.execute(
baseUri: Uri.parse(AppConfig.saasJmapServerUrl),
tokenOIDC: deepLinkData.getTokenOIDC(),
oidcConfiguration: OIDCConfiguration(
authority: AppConfig.saasRegistrationUrl,
clientId: OIDCConstant.clientId,
scopes: AppConfig.oidcScopes,
),
).last;
autoSignInViewState.fold(
(failure) => onFailureCallback?.call(),
(success) {
if (success is AutoSignInViaDeepLinkSuccess) {
onSuccessCallback(success);
} else {
onFailureCallback?.call();
}
},
);
} else {
onFailureCallback?.call();
}
} catch (e) {
logError('DeepLinksManager::_autoSignInViaDeepLink:Exception = $e');
onFailureCallback?.call();
}
}
void _showConfirmDialogSwitchAccount({
required BuildContext context,
required String username,
required Function onConfirmAction,
required Function onCancelAction,
}) {
final appLocalizations = AppLocalizations.of(context);
showConfirmDialogAction(
context,
'',
appLocalizations.yesLogout,
title: appLocalizations.logoutConfirmation,
alignCenter: true,
outsideDismissible: false,
titleActionButtonMaxLines: 1,
titlePadding: const EdgeInsetsDirectional.only(start: 24, top: 24, end: 24, bottom: 12),
messageStyle: const TextStyle(
color: AppColor.colorTextBody,
fontSize: 15,
fontWeight: FontWeight.w400,
),
listTextSpan: [
TextSpan(text: appLocalizations.messageConfirmationLogout),
TextSpan(
text: ' $username',
style: const TextStyle(
color: AppColor.colorTextBody,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
const TextSpan(text: ' ?'),
],
onConfirmAction: onConfirmAction,
onCancelAction: onCancelAction,
);
onSuccessCallback(deepLinkData);
}
void dispose() {
@@ -0,0 +1,72 @@
import 'package:model/oidc/oidc_configuration.dart';
import 'package:model/oidc/token_id.dart';
import 'package:model/oidc/token_oidc.dart';
import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_action_type.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_data.dart';
import 'package:tmail_ui_user/main/utils/app_config.dart';
class OpenAppDeepLinkData extends DeepLinkData {
final String registrationUrl;
final String jmapUrl;
final String username;
final String accessToken;
final String? refreshToken;
final String? idToken;
final int? expiresIn;
OpenAppDeepLinkData({
super.actionType = DeepLinkActionType.openApp,
required this.registrationUrl,
required this.jmapUrl,
required this.username,
required this.accessToken,
this.refreshToken,
this.idToken,
this.expiresIn,
});
bool isValidAuthentication() =>
accessToken.isNotEmpty &&
username.isNotEmpty &&
registrationUrl.isNotEmpty &&
jmapUrl.isNotEmpty;
DateTime? _getExpiredTime() {
return expiresIn != null
? DateTime.now().add(Duration(seconds: expiresIn!))
: null;
}
bool isLoggedInWith(String currentUsername) => username == currentUsername;
TokenOIDC get tokenOIDC {
return TokenOIDC(
accessToken,
TokenId(idToken ?? ''),
refreshToken ?? '',
expiredTime: _getExpiredTime(),
);
}
Uri get baseUri => Uri.parse(jmapUrl);
OIDCConfiguration get oidcConfiguration => OIDCConfiguration(
authority: registrationUrl,
clientId: OIDCConstant.clientId,
scopes: AppConfig.oidcScopes,
isTWP: true,
);
@override
List<Object?> get props => [
...super.props,
registrationUrl,
jmapUrl,
username,
accessToken,
refreshToken,
idToken,
expiresIn,
];
}
@@ -0,0 +1,163 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/extensions/either_view_state_extension.dart';
import 'package:core/utils/string_convert.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:jmap_dart_client/jmap/core/user_name.dart';
import 'package:rich_text_composer/views/commons/logger.dart';
import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_mixin.dart';
import 'package:tmail_ui_user/features/home/domain/state/auto_sign_in_via_deep_link_state.dart';
import 'package:tmail_ui_user/features/home/domain/usecases/auto_sign_in_via_deep_link_interactor.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_callback_action_define.dart';
import 'package:tmail_ui_user/main/deep_links/open_app_deep_link_data.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
mixin OpenAppDeepLinkHandlerMixin on MessageDialogActionMixin {
OpenAppDeepLinkData? parseOpenAppDeepLink(Uri uri) {
try {
final accessToken = uri.queryParameters['access_token'] ?? '';
final refreshToken = uri.queryParameters['refresh_token'];
final idToken = uri.queryParameters['id_token'];
final expiresInStr = uri.queryParameters['expires_in'];
final username = uri.queryParameters['username'];
final registrationUrl = uri.queryParameters['registrationUrl'] ?? '';
final jmapUrl = uri.queryParameters['jmapUrl'] ?? '';
final expiresIn = expiresInStr != null
? int.tryParse(expiresInStr)
: null;
final usernameDecoded = username?.isNotEmpty == true
? StringConvert.decodeBase64ToString(username!)
: username ?? '';
return OpenAppDeepLinkData(
registrationUrl: registrationUrl,
jmapUrl: jmapUrl,
accessToken: accessToken,
refreshToken: refreshToken,
idToken: idToken,
expiresIn: expiresIn,
username: usernameDecoded,
);
} catch (e) {
logError('DeepLinksManager::parseOpenAppDeepLink:Exception = $e');
return null;
}
}
void handleOpenAppDeepLinks({
required OpenAppDeepLinkData openAppDeepLinkData,
OnDeepLinkFailureCallback? onFailureCallback,
OnAutoSignInViaDeepLinkSuccessCallback? onAutoSignInSuccessCallback,
OnDeepLinkConfirmLogoutCallback<OpenAppDeepLinkData>? onConfirmLogoutCallback,
UserName? username,
bool isSignedIn = true,
}) {
if (!openAppDeepLinkData.isValidAuthentication()) {
onFailureCallback?.call();
return;
}
if (isSignedIn) {
if (currentContext == null || username == null) {
onFailureCallback?.call();
return;
}
if (openAppDeepLinkData.isLoggedInWith(username.value)) {
onFailureCallback?.call();
return;
}
_showConfirmDialogSwitchAccount(
context: currentContext!,
currentUsername: username.value,
newUsername: openAppDeepLinkData.username,
onConfirmAction: () => onConfirmLogoutCallback?.call(openAppDeepLinkData),
onCancelAction: () => onFailureCallback?.call()
);
} else {
autoSignInViaDeepLink(
openAppDeepLinkData: openAppDeepLinkData,
onAutoSignInSuccessCallback: (viewState) => onAutoSignInSuccessCallback?.call(viewState),
onFailureCallback: () => onFailureCallback?.call(),
);
}
}
Future<void> autoSignInViaDeepLink({
required OpenAppDeepLinkData openAppDeepLinkData,
required OnAutoSignInViaDeepLinkSuccessCallback onAutoSignInSuccessCallback,
required OnDeepLinkFailureCallback onFailureCallback,
}) async {
try {
final autoSignInViaDeepLinkInteractor = Get.find<AutoSignInViaDeepLinkInteractor>();
final autoSignInViewState = await autoSignInViaDeepLinkInteractor.execute(
baseUri: openAppDeepLinkData.baseUri,
tokenOIDC: openAppDeepLinkData.tokenOIDC,
oidcConfiguration: openAppDeepLinkData.oidcConfiguration,
).last;
autoSignInViewState.foldSuccess<AutoSignInViaDeepLinkSuccess>(
onSuccess: onAutoSignInSuccessCallback,
onFailure: (failure) => onFailureCallback.call(),
);
} catch (e) {
logError('DeepLinksManager::_autoSignInViaDeepLink:Exception = $e');
onFailureCallback.call();
}
}
void _showConfirmDialogSwitchAccount({
required BuildContext context,
required String currentUsername,
required String newUsername,
required Function onConfirmAction,
required Function onCancelAction,
}) {
final appLocalizations = AppLocalizations.of(context);
showConfirmDialogAction(
context,
'',
appLocalizations.yes,
title: appLocalizations.switchAccountConfirmation,
alignCenter: true,
outsideDismissible: false,
titleActionButtonMaxLines: 1,
titlePadding: const EdgeInsetsDirectional.only(start: 24, top: 24, end: 24, bottom: 12),
messageStyle: const TextStyle(
color: AppColor.colorTextBody,
fontSize: 15,
fontWeight: FontWeight.w400,
),
listTextSpan: [
TextSpan(text: appLocalizations.youAreCurrentlyLoggedInWith),
TextSpan(
text: ' $currentUsername',
style: const TextStyle(
color: AppColor.colorTextBody,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
const TextSpan(text: '. '),
TextSpan(text: appLocalizations.doYouWantToLogOutAndSwitchTo),
TextSpan(
text: ' $newUsername',
style: const TextStyle(
color: AppColor.colorTextBody,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
const TextSpan(text: '?'),
],
onConfirmAction: onConfirmAction,
onCancelAction: onCancelAction,
);
}
}
@@ -4315,6 +4315,27 @@ class AppLocalizations {
);
}
String get switchAccountConfirmation {
return Intl.message(
'Switch Account Confirmation',
name: 'switchAccountConfirmation',
);
}
String get youAreCurrentlyLoggedInWith {
return Intl.message(
'You are currently logged in with',
name: 'youAreCurrentlyLoggedInWith',
);
}
String get doYouWantToLogOutAndSwitchTo {
return Intl.message(
'Do you want to log out and switch to',
name: 'doYouWantToLogOutAndSwitchTo',
);
}
String get getHelpOrReportABug {
return Intl.message(
'Get help or report a bug',
-1
View File
@@ -19,7 +19,6 @@ class AppConfig {
static const String linagoraPrivacyUrl = 'https://www.linagora.com/en/legal/privacy';
static const String saasRegistrationUrl = 'https://sign-up.twake.app';
static const String saasJmapServerUrl = 'https://jmap.twake.app';
static const String openAppHostDeepLink = 'openApp';
static String get baseUrl => dotenv.get('SERVER_URL', fallback: '');
static String get domainRedirectUrl => dotenv.get('DOMAIN_REDIRECT_URL', fallback: '');
+7 -4
View File
@@ -82,7 +82,7 @@ class IOSSharingManager {
userName: personalAccount.userName!
);
final tokenRecords = await _getTokenEndpointAndScopes();
final tokenRecords = await _getTokenEndpointScopesAndIsTWP();
final mailboxIdsBlockNotification = await _getMailboxIdsBlockNotification(
accountId: personalAccount.accountId!,
@@ -99,7 +99,9 @@ class IOSSharingManager {
basicAuth: credentialInfo,
tokenEndpoint: tokenRecords?.tokenEndpoint,
oidcScopes: tokenRecords?.scopes,
mailboxIdsBlockNotification: mailboxIdsBlockNotification);
mailboxIdsBlockNotification: mailboxIdsBlockNotification,
isTWP: tokenRecords?.isTWP ?? false,
);
await _keychainSharingManager.save(keychainSharingSession);
@@ -171,13 +173,14 @@ class IOSSharingManager {
}
}
Future<({String? tokenEndpoint, List<String>? scopes})?> _getTokenEndpointAndScopes() async {
Future<({String? tokenEndpoint, List<String>? scopes, bool isTWP})?> _getTokenEndpointScopesAndIsTWP() async {
try {
final oidcConfig = await _oidcConfigurationCacheManager.getOidcConfiguration();
final oidcDiscoveryResponse = await _oidcHttpClient.discoverOIDC(oidcConfig);
return (
tokenEndpoint: oidcDiscoveryResponse.tokenEndpoint,
scopes: oidcConfig.scopes
scopes: oidcConfig.scopes,
isTWP: oidcConfig.isTWP,
);
} catch (e) {
logError('IOSSharingManager::_getTokenEndpointAndScopes:Exception: $e');
+8 -8
View File
@@ -2,26 +2,25 @@
import 'package:equatable/equatable.dart';
class OIDCConfiguration with EquatableMixin {
final redirectOidcMobile = 'teammail.mobile://oauthredirect';
final wellKnownOpenId = '.well-known/openid-configuration';
final loginRedirectOidcWeb = 'login-callback.html';
final logoutRedirectOidcWeb = 'logout-callback.html';
static const String _wellKnownOpenId = '.well-known/openid-configuration';
final String authority;
final String clientId;
final List<String> scopes;
final bool isTWP;
OIDCConfiguration({
required this.authority,
required this.clientId,
required this.scopes
required this.scopes,
this.isTWP = false,
});
String get discoveryUrl {
if (authority.endsWith('/')) {
return authority + wellKnownOpenId;
return authority + _wellKnownOpenId;
} else {
return '$authority/$wellKnownOpenId';
return '$authority/$_wellKnownOpenId';
}
}
@@ -29,6 +28,7 @@ class OIDCConfiguration with EquatableMixin {
List<Object?> get props => [
authority,
clientId,
scopes
scopes,
isTWP,
];
}
@@ -68,6 +68,7 @@ void main() {
apiUrl: 'https://jmap.domain.com/oidc/jmap',
tokenEndpoint: 'https://jmap.domain.com/oidc/jmap',
oidcScopes: ['email'],
isTWP: true,
emailState: 'ae08b34da40b48f30ec0b94',
tokenOIDC: TokenOIDC(
'ae08b34da40b48f30ec0b94',
@@ -88,6 +89,7 @@ void main() {
expect(keychainSession?.emailState, equals('ae08b34da40b48f30ec0b94'));
expect(keychainSession?.tokenEndpoint, equals('https://jmap.domain.com/oidc/jmap',));
expect(keychainSession?.oidcScopes, equals(['email']));
expect(keychainSession?.isTWP, isTrue);
});
});
}
@@ -1,51 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:tmail_ui_user/features/home/domain/usecases/auto_sign_in_via_deep_link_interactor.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_data.dart';
import 'package:tmail_ui_user/main/deep_links/deep_links_manager.dart';
class MockAutoSignInViaDeepLinkInteractor extends Mock implements AutoSignInViaDeepLinkInteractor {}
void main() {
final mockAutoSignInViaDeepLinkInteractor = MockAutoSignInViaDeepLinkInteractor();
final deepLinkManager = DeepLinksManager(mockAutoSignInViaDeepLinkInteractor);
group('DeepLinksManager::parseDeepLink::test', () {
test('Valid deep link with multiple query parameters', () {
const deepLink = 'twake.mail://openApp?access_token=ey123456&refresh_token=ey7890&id_token=token&expires_in=3600&username=user@example.com';
final expectedData = DeepLinkData(
action: 'openapp',
accessToken: 'ey123456',
refreshToken: 'ey7890',
idToken: 'token',
expiresIn: 3600,
username: 'user@example.com',
);
final deepLinkData = deepLinkManager.parseDeepLink(deepLink);
expect(deepLinkData, equals(expectedData));
});
test('Deep link with no query parameters', () {
const deepLink = 'twake.mail://openApp';
final expectedData = DeepLinkData(action: 'openapp');
final deepLinkData = deepLinkManager.parseDeepLink(deepLink);
expect(deepLinkData, expectedData);
});
test('Deep link with one query parameter', () {
const deepLink = 'twake.mail://openApp?access_token=ey123456';
final expectedData = DeepLinkData(action: 'openapp', accessToken: 'ey123456',);
final deepLinkData = deepLinkManager.parseDeepLink(deepLink);
expect(deepLinkData, equals(expectedData));
});
test('Invalid deep link format', () {
const deepLink = 'Invalid link: invalid';
final deepLinkData = deepLinkManager.parseDeepLink(deepLink);
expect(deepLinkData, isNull);
});
});
}
@@ -0,0 +1,151 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tmail_ui_user/main/deep_links/deep_link_action_type.dart';
import 'package:tmail_ui_user/main/deep_links/deep_links_manager.dart';
void main() {
final deepLinkManager = DeepLinksManager();
group('DeepLinksManager::test', () {
group('parseDeepLink::test', () {
test('SHOULD returns correct DeepLinkData for valid openApp deep link', () {
const url = 'twake://openApp/some-data';
final result = deepLinkManager.parseDeepLink(url);
expect(result, isNotNull);
expect(result?.actionType, DeepLinkActionType.openApp);
});
test('SHOULD returns DeepLinkData with unknown action for unhandled action', () {
const url = 'twake://unknownAction/some-data';
final result = deepLinkManager.parseDeepLink(url);
expect(result, isNotNull);
expect(result?.actionType, DeepLinkActionType.unknown);
});
test('SHOULD returns null for malformed URL', () {
const url = 'Invalid link: invalid';
final result = deepLinkManager.parseDeepLink(url);
expect(result, isNull);
});
test('SHOULD returns null for exception during parsing', () {
const url = 'twake://malformedurl%';
final result = deepLinkManager.parseDeepLink(url);
expect(result, isNull);
});
});
group('parseOpenAppDeepLink::test', () {
test('SHOULD returns OpenAppDeepLinkData with all valid parameters', () {
final uri = Uri.parse(
'twake://openApp?access_token=token123'
'&refresh_token=refresh123'
'&id_token=id123'
'&expires_in=3600'
'&username=dXNlcg=='
'&registrationUrl=https://registration.url'
'&jmapUrl=https://jmap.url',
);
final result = deepLinkManager.parseOpenAppDeepLink(uri);
expect(result, isNotNull);
expect(result?.accessToken, 'token123');
expect(result?.refreshToken, 'refresh123');
expect(result?.idToken, 'id123');
expect(result?.expiresIn, 3600);
expect(result?.username, 'user');
expect(result?.registrationUrl, 'https://registration.url');
expect(result?.jmapUrl, 'https://jmap.url');
expect(result?.isValidAuthentication(), isTrue);
});
test('SHOULD returns OpenAppDeepLinkData with missing optional parameters', () {
final uri = Uri.parse(
'twake://openApp?access_token=token123'
'&username=user@example.com'
'&registrationUrl=https://registration.url'
'&jmapUrl=https://jmap.url',
);
final result = deepLinkManager.parseOpenAppDeepLink(uri);
expect(result, isNotNull);
expect(result?.accessToken, 'token123');
expect(result?.refreshToken, isNull);
expect(result?.idToken, isNull);
expect(result?.expiresIn, isNull);
expect(result?.username, 'user@example.com');
expect(result?.registrationUrl, 'https://registration.url');
expect(result?.jmapUrl, 'https://jmap.url');
expect(result?.isValidAuthentication(), isTrue);
});
test('SHOULD returns OpenAppDeepLinkData with invalid expires_in', () {
final uri = Uri.parse(
'twake://openApp?access_token=token123'
'&expires_in=not_a_number'
'&registrationUrl=https://registration.url'
'&jmapUrl=https://jmap.url',
);
final result = deepLinkManager.parseOpenAppDeepLink(uri);
expect(result, isNotNull);
expect(result?.expiresIn, isNull);
});
test('SHOULD returns OpenAppDeepLinkData with origin username if Base64 decoding fails', () {
final uri = Uri.parse(
'twake://openApp?access_token=token123'
'&username=invalid_base64'
'&registrationUrl=https://registration.url'
'&jmapUrl=https://jmap.url',
);
final result = deepLinkManager.parseOpenAppDeepLink(uri);
expect(result, isNotNull);
expect(result?.username, 'invalid_base64');
});
test('SHOULD returns OpenAppDeepLinkData with jmapUrl contains sub-path and port', () {
final uri = Uri.parse(
'twake://openApp?access_token=token123'
'&refresh_token=refresh123'
'&id_token=id123'
'&expires_in=3600'
'&username=dXNlcg=='
'&registrationUrl=https://registration.url'
'&jmapUrl=https://jmap.url:1000/jmap',
);
final result = deepLinkManager.parseOpenAppDeepLink(uri);
expect(result, isNotNull);
expect(result?.jmapUrl, 'https://jmap.url:1000/jmap');
});
test('SHOULD returns OpenAppDeepLinkData with registrationUrl contains sub-path and port', () {
final uri = Uri.parse(
'twake://openApp?access_token=token123'
'&refresh_token=refresh123'
'&id_token=id123'
'&expires_in=3600'
'&username=dXNlcg=='
'&registrationUrl=https://registration.url:1000/register'
'&jmapUrl=https://jmap.url',
);
final result = deepLinkManager.parseOpenAppDeepLink(uri);
expect(result, isNotNull);
expect(result?.registrationUrl, 'https://registration.url:1000/register');
});
});
});
}