From e1c5ca3ac04dd7571b2cb86e2b3bde67912f2292 Mon Sep 17 00:00:00 2001 From: Mark Dawson Date: Mon, 10 Apr 2017 13:05:24 -0700 Subject: [PATCH 1/2] Support multiple attachments This change adds support for multiple attachments, allowing callers to create share extensions that can select more than one item to share. --- ios/ReactNativeShareExtension.m | 139 +++++++++++++++++++------------- lib/index.js | 1 + 2 files changed, 83 insertions(+), 57 deletions(-) diff --git a/ios/ReactNativeShareExtension.m b/ios/ReactNativeShareExtension.m index 10703791..2546d192 100644 --- a/ios/ReactNativeShareExtension.m +++ b/ios/ReactNativeShareExtension.m @@ -39,69 +39,94 @@ - (void)viewDidLoad { completionHandler:nil]; } - - RCT_REMAP_METHOD(data, resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - [self extractDataFromContext: extensionContext withCallback:^(NSURL* url,NSString* contentType ,NSException* err) { - NSDictionary *inventory = @{ - @"type": contentType, - @"value": [url absoluteString] - }; - - resolve(inventory); - }]; + [self extractDataFromContext: extensionContext withCallback:^(NSArray* items ,NSException* err) { + if (items == nil) { + resolve(nil); + return; + } + resolve(items[0]); + }]; } -- (void)extractDataFromContext:(NSExtensionContext *)context withCallback:(void(^)(NSURL *url, NSString* contentType ,NSException *exception))callback { - @try { - NSExtensionItem *item = [context.inputItems firstObject]; - NSArray *attachments = item.attachments; - __block NSItemProvider *urlProvider = nil; - __block NSItemProvider *imageProvider = nil; - [attachments enumerateObjectsUsingBlock:^(NSItemProvider *provider, NSUInteger idx, BOOL *stop) { - if([provider hasItemConformingToTypeIdentifier:ITEM_IDENTIFIER]) { - urlProvider = provider; - *stop = YES; - }else if ([provider hasItemConformingToTypeIdentifier:IMAGE_IDENTIFIER]){ - imageProvider = provider; - *stop = YES; - - } - }]; - - if(urlProvider) { - [urlProvider loadItemForTypeIdentifier:ITEM_IDENTIFIER options:nil completionHandler:^(id item, NSError *error) { - NSURL *url = (NSURL *)item; - - if(callback) { - callback(url,@"text/plain" ,nil); - } - }]; - }else if (imageProvider){ - [imageProvider loadItemForTypeIdentifier:IMAGE_IDENTIFIER options:nil completionHandler:^(id item, NSError *error) { - NSURL *url = (NSURL *)item; - - if(callback) { - callback(url,[[[url absoluteString] pathExtension] lowercaseString] ,nil); - } - }]; - - - } - else { - if(callback) { - callback(nil, nil,[NSException exceptionWithName:@"Error" reason:@"couldn't find provider" userInfo:nil]); - } - } - } - @catch (NSException *exception) { - if(callback) { - callback(nil,nil ,exception); - } - } +RCT_REMAP_METHOD(dataMulti, + resolverMulti:(RCTPromiseResolveBlock)resolve + rejecterMulti:(RCTPromiseRejectBlock)reject) +{ + [self extractDataFromContext: extensionContext withCallback:^(NSArray* items ,NSException* err) { + resolve(items); + }]; +} + +typedef void (^ProviderCallback)(NSURL *url, NSString *contentType, NSException *exception); + +- (void)extractDataFromContext:(NSExtensionContext *)context withCallback:(void(^)(NSArray *items ,NSException *exception))callback { + @try { + NSExtensionItem *item = [context.inputItems firstObject]; + NSArray *attachments = item.attachments; + NSMutableArray *items = [[NSMutableArray alloc] init]; + + __block int attachmentIdx = 0; + __block ProviderCallback providerCb = nil; + providerCb = ^ void (NSURL *url, NSString *contentType, NSException *exception) { + if (exception) { + callback(nil, exception); + return; + } + + [items addObject:@{ + @"type": contentType, + @"value": [url absoluteString] + }]; + + ++attachmentIdx; + if (attachmentIdx == [attachments count]) { + callback(items, nil); + } else { + [self extractDataFromProvider:attachments[attachmentIdx] withCallback: providerCb]; + } + }; + [self extractDataFromProvider:attachments[0] withCallback: providerCb]; + } + @catch (NSException *exception) { + callback(nil,exception); + } +} + +- (void)extractDataFromProvider:(NSItemProvider *)provider withCallback:(void(^)(NSURL* url, NSString* contentType ,NSException *exception))callback { + NSItemProvider *urlProvider = nil; + NSItemProvider *imageProvider = nil; + + if([provider hasItemConformingToTypeIdentifier:ITEM_IDENTIFIER]) { + urlProvider = provider; + }else if ([provider hasItemConformingToTypeIdentifier:IMAGE_IDENTIFIER]){ + imageProvider = provider; + } + + if(urlProvider) { + [urlProvider loadItemForTypeIdentifier:ITEM_IDENTIFIER options:nil completionHandler:^(id item, NSError *error) { + NSURL *url = (NSURL *)item; + + if(callback) { + callback(url,@"text/plain" ,nil); + } + }]; + }else if (imageProvider){ + [imageProvider loadItemForTypeIdentifier:IMAGE_IDENTIFIER options:nil completionHandler:^(id item, NSError *error) { + NSURL *url = (NSURL *)item; + + if(callback) { + callback(url,[[[url absoluteString] pathExtension] lowercaseString] ,nil); + } + }]; + } else { + if(callback) { + callback(nil, nil,[NSException exceptionWithName:@"Error" reason:@"couldn't find provider" userInfo:nil]); + } + } } @end diff --git a/lib/index.js b/lib/index.js index aa17dce1..af8652f7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5,5 +5,6 @@ import { NativeModules } from 'react-native' // NativeModules.ShareExtension.close() export default { data: () => NativeModules.ReactNativeShareExtension.data(), + dataMulti: () => NativeModules.ReactNativeShareExtension.dataMulti(), close: () => NativeModules.ReactNativeShareExtension.close() } From 7812f29a88b07a6fb0d4969b0cd5e79181666028 Mon Sep 17 00:00:00 2001 From: Mark Dawson Date: Wed, 12 Apr 2017 12:34:02 -0700 Subject: [PATCH 2/2] Expand support for different share types This is a breaking change. The current version of the code doesn't handle public.image correctly in some cases. It assumes that the value returned from getItemForTypeIdentifier is always a NSURL*. This is true for things like Photos, but the app can define what it passes back, while testing, I found that it can also be a UIImage* instance or even NSData*, for example in Notes if you create a note, add a drawing and insert a photo, the drawing will be found as a public.image but it will be in NSData format, not NSURL. In the cases where we get UIImage* or NSData* in order to share them later on we have to have a URL to them on disk, so I used app groups to create a temporary container location to store the files. Then the caller can reference these URLs in the JS code, and finally when we close the share extension we clean up all the temp files to make sure we don't keep growing the size of our disk footprint. --- ios/ReactNativeShareExtension.m | 232 +++++++++++++++++++++++++------- lib/index.js | 6 +- package.json | 94 ++++++++++--- 3 files changed, 265 insertions(+), 67 deletions(-) diff --git a/ios/ReactNativeShareExtension.m b/ios/ReactNativeShareExtension.m index 2546d192..65e81aff 100644 --- a/ios/ReactNativeShareExtension.m +++ b/ios/ReactNativeShareExtension.m @@ -1,9 +1,6 @@ #import "ReactNativeShareExtension.h" #import "React/RCTRootView.h" -#define ITEM_IDENTIFIER @"public.url" -#define IMAGE_IDENTIFIER @"public.image" - NSExtensionContext* extensionContext; @implementation ReactNativeShareExtension { @@ -34,16 +31,18 @@ - (void)viewDidLoad { } -RCT_EXPORT_METHOD(close) { - [extensionContext completeRequestReturningItems:nil - completionHandler:nil]; +RCT_EXPORT_METHOD(close:(NSString *)appGroupId) { + [self cleanUpTempFiles:appGroupId]; + [extensionContext completeRequestReturningItems:nil + completionHandler:nil]; } RCT_REMAP_METHOD(data, - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) + appGroupId: (NSString *)appGroupId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { - [self extractDataFromContext: extensionContext withCallback:^(NSArray* items ,NSException* err) { + [self extractDataFromContext: extensionContext withAppGroup:appGroupId andCallback:^(NSArray* items ,NSError* err) { if (items == nil) { resolve(nil); return; @@ -53,80 +52,215 @@ - (void)viewDidLoad { } RCT_REMAP_METHOD(dataMulti, + appGroupId: (NSString *)appGroupId resolverMulti:(RCTPromiseResolveBlock)resolve rejecterMulti:(RCTPromiseRejectBlock)reject) { - [self extractDataFromContext: extensionContext withCallback:^(NSArray* items ,NSException* err) { + [self extractDataFromContext: extensionContext withAppGroup: appGroupId andCallback:^(NSArray* items ,NSError* err) { + if (err) { + reject(@"dataMulti", @"Failed to extract attachment content", err); + return; + } resolve(items); }]; } -typedef void (^ProviderCallback)(NSURL *url, NSString *contentType, NSException *exception); +typedef void (^ProviderCallback)(NSString *content, NSString *contentType, BOOL owner, NSError *err); -- (void)extractDataFromContext:(NSExtensionContext *)context withCallback:(void(^)(NSArray *items ,NSException *exception))callback { +- (void)extractDataFromContext:(NSExtensionContext *)context withAppGroup:(NSString *) appGroupId andCallback:(void(^)(NSArray *items ,NSError *err))callback { @try { NSExtensionItem *item = [context.inputItems firstObject]; NSArray *attachments = item.attachments; NSMutableArray *items = [[NSMutableArray alloc] init]; - + __block int attachmentIdx = 0; __block ProviderCallback providerCb = nil; - providerCb = ^ void (NSURL *url, NSString *contentType, NSException *exception) { - if (exception) { - callback(nil, exception); + __block __weak ProviderCallback weakProviderCb = nil; + providerCb = ^ void (NSString *content, NSString *contentType, BOOL owner, NSError *err) { + if (err) { + callback(nil, err); return; } - - [items addObject:@{ - @"type": contentType, - @"value": [url absoluteString] - }]; + + if (content != nil) { + [items addObject:@{ + @"type": contentType, + @"value": content, + @"owner": [NSNumber numberWithBool:owner], + }]; + } ++attachmentIdx; if (attachmentIdx == [attachments count]) { callback(items, nil); } else { - [self extractDataFromProvider:attachments[attachmentIdx] withCallback: providerCb]; + [self extractDataFromProvider:attachments[attachmentIdx] withAppGroup:appGroupId andCallback: weakProviderCb]; } }; - [self extractDataFromProvider:attachments[0] withCallback: providerCb]; + weakProviderCb = providerCb; + [self extractDataFromProvider:attachments[0] withAppGroup:appGroupId andCallback: providerCb]; } - @catch (NSException *exception) { - callback(nil,exception); + @catch (NSException *exc) { + NSError *error = [NSError errorWithDomain:@"fiftythree.paste" code:1 userInfo:@{ + @"reason": [exc description] + }]; + callback(nil, error); } } -- (void)extractDataFromProvider:(NSItemProvider *)provider withCallback:(void(^)(NSURL* url, NSString* contentType ,NSException *exception))callback { - NSItemProvider *urlProvider = nil; - NSItemProvider *imageProvider = nil; - - if([provider hasItemConformingToTypeIdentifier:ITEM_IDENTIFIER]) { - urlProvider = provider; - }else if ([provider hasItemConformingToTypeIdentifier:IMAGE_IDENTIFIER]){ - imageProvider = provider; +- (void)extractDataFromProvider:(NSItemProvider *)provider withAppGroup:(NSString *) appGroupId andCallback:(void(^)(NSString* content, NSString* contentType, BOOL owner, NSError *err))callback { + + if([provider hasItemConformingToTypeIdentifier:@"public.image"]) { + [provider loadItemForTypeIdentifier:@"public.image" options:nil completionHandler:^(id item, NSError *error) { + if (error) { + callback(nil, nil, NO, error); + return; + } + + @try { + if ([item isKindOfClass: NSURL.class]) { + NSURL *url = (NSURL *)item; + return callback([url absoluteString], @"public.image", NO, nil); + } else if ([item isKindOfClass: UIImage.class]) { + UIImage *image = (UIImage *)item; + NSString *fileName = [NSString stringWithFormat:@"%@.jpg", [[NSUUID UUID] UUIDString]]; + NSURL *tempContainerURL = [ReactNativeShareExtension tempContainerURL:appGroupId]; + if (tempContainerURL == nil){ + return callback(nil, nil, NO, nil); + } + + NSURL *tempFileURL = [tempContainerURL URLByAppendingPathComponent: fileName]; + BOOL created = [UIImageJPEGRepresentation(image, 0.95) writeToFile:[tempFileURL path] atomically:YES]; + if (created) { + return callback([tempFileURL absoluteString], @"public.image", YES, nil); + } else { + return callback(nil, nil, NO, nil); + } + } else if ([item isKindOfClass: NSData.class]) { + NSString *fileName = [NSString stringWithFormat:@"%@.jpg", [[NSUUID UUID] UUIDString]]; + NSData *data = (NSData *)item; + UIImage *image = [UIImage imageWithData:data]; + NSURL *tempContainerURL = [ReactNativeShareExtension tempContainerURL:appGroupId]; + if (tempContainerURL == nil){ + return callback(nil, nil, NO, nil); + } + NSURL *tempFileURL = [tempContainerURL URLByAppendingPathComponent: fileName]; + BOOL created = [UIImageJPEGRepresentation(image, 0.95) writeToFile:[tempFileURL path] atomically:YES]; + if (created) { + return callback([tempFileURL absoluteString], @"public.image", YES, nil); + } else { + return callback(nil, nil, NO, nil); + } + } else { + // Do nothing, some type we don't support. + return callback(nil, nil, NO, nil); + } + } + @catch(NSException *exc) { + NSError *error = [NSError errorWithDomain:@"fiftythree.paste" code:2 userInfo:@{ + @"reason": [exc description] + }]; + callback(nil, nil, NO, error); + } + }]; + return; } - - if(urlProvider) { - [urlProvider loadItemForTypeIdentifier:ITEM_IDENTIFIER options:nil completionHandler:^(id item, NSError *error) { - NSURL *url = (NSURL *)item; - - if(callback) { - callback(url,@"text/plain" ,nil); + + if([provider hasItemConformingToTypeIdentifier:@"public.file-url"]) { + [provider loadItemForTypeIdentifier:@"public.file-url" options:nil completionHandler:^(id item, NSError *error) { + if (error) { + callback(nil, nil, NO, error); + return; + } + + if ([item isKindOfClass:NSURL.class]) { + return callback([(NSURL *)item absoluteString], @"public.file-url", NO, nil); + } else if ([item isKindOfClass:NSString.class]) { + return callback((NSString *)item, @"public.file-url", NO, nil); } + callback(nil, nil, NO, nil); }]; - }else if (imageProvider){ - [imageProvider loadItemForTypeIdentifier:IMAGE_IDENTIFIER options:nil completionHandler:^(id item, NSError *error) { - NSURL *url = (NSURL *)item; - - if(callback) { - callback(url,[[[url absoluteString] pathExtension] lowercaseString] ,nil); + return; + } + + if([provider hasItemConformingToTypeIdentifier:@"public.url"]) { + [provider loadItemForTypeIdentifier:@"public.url" options:nil completionHandler:^(id item, NSError *error) { + if (error) { + callback(nil, nil, NO, error); + return; + } + + if ([item isKindOfClass:NSURL.class]) { + return callback([(NSURL *)item absoluteString], @"public.url", NO, nil); + } else if ([item isKindOfClass:NSString.class]) { + return callback((NSString *)item, @"public.url", NO, nil); } }]; - } else { - if(callback) { - callback(nil, nil,[NSException exceptionWithName:@"Error" reason:@"couldn't find provider" userInfo:nil]); + return; + } + + if([provider hasItemConformingToTypeIdentifier:@"public.plain-text"]) { + [provider loadItemForTypeIdentifier:@"public.plain-text" options:nil completionHandler:^(id item, NSError *error) { + if (error) { + callback(nil, nil, NO, error); + return; + } + + if ([item isKindOfClass:NSString.class]) { + return callback((NSString *)item, @"public.plain-text", NO, nil); + } else if ([item isKindOfClass:NSAttributedString.class]) { + NSAttributedString *str = (NSAttributedString *)item; + return callback([str string], @"public.plain-text", NO, nil); + } else if ([item isKindOfClass:NSData.class]) { + NSString *str = [[NSString alloc] initWithData:(NSData *)item encoding:NSUTF8StringEncoding]; + if (str) { + return callback(str, @"public.plain-text", NO, nil); + } else { + return callback(nil, nil, NO, nil); + } + } else { + return callback(nil, nil, NO, nil); + } + }]; + return; + } + + callback(nil, nil, NO, nil); +} + ++ (NSURL*) tempContainerURL: (NSString*)appGroupId { + NSFileManager *manager = [NSFileManager defaultManager]; + NSURL *containerURL = [manager containerURLForSecurityApplicationGroupIdentifier: appGroupId]; + NSURL *tempDirectoryURL = [containerURL URLByAppendingPathComponent:@"shareTempItems"]; + if (![manager fileExistsAtPath:[tempDirectoryURL path]]) { + NSError *err; + [manager createDirectoryAtURL:tempDirectoryURL withIntermediateDirectories:YES attributes:nil error:&err]; + if (err) { + return nil; } } + + return tempDirectoryURL; +} + +- (void) cleanUpTempFiles:(NSString *)appGroupId { + NSURL *tmpDirectoryURL = [ReactNativeShareExtension tempContainerURL:appGroupId]; + if (tmpDirectoryURL == nil) { + return; + } + + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSError *error; + NSArray *tmpFiles = [fileManager contentsOfDirectoryAtPath:[tmpDirectoryURL path] error:&error]; + if (error) { + return; + } + + for (NSString *file in tmpFiles) + { + error = nil; + [fileManager removeItemAtPath:[[tmpDirectoryURL URLByAppendingPathComponent:file] path] error:&error]; + } } @end diff --git a/lib/index.js b/lib/index.js index af8652f7..05a71965 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,7 +4,7 @@ import { NativeModules } from 'react-native' // const { type, value } = await NativeModules.ShareExtension.data() // NativeModules.ShareExtension.close() export default { - data: () => NativeModules.ReactNativeShareExtension.data(), - dataMulti: () => NativeModules.ReactNativeShareExtension.dataMulti(), - close: () => NativeModules.ReactNativeShareExtension.close() + data: (appGroupId) => NativeModules.ReactNativeShareExtension.data(appGroupId), + dataMulti: (appGroupId) => NativeModules.ReactNativeShareExtension.dataMulti(appGroupId), + close: (appGroupId) => NativeModules.ReactNativeShareExtension.close(appGroupId) } diff --git a/package.json b/package.json index 6e7c17af..2d81e456 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,92 @@ { - "name": "react-native-share-extension", - "version": "1.1.1", - "description": "share extension using react-native for both ios and android", - "main": "lib/index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "_args": [ + [ + { + "raw": "react-native-share-extension", + "scope": null, + "escapedName": "react-native-share-extension", + "name": "react-native-share-extension", + "rawSpec": "", + "spec": "latest", + "type": "tag" + }, + "/Users/mark/code/PresentMobile" + ] + ], + "_from": "react-native-share-extension@latest", + "_id": "react-native-share-extension@1.1.1", + "_inCache": true, + "_installable": true, + "_location": "/react-native-share-extension", + "_nodeVersion": "6.9.1", + "_npmOperationalInternal": { + "host": "packages-12-west.internal.npmjs.com", + "tmp": "tmp/react-native-share-extension-1.1.1.tgz_1483985855792_0.05685301055200398" }, - "repository": { - "type": "git", - "url": "git@github.com:alinz/react-native-share-extension.git" + "_npmUser": { + "name": "alinz", + "email": "a.najafizadeh@gmail.com" }, - "keywords": [ - "react-component", - "react-native", - "share-extension" + "_npmVersion": "3.10.8", + "_phantomChildren": {}, + "_requested": { + "raw": "react-native-share-extension", + "scope": null, + "escapedName": "react-native-share-extension", + "name": "react-native-share-extension", + "rawSpec": "", + "spec": "latest", + "type": "tag" + }, + "_requiredBy": [ + "#USER", + "/" ], + "_resolved": "https://registry.npmjs.org/react-native-share-extension/-/react-native-share-extension-1.1.1.tgz", + "_shasum": "da2c4758c8a800c2241e797deb90298b5388053c", + "_shrinkwrap": null, + "_spec": "react-native-share-extension", + "_where": "/Users/mark/code/PresentMobile", "author": { "name": "Ali Najafizadeh", "email": "a.najafizadeh@gmail.com", "url": "http://github.com/alinz" }, - "license": "MIT", "bugs": { "url": "https://github.com/alinz/react-native-share-extension" }, - "homepage": "https://github.com/alinz/react-native-share-extension" + "dependencies": {}, + "description": "share extension using react-native for both ios and android", + "devDependencies": {}, + "directories": {}, + "dist": { + "shasum": "da2c4758c8a800c2241e797deb90298b5388053c", + "tarball": "https://registry.npmjs.org/react-native-share-extension/-/react-native-share-extension-1.1.1.tgz" + }, + "gitHead": "66d3a720cda1a032aaea2d4609a7f427c252a2b2", + "homepage": "https://github.com/alinz/react-native-share-extension", + "keywords": [ + "react-component", + "react-native", + "share-extension" + ], + "license": "MIT", + "main": "lib/index.js", + "maintainers": [ + { + "name": "alinz", + "email": "a.najafizadeh@gmail.com" + } + ], + "name": "react-native-share-extension", + "optionalDependencies": {}, + "readme": "ERROR: No README data found!", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/alinz/react-native-share-extension.git" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "version": "1.1.1" }