[share] Add sharing file support (android & ios) (#970)
Co-authored-by: Kifah Meeran <23234883+MaskyS@users.noreply.github.com>
Co-authored-by: Aloìˆs Deniel <alois.deniel@gmail.com>
Co-authored-by: Colin Stewart <colin@owlfish.com>
diff --git a/packages/share/CHANGELOG.md b/packages/share/CHANGELOG.md
index 8c3814d..c4ee830 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 14be8da..750fca6 100644
--- a/packages/share/README.md
+++ b/packages/share/README.md
@@ -39,3 +39,9 @@
``` 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 e154b06..7506f4d 100644
--- a/packages/share/android/build.gradle
+++ b/packages/share/android/build.gradle
@@ -31,4 +31,9 @@
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 407eae4..c141a5c 100644
--- a/packages/share/android/src/main/AndroidManifest.xml
+++ b/packages/share/android/src/main/AndroidManifest.xml
@@ -1,3 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.flutter.plugins.share">
+ <application>
+ <provider
+ android:name="io.flutter.plugins.share.ShareFileProvider"
+ android:authorities="${applicationId}.flutter.share_provider"
+ android:exported="false"
+ android:grantUriPermissions="true">
+ <meta-data
+ android:name="android.support.FILE_PROVIDER_PATHS"
+ android:resource="@xml/flutter_share_file_paths"/>
+ </provider>
+ </application>
</manifest>
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 f7e4d57..02841d3 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 @@
@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<String>) call.argument("paths"),
+ (List<String>) 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 8c9e833..eb856bf 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 @@
shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
shareIntent.setType("text/plain");
Intent chooserIntent = Intent.createChooser(shareIntent, null /* dialog title optional */);
- if (activity != null) {
- activity.startActivity(chooserIntent);
+ startActivity(chooserIntent);
+ }
+
+ void shareFiles(List<String> paths, List<String> mimeTypes, String text, String subject)
+ throws IOException {
+ if (paths == null || paths.isEmpty()) {
+ throw new IllegalArgumentException("Non-empty path expected");
+ }
+
+ clearExternalShareFolder();
+ ArrayList<Uri> 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 {
- chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- activity.startActivity(chooserIntent);
+ 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<ResolveInfo> 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(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<Uri> getUrisForPaths(List<String> paths) throws IOException {
+ ArrayList<Uri> 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<String> 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 {
+ 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 0000000..87e4e42
--- /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 <provider>} name collisions.
+ *
+ * <p>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 fdb9dc4..bd7dfc2 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 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 @@
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 0000000..e68bf91
--- /dev/null
+++ b/packages/share/android/src/main/res/xml/flutter_share_file_paths.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+ <external-path name="external" path="."/>
+ <external-files-path name="external_files" path="."/>
+ <external-cache-path name="external_cache" path="."/>
+</paths>
diff --git a/packages/share/example/ios/Runner/Info.plist b/packages/share/example/ios/Runner/Info.plist
index ac44e05..7165610 100644
--- a/packages/share/example/ios/Runner/Info.plist
+++ b/packages/share/example/ios/Runner/Info.plist
@@ -45,5 +45,11 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
+ <key>NSPhotoLibraryUsageDescription</key>
+ <string>This app requires access to the photo library for sharing images.</string>
+ <key>NSMicrophoneUsageDescription</key>
+ <string>This app does not require access to the microphone for sharing images.</string>
+ <key>NSCameraUsageDescription</key>
+ <string>This app requires access to the camera for sharing images.</string>
</dict>
</plist>
diff --git a/packages/share/example/lib/image_previews.dart b/packages/share/example/lib/image_previews.dart
new file mode 100644
index 0000000..61ecec4
--- /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<String> 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<Widget> 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: <Widget>[
+ 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 b68195c..d6f1a16 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 DemoAppState extends State<DemoApp> {
String text = '';
String subject = '';
+ List<String> imagePaths = [];
@override
Widget build(BuildContext context) {
@@ -28,59 +32,92 @@
appBar: AppBar(
title: const Text('Share Plugin Demo'),
),
- body: Padding(
- padding: const EdgeInsets.all(24.0),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: <Widget>[
- 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: <Widget>[
+ TextField(
+ decoration: const InputDecoration(
+ labelText: 'Share text:',
+ hintText: 'Enter some text and/or link to share',
+ ),
+ maxLines: 2,
+ onChanged: (String value) => setState(() {
+ text = value;
+ }),
),
- maxLines: 2,
- onChanged: (String value) => setState(() {
- text = value;
- }),
- ),
- TextField(
- decoration: const InputDecoration(
- labelText: 'Share subject:',
- hintText: 'Enter subject to share (optional)',
+ 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(() {
- 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)),
+ 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);
+ });
+ }
+ },
+ ),
+ 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 4830b71..8b86239 100644
--- a/packages/share/example/pubspec.yaml
+++ b/packages/share/example/pubspec.yaml
@@ -6,6 +6,7 @@
sdk: flutter
share:
path: ../
+ image_picker: ^0.6.7+4
dev_dependencies:
flutter_driver:
@@ -20,4 +21,3 @@
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 335ba5b..837623a 100644
--- a/packages/share/ios/Classes/FLTSharePlugin.m
+++ b/packages/share/ios/Classes/FLTSharePlugin.m
@@ -10,8 +10,12 @@
@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)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 @@
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 @@
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"];
- CGRect originRect = CGRectZero;
- if (originX != nil && originY != nil && originWidth != nil && originHeight != nil) {
- originRect = CGRectMake([originX doubleValue], [originY doubleValue],
- [originWidth doubleValue], [originHeight doubleValue]);
+ if (paths.count == 0) {
+ result([FlutterError errorWithCode:@"error"
+ message:@"Non-empty paths expected"
+ details:nil]);
+ return;
}
- [self share:shareText
- subject:shareSubject
+ for (NSString *path in paths) {
+ if (path.length == 0) {
+ result([FlutterError errorWithCode:@"error"
+ message:@"Each path must not be empty"
+ details:nil]);
+ return;
+ }
+ }
+
+ [self shareFiles:paths
+ withMimeType:mimeTypes
+ withSubject:subject
+ withText:text
withController:[UIApplication sharedApplication].keyWindow.rootViewController
atSource:originRect];
result(nil);
@@ -91,13 +162,11 @@
}];
}
-+ (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 @@
[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 ff20d19..4a3ff6f 100644
--- a/packages/share/lib/share.dart
+++ b/packages/share/lib/share.dart
@@ -7,6 +7,7 @@
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 @@
return channel.invokeMethod<void>('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<void> shareFiles(
+ List<String> paths, {
+ List<String> mimeTypes,
+ String subject,
+ String text,
+ Rect sharePositionOrigin,
+ }) {
+ assert(paths != null);
+ assert(paths.isNotEmpty);
+ assert(paths.every((element) => element != null && element.isNotEmpty));
+ final Map<String, dynamic> params = <String, dynamic>{
+ '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 f5e545c..918087b 100644
--- a/packages/share/pubspec.yaml
+++ b/packages/share/pubspec.yaml
@@ -5,7 +5,7 @@
# 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 @@
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 c03f8fb..e862d1b 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 @@
'originHeight': 4.0,
}));
});
+
+ test('sharing null file fails', () {
+ expect(
+ () => Share.shareFiles([null]),
+ throwsA(const TypeMatcher<AssertionError>()),
+ );
+ verifyZeroInteractions(mockChannel);
+ });
+
+ test('sharing empty file fails', () {
+ expect(
+ () => Share.shareFiles(['']),
+ throwsA(const TypeMatcher<AssertionError>()),
+ );
+ 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', <String, dynamic>{
+ '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', <String, dynamic>{
+ 'paths': [file.path],
+ 'mimeTypes': ['*/*'],
+ }));
+ } finally {
+ file.deleteSync();
+ }
+ });
}
class MockMethodChannel extends Mock implements MethodChannel {}