diff --git a/packages/share/CHANGELOG.md b/packages/share/CHANGELOG.md index 8c3814d2f559..c4ee830ed34f 100644 --- a/packages/share/CHANGELOG.md +++ b/packages/share/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.5 + +* Added support for sharing files + ## 0.6.4+5 * Update package:e2e -> package:integration_test diff --git a/packages/share/README.md b/packages/share/README.md index 14be8da7d10e..750fca6a5b18 100644 --- a/packages/share/README.md +++ b/packages/share/README.md @@ -39,3 +39,9 @@ sharing to email. ``` dart Share.share('check out my website https://example.com', subject: 'Look what I made!'); ``` + +To share one or multiple files invoke the static `shareFiles` method anywhere in your Dart code. Optionally you can also pass in `text` and `subject`. +``` dart +Share.shareFiles(['${directory.path}/image.jpg'], text: 'Great picture'); +Share.shareFiles(['${directory.path}/image1.jpg', '${directory.path}/image2.jpg']); +``` \ No newline at end of file diff --git a/packages/share/android/build.gradle b/packages/share/android/build.gradle index e154b068c5dd..7506f4db8261 100644 --- a/packages/share/android/build.gradle +++ b/packages/share/android/build.gradle @@ -31,4 +31,9 @@ android { lintOptions { disable 'InvalidPackage' } + + dependencies { + implementation 'androidx.core:core:1.3.1' + implementation 'androidx.annotation:annotation:1.1.0' + } } diff --git a/packages/share/android/src/main/AndroidManifest.xml b/packages/share/android/src/main/AndroidManifest.xml index 407eae4d8128..c141a5c67928 100644 --- a/packages/share/android/src/main/AndroidManifest.xml +++ b/packages/share/android/src/main/AndroidManifest.xml @@ -1,3 +1,14 @@ + + + + + diff --git a/packages/share/android/src/main/java/io/flutter/plugins/share/MethodCallHandler.java b/packages/share/android/src/main/java/io/flutter/plugins/share/MethodCallHandler.java index f7e4d579e7a2..02841d3a4ae2 100644 --- a/packages/share/android/src/main/java/io/flutter/plugins/share/MethodCallHandler.java +++ b/packages/share/android/src/main/java/io/flutter/plugins/share/MethodCallHandler.java @@ -6,6 +6,8 @@ import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; +import java.io.*; +import java.util.List; import java.util.Map; /** Handles the method calls for the plugin. */ @@ -19,15 +21,37 @@ class MethodCallHandler implements MethodChannel.MethodCallHandler { @Override public void onMethodCall(MethodCall call, MethodChannel.Result result) { - if (call.method.equals("share")) { - if (!(call.arguments instanceof Map)) { - throw new IllegalArgumentException("Map argument expected"); - } - // Android does not support showing the share sheet at a particular point on screen. - share.share((String) call.argument("text"), (String) call.argument("subject")); - result.success(null); - } else { - result.notImplemented(); + switch (call.method) { + case "share": + expectMapArguments(call); + // Android does not support showing the share sheet at a particular point on screen. + share.share((String) call.argument("text"), (String) call.argument("subject")); + result.success(null); + break; + case "shareFiles": + expectMapArguments(call); + + // Android does not support showing the share sheet at a particular point on screen. + try { + share.shareFiles( + (List) call.argument("paths"), + (List) call.argument("mimeTypes"), + (String) call.argument("text"), + (String) call.argument("subject")); + result.success(null); + } catch (IOException e) { + result.error(e.getMessage(), null, null); + } + break; + default: + result.notImplemented(); + break; + } + } + + private void expectMapArguments(MethodCall call) throws IllegalArgumentException { + if (!(call.arguments instanceof Map)) { + throw new IllegalArgumentException("Map argument expected"); } } } diff --git a/packages/share/android/src/main/java/io/flutter/plugins/share/Share.java b/packages/share/android/src/main/java/io/flutter/plugins/share/Share.java index 8c9e833ee9d3..eb856bf572ee 100644 --- a/packages/share/android/src/main/java/io/flutter/plugins/share/Share.java +++ b/packages/share/android/src/main/java/io/flutter/plugins/share/Share.java @@ -5,19 +5,36 @@ package io.flutter.plugins.share; import android.app.Activity; +import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Environment; +import androidx.annotation.NonNull; +import androidx.core.content.FileProvider; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; /** Handles share intent. */ class Share { + private Context context; private Activity activity; /** - * Constructs a Share object. The {@code activity} is used to start the share intent. It might be - * null when constructing the {@link Share} object and set to non-null when an activity is - * available using {@link #setActivity(Activity)}. + * Constructs a Share object. The {@code context} and {@code activity} are used to start the share + * intent. The {@code activity} might be null when constructing the {@link Share} object and set + * to non-null when an activity is available using {@link #setActivity(Activity)}. */ - Share(Activity activity) { + Share(Context context, Activity activity) { + this.context = context; this.activity = activity; } @@ -40,11 +57,177 @@ void share(String text, String subject) { shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject); shareIntent.setType("text/plain"); Intent chooserIntent = Intent.createChooser(shareIntent, null /* dialog title optional */); + startActivity(chooserIntent); + } + + void shareFiles(List paths, List mimeTypes, String text, String subject) + throws IOException { + if (paths == null || paths.isEmpty()) { + throw new IllegalArgumentException("Non-empty path expected"); + } + + clearExternalShareFolder(); + ArrayList fileUris = getUrisForPaths(paths); + + Intent shareIntent = new Intent(); + if (fileUris.isEmpty()) { + share(text, subject); + return; + } else if (fileUris.size() == 1) { + shareIntent.setAction(Intent.ACTION_SEND); + shareIntent.putExtra(Intent.EXTRA_STREAM, fileUris.get(0)); + shareIntent.setType( + !mimeTypes.isEmpty() && mimeTypes.get(0) != null ? mimeTypes.get(0) : "*/*"); + } else { + shareIntent.setAction(Intent.ACTION_SEND_MULTIPLE); + shareIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, fileUris); + shareIntent.setType(reduceMimeTypes(mimeTypes)); + } + if (text != null) shareIntent.putExtra(Intent.EXTRA_TEXT, text); + if (subject != null) shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject); + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + Intent chooserIntent = Intent.createChooser(shareIntent, null /* dialog title optional */); + + List resInfoList = + getContext() + .getPackageManager() + .queryIntentActivities(chooserIntent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + for (Uri fileUri : fileUris) { + getContext() + .grantUriPermission( + packageName, + fileUri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + + startActivity(chooserIntent); + } + + private void startActivity(Intent intent) { if (activity != null) { - activity.startActivity(chooserIntent); + activity.startActivity(intent); + } else if (context != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } else { + throw new IllegalStateException("Both context and activity are null"); + } + } + + private ArrayList getUrisForPaths(List paths) throws IOException { + ArrayList uris = new ArrayList<>(paths.size()); + for (String path : paths) { + File file = new File(path); + if (!fileIsOnExternal(file)) { + file = copyToExternalShareFolder(file); + } + + uris.add( + FileProvider.getUriForFile( + getContext(), getContext().getPackageName() + ".flutter.share_provider", file)); + } + return uris; + } + + private String reduceMimeTypes(List mimeTypes) { + if (mimeTypes.size() > 1) { + String reducedMimeType = mimeTypes.get(0); + for (int i = 1; i < mimeTypes.size(); i++) { + String mimeType = mimeTypes.get(i); + if (!reducedMimeType.equals(mimeType)) { + if (getMimeTypeBase(mimeType).equals(getMimeTypeBase(reducedMimeType))) { + reducedMimeType = getMimeTypeBase(mimeType) + "/*"; + } else { + reducedMimeType = "*/*"; + break; + } + } + } + return reducedMimeType; + } else if (mimeTypes.size() == 1) { + return mimeTypes.get(0); } else { - chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - activity.startActivity(chooserIntent); + return "*/*"; + } + } + + @NonNull + private String getMimeTypeBase(String mimeType) { + if (mimeType == null || !mimeType.contains("/")) { + return "*"; + } + + return mimeType.substring(0, mimeType.indexOf("/")); + } + + private boolean fileIsOnExternal(File file) { + try { + String filePath = file.getCanonicalPath(); + File externalDir = Environment.getExternalStorageDirectory(); + return externalDir != null && filePath.startsWith(externalDir.getCanonicalPath()); + } catch (IOException e) { + return false; + } + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private void clearExternalShareFolder() { + File folder = getExternalShareFolder(); + if (folder.exists()) { + for (File file : folder.listFiles()) { + file.delete(); + } + folder.delete(); + } + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private File copyToExternalShareFolder(File file) throws IOException { + File folder = getExternalShareFolder(); + if (!folder.exists()) { + folder.mkdirs(); + } + + File newFile = new File(folder, file.getName()); + copy(file, newFile); + return newFile; + } + + @NonNull + private File getExternalShareFolder() { + return new File(getContext().getExternalCacheDir(), "share"); + } + + private Context getContext() { + if (activity != null) { + return activity; + } + if (context != null) { + return context; + } + + throw new IllegalStateException("Both context and activity are null"); + } + + private static void copy(File src, File dst) throws IOException { + InputStream in = new FileInputStream(src); + try { + OutputStream out = new FileOutputStream(dst); + try { + // Transfer bytes from in to out + byte[] buf = new byte[1024]; + int len; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + } + } finally { + out.close(); + } + } finally { + in.close(); } } } diff --git a/packages/share/android/src/main/java/io/flutter/plugins/share/ShareFileProvider.java b/packages/share/android/src/main/java/io/flutter/plugins/share/ShareFileProvider.java new file mode 100644 index 000000000000..87e4e42a03d4 --- /dev/null +++ b/packages/share/android/src/main/java/io/flutter/plugins/share/ShareFileProvider.java @@ -0,0 +1,14 @@ +// Copyright 2019 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.share; + +import androidx.core.content.FileProvider; + +/** + * Providing a custom {@code FileProvider} prevents manifest {@code } name collisions. + * + *

