// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/ui/authentication/signout_action_sheet/signout_action_sheet_coordinator.h"
#import <MaterialComponents/MaterialSnackbar.h>
#import "base/check.h"
#import "base/format_macros.h"
#import "base/metrics/histogram_functions.h"
#import "base/metrics/histogram_macros.h"
#import "base/metrics/user_metrics.h"
#import "base/strings/utf_string_conversions.h"
#import "components/browser_sync/sync_to_signin_migration.h"
#import "components/signin/public/base/signin_metrics.h"
#import "components/strings/grit/components_strings.h"
#import "components/sync/service/sync_service.h"
#import "components/sync/service/sync_user_settings.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
#import "ios/chrome/browser/shared/public/commands/snackbar_commands.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/util/snackbar_util.h"
#import "ios/chrome/browser/signin/model/authentication_service.h"
#import "ios/chrome/browser/signin/model/authentication_service_factory.h"
#import "ios/chrome/browser/signin/model/identity_manager_factory.h"
#import "ios/chrome/browser/sync/model/enterprise_utils.h"
#import "ios/chrome/browser/sync/model/sync_service_factory.h"
#import "ios/chrome/browser/ui/authentication/authentication_ui_util.h"
#import "ios/chrome/grit/ios_branded_strings.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util_mac.h"
using signin_metrics::SignoutDataLossAlertReason;
// Enum to describe all 5 cases for a user being signed-in. This enum is used
// internaly by SignoutActionSheetCoordinator().
typedef NS_ENUM(NSUInteger, SignedInUserState) {
// Sign-in with UNO. The sign-out needs to ask confirmation to sign out only
// if there are unsaved data. When signed out, a snackbar needs to be
// diplayed.
SignedInUserStateWithNotSyncingAndReplaceSyncWithSignin,
// Sign-in with UNO, where the user is managed, and was migrated from the
// syncing state. In this state, data needs to be cleared on signout, similar
// to SignedInUserStateWithManagedAccountAndSyncing.
SignedInUserStateWithManagedAccountAndMigratedFromSyncing,
// Sign-in with a managed account and sync is turned on.
SignedInUserStateWithManagedAccountAndSyncing,
// Sign-in with a managed account and sync is turned off.
SignedInUserStateWithManagedAccountAndNotSyncing,
// Sign-in with a regular account and sync is turned on.
SignedInUserStateWithNonManagedAccountAndSyncing,
// Sign-in with a regular account and sync is turned off.
SignedInUserStateWithNoneManagedAccountAndNotSyncing,
// Sign-in with a requirement to give more contextual information when the
// forced sign-in policy is enabled.
SignedInUserStateWithForcedSigninInfoRequired,
// Signed in with managed account with the ClearDeviceDataOnSignoutForManaged
// user feature enabled.
SignedInUserStateWithManagedAccountClearsDataOnSignout
};
@interface SignoutActionSheetCoordinator () {
// YES if the coordinator asked its delegate to block the user interaction.
// This boolean makes sure the user interaction is allowed when `stop` is
// called.
BOOL _userActionBlocked;
// YES if the coordinator has been stopped.
BOOL _stopped;
// Rectangle for the popover alert.
CGRect _rect;
// View for the popovert alert.
__weak UIView* _view;
// Source of the sign-out action. For histogram if the sign-out occurs.
signin_metrics::ProfileSignout _signout_source_metric;
}
// Service for managing identity authentication.
@property(nonatomic, assign, readonly)
AuthenticationService* authenticationService;
// Action sheet to display sign-out actions.
@property(nonatomic, strong) ActionSheetCoordinator* actionSheetCoordinator;
// YES if the user has confirmed that they want to signout.
@property(nonatomic, assign) BOOL confirmSignOut;
// YES if sign-in is forced by enterprise policy.
@property(nonatomic, assign, readonly) BOOL isForceSigninEnabled;
@end
@implementation SignoutActionSheetCoordinator
- (instancetype)initWithBaseViewController:(UIViewController*)viewController
browser:(Browser*)browser
rect:(CGRect)rect
view:(UIView*)view
withSource:(signin_metrics::ProfileSignout)
signout_source_metric {
self = [super initWithBaseViewController:viewController browser:browser];
if (self) {
_rect = rect;
_view = view;
_signout_source_metric = signout_source_metric;
}
return self;
}
#pragma mark - ChromeCoordinator
- (void)start {
DCHECK(self.completion);
DCHECK(self.authenticationService->HasPrimaryIdentity(
signin::ConsentLevel::kSignin));
switch (self.signedInUserState) {
case SignedInUserStateWithNotSyncingAndReplaceSyncWithSignin:
[self checkForUnsyncedDataAndSignOut];
break;
case SignedInUserStateWithManagedAccountClearsDataOnSignout:
case SignedInUserStateWithManagedAccountAndMigratedFromSyncing:
case SignedInUserStateWithManagedAccountAndSyncing:
case SignedInUserStateWithManagedAccountAndNotSyncing:
case SignedInUserStateWithNonManagedAccountAndSyncing:
case SignedInUserStateWithNoneManagedAccountAndNotSyncing:
case SignedInUserStateWithForcedSigninInfoRequired:
[self startActionSheetCoordinatorForSignout];
break;
}
}
- (void)stop {
if (_userActionBlocked) {
[self allowUserInteraction];
}
[self dismissActionSheetCoordinator];
_stopped = YES;
[self callCompletionBlock:NO];
}
- (void)dealloc {
DCHECK(!_userActionBlocked);
DCHECK(_stopped);
DCHECK(!self.actionSheetCoordinator);
}
#pragma mark - ActionSheetCoordinator properties
- (NSString*)title {
return self.actionSheetCoordinator.title;
}
- (NSString*)message {
return self.actionSheetCoordinator.message;
}
#pragma mark - Browser-based properties
- (AuthenticationService*)authenticationService {
return AuthenticationServiceFactory::GetForBrowserState(
self.browser->GetBrowserState());
}
// Returns the user's sign-in and syncing state.
- (SignedInUserState)signedInUserState {
DCHECK(self.browser);
syncer::SyncService* syncService =
SyncServiceFactory::GetForBrowserState(self.browser->GetBrowserState());
ChromeBrowserState* browserState = self.browser->GetBrowserState();
AuthenticationService* authenticationService = self.authenticationService;
const bool is_managed_account_migrated_from_syncing =
browser_sync::WasPrimaryAccountMigratedFromSyncingToSignedIn(
IdentityManagerFactory::GetForBrowserState(browserState),
browserState->GetPrefs()) &&
authenticationService->HasPrimaryIdentityManaged(
signin::ConsentLevel::kSignin);
// TODO(crbug.com/40066949): Simplify once ConsentLevel::kSync and
// SyncService::IsSyncFeatureEnabled() are deleted from the codebase.
if (is_managed_account_migrated_from_syncing) {
return SignedInUserStateWithManagedAccountAndMigratedFromSyncing;
}
if (authenticationService->ShouldClearDataForSignedInPeriodOnSignOut()) {
return SignedInUserStateWithManagedAccountClearsDataOnSignout;
}
if (!authenticationService->HasPrimaryIdentity(signin::ConsentLevel::kSync)) {
return SignedInUserStateWithNotSyncingAndReplaceSyncWithSignin;
}
BOOL syncEnabled =
syncService->GetUserSettings()->IsInitialSyncFeatureSetupComplete();
// Need a first step to show logout contextual information about the forced
// sign-in policy. Only return this state when sync is enabled because it is
// already shown for sync disabled.
if (self.isForceSigninEnabled && syncEnabled && !self.confirmSignOut) {
return SignedInUserStateWithForcedSigninInfoRequired;
}
if (self.authenticationService->HasPrimaryIdentityManaged(
signin::ConsentLevel::kSignin)) {
return syncEnabled ? SignedInUserStateWithManagedAccountAndSyncing
: SignedInUserStateWithManagedAccountAndNotSyncing;
}
return syncEnabled ? SignedInUserStateWithNonManagedAccountAndSyncing
: SignedInUserStateWithNoneManagedAccountAndNotSyncing;
}
// Returns the title associated to the given user sign-in state or nil if no
// title is defined for the state.
- (NSString*)actionSheetCoordinatorTitle {
DCHECK(self.browser);
NSString* title = nil;
switch (self.signedInUserState) {
case SignedInUserStateWithNotSyncingAndReplaceSyncWithSignin:
// This dialog is triggered only if there is unsync data.
title = l10n_util::GetNSString(
IDS_IOS_SIGNOUT_DIALOG_SIGN_OUT_AND_DELETE_TITLE);
break;
case SignedInUserStateWithManagedAccountAndMigratedFromSyncing:
case SignedInUserStateWithManagedAccountAndSyncing: {
std::u16string hostedDomain = HostedDomainForPrimaryAccount(self.browser);
title = l10n_util::GetNSStringF(
IDS_IOS_SIGNOUT_DIALOG_TITLE_WITH_SYNCING_MANAGED_ACCOUNT,
hostedDomain);
break;
}
case SignedInUserStateWithManagedAccountClearsDataOnSignout: {
title = l10n_util::GetNSString(
IDS_IOS_SIGNOUT_CLEARS_DATA_DIALOG_TITLE_WITH_MANAGED_ACCOUNT);
break;
}
case SignedInUserStateWithNonManagedAccountAndSyncing: {
title = l10n_util::GetNSString(
IDS_IOS_SIGNOUT_DIALOG_TITLE_WITH_SYNCING_ACCOUNT);
break;
}
case SignedInUserStateWithForcedSigninInfoRequired:
case SignedInUserStateWithManagedAccountAndNotSyncing:
case SignedInUserStateWithNoneManagedAccountAndNotSyncing: {
if (self.isForceSigninEnabled) {
title = l10n_util::GetNSString(
IDS_IOS_ENTERPRISE_FORCED_SIGNIN_SIGNOUT_DIALOG_TITLE);
} else if (self.showUnavailableFeatureDialogHeader) {
title = l10n_util::GetNSString(
IDS_IOS_SIGNOUT_DIALOG_TITLE_WITHOUT_SYNCING_ACCOUNT);
}
break;
}
}
return title;
}
// Returns the message associated to the given user sign-in state or nil if no
// message is defined for the state.
- (NSString*)actionSheetCoordinatorMessage {
switch (self.signedInUserState) {
case SignedInUserStateWithNotSyncingAndReplaceSyncWithSignin:
// This dialog is triggered only if there is unsync data.
return l10n_util::GetNSString(
IDS_IOS_SIGNOUT_DIALOG_MESSAGE_WITH_NOT_SAVED_DATA);
case SignedInUserStateWithForcedSigninInfoRequired:
case SignedInUserStateWithNoneManagedAccountAndNotSyncing:
case SignedInUserStateWithManagedAccountAndNotSyncing: {
if (self.isForceSigninEnabled) {
return l10n_util::GetNSString(IDS_IOS_ENTERPRISE_FORCED_SIGNIN_MESSAGE);
}
return nil;
}
case SignedInUserStateWithManagedAccountClearsDataOnSignout:
return l10n_util::GetNSString(
IDS_IOS_SIGNOUT_CLEARS_DATA_DIALOG_MESSAGE_WITH_MANAGED_ACCOUNT);
case SignedInUserStateWithManagedAccountAndMigratedFromSyncing:
case SignedInUserStateWithManagedAccountAndSyncing:
case SignedInUserStateWithNonManagedAccountAndSyncing: {
return nil;
}
}
}
#pragma mark - Properties
- (BOOL)isForceSigninEnabled {
return self.authenticationService->GetServiceStatus() ==
AuthenticationService::ServiceStatus::SigninForcedByPolicy;
}
#pragma mark - Private
// Calls the delegate to prevent user actions, and updates `_userActionBlocked`.
- (void)preventUserInteraction {
DCHECK(!_userActionBlocked);
_userActionBlocked = YES;
[self.delegate signoutActionSheetCoordinatorPreventUserInteraction:self];
}
// Calls the delegate to allow user actions, and updates `_userActionBlocked`.
- (void)allowUserInteraction {
DCHECK(_userActionBlocked);
_userActionBlocked = NO;
[self.delegate signoutActionSheetCoordinatorAllowUserInteraction:self];
}
// Fetches for unsynced data, and the sign-out continued after (with unsynced
// data dialog if needed, and then sign-out).
- (void)checkForUnsyncedDataAndSignOut {
[self preventUserInteraction];
constexpr syncer::DataTypeSet kDataTypesToQuery =
syncer::TypesRequiringUnsyncedDataCheckOnSignout();
syncer::SyncService* syncService =
SyncServiceFactory::GetForBrowserState(self.browser->GetBrowserState());
__weak __typeof(self) weakSelf = self;
auto callback = base::BindOnce(^(syncer::DataTypeSet set) {
CHECK(kDataTypesToQuery.HasAll(set))
<< "Result: {" << set << "} not a subset of the queried types: {"
<< kDataTypesToQuery << "}.";
[weakSelf continueSignOutWithUnsyncedDataTypeSet:set];
});
syncService->GetTypesWithUnsyncedData(kDataTypesToQuery, std::move(callback));
}
// Displays the sign-out confirmation dialog if `set` contains an "interesting"
// data type, otherwise the sign-out is triggered without dialog.
- (void)continueSignOutWithUnsyncedDataTypeSet:(syncer::DataTypeSet)set {
[self allowUserInteraction];
if (!set.empty()) {
for (syncer::DataType type : set) {
base::UmaHistogramEnumeration("Sync.UnsyncedDataOnSignout2",
syncer::DataTypeHistogramValue(type));
}
[self startActionSheetCoordinatorForSignout];
} else {
[self handleSignOutWithForceClearData:NO];
}
}
- (void)dismissActionSheetCoordinator {
[self.actionSheetCoordinator stop];
self.actionSheetCoordinator = nil;
}
// Starts the signout action sheet for the current user state.
- (void)startActionSheetCoordinatorForSignout {
self.actionSheetCoordinator = [[ActionSheetCoordinator alloc]
initWithBaseViewController:self.baseViewController
browser:self.browser
title:self.actionSheetCoordinatorTitle
message:self.actionSheetCoordinatorMessage
rect:_rect
view:_view];
__weak SignoutActionSheetCoordinator* weakSelf = self;
switch (self.signedInUserState) {
case SignedInUserStateWithNotSyncingAndReplaceSyncWithSignin: {
// This dialog is triggered only if there is unsynced data.
self.actionSheetCoordinator.alertStyle = UIAlertControllerStyleAlert;
NSString* const signOutButtonTitle = l10n_util::GetNSString(
IDS_IOS_SIGNOUT_DIALOG_SIGN_OUT_AND_DELETE_BUTTON);
[self.actionSheetCoordinator
addItemWithTitle:signOutButtonTitle
action:^{
[weakSelf signoutWithForceClearData:NO
recordHistogram:
SignoutDataLossAlertReason::
kSignoutWithUnsyncedData];
}
style:UIAlertActionStyleDestructive];
[self.actionSheetCoordinator
addItemWithTitle:l10n_util::GetNSString(IDS_CANCEL)
action:^{
[weakSelf cancelSignoutAndRecordHistogram:
SignoutDataLossAlertReason::
kSignoutWithUnsyncedData];
}
style:UIAlertActionStyleCancel];
[self.actionSheetCoordinator start];
return;
}
case SignedInUserStateWithForcedSigninInfoRequired: {
NSString* const signOutButtonTitle =
l10n_util::GetNSString(IDS_IOS_SIGNOUT_DIALOG_SIGN_OUT_BUTTON);
[self.actionSheetCoordinator
addItemWithTitle:signOutButtonTitle
action:^{
[weakSelf handleSignOutForForcedSigninUsers];
}
style:UIAlertActionStyleDestructive];
break;
}
case SignedInUserStateWithManagedAccountClearsDataOnSignout: {
self.actionSheetCoordinator.alertStyle = UIAlertControllerStyleAlert;
NSString* const signOutButtonTitle =
l10n_util::GetNSString(IDS_IOS_SIGNOUT_DIALOG_SIGN_OUT_BUTTON);
[self.actionSheetCoordinator
addItemWithTitle:signOutButtonTitle
action:^{
// `clearData` should not be set
// based on the useer choice, but based on the account
// state in `AuthenticationService`.
[weakSelf
signoutWithForceClearData:NO
recordHistogram:
SignoutDataLossAlertReason::
kSignoutWithClearDataForManagedUser];
}
style:UIAlertActionStyleDestructive];
[self.actionSheetCoordinator
addItemWithTitle:l10n_util::GetNSString(IDS_CANCEL)
action:^{
[weakSelf cancelSignoutAndRecordHistogram:
SignoutDataLossAlertReason::
kSignoutWithClearDataForManagedUser];
}
style:UIAlertActionStyleCancel];
[self.actionSheetCoordinator start];
return;
}
case SignedInUserStateWithManagedAccountAndMigratedFromSyncing:
case SignedInUserStateWithManagedAccountAndSyncing: {
NSString* const clearFromDeviceTitle =
l10n_util::GetNSString(IDS_IOS_SIGNOUT_DIALOG_CLEAR_DATA_BUTTON);
[self.actionSheetCoordinator
addItemWithTitle:clearFromDeviceTitle
action:^{
// Note that it doesn't really make a difference whether
// `forceClearData` is set to YES or NO here - based on
// the account's state, AuthenticationService will decide
// to clear the data anyway.
[weakSelf signoutWithForceClearData:YES];
}
style:UIAlertActionStyleDestructive];
break;
}
case SignedInUserStateWithManagedAccountAndNotSyncing: {
NSString* const clearFromDeviceTitle =
l10n_util::GetNSString(IDS_IOS_SIGNOUT_DIALOG_SIGN_OUT_BUTTON);
[self.actionSheetCoordinator
addItemWithTitle:clearFromDeviceTitle
action:^{
[weakSelf signoutWithForceClearData:NO];
}
style:UIAlertActionStyleDestructive];
break;
}
case SignedInUserStateWithNonManagedAccountAndSyncing: {
NSString* const clearFromDeviceTitle =
l10n_util::GetNSString(IDS_IOS_SIGNOUT_DIALOG_CLEAR_DATA_BUTTON);
NSString* const keepOnDeviceTitle =
l10n_util::GetNSString(IDS_IOS_SIGNOUT_DIALOG_KEEP_DATA_BUTTON);
[self.actionSheetCoordinator
addItemWithTitle:clearFromDeviceTitle
action:^{
[weakSelf signoutWithForceClearData:YES];
}
style:UIAlertActionStyleDestructive];
[self.actionSheetCoordinator
addItemWithTitle:keepOnDeviceTitle
action:^{
[weakSelf signoutWithForceClearData:NO];
}
style:UIAlertActionStyleDefault];
break;
}
case SignedInUserStateWithNoneManagedAccountAndNotSyncing: {
NSString* const signOutButtonTitle =
l10n_util::GetNSString(IDS_IOS_SIGNOUT_DIALOG_SIGN_OUT_BUTTON);
[self.actionSheetCoordinator
addItemWithTitle:signOutButtonTitle
action:^{
[weakSelf signoutWithForceClearData:NO];
}
style:UIAlertActionStyleDestructive];
break;
}
}
[self.actionSheetCoordinator
addItemWithTitle:l10n_util::GetNSString(IDS_CANCEL)
action:^{
[weakSelf cancelSignout];
}
style:UIAlertActionStyleCancel];
[self.actionSheetCoordinator start];
}
- (void)cancelSignoutAndRecordHistogram:(SignoutDataLossAlertReason)reason {
signin_metrics::RecordSignoutConfirmationFromDataLossAlert(reason, false);
[self cancelSignout];
}
- (void)cancelSignout {
[self callCompletionBlock:NO];
[self dismissActionSheetCoordinator];
}
- (void)signoutWithForceClearData:(BOOL)clearData
recordHistogram:(SignoutDataLossAlertReason)reason {
signin_metrics::RecordSignoutConfirmationFromDataLossAlert(reason, true);
[self signoutWithForceClearData:clearData];
}
- (void)signoutWithForceClearData:(BOOL)clearData {
[self handleSignOutWithForceClearData:clearData];
[self dismissActionSheetCoordinator];
}
- (void)handleSignOutForForcedSigninUsers {
self.confirmSignOut = YES;
// Stop the current action sheet coordinator and start a
// new one for the next step.
[self dismissActionSheetCoordinator];
[self startActionSheetCoordinatorForSignout];
}
// Signs the user out of the primary account and clears the data from their
// device if specified to do so.
- (void)handleSignOutWithForceClearData:(BOOL)forceClearData {
if (!self.browser)
return;
if (!self.authenticationService->HasPrimaryIdentity(
signin::ConsentLevel::kSignin)) {
[self callCompletionBlock:YES];
return;
}
[self preventUserInteraction];
id<SnackbarCommands> snackbarCommandsHandler = HandlerForProtocol(
self.browser->GetCommandDispatcher(), SnackbarCommands);
// The snackbar message might be nil if the snackbar is not needed.
MDCSnackbarMessage* snackbarMessage = [self signoutSnackbarMessage];
__weak __typeof(self) weakSelf = self;
self.authenticationService->SignOut(_signout_source_metric, forceClearData, ^{
// The snackbar should be displayed even if self has been deallocated.
[snackbarCommandsHandler showSnackbarMessage:snackbarMessage
bottomOffset:0];
[weakSelf signOutDidFinish];
});
// Get UMA metrics on the usage of different options for signout available
// for users with non-managed accounts.
if (!self.authenticationService->HasPrimaryIdentityManaged(
signin::ConsentLevel::kSignin)) {
signin_metrics::RecordSignoutForceClearDataChoice(forceClearData);
}
signin_metrics::RecordSignoutUserAction(forceClearData);
}
// Called when the sign-out is done.
- (void)signOutDidFinish {
if (_stopped) {
// The coordinator has been stopped. The UI has been unblocked, and the
// owner doesn't expect the completion call anymore.
return;
}
[self allowUserInteraction];
[self callCompletionBlock:YES];
}
// Returns snackbar if needed.
- (MDCSnackbarMessage*)signoutSnackbarMessage {
if (self.skipPostSignoutSnackbar) {
return nil;
}
switch (self.signedInUserState) {
case SignedInUserStateWithManagedAccountClearsDataOnSignout:
case SignedInUserStateWithNotSyncingAndReplaceSyncWithSignin:
case SignedInUserStateWithManagedAccountAndMigratedFromSyncing:
break;
case SignedInUserStateWithManagedAccountAndSyncing:
case SignedInUserStateWithManagedAccountAndNotSyncing:
case SignedInUserStateWithNonManagedAccountAndSyncing:
case SignedInUserStateWithNoneManagedAccountAndNotSyncing:
case SignedInUserStateWithForcedSigninInfoRequired:
return nil;
}
if (self.isForceSigninEnabled) {
// Snackbar should be skipped since force sign-in dialog will be shown right
// after.
return nil;
}
syncer::SyncService* syncService =
SyncServiceFactory::GetForBrowserState(self.browser->GetBrowserState());
int message_id =
syncService->HasDisableReason(
syncer::SyncService::DISABLE_REASON_ENTERPRISE_POLICY) ||
HasManagedSyncDataType(syncService)
? IDS_IOS_GOOGLE_ACCOUNT_SETTINGS_SIGN_OUT_SNACKBAR_MESSAGE_ENTERPRISE
: IDS_IOS_GOOGLE_ACCOUNT_SETTINGS_SIGN_OUT_SNACKBAR_MESSAGE;
MDCSnackbarMessage* message =
CreateSnackbarMessage(l10n_util::GetNSString(message_id));
return message;
}
// Calls `self.completion` if available, and sets it to `null` before the call.
- (void)callCompletionBlock:(BOOL)signedOut {
if (!self.completion) {
return;
}
signin_ui::CompletionCallback completion = self.completion;
self.completion = nil;
completion(signedOut);
}
@end