// Copyright 2016 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/share_extension/model/share_extension_item_receiver.h"
#import <UIKit/UIKit.h>
#import "base/apple/foundation_util.h"
#import "base/functional/bind.h"
#import "base/ios/block_types.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/histogram_macros.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/sys_string_conversions.h"
#import "base/strings/utf_string_conversions.h"
#import "base/task/sequenced_task_runner.h"
#import "base/task/thread_pool.h"
#import "base/threading/scoped_blocking_call.h"
#import "components/bookmarks/browser/bookmark_model.h"
#import "components/reading_list/core/reading_list_model.h"
#import "components/reading_list/core/reading_list_model_observer.h"
#import "ios/chrome/browser/shared/public/features/system_flags.h"
#import "ios/chrome/common/app_group/app_group_constants.h"
#import "ios/web/public/thread/web_task_traits.h"
#import "ios/web/public/thread/web_thread.h"
#import "net/base/apple/url_conversions.h"
#import "url/gurl.h"
namespace {
// Enum used to send metrics on item reception.
// If you change this enum, update histograms.xml.
enum ShareExtensionItemReceived {
INVALID_ENTRY = 0,
CANCELLED_ENTRY,
READINGLIST_ENTRY,
BOOKMARK_ENTRY,
OPEN_IN_CHROME_ENTRY,
SHARE_EXTENSION_ITEM_RECEIVED_COUNT
};
// Enum used to send metrics on item reception.
// If you change this enum, update histograms.xml.
enum ShareExtensionSource {
UNKNOWN_SOURCE = 0,
SHARE_EXTENSION,
SHARE_EXTENSION_SOURCE_COUNT
};
ShareExtensionSource SourceIDFromSource(NSString* source) {
if ([source isEqualToString:app_group::kShareItemSourceShareExtension]) {
return SHARE_EXTENSION;
}
return UNKNOWN_SOURCE;
}
void LogHistogramReceivedItem(ShareExtensionItemReceived type) {
UMA_HISTOGRAM_ENUMERATION("IOS.ShareExtension.ReceivedEntry", type,
SHARE_EXTENSION_ITEM_RECEIVED_COUNT);
}
} // namespace
@interface ShareExtensionItemReceiver () <NSFilePresenter>
// Checks if the reading list folder is already created and if not, create it.
- (void)createReadingListFolder;
// Invoked on UI thread once the reading list folder has been created.
- (void)readingListFolderCreated;
// Processes the data sent by the share extension. Data should be a NSDictionary
// serialized by +|NSKeyedArchiver archivedDataWithRootObject:`.
// `completion` is called if `data` has been fully processed.
- (BOOL)receivedData:(NSData*)data withCompletion:(ProceduralBlock)completion;
// Reads the file pointed by `url` and calls `receivedData:` on the content.
// If the file is processed, delete it.
// `completion` is only called if the file handling is completed without error.
- (void)handleFileAtURL:(NSURL*)url withCompletion:(ProceduralBlock)completion;
// Deletes the file pointed by `url` then call `completion`.
- (void)deleteFileAtURL:(NSURL*)url withCompletion:(ProceduralBlock)completion;
// Called on UIApplicationDidBecomeActiveNotification notification.
- (void)applicationDidBecomeActive;
// Processes files that are already in the folder and starts observing the
// app_group::ShareExtensionItemsFolder() folder for new files.
- (void)processExistingFiles;
// Invoked with the list of pre-existing files in the folder to process them.
- (void)entriesReceived:(NSArray<NSURL*>*)files;
// Called on UIApplicationWillResignActiveNotification. Stops observing the
// app_group::ShareExtensionItemsFolder() folder for new files.
- (void)applicationWillResignActive;
// Called whenever a file is modified in app_group::ShareExtensionItemsFolder().
- (void)presentedSubitemDidChangeAtURL:(NSURL*)url;
@end
@implementation ShareExtensionItemReceiver {
BOOL _isObservingReadingListFolder;
BOOL _readingListFolderCreated;
BOOL _shutdownCalled;
raw_ptr<ReadingListModel> _readingListModel;
raw_ptr<bookmarks::BookmarkModel> _bookmarkModel;
scoped_refptr<base::SequencedTaskRunner> _taskRunner;
}
#pragma mark - NSObject lifetime
- (void)dealloc {
DCHECK(!_taskRunner) << "-shutdown must be called before -dealloc";
}
#pragma mark - Public API
- (instancetype)initWithBookmarkModel:(bookmarks::BookmarkModel*)bookmarkModel
readingListModel:(ReadingListModel*)readingListModel {
DCHECK(bookmarkModel);
DCHECK(readingListModel);
self = [super init];
if (![self presentedItemURL]) {
return nil;
}
if (self) {
_readingListModel = readingListModel;
_bookmarkModel = bookmarkModel;
_taskRunner = base::ThreadPool::CreateSequencedTaskRunner(
{base::MayBlock(), base::TaskPriority::BEST_EFFORT});
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(applicationDidBecomeActive)
name:UIApplicationDidBecomeActiveNotification
object:nil];
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(applicationWillResignActive)
name:UIApplicationWillResignActiveNotification
object:nil];
__weak ShareExtensionItemReceiver* weakSelf = self;
_taskRunner->PostTask(FROM_HERE, base::BindOnce(^{
[weakSelf createReadingListFolder];
}));
}
return self;
}
- (void)shutdown {
_shutdownCalled = YES;
if (_isObservingReadingListFolder) {
[NSFileCoordinator removeFilePresenter:self];
}
_readingListModel = nil;
_bookmarkModel = nil;
_taskRunner = nullptr;
}
#pragma mark - Private API
- (void)createReadingListFolder {
{
if (_shutdownCalled) {
return;
}
base::ScopedBlockingCall scoped_blocking_call(
FROM_HERE, base::BlockingType::WILL_BLOCK);
NSFileManager* manager = [NSFileManager defaultManager];
if (![manager fileExistsAtPath:[[self presentedItemURL] path]]) {
[manager createDirectoryAtPath:[[self presentedItemURL] path]
withIntermediateDirectories:NO
attributes:nil
error:nil];
}
}
__weak ShareExtensionItemReceiver* weakSelf = self;
web::GetUIThreadTaskRunner({})->PostTask(
FROM_HERE, base::BindOnce(^{
[weakSelf readingListFolderCreated];
}));
}
- (void)readingListFolderCreated {
if (_shutdownCalled) {
return;
}
UIApplication* application = [UIApplication sharedApplication];
if ([application applicationState] == UIApplicationStateActive) {
_readingListFolderCreated = YES;
[self applicationDidBecomeActive];
}
}
- (BOOL)receivedData:(NSData*)data withCompletion:(ProceduralBlock)completion {
if (_shutdownCalled) {
return NO;
}
NSError* error = nil;
NSKeyedUnarchiver* unarchiver =
[[NSKeyedUnarchiver alloc] initForReadingFromData:data error:&error];
if (!unarchiver || error) {
DLOG(WARNING) << "Error creating share extension item unarchiver: "
<< base::SysNSStringToUTF8([error description]);
return NO;
}
unarchiver.requiresSecureCoding = NO;
id entryID = [unarchiver decodeObjectForKey:NSKeyedArchiveRootObjectKey];
NSDictionary* entry = base::apple::ObjCCast<NSDictionary>(entryID);
if (!entry) {
if (completion) {
completion();
}
return NO;
}
NSNumber* cancelled = base::apple::ObjCCast<NSNumber>(
[entry objectForKey:app_group::kShareItemCancel]);
if (!cancelled) {
if (completion) {
completion();
}
return NO;
}
if ([cancelled boolValue]) {
LogHistogramReceivedItem(CANCELLED_ENTRY);
if (completion) {
completion();
}
return YES;
}
NSURL* entryURL = [entry objectForKey:app_group::kShareItemURL];
GURL entryGURL = net::GURLWithNSURL(entryURL);
NSString* entryTitle = [entry objectForKey:app_group::kShareItemTitle];
NSDate* entryDate = base::apple::ObjCCast<NSDate>(
[entry objectForKey:app_group::kShareItemDate]);
NSNumber* entryType = base::apple::ObjCCast<NSNumber>(
[entry objectForKey:app_group::kShareItemType]);
NSString* entrySource = base::apple::ObjCCast<NSString>(
[entry objectForKey:app_group::kShareItemSource]);
if (!entryGURL.is_valid() || !entrySource || !entryDate || !entryType ||
!entryGURL.SchemeIsHTTPOrHTTPS()) {
if (completion) {
completion();
}
return NO;
}
UMA_HISTOGRAM_TIMES(
"IOS.ShareExtension.ReceivedEntryDelay",
base::Seconds([[NSDate date] timeIntervalSinceDate:entryDate]));
UMA_HISTOGRAM_ENUMERATION("IOS.ShareExtension.Source",
SourceIDFromSource(entrySource),
SHARE_EXTENSION_SOURCE_COUNT);
__weak ShareExtensionItemReceiver* weakSelf = self;
web::GetUIThreadTaskRunner({})->PostTask(
FROM_HERE, base::BindOnce(^{
[weakSelf processEntryWithType:entryType
title:entryTitle
URL:entryURL
completion:completion];
}));
return YES;
}
- (void)processEntryWithType:(NSNumber*)entryType
title:(NSString*)entryNSTitle
URL:(NSURL*)entryNSURL
completion:(ProceduralBlock)completion {
if (_shutdownCalled || !_readingListModel || !_bookmarkModel) {
// Models may have been deleted after the file
// processing started.
return;
}
std::string entryTitle = base::SysNSStringToUTF8(entryNSTitle);
GURL entryURL = net::GURLWithNSURL(entryNSURL);
app_group::ShareExtensionItemType type =
static_cast<app_group::ShareExtensionItemType>([entryType integerValue]);
switch (type) {
case app_group::READING_LIST_ITEM: {
LogHistogramReceivedItem(READINGLIST_ENTRY);
_readingListModel->AddOrReplaceEntry(
entryURL, entryTitle, reading_list::ADDED_VIA_EXTENSION,
/*estimated_read_time=*/base::TimeDelta());
break;
}
case app_group::BOOKMARK_ITEM: {
LogHistogramReceivedItem(BOOKMARK_ENTRY);
// TODO(crbug.com/40260909): Once feature
// `syncer::kSyncEnableBookmarksInTransportMode` is launched, this
// may want to save bookmarks under `_bookmarkModel->mobile_node()`, if
// it returns non-null.
_bookmarkModel->AddNewURL(_bookmarkModel->mobile_node(), 0,
base::UTF8ToUTF16(entryTitle), entryURL);
break;
}
case app_group::OPEN_IN_CHROME_ITEM: {
LogHistogramReceivedItem(OPEN_IN_CHROME_ENTRY);
// Open URL command is sent directly by the extension. No processing is
// needed here.
break;
}
}
if (completion && _taskRunner) {
_taskRunner->PostTask(FROM_HERE, base::BindOnce(^{
completion();
}));
}
}
- (void)handleFileAtURL:(NSURL*)url withCompletion:(ProceduralBlock)completion {
if (_shutdownCalled) {
return;
}
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
if (![[NSFileManager defaultManager] fileExistsAtPath:[url path]]) {
// The handler is called on file modification, including deletion. Check
// that the file exists before continuing.
return;
}
__weak ShareExtensionItemReceiver* weakSelf = self;
ProceduralBlock successCompletion = ^{
[weakSelf deleteFileAtURL:url withCompletion:completion];
};
void (^readingAccessor)(NSURL*) = ^(NSURL* newURL) {
if (!weakSelf) {
return;
}
base::ScopedBlockingCall inner_scoped_blocking_call(
FROM_HERE, base::BlockingType::WILL_BLOCK);
NSFileManager* manager = [NSFileManager defaultManager];
NSData* data = [manager contentsAtPath:[newURL path]];
if (![weakSelf receivedData:data withCompletion:successCompletion]) {
LogHistogramReceivedItem(INVALID_ENTRY);
}
};
NSError* error = nil;
NSFileCoordinator* readingCoordinator =
[[NSFileCoordinator alloc] initWithFilePresenter:self];
[readingCoordinator
coordinateReadingItemAtURL:url
options:NSFileCoordinatorReadingWithoutChanges
error:&error
byAccessor:readingAccessor];
}
- (void)deleteFileAtURL:(NSURL*)url withCompletion:(ProceduralBlock)completion {
if (_shutdownCalled) {
return;
}
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
void (^deletingAccessor)(NSURL*) = ^(NSURL* newURL) {
base::ScopedBlockingCall inner_scoped_blocking_call(
FROM_HERE, base::BlockingType::MAY_BLOCK);
NSFileManager* manager = [NSFileManager defaultManager];
[manager removeItemAtURL:newURL error:nil];
};
NSError* error = nil;
NSFileCoordinator* deletingCoordinator =
[[NSFileCoordinator alloc] initWithFilePresenter:self];
[deletingCoordinator
coordinateWritingItemAtURL:url
options:NSFileCoordinatorWritingForDeleting
error:&error
byAccessor:deletingAccessor];
if (completion) {
completion();
}
}
- (void)applicationDidBecomeActive {
if (_shutdownCalled || !_readingListFolderCreated ||
_isObservingReadingListFolder) {
return;
}
_isObservingReadingListFolder = YES;
// Start observing for new files.
[NSFileCoordinator addFilePresenter:self];
// There may already be files. Process them.
if (_taskRunner) {
__weak ShareExtensionItemReceiver* weakSelf = self;
_taskRunner->PostTask(FROM_HERE, base::BindOnce(^{
[weakSelf processExistingFiles];
}));
}
}
- (void)processExistingFiles {
if (_shutdownCalled) {
return;
}
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
NSMutableArray<NSURL*>* files = [NSMutableArray array];
NSFileManager* manager = [NSFileManager defaultManager];
NSArray<NSURL*>* oldFiles = [manager
contentsOfDirectoryAtURL:app_group::LegacyShareExtensionItemsFolder()
includingPropertiesForKeys:nil
options:NSDirectoryEnumerationSkipsHiddenFiles
error:nil];
[files addObjectsFromArray:oldFiles];
NSArray<NSURL*>* newFiles =
[manager contentsOfDirectoryAtURL:[self presentedItemURL]
includingPropertiesForKeys:nil
options:NSDirectoryEnumerationSkipsHiddenFiles
error:nil];
[files addObjectsFromArray:newFiles];
if ([files count]) {
__weak ShareExtensionItemReceiver* weakSelf = self;
web::GetUIThreadTaskRunner({})->PostTask(FROM_HERE, base::BindOnce(^{
[weakSelf entriesReceived:files];
}));
}
}
- (void)entriesReceived:(NSArray<NSURL*>*)files {
UMA_HISTOGRAM_COUNTS_100("IOS.ShareExtension.ReceivedEntriesCount",
[files count]);
if (_shutdownCalled || !_taskRunner) {
return;
}
__weak ShareExtensionItemReceiver* weakSelf = self;
for (NSURL* fileURL : files) {
__block std::unique_ptr<ReadingListModel::ScopedReadingListBatchUpdate>
batchToken(_readingListModel->BeginBatchUpdates());
_taskRunner->PostTask(FROM_HERE, base::BindOnce(^{
[weakSelf
handleFileAtURL:fileURL
withCompletion:^{
web::GetUIThreadTaskRunner({})->PostTask(
FROM_HERE, base::BindOnce(^{
batchToken.reset();
}));
}];
}));
}
}
- (void)applicationWillResignActive {
if (_shutdownCalled || !_isObservingReadingListFolder) {
return;
}
_isObservingReadingListFolder = NO;
[NSFileCoordinator removeFilePresenter:self];
}
#pragma mark - NSFilePresenter methods
- (void)presentedSubitemDidChangeAtURL:(NSURL*)url {
if (_shutdownCalled) {
return;
}
if (_taskRunner) {
__weak ShareExtensionItemReceiver* weakSelf = self;
_taskRunner->PostTask(FROM_HERE, base::BindOnce(^{
[weakSelf handleFileAtURL:url withCompletion:nil];
}));
}
}
- (NSOperationQueue*)presentedItemOperationQueue {
return [NSOperationQueue mainQueue];
}
- (NSURL*)presentedItemURL {
return app_group::ExternalCommandsItemsFolder();
}
@end