} 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 {}