[go: nahoru, domu]

blob: 852eb77c78814dddd0a097ed2e8c32c9892eb3d6 [file] [log] [blame]
// Copyright 2023 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/save_to_photos/save_to_photos_mediator.h"
#import <UIKit/UIKit.h>
#import "base/ios/block_types.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/histogram_functions.h"
#import "base/metrics/histogram_macros.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/sys_string_conversions.h"
#import "components/prefs/pref_service.h"
#import "components/signin/public/base/signin_metrics.h"
#import "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/drive/model/manage_storage_url_util.h"
#import "ios/chrome/browser/photos/model/photos_metrics.h"
#import "ios/chrome/browser/photos/model/photos_service.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/shared/public/commands/application_commands.h"
#import "ios/chrome/browser/shared/public/commands/manage_storage_alert_commands.h"
#import "ios/chrome/browser/shared/public/commands/open_new_tab_command.h"
#import "ios/chrome/browser/signin/model/authentication_service.h"
#import "ios/chrome/browser/signin/model/chrome_account_manager_service.h"
#import "ios/chrome/browser/signin/model/system_identity.h"
#import "ios/chrome/browser/ui/account_picker/account_picker_configuration.h"
#import "ios/chrome/browser/ui/save_to_photos/save_to_photos_mediator_delegate.h"
#import "ios/chrome/browser/web/model/image_fetch/image_fetch_tab_helper.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util_mac.h"
namespace {
// Maximum length of the suggested image name passed to the Photos service.
constexpr size_t kSuggestedImageNameMaxLength = 100;
NSString* const kNotEnoughStorageErrorLocalizedDescription =
@"The remaining storage in the user's account is not enough to perform "
@"this operation.";
NSURL* GetGooglePhotosAppURL() {
NSURLComponents* photosAppURLComponents = [[NSURLComponents alloc] init];
photosAppURLComponents.scheme = kGooglePhotosAppURLScheme;
return photosAppURLComponents.URL;
}
// Returns formatted size string.
NSString* GetSizeString(NSUInteger sizeInBytes) {
return [NSByteCountFormatter
stringFromByteCount:sizeInBytes
countStyle:NSByteCountFormatterCountStyleFile];
}
// Helper to call -[mediator startWithImageURL:referrer:webState:]
void StartMediatorHelper(__weak SaveToPhotosMediator* mediator,
const GURL& image_url,
const web::Referrer referrer,
base::WeakPtr<web::WebState> web_state) {
[mediator startWithImageURL:image_url
referrer:referrer
webState:web_state.get()];
}
} // namespace
NSString* const kGooglePhotosAppProductIdentifier = @"962194608";
NSString* const kGooglePhotosStoreKitProviderToken = @"9008";
NSString* const kGooglePhotosStoreKitCampaignToken = @"chrome-x-photos";
NSString* const kGooglePhotosRecentlyAddedURLString =
@"https://photos.google.com/search/_tra_?obfsgid=";
NSString* const kGooglePhotosAppURLScheme = @"googlephotos";
@interface SaveToPhotosMediator ()
// Identity used to perform an upload. Should be set when the user selects an
// identity, right before starting to upload. If the upload fails, should be
// reset to nil.
@property(nonatomic, strong) id<SystemIdentity> identity;
@end
@implementation SaveToPhotosMediator {
raw_ptr<PhotosService> _photosService;
raw_ptr<PrefService> _prefService;
raw_ptr<ChromeAccountManagerService> _accountManagerService;
raw_ptr<signin::IdentityManager> _identityManager;
id<ManageStorageAlertCommands> _manageStorageAlertHandler;
id<ApplicationCommands> _applicationHandler;
NSString* _imageName;
NSData* _imageData;
BOOL _userTappedSuccessSnackbarButton;
base::TimeTicks _uploadStart;
BOOL _successSnackbarAppeared;
BOOL _successSnackbarDisappeared;
BOOL _uploadCompletedSuccessfully;
}
#pragma mark - Initialization
- (instancetype)initWithPhotosService:(PhotosService*)photosService
prefService:(PrefService*)prefService
accountManagerService:
(ChromeAccountManagerService*)accountManagerService
identityManager:(signin::IdentityManager*)identityManager
manageStorageAlertHandler:
(id<ManageStorageAlertCommands>)manageStorageAlertHandler
applicationHandler:
(id<ApplicationCommands>)applicationHandler {
self = [super init];
if (self) {
CHECK(photosService);
CHECK(prefService);
CHECK(accountManagerService);
CHECK(identityManager);
CHECK(manageStorageAlertHandler);
CHECK(applicationHandler);
_photosService = photosService;
_prefService = prefService;
_accountManagerService = accountManagerService;
_identityManager = identityManager;
_manageStorageAlertHandler = manageStorageAlertHandler;
_applicationHandler = applicationHandler;
}
return self;
}
#pragma mark - Public
- (void)startWithImageURL:(const GURL&)imageURL
referrer:(const web::Referrer&)referrer
webState:(web::WebState*)webState {
// If the web state does not exist anymore (which can happen when the user
// tries again), hide Save to Photos.
if (!webState) {
base::UmaHistogramEnumeration(
kSaveToPhotosActionsHistogram,
SaveToPhotosActions::kFailureWebStateDestroyed);
[self.delegate hideSaveToPhotos];
return;
}
// If the image cannot be fetched, the user can "Try Again" from here. It
// makes sense to let the user try again if the image cannot be fetched
// because of a connection issue.
__weak __typeof(self) weakSelf = self;
ProceduralBlock tryAgainBlock = base::CallbackToBlock(
base::BindOnce(&StartMediatorHelper, weakSelf, imageURL, referrer,
webState->GetWeakPtr()));
_imageName = base::SysUTF8ToNSString(imageURL.ExtractFileName());
ImageFetchTabHelper* imageFetcher =
ImageFetchTabHelper::FromWebState(webState);
CHECK(imageFetcher);
imageFetcher->GetImageData(imageURL, referrer, ^(NSData* imageData) {
if (imageData) {
[weakSelf continueSaveImageWithData:imageData];
} else {
[weakSelf showTryAgainOrCancelAlertWithTryAgainBlock:tryAgainBlock];
}
});
}
- (void)accountPickerDidSelectIdentity:(id<SystemIdentity>)identity
askEveryTime:(BOOL)askEveryTime {
CHECK(identity);
base::UmaHistogramEnumeration(
kSaveToPhotosAccountPickerActionsHistogram,
SaveToPhotosAccountPickerActions::kSelectedIdentity);
// Memorize the account that was picked and whether to ask which account to
// use every time.
_prefService->SetString(prefs::kIosSaveToPhotosDefaultGaiaId,
base::SysNSStringToUTF8(identity.gaiaID));
_prefService->SetBoolean(prefs::kIosSaveToPhotosSkipAccountPicker,
!askEveryTime);
_identity = identity;
[self.delegate startValidationSpinnerForAccountPicker];
[self.delegate hideAccountPicker];
[self tryUploadImage];
}
- (void)accountPickerDidCancel {
if (!_identity) {
base::UmaHistogramEnumeration(kSaveToPhotosAccountPickerActionsHistogram,
SaveToPhotosAccountPickerActions::kCancelled);
[self.delegate hideAccountPicker];
return;
}
// If `_identity` is not nil while the account picker is presented, that means
// the user has already tapped "Save" for a given identity.
_photosService->CancelUpload();
_identity = nil;
}
- (void)accountPickerWasHidden {
if (!_identity) {
base::UmaHistogramEnumeration(
kSaveToPhotosActionsHistogram,
SaveToPhotosActions::kFailureUserCancelledWithAccountPicker);
[self.delegate hideSaveToPhotos];
}
}
- (void)storeKitWantsToHide {
BOOL photosAppInstalled =
[UIApplication.sharedApplication canOpenURL:GetGooglePhotosAppURL()];
base::UmaHistogramEnumeration(
kSaveToPhotosActionsHistogram,
photosAppInstalled
? SaveToPhotosActions::kSuccessAndOpenStoreKitAndAppInstalled
: SaveToPhotosActions::kSuccessAndOpenStoreKitAndAppNotInstalled);
[self.delegate hideSaveToPhotos];
}
- (void)disconnect {
self.delegate = nil;
_photosService = nullptr;
_prefService = nullptr;
_accountManagerService = nullptr;
_identityManager = nullptr;
_imageName = nil;
_imageData = nil;
_identity = nil;
}
- (void)showManageStorageForIdentity:(id<SystemIdentity>)identity {
base::RecordAction(
base::UserMetricsAction("MobileSaveToPhotosManageStorage"));
// The uploading identity's user email is used to switch to the uploading
// account before loading the "Manage Storage" web page.
GURL manageStorageURL = GenerateManageDriveStorageUrl(
base::SysNSStringToUTF8(identity.userEmail));
OpenNewTabCommand* newTabCommand =
[OpenNewTabCommand commandWithURLFromChrome:manageStorageURL];
[_applicationHandler openURLInNewTab:newTabCommand];
base::UmaHistogramEnumeration(
kSaveToPhotosActionsHistogram,
SaveToPhotosActions::kFailureOutOfStorageDidManageStorage);
[self.delegate hideSaveToPhotos];
}
- (void)manageStorageAlertDidCancel {
base::UmaHistogramEnumeration(
kSaveToPhotosActionsHistogram,
SaveToPhotosActions::kFailureOutOfStorageDidNotManageStorage);
[self.delegate hideSaveToPhotos];
}
#pragma mark - Private
// Resume the process of saving the image once the data has been fetched.
- (void)continueSaveImageWithData:(NSData*)imageData {
_imageData = imageData;
// Although it is unlikely, the user could sign-out while the image data is
// being fetched. Exit now if that happened.
if (!_identityManager->HasPrimaryAccount(signin::ConsentLevel::kSignin)) {
base::UmaHistogramEnumeration(kSaveToPhotosActionsHistogram,
SaveToPhotosActions::kFailureUserSignedOut);
[self.delegate hideSaveToPhotos];
return;
}
const std::string defaultGaiaId =
_prefService->GetString(prefs::kIosSaveToPhotosDefaultGaiaId);
id<SystemIdentity> defaultIdentity =
_accountManagerService->GetIdentityWithGaiaID(defaultGaiaId);
bool skipAccountPicker =
_prefService->GetBoolean(prefs::kIosSaveToPhotosSkipAccountPicker);
// If the user has already selected a default account to save images to
// Photos and opted to skip the account picker, use that default.
if (skipAccountPicker && defaultIdentity) {
_identity = defaultIdentity;
base::UmaHistogramEnumeration(kSaveToPhotosAccountPickerActionsHistogram,
SaveToPhotosAccountPickerActions::kSkipped);
[self tryUploadImage];
return;
}
// If the memorized account is not found on the device, unmemorize it.
if (skipAccountPicker) {
_prefService->ClearPref(prefs::kIosSaveToPhotosDefaultGaiaId);
_prefService->ClearPref(prefs::kIosSaveToPhotosSkipAccountPicker);
}
// If no default account can be used, present the account picker instead.
AccountPickerConfiguration* configuration =
[[AccountPickerConfiguration alloc] init];
configuration.titleText =
l10n_util::GetNSString(IDS_IOS_SAVE_TO_PHOTOS_ACCOUNT_PICKER_TITLE);
NSString* imageSize = GetSizeString(_imageData.length);
configuration.bodyText =
l10n_util::GetNSStringF(IDS_IOS_SAVE_TO_PHOTOS_ACCOUNT_PICKER_BODY,
base::SysNSStringToUTF16(_imageName),
base::SysNSStringToUTF16(imageSize));
configuration.submitButtonTitle =
l10n_util::GetNSString(IDS_IOS_SAVE_TO_PHOTOS_ACCOUNT_PICKER_SUBMIT);
configuration.askEveryTimeSwitchLabelText = l10n_util::GetNSString(
IDS_IOS_SAVE_TO_PHOTOS_ACCOUNT_PICKER_ASK_EVERY_TIME);
[self.delegate showAccountPickerWithConfiguration:configuration
selectedIdentity:defaultIdentity];
}
// Once the destination account is known, tries to upload the image using the
// Photos service.
- (void)tryUploadImage {
__weak __typeof(self) weakSelf = self;
// Reset part of the state in case this is not the first attempt.
_userTappedSuccessSnackbarButton = NO;
_successSnackbarAppeared = NO;
_successSnackbarDisappeared = NO;
_uploadCompletedSuccessfully = NO;
// If the Photos service is unavailable (maybe busy in a separate window),
// present an alert.
if (!_photosService->IsAvailable()) {
[self showTryAgainOrCancelAlertWithTryAgainBlock:^{
[weakSelf tryUploadImage];
}];
return;
}
// Else start uploading the image.
auto uploadCompletionCallback =
base::BindOnce(^(PhotosService::UploadResult result) {
[weakSelf photosServiceFinishedUploadWithResult:result];
});
_uploadStart = base::TimeTicks::Now();
auto uploadProgressCallback =
base::BindRepeating(^(const PhotosService::UploadProgress& progress) {
[weakSelf photosServiceReportedUploadProgress:progress];
});
NSString* suggestedImageName =
_imageName.length > kSuggestedImageNameMaxLength ? nil : _imageName;
_photosService->UploadImage(suggestedImageName, _imageData, _identity,
std::move(uploadProgressCallback),
std::move(uploadCompletionCallback));
}
// After upload failed with `failureIdentity`, this can be called to retry an
// upload with the same identity.
- (void)retryUploadImageWithIdentity:(id<SystemIdentity>)failureIdentity {
self.identity = failureIdentity;
[self tryUploadImage];
}
// Called when the Photos service reports upload completion.
- (void)photosServiceFinishedUploadWithResult:
(PhotosService::UploadResult)result {
if (!result.successful) {
// `_identity` is used to determine whether an upload is ongoing or was
// successful. If the upload failed, `_identity` should be reset.
id<SystemIdentity> failureIdentity = _identity;
_identity = nil;
base::UmaHistogramTimes(kSaveToPhotosUploadFailureLatencyHistogram,
base::TimeTicks::Now() - _uploadStart);
// TODO(crbug.com/1513891): Emit the failure type as-is once the service is
// able to identify out-of-storage errors by itself.
if (result.failure_type == PhotosServiceUploadFailureType::kUploadPhoto2 &&
[result.error.localizedDescription
isEqualToString:kNotEnoughStorageErrorLocalizedDescription]) {
result.failure_type =
PhotosServiceUploadFailureType::kUploadPhoto2NotEnoughStorage;
}
base::UmaHistogramEnumeration(kSaveToPhotosUploadFailureTypeHistogram,
result.failure_type);
// If the user is out of storage, offer to manage their storage.
if (result.failure_type ==
PhotosServiceUploadFailureType::kUploadPhoto2NotEnoughStorage) {
[_manageStorageAlertHandler
showManageStorageAlertForIdentity:failureIdentity];
return;
}
// Otherwise let the user "Try Again" with the same account.
__weak __typeof(self) weakSelf = self;
[self showTryAgainOrCancelAlertWithTryAgainBlock:^{
[weakSelf retryUploadImageWithIdentity:failureIdentity];
}];
return;
}
base::UmaHistogramTimes(kSaveToPhotosUploadSuccessLatencyHistogram,
base::TimeTicks::Now() - _uploadStart);
_uploadCompletedSuccessfully = YES;
if (!_successSnackbarAppeared) {
// If the success snackbar did not appear for some reason (no progress has
// been reported), show it now.
[self showSnackbarWithSuccessMessageAndOpenButton];
return;
}
if (!_successSnackbarDisappeared) {
// If the success snackbar has not disappeared, wait until it does to
// maybe open the photo and then finish.
return;
}
if (_userTappedSuccessSnackbarButton) {
[self openPhotosAppOrShowInStoreKit];
return;
}
base::UmaHistogramEnumeration(kSaveToPhotosActionsHistogram,
SaveToPhotosActions::kSuccess);
[self.delegate hideSaveToPhotos];
}
- (void)photosServiceReportedUploadProgress:
(const PhotosService::UploadProgress&)progress {
// If all of the image data has been uploaded, show success early.
if (progress.total_bytes_sent == progress.total_bytes_expected_to_send) {
[self showSnackbarWithSuccessMessageAndOpenButton];
}
}
// Shows an alert with a "Try Again" button (calls `tryAgain`) and a "Cancel"
// button.
- (void)showTryAgainOrCancelAlertWithTryAgainBlock:(ProceduralBlock)tryAgain {
NSString* title = l10n_util::GetNSString(
IDS_IOS_SAVE_TO_PHOTOS_THIS_FILE_COULD_NOT_BE_UPLOADED_TITLE);
NSString* imageSize = GetSizeString(_imageData.length);
NSString* message = l10n_util::GetNSStringF(
IDS_IOS_SAVE_TO_PHOTOS_THIS_FILE_COULD_NOT_BE_UPLOADED_MESSAGE,
base::SysNSStringToUTF16(_imageName),
base::SysNSStringToUTF16(imageSize));
NSString* cancelTitle = l10n_util::GetNSString(IDS_CANCEL);
NSString* tryAgainTitle = l10n_util::GetNSString(
IDS_IOS_SAVE_TO_PHOTOS_THIS_FILE_COULD_NOT_BE_UPLOADED_TRY_AGAIN);
__weak __typeof(self.delegate) weakDelegate = self.delegate;
[self.delegate
showTryAgainOrCancelAlertWithTitle:title
message:message
tryAgainTitle:tryAgainTitle
tryAgainAction:tryAgain
cancelTitle:cancelTitle
cancelAction:^{
base::UmaHistogramEnumeration(
kSaveToPhotosActionsHistogram,
SaveToPhotosActions::
kFailureUserCancelledWithTryAgainAlert);
[weakDelegate hideSaveToPhotos];
}];
}
// Shows a snackbar to let the user know the Photos service is done uploading
// the image with a button to either open the Photos app if it is installed or
// show the Photos app in the AppStore otherwise.
- (void)showSnackbarWithSuccessMessageAndOpenButton {
_successSnackbarAppeared = YES;
NSString* message = l10n_util::GetNSStringF(
IDS_IOS_SAVE_TO_PHOTOS_SNACKBAR_IMAGE_SAVED_MESSAGE,
base::SysNSStringToUTF16(_identity.userEmail));
NSString* buttonText = l10n_util::GetNSString(
IDS_IOS_SAVE_TO_PHOTOS_SNACKBAR_IMAGE_SAVED_OPEN_BUTTON);
__weak __typeof(self) weakSelf = self;
[self.delegate showSnackbarWithMessage:message
buttonText:buttonText
messageAction:^{
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
strongSelf->_userTappedSuccessSnackbarButton = YES;
}
completionAction:^(BOOL userTriggered) {
[weakSelf successSnackbarDisappearedUserTriggered:userTriggered];
}];
}
// Called when the snackbar shown upon completion disappears. `userTriggered` is
// YES if the snackbar has been dismissed by the user.
- (void)successSnackbarDisappearedUserTriggered:(BOOL)userTriggered {
_successSnackbarDisappeared = YES;
if (!_uploadCompletedSuccessfully) {
// If the upload has not completed, wait until it does to finish. The
// success snackbar can disappear before the upload has completed because it
// is presented early.
return;
}
// If the upload completed and the user tapped "Open", open the photo.
if (_userTappedSuccessSnackbarButton) {
[self openPhotosAppOrShowInStoreKit];
return;
}
// If the user did not interact with the snackbar, finish.
base::UmaHistogramEnumeration(kSaveToPhotosActionsHistogram,
SaveToPhotosActions::kSuccess);
[self.delegate hideSaveToPhotos];
}
// Opens the Photos app if it is installed or show the Photos app in the
// AppStore otherwise.
- (void)openPhotosAppOrShowInStoreKit {
// If the Photos app is not installed, show StoreKit.
if (![UIApplication.sharedApplication canOpenURL:GetGooglePhotosAppURL()]) {
[self.delegate
showStoreKitWithProductIdentifier:kGooglePhotosAppProductIdentifier
providerToken:kGooglePhotosStoreKitProviderToken
campaignToken:kGooglePhotosStoreKitCampaignToken];
return;
}
// Otherwise, open the Photos app and hide Save to Photos.
NSString* recentlyAddedURLString = [kGooglePhotosRecentlyAddedURLString
stringByAppendingString:_identity.gaiaID];
NSURL* photosURL = [NSURL URLWithString:recentlyAddedURLString];
[UIApplication.sharedApplication
openURL:photosURL
options:@{UIApplicationOpenURLOptionUniversalLinksOnly : @YES}
completionHandler:nil];
base::UmaHistogramEnumeration(kSaveToPhotosActionsHistogram,
SaveToPhotosActions::kSuccessAndOpenPhotosApp);
[self.delegate hideSaveToPhotos];
}
@end