See https://developer.android.com/guide/topics/manifest/provider-element.html for details. + */ +public class ShareFileProvider extends FileProvider {} diff --git a/packages/share/android/src/main/java/io/flutter/plugins/share/SharePlugin.java b/packages/share/android/src/main/java/io/flutter/plugins/share/SharePlugin.java index fdb9dc4fe644..bd7dfc22a3cd 100644 --- a/packages/share/android/src/main/java/io/flutter/plugins/share/SharePlugin.java +++ b/packages/share/android/src/main/java/io/flutter/plugins/share/SharePlugin.java @@ -5,6 +5,7 @@ package io.flutter.plugins.share; import android.app.Activity; +import android.content.Context; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; @@ -22,12 +23,12 @@ public class SharePlugin implements FlutterPlugin, ActivityAware { public static void registerWith(Registrar registrar) { SharePlugin plugin = new SharePlugin(); - plugin.setUpChannel(registrar.activity(), registrar.messenger()); + plugin.setUpChannel(registrar.context(), registrar.activity(), registrar.messenger()); } @Override public void onAttachedToEngine(FlutterPluginBinding binding) { - setUpChannel(null, binding.getBinaryMessenger()); + setUpChannel(binding.getApplicationContext(), null, binding.getBinaryMessenger()); } @Override @@ -57,9 +58,9 @@ public void onDetachedFromActivityForConfigChanges() { onDetachedFromActivity(); } - private void setUpChannel(Activity activity, BinaryMessenger messenger) { + private void setUpChannel(Context context, Activity activity, BinaryMessenger messenger) { methodChannel = new MethodChannel(messenger, CHANNEL); - share = new Share(activity); + share = new Share(context, activity); handler = new MethodCallHandler(share); methodChannel.setMethodCallHandler(handler); } diff --git a/packages/share/android/src/main/res/xml/flutter_share_file_paths.xml b/packages/share/android/src/main/res/xml/flutter_share_file_paths.xml new file mode 100644 index 000000000000..e68bf916a30b --- /dev/null +++ b/packages/share/android/src/main/res/xml/flutter_share_file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/share/example/ios/Runner/Info.plist b/packages/share/example/ios/Runner/Info.plist index ac44e05ef845..71656105a1fa 100644 --- a/packages/share/example/ios/Runner/Info.plist +++ b/packages/share/example/ios/Runner/Info.plist @@ -45,5 +45,11 @@ UIViewControllerBasedStatusBarAppearance + NSPhotoLibraryUsageDescription + This app requires access to the photo library for sharing images. + NSMicrophoneUsageDescription + This app does not require access to the microphone for sharing images. + NSCameraUsageDescription + This app requires access to the camera for sharing images. diff --git a/packages/share/example/lib/image_previews.dart b/packages/share/example/lib/image_previews.dart new file mode 100644 index 000000000000..61ecec43bdc7 --- /dev/null +++ b/packages/share/example/lib/image_previews.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +/// Widget for displaying a preview of images +class ImagePreviews extends StatelessWidget { + /// The image paths of the displayed images + final List imagePaths; + + /// Callback when an image should be removed + final Function(int) onDelete; + + /// Creates a widget for preview of images. [imagePaths] can not be empty + /// and all contained paths need to be non empty. + const ImagePreviews(this.imagePaths, {Key key, this.onDelete}) + : super(key: key); + + @override + Widget build(BuildContext context) { + if (imagePaths.isEmpty) { + return Container(); + } + + List imageWidgets = []; + for (int i = 0; i < imagePaths.length; i++) { + imageWidgets.add(_ImagePreview( + imagePaths[i], + onDelete: onDelete != null ? () => onDelete(i) : null, + )); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: imageWidgets), + ); + } +} + +class _ImagePreview extends StatelessWidget { + final String imagePath; + final VoidCallback onDelete; + + const _ImagePreview(this.imagePath, {Key key, this.onDelete}) + : super(key: key); + + @override + Widget build(BuildContext context) { + File imageFile = File(imagePath); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Stack( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 200, + maxHeight: 200, + ), + child: Image.file(imageFile), + ), + Positioned( + right: 0, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: FloatingActionButton( + backgroundColor: Colors.red, + child: Icon(Icons.delete), + onPressed: onDelete), + ), + ), + ], + ), + ); + } +} diff --git a/packages/share/example/lib/main.dart b/packages/share/example/lib/main.dart index b68195cd3507..d6f1a1622b3c 100644 --- a/packages/share/example/lib/main.dart +++ b/packages/share/example/lib/main.dart @@ -5,8 +5,11 @@ // ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:share/share.dart'; +import 'image_previews.dart'; + void main() { runApp(DemoApp()); } @@ -19,6 +22,7 @@ class DemoApp extends StatefulWidget { class DemoAppState extends State { String text = ''; String subject = ''; + List imagePaths = []; @override Widget build(BuildContext context) { @@ -28,59 +32,92 @@ class DemoAppState extends State { appBar: AppBar( title: const Text('Share Plugin Demo'), ), - body: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextField( - decoration: const InputDecoration( - labelText: 'Share text:', - hintText: 'Enter some text and/or link to share', + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + decoration: const InputDecoration( + labelText: 'Share text:', + hintText: 'Enter some text and/or link to share', + ), + maxLines: 2, + onChanged: (String value) => setState(() { + text = value; + }), + ), + TextField( + decoration: const InputDecoration( + labelText: 'Share subject:', + hintText: 'Enter subject to share (optional)', + ), + maxLines: 2, + onChanged: (String value) => setState(() { + subject = value; + }), ), - maxLines: 2, - onChanged: (String value) => setState(() { - text = value; - }), - ), - TextField( - decoration: const InputDecoration( - labelText: 'Share subject:', - hintText: 'Enter subject to share (optional)', + const Padding(padding: EdgeInsets.only(top: 12.0)), + ImagePreviews(imagePaths, onDelete: _onDeleteImage), + ListTile( + leading: Icon(Icons.add), + title: Text("Add image"), + onTap: () async { + final imagePicker = ImagePicker(); + final pickedFile = await imagePicker.getImage( + source: ImageSource.gallery, + ); + if (pickedFile != null) { + setState(() { + imagePaths.add(pickedFile.path); + }); + } + }, ), - maxLines: 2, - onChanged: (String value) => setState(() { - subject = value; - }), - ), - const Padding(padding: EdgeInsets.only(top: 24.0)), - Builder( - builder: (BuildContext context) { - return RaisedButton( - child: const Text('Share'), - onPressed: text.isEmpty - ? null - : () { - // A builder is used to retrieve the context immediately - // surrounding the RaisedButton. - // - // The context's `findRenderObject` returns the first - // RenderObject in its descendent tree when it's not - // a RenderObjectWidget. The RaisedButton's RenderObject - // has its position and size after it's built. - final RenderBox box = context.findRenderObject(); - Share.share(text, - subject: subject, - sharePositionOrigin: - box.localToGlobal(Offset.zero) & - box.size); - }, - ); - }, - ), - ], + const Padding(padding: EdgeInsets.only(top: 12.0)), + Builder( + builder: (BuildContext context) { + return RaisedButton( + child: const Text('Share'), + onPressed: text.isEmpty && imagePaths.isEmpty + ? null + : () => _onShare(context), + ); + }, + ), + ], + ), ), )), ); } + + _onDeleteImage(int position) { + setState(() { + imagePaths.removeAt(position); + }); + } + + _onShare(BuildContext context) async { + // A builder is used to retrieve the context immediately + // surrounding the RaisedButton. + // + // The context's `findRenderObject` returns the first + // RenderObject in its descendent tree when it's not + // a RenderObjectWidget. The RaisedButton's RenderObject + // has its position and size after it's built. + final RenderBox box = context.findRenderObject(); + + if (imagePaths.isNotEmpty) { + await Share.shareFiles(imagePaths, + text: text, + subject: subject, + sharePositionOrigin: box.localToGlobal(Offset.zero) & box.size); + } else { + await Share.share(text, + subject: subject, + sharePositionOrigin: box.localToGlobal(Offset.zero) & box.size); + } + } } diff --git a/packages/share/example/pubspec.yaml b/packages/share/example/pubspec.yaml index 4830b7186019..8b8623910b7a 100644 --- a/packages/share/example/pubspec.yaml +++ b/packages/share/example/pubspec.yaml @@ -6,6 +6,7 @@ dependencies: sdk: flutter share: path: ../ + image_picker: ^0.6.7+4 dev_dependencies: flutter_driver: @@ -20,4 +21,3 @@ flutter: environment: sdk: ">=2.0.0-dev.28.0 <3.0.0" flutter: ">=1.9.1+hotfix.2 <2.0.0" - diff --git a/packages/share/ios/Classes/FLTSharePlugin.m b/packages/share/ios/Classes/FLTSharePlugin.m index 335ba5b819e5..837623a0119a 100644 --- a/packages/share/ios/Classes/FLTSharePlugin.m +++ b/packages/share/ios/Classes/FLTSharePlugin.m @@ -10,8 +10,12 @@ @interface ShareData : NSObject @property(readonly, nonatomic, copy) NSString *subject; @property(readonly, nonatomic, copy) NSString *text; +@property(readonly, nonatomic, copy) NSString *path; +@property(readonly, nonatomic, copy) NSString *mimeType; - (instancetype)initWithSubject:(NSString *)subject text:(NSString *)text NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithFile:(NSString *)path + mimeType:(NSString *)mimeType NS_DESIGNATED_INITIALIZER; - (instancetype)init __attribute__((unavailable("Use initWithSubject:text: instead"))); @@ -27,24 +31,62 @@ - (instancetype)init { - (instancetype)initWithSubject:(NSString *)subject text:(NSString *)text { self = [super init]; if (self) { - _subject = subject; + _subject = [subject isKindOfClass:NSNull.class] ? @"" : subject; _text = text; } return self; } +- (instancetype)initWithFile:(NSString *)path mimeType:(NSString *)mimeType { + self = [super init]; + if (self) { + _path = path; + _mimeType = mimeType; + } + return self; +} + - (id)activityViewControllerPlaceholderItem:(UIActivityViewController *)activityViewController { return @""; } - (id)activityViewController:(UIActivityViewController *)activityViewController itemForActivityType:(UIActivityType)activityType { - return _text; + if (!_path || !_mimeType) { + return _text; + } + + if ([_mimeType hasPrefix:@"image/"]) { + UIImage *image = [UIImage imageWithContentsOfFile:_path]; + return image; + } else { + NSURL *url = [NSURL fileURLWithPath:_path]; + return url; + } } - (NSString *)activityViewController:(UIActivityViewController *)activityViewController subjectForActivityType:(UIActivityType)activityType { - return [_subject isKindOfClass:NSNull.class] ? @"" : _subject; + return _subject; +} + +- (UIImage *)activityViewController:(UIActivityViewController *)activityViewController + thumbnailImageForActivityType:(UIActivityType)activityType + suggestedSize:(CGSize)suggestedSize { + if (!_path || !_mimeType || ![_mimeType hasPrefix:@"image/"]) { + return nil; + } + + UIImage *image = [UIImage imageWithContentsOfFile:_path]; + return [self imageWithImage:image scaledToSize:suggestedSize]; +} + +- (UIImage *)imageWithImage:(UIImage *)image scaledToSize:(CGSize)newSize { + UIGraphicsBeginImageContext(newSize); + [image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)]; + UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return newImage; } @end @@ -57,8 +99,19 @@ + (void)registerWithRegistrar:(NSObject *)registrar { binaryMessenger:registrar.messenger]; [shareChannel setMethodCallHandler:^(FlutterMethodCall *call, FlutterResult result) { + NSDictionary *arguments = [call arguments]; + NSNumber *originX = arguments[@"originX"]; + NSNumber *originY = arguments[@"originY"]; + NSNumber *originWidth = arguments[@"originWidth"]; + NSNumber *originHeight = arguments[@"originHeight"]; + + CGRect originRect = CGRectZero; + if (originX && originY && originWidth && originHeight) { + originRect = CGRectMake([originX doubleValue], [originY doubleValue], + [originWidth doubleValue], [originHeight doubleValue]); + } + if ([@"share" isEqualToString:call.method]) { - NSDictionary *arguments = [call arguments]; NSString *shareText = arguments[@"text"]; NSString *shareSubject = arguments[@"subject"]; @@ -69,19 +122,37 @@ + (void)registerWithRegistrar:(NSObject *)registrar { return; } - NSNumber *originX = arguments[@"originX"]; - NSNumber *originY = arguments[@"originY"]; - NSNumber *originWidth = arguments[@"originWidth"]; - NSNumber *originHeight = arguments[@"originHeight"]; + [self shareText:shareText + subject:shareSubject + withController:[UIApplication sharedApplication].keyWindow.rootViewController + atSource:originRect]; + result(nil); + } else if ([@"shareFiles" isEqualToString:call.method]) { + NSArray *paths = arguments[@"paths"]; + NSArray *mimeTypes = arguments[@"mimeTypes"]; + NSString *subject = arguments[@"subject"]; + NSString *text = arguments[@"text"]; + + if (paths.count == 0) { + result([FlutterError errorWithCode:@"error" + message:@"Non-empty paths expected" + details:nil]); + return; + } - CGRect originRect = CGRectZero; - if (originX != nil && originY != nil && originWidth != nil && originHeight != nil) { - originRect = CGRectMake([originX doubleValue], [originY doubleValue], - [originWidth doubleValue], [originHeight doubleValue]); + for (NSString *path in paths) { + if (path.length == 0) { + result([FlutterError errorWithCode:@"error" + message:@"Each path must not be empty" + details:nil]); + return; + } } - [self share:shareText - subject:shareSubject + [self shareFiles:paths + withMimeType:mimeTypes + withSubject:subject + withText:text withController:[UIApplication sharedApplication].keyWindow.rootViewController atSource:originRect]; result(nil); @@ -91,13 +162,11 @@ + (void)registerWithRegistrar:(NSObject *)registrar { }]; } -+ (void)share:(NSString *)shareText - subject:(NSString *)subject ++ (void)share:(NSArray *)shareItems withController:(UIViewController *)controller atSource:(CGRect)origin { - ShareData *data = [[ShareData alloc] initWithSubject:subject text:shareText]; UIActivityViewController *activityViewController = - [[UIActivityViewController alloc] initWithActivityItems:@[ data ] applicationActivities:nil]; + [[UIActivityViewController alloc] initWithActivityItems:shareItems applicationActivities:nil]; activityViewController.popoverPresentationController.sourceView = controller.view; if (!CGRectIsEmpty(origin)) { activityViewController.popoverPresentationController.sourceRect = origin; @@ -105,4 +174,44 @@ + (void)share:(NSString *)shareText [controller presentViewController:activityViewController animated:YES completion:nil]; } ++ (void)shareText:(NSString *)shareText + subject:(NSString *)subject + withController:(UIViewController *)controller + atSource:(CGRect)origin { + ShareData *data = [[ShareData alloc] initWithSubject:subject text:shareText]; + [self share:@[ data ] withController:controller atSource:origin]; +} + ++ (void)shareFiles:(NSArray *)paths + withMimeType:(NSArray *)mimeTypes + withSubject:(NSString *)subject + withText:(NSString *)text + withController:(UIViewController *)controller + atSource:(CGRect)origin { + NSMutableArray *items = [[NSMutableArray alloc] init]; + + if (text || subject) { + [items addObject:[[ShareData alloc] initWithSubject:subject text:text]]; + } + + for (int i = 0; i < [paths count]; i++) { + NSString *path = paths[i]; + NSString *pathExtension = [path pathExtension]; + NSString *mimeType = mimeTypes[i]; + if ([pathExtension.lowercaseString isEqualToString:@"jpg"] || + [pathExtension.lowercaseString isEqualToString:@"jpeg"] || + [pathExtension.lowercaseString isEqualToString:@"png"] || + [mimeType.lowercaseString isEqualToString:@"image/jpg"] || + [mimeType.lowercaseString isEqualToString:@"image/jpeg"] || + [mimeType.lowercaseString isEqualToString:@"image/png"]) { + UIImage *image = [UIImage imageWithContentsOfFile:path]; + [items addObject:image]; + } else { + [items addObject:[[ShareData alloc] initWithFile:path mimeType:mimeType]]; + } + } + + [self share:items withController:controller atSource:origin]; +} + @end diff --git a/packages/share/lib/share.dart b/packages/share/lib/share.dart index ff20d194f9e5..4a3ff6f1de09 100644 --- a/packages/share/lib/share.dart +++ b/packages/share/lib/share.dart @@ -7,6 +7,7 @@ import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:meta/meta.dart' show visibleForTesting; +import 'package:mime/mime.dart' show lookupMimeType; /// Plugin for summoning a platform share sheet. class Share { @@ -51,4 +52,50 @@ class Share { return channel.invokeMethod('share', params); } + + /// Summons the platform's share sheet to share multiple files. + /// + /// Wraps the platform's native share dialog. Can share a file. + /// It uses the `ACTION_SEND` Intent on Android and `UIActivityViewController` + /// on iOS. + /// + /// The optional `sharePositionOrigin` parameter can be used to specify a global + /// origin rect for the share sheet to popover from on iPads. It has no effect + /// on non-iPads. + /// + /// May throw [PlatformException] or [FormatException] + /// from [MethodChannel]. + static Future shareFiles( + List paths, { + List mimeTypes, + String subject, + String text, + Rect sharePositionOrigin, + }) { + assert(paths != null); + assert(paths.isNotEmpty); + assert(paths.every((element) => element != null && element.isNotEmpty)); + final Map params = { + 'paths': paths, + 'mimeTypes': mimeTypes ?? + paths.map((String path) => _mimeTypeForPath(path)).toList(), + }; + + if (subject != null) params['subject'] = subject; + if (text != null) params['text'] = text; + + if (sharePositionOrigin != null) { + params['originX'] = sharePositionOrigin.left; + params['originY'] = sharePositionOrigin.top; + params['originWidth'] = sharePositionOrigin.width; + params['originHeight'] = sharePositionOrigin.height; + } + + return channel.invokeMethod('shareFiles', params); + } + + static String _mimeTypeForPath(String path) { + assert(path != null); + return lookupMimeType(path) ?? 'application/octet-stream'; + } } diff --git a/packages/share/pubspec.yaml b/packages/share/pubspec.yaml index f5e545ca112e..918087b139ec 100644 --- a/packages/share/pubspec.yaml +++ b/packages/share/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://github.com/flutter/plugins/tree/master/packages/share # 0.6.y+z is compatible with 1.0.0, if you land a breaking change bump # the version to 2.0.0. # See more details: https://github.com/flutter/flutter/wiki/Package-migration-to-1.0.0 -version: 0.6.4+5 +version: 0.6.5 flutter: plugin: @@ -18,6 +18,7 @@ flutter: dependencies: meta: ^1.0.5 + mime: ^0.9.7 flutter: sdk: flutter diff --git a/packages/share/test/share_test.dart b/packages/share/test/share_test.dart index c03f8fb439df..e862d1baf579 100644 --- a/packages/share/test/share_test.dart +++ b/packages/share/test/share_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io'; import 'dart:ui'; import 'package:flutter_test/flutter_test.dart' show TestWidgetsFlutterBinding; @@ -56,6 +57,52 @@ void main() { 'originHeight': 4.0, })); }); + + test('sharing null file fails', () { + expect( + () => Share.shareFiles([null]), + throwsA(const TypeMatcher()), + ); + verifyZeroInteractions(mockChannel); + }); + + test('sharing empty file fails', () { + expect( + () => Share.shareFiles(['']), + throwsA(const TypeMatcher()), + ); + verifyZeroInteractions(mockChannel); + }); + + test('sharing file sets correct mimeType', () async { + final String path = 'tempfile-83649a.png'; + final File file = File(path); + try { + file.createSync(); + await Share.shareFiles([path]); + verify(mockChannel.invokeMethod('shareFiles', { + 'paths': [path], + 'mimeTypes': ['image/png'], + })); + } finally { + file.deleteSync(); + } + }); + + test('sharing file sets passed mimeType', () async { + final String path = 'tempfile-83649a.png'; + final File file = File(path); + try { + file.createSync(); + await Share.shareFiles([path], mimeTypes: ['*/*']); + verify(mockChannel.invokeMethod('shareFiles', { + 'paths': [file.path], + 'mimeTypes': ['*/*'], + })); + } finally { + file.deleteSync(); + } + }); } class MockMethodChannel extends Mock implements MethodChannel {}