diff --git a/README.md b/README.md index 218b1fd4..f579291f 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ The type or types of documents to allow selection of. May be an array of types a - If `type` is omitted it will be treated as `*/*` or `public.content`. - Multiple type strings are not supported on Android before KitKat (API level 19), Jellybean will fall back to `*/*` if you provide an array with more than one value. +##### [iOS only] `mode`:`"import" | "open"`: + +Defaults to `import`. If `mode` is set to `import` the document picker imports the file from outside to inside the sandbox, otherwise if `mode` is set to `open` the document picker opens the file right in place. + ##### [iOS only] `copyTo`:`"cachesDirectory" | "documentDirectory"`: If specified, the picked file is copied to `NSCachesDirectory` / `NSDocumentDirectory` directory. The uri of the copy will be available in result's `fileCopyUri`. If copying the file fails (eg. due to lack of space), `fileCopyUri` will be the same as `uri`, and more details about the error will be available in `copyError` field in the result. @@ -75,7 +79,7 @@ The object a `pick` Promise resolves to or the objects in the array a `pickMulti ##### `uri`: -The URI representing the document picked by the user. _On iOS this will be a `file://` URI for a temporary file in your app's container. On Android this will be a `content://` URI for a document provided by a DocumentProvider that must be accessed with a ContentResolver._ +The URI representing the document picked by the user. _On iOS this will be a `file://` URI for a temporary file in your app's container if `mode` is not specified or set at `import` otherwise it will be the original `file://` URI. On Android this will be a `content://` URI for a document provided by a DocumentProvider that must be accessed with a ContentResolver._ ##### `fileCopyUri`: @@ -109,10 +113,15 @@ The base64 encoded content of the picked file if the option `readContent` was se - `DocumentPicker.types.zip`: Zip files (`application/zip` or `public.zip-archive`) - `DocumentPicker.types.csv`: Csv files (`text/csv` or `public.comma-separated-values-text`) -### `DocumentPicker.isCancel(err)` +#### `DocumentPicker.isCancel(err)` If the user cancels the document picker without choosing a file (by pressing the system back button on Android or the Cancel button on iOS) the Promise will be rejected with a cancellation error. You can check for this error using `DocumentPicker.isCancel(err)` allowing you to ignore it and cleanup any parts of your interface that may not be needed anymore. +#### [iOS only] `DocumentPicker.releaseSecureAccess(uris: Array)` + +If `mode` is set to `open` iOS is giving you a secure access to a file located outside from your sandbox. +In that case Apple is asking you to release the access as soon as you finish using the resource. + ## Example ```javascript diff --git a/index.d.ts b/index.d.ts index 42ef9e59..796aa243 100644 --- a/index.d.ts +++ b/index.d.ts @@ -48,6 +48,7 @@ declare module 'react-native-document-picker' { }; interface DocumentPickerOptions { type: Array | DocumentType[OS]; + mode?: 'import' | 'open'; copyTo?: 'cachesDirectory' | 'documentDirectory'; } interface DocumentPickerResponse { @@ -68,5 +69,6 @@ declare module 'react-native-document-picker' { options: DocumentPickerOptions ): Promise; static isCancel(err?: IError): boolean; + static releaseSecureAccess(uris: Array): void; } } diff --git a/index.js b/index.js index 7e49badc..3ddd1d68 100644 --- a/index.js +++ b/index.js @@ -62,6 +62,10 @@ function pick(opts) { ); } + if ('mode' in opts && !['import', 'open'].includes(opts.mode)) { + throw new TypeError('Invalid mode option: ' + opts.mode); + } + if ('copyTo' in opts && !['cachesDirectory', 'documentDirectory'].includes(opts.copyTo)) { throw new TypeError('Invalid copyTo option: ' + opts.copyTo); } @@ -69,6 +73,24 @@ function pick(opts) { return RNDocumentPicker.pick(opts); } +function releaseSecureAccess(uris) { + if (Platform.OS !== 'ios') { + return; + } + + if (!Array.isArray(uris)) { + throw new TypeError('`uris` should be an array of strings'); + } + + uris.forEach((uri) => { + if (typeof uri !== 'string') { + throw new TypeError('Invalid uri parameter, expected a string not: ' + uri); + } + }); + + RNDocumentPicker.releaseSecureAccess(uris); +} + const Types = { mimeTypes: { allFiles: '*/*', @@ -138,4 +160,8 @@ export default class DocumentPicker { static isCancel(err) { return err && err.code === E_DOCUMENT_PICKER_CANCELED; } + + static releaseSecureAccess(uris) { + releaseSecureAccess(uris); + } } diff --git a/ios/RNDocumentPicker/RNDocumentPicker.m b/ios/RNDocumentPicker/RNDocumentPicker.m index 2b44a039..de08e900 100644 --- a/ios/RNDocumentPicker/RNDocumentPicker.m +++ b/ios/RNDocumentPicker/RNDocumentPicker.m @@ -11,7 +11,7 @@ static NSString *const E_INVALID_DATA_RETURNED = @"INVALID_DATA_RETURNED"; static NSString *const OPTION_TYPE = @"type"; -static NSString *const OPTION_MULIPLE = @"multiple"; +static NSString *const OPTION_MULTIPLE = @"multiple"; static NSString *const FIELD_URI = @"uri"; static NSString *const FIELD_FILE_COPY_URI = @"fileCopyUri"; @@ -24,9 +24,11 @@ @interface RNDocumentPicker () @end @implementation RNDocumentPicker { + UIDocumentPickerMode mode; + NSString *copyDestination; NSMutableArray *composeResolvers; NSMutableArray *composeRejecters; - NSString* copyDestination; + NSMutableArray *urls; } @synthesize bridge = _bridge; @@ -34,13 +36,22 @@ @implementation RNDocumentPicker { - (instancetype)init { if ((self = [super init])) { - composeResolvers = [[NSMutableArray alloc] init]; - composeRejecters = [[NSMutableArray alloc] init]; + composeResolvers = [NSMutableArray new]; + composeRejecters = [NSMutableArray new]; + urls = [NSMutableArray new]; } return self; } -+ (BOOL)requiresMainQueueSetup { +- (void)dealloc +{ + for (NSURL *url in urls) { + [url stopAccessingSecurityScopedResource]; + } +} + ++ (BOOL)requiresMainQueueSetup +{ return NO; } @@ -55,20 +66,19 @@ - (dispatch_queue_t)methodQueue resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - NSArray *allowedUTIs = [RCTConvert NSArray:options[OPTION_TYPE]]; - UIDocumentPickerViewController *documentPicker = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:(NSArray *)allowedUTIs inMode:UIDocumentPickerModeImport]; - + mode = options[@"mode"] && [options[@"mode"] isEqualToString:@"open"] ? UIDocumentPickerModeOpen : UIDocumentPickerModeImport; + copyDestination = options[@"copyTo"] ? options[@"copyTo"] : nil; [composeResolvers addObject:resolve]; [composeRejecters addObject:reject]; - copyDestination = options[@"copyTo"] ? options[@"copyTo"] : nil; - + NSArray *allowedUTIs = [RCTConvert NSArray:options[OPTION_TYPE]]; + UIDocumentPickerViewController *documentPicker = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:(NSArray *)allowedUTIs inMode:mode]; documentPicker.delegate = self; documentPicker.modalPresentationStyle = UIModalPresentationFormSheet; #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 if (@available(iOS 11, *)) { - documentPicker.allowsMultipleSelection = [RCTConvert BOOL:options[OPTION_MULIPLE]]; + documentPicker.allowsMultipleSelection = [RCTConvert BOOL:options[OPTION_MULTIPLE]]; } #endif @@ -79,19 +89,22 @@ - (dispatch_queue_t)methodQueue - (NSMutableDictionary *)getMetadataForUrl:(NSURL *)url error:(NSError **)error { - __block NSMutableDictionary* result = [NSMutableDictionary dictionary]; + __block NSMutableDictionary *result = [NSMutableDictionary dictionary]; + if (mode == UIDocumentPickerModeOpen) + [urls addObject:url]; [url startAccessingSecurityScopedResource]; - NSFileCoordinator *coordinator = [[NSFileCoordinator alloc] init]; + NSFileCoordinator *coordinator = [NSFileCoordinator new]; NSError *fileError; [coordinator coordinateReadingItemAtURL:url options:NSFileCoordinatorReadingResolvesSymbolicLink error:&fileError byAccessor:^(NSURL *newURL) { + if (!fileError) { - [result setValue:newURL.absoluteString forKey:FIELD_URI]; + [result setValue:((mode == UIDocumentPickerModeOpen) ? url : newURL).absoluteString forKey:FIELD_URI]; NSError *copyError; - NSURL* maybeFileCopyPath = copyDestination ? [RNDocumentPicker copyToUniqueDestinationFrom:newURL usingDestinationPreset:copyDestination error:copyError] : newURL; - [result setValue: maybeFileCopyPath.absoluteString forKey:FIELD_FILE_COPY_URI]; + NSURL *maybeFileCopyPath = copyDestination ? [RNDocumentPicker copyToUniqueDestinationFrom:newURL usingDestinationPreset:copyDestination error:copyError] : newURL; + [result setValue:maybeFileCopyPath.absoluteString forKey:FIELD_FILE_COPY_URI]; if (copyError) { [result setValue:copyError.description forKey:FIELD_COPY_ERR]; } @@ -118,7 +131,8 @@ - (NSMutableDictionary *)getMetadataForUrl:(NSURL *)url error:(NSError **)error } }]; - [url stopAccessingSecurityScopedResource]; + if (mode != UIDocumentPickerModeOpen) + [url stopAccessingSecurityScopedResource]; if (fileError) { *error = fileError; @@ -128,19 +142,35 @@ - (NSMutableDictionary *)getMetadataForUrl:(NSURL *)url error:(NSError **)error } } -+ (NSURL*)getDirectoryForFileCopy:(NSString*) copyToDirectory { +RCT_EXPORT_METHOD(releaseSecureAccess:(NSArray *)uris) +{ + NSMutableArray *discardedItems = [NSMutableArray array]; + for (NSString *uri in uris) { + for (NSURL *url in urls) { + if ([url.absoluteString isEqual:uri]) { + [url stopAccessingSecurityScopedResource]; + [discardedItems addObject:url]; + break; + } + } + } + [urls removeObjectsInArray:discardedItems]; +} + ++ (NSURL *)getDirectoryForFileCopy:(NSString *)copyToDirectory +{ if ([@"cachesDirectory" isEqualToString:copyToDirectory]) { return [NSFileManager.defaultManager URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask].firstObject; } else if ([@"documentDirectory" isEqualToString:copyToDirectory]) { return [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; } // this should not happen as the value is checked in JS, but we fall back to NSTemporaryDirectory() - return [NSURL fileURLWithPath: NSTemporaryDirectory() isDirectory: YES]; + return [NSURL fileURLWithPath:NSTemporaryDirectory() isDirectory:YES]; } -+ (NSURL *)copyToUniqueDestinationFrom:(NSURL *) url usingDestinationPreset: (NSString*) copyToDirectory error:(NSError *)error ++ (NSURL *)copyToUniqueDestinationFrom:(NSURL *)url usingDestinationPreset:(NSString *)copyToDirectory error:(NSError *)error { - NSURL* destinationRootDir = [self getDirectoryForFileCopy:copyToDirectory]; + NSURL *destinationRootDir = [self getDirectoryForFileCopy:copyToDirectory]; // we don't want to rename the file so we put it into a unique location NSString *uniqueSubDirName = [[NSUUID UUID] UUIDString]; NSURL *destinationDir = [destinationRootDir URLByAppendingPathComponent:[NSString stringWithFormat:@"%@/", uniqueSubDirName]]; @@ -160,56 +190,50 @@ + (NSURL *)copyToUniqueDestinationFrom:(NSURL *) url usingDestinationPreset: (NS - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url { - if (controller.documentPickerMode == UIDocumentPickerModeImport) { - RCTPromiseResolveBlock resolve = [composeResolvers lastObject]; - RCTPromiseRejectBlock reject = [composeRejecters lastObject]; - [composeResolvers removeLastObject]; - [composeRejecters removeLastObject]; - - NSError *error; - NSMutableDictionary* result = [self getMetadataForUrl:url error:&error]; - if (result) { - NSArray *results = @[result]; - resolve(results); - } else { - reject(E_INVALID_DATA_RETURNED, error.localizedDescription, error); - } + RCTPromiseResolveBlock resolve = [composeResolvers lastObject]; + RCTPromiseRejectBlock reject = [composeRejecters lastObject]; + [composeResolvers removeLastObject]; + [composeRejecters removeLastObject]; + + NSError *error; + NSMutableDictionary *result = [self getMetadataForUrl:url error:&error]; + if (result) { + NSArray *results = @[result]; + resolve(results); + } else { + reject(E_INVALID_DATA_RETURNED, error.localizedDescription, error); } } - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { - if (controller.documentPickerMode == UIDocumentPickerModeImport) { - RCTPromiseResolveBlock resolve = [composeResolvers lastObject]; - RCTPromiseRejectBlock reject = [composeRejecters lastObject]; - [composeResolvers removeLastObject]; - [composeRejecters removeLastObject]; - - NSMutableArray *results = [NSMutableArray array]; - for (id url in urls) { - NSError *error; - NSMutableDictionary* result = [self getMetadataForUrl:url error:&error]; - if (result) { - [results addObject:result]; - } else { - reject(E_INVALID_DATA_RETURNED, error.localizedDescription, error); - return; - } + RCTPromiseResolveBlock resolve = [composeResolvers lastObject]; + RCTPromiseRejectBlock reject = [composeRejecters lastObject]; + [composeResolvers removeLastObject]; + [composeRejecters removeLastObject]; + + NSMutableArray *results = [NSMutableArray array]; + for (id url in urls) { + NSError *error; + NSMutableDictionary *result = [self getMetadataForUrl:url error:&error]; + if (result) { + [results addObject:result]; + } else { + reject(E_INVALID_DATA_RETURNED, error.localizedDescription, error); + return; } - - resolve(results); } + + resolve(results); } - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { - if (controller.documentPickerMode == UIDocumentPickerModeImport) { - RCTPromiseRejectBlock reject = [composeRejecters lastObject]; - [composeResolvers removeLastObject]; - [composeRejecters removeLastObject]; - - reject(E_DOCUMENT_PICKER_CANCELED, @"User canceled document picker", nil); - } + RCTPromiseRejectBlock reject = [composeRejecters lastObject]; + [composeResolvers removeLastObject]; + [composeRejecters removeLastObject]; + + reject(E_DOCUMENT_PICKER_CANCELED, @"User canceled document picker", nil); } @end