diff --git a/packages/share/README.md b/packages/share/README.md index 4536c5818b7d..0a58de0f7f61 100644 --- a/packages/share/README.md +++ b/packages/share/README.md @@ -22,3 +22,8 @@ Then invoke the static `share` method anywhere in your Dart code ``` dart Share.share('check out my website https://example.com'); ``` + +To share a file invoke the static `shareFile` method anywhere in your Dart code +``` dart +Share.shareFile(File('${directory.path}/image.jpg')); +``` diff --git a/packages/share/android/build.gradle b/packages/share/android/build.gradle index 3b265e6c5ca7..f2b61ca35e13 100644 --- a/packages/share/android/build.gradle +++ b/packages/share/android/build.gradle @@ -45,3 +45,8 @@ android { disable 'InvalidPackage' } } + +dependencies { + // use support-compat starting from 28.0.0 + implementation 'com.android.support:support-core-utils:27.1.1' +} \ No newline at end of file diff --git a/packages/share/android/src/main/AndroidManifest.xml b/packages/share/android/src/main/AndroidManifest.xml index 407eae4d8128..e687dab0032d 100644 --- a/packages/share/android/src/main/AndroidManifest.xml +++ b/packages/share/android/src/main/AndroidManifest.xml @@ -1,3 +1,15 @@ + package="io.flutter.plugins.share"> + + + + + + 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 25d4e842ae1d..76e6b7a95028 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,9 +5,14 @@ package io.flutter.plugins.share; import android.content.Intent; +import android.net.Uri; +import android.os.Environment; +import androidx.annotation.NonNull; +import androidx.core.content.FileProvider; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.PluginRegistry.Registrar; +import java.io.*; import java.util.Map; /** Plugin method host for presenting a share sheet via Intent */ @@ -29,15 +34,36 @@ private SharePlugin(Registrar registrar) { @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((String) call.argument("text")); - 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((String) call.argument("text")); + result.success(null); + break; + case "shareFile": + expectMapArguments(call); + // Android does not support showing the share sheet at a particular point on screen. + try { + shareFile( + (String) call.argument("path"), + (String) call.argument("mimeType"), + (String) call.argument("subject"), + (String) call.argument("text")); + 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"); } } @@ -58,4 +84,95 @@ private void share(String text) { mRegistrar.context().startActivity(chooserIntent); } } + + private void shareFile(String path, String mimeType, String subject, String text) + throws IOException { + if (path == null || path.isEmpty()) { + throw new IllegalArgumentException("Non-empty path expected"); + } + + File file = new File(path); + clearExternalShareFolder(); + if (!fileIsOnExternal(file)) { + file = copyToExternalShareFolder(file); + } + + Uri fileUri = + FileProvider.getUriForFile( + mRegistrar.context(), + mRegistrar.context().getPackageName() + ".flutter.share_provider", + file); + + Intent shareIntent = new Intent(); + shareIntent.setAction(Intent.ACTION_SEND); + shareIntent.putExtra(Intent.EXTRA_STREAM, fileUri); + if (subject != null) shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject); + if (text != null) shareIntent.putExtra(Intent.EXTRA_TEXT, text); + shareIntent.setType(mimeType != null ? mimeType : "*/*"); + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + Intent chooserIntent = Intent.createChooser(shareIntent, null /* dialog title optional */); + if (mRegistrar.activity() != null) { + mRegistrar.activity().startActivity(chooserIntent); + } else { + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mRegistrar.context().startActivity(chooserIntent); + } + } + + 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(mRegistrar.context().getExternalCacheDir(), "share"); + } + + 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/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..ce94d87583e4 --- /dev/null +++ b/packages/share/android/src/main/res/xml/flutter_share_file_paths.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/share/ios/Classes/SharePlugin.m b/packages/share/ios/Classes/SharePlugin.m index 7706e58e7152..2411650ae56e 100644 --- a/packages/share/ios/Classes/SharePlugin.m +++ b/packages/share/ios/Classes/SharePlugin.m @@ -14,8 +14,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; + if (originX != nil && originY != nil && originWidth != nil && originHeight != nil) { + originRect = CGRectMake([originX doubleValue], [originY doubleValue], + [originWidth doubleValue], [originHeight doubleValue]); + } + if ([@"share" isEqualToString:call.method]) { - NSDictionary *arguments = [call arguments]; NSString *shareText = arguments[@"text"]; if (shareText.length == 0) { @@ -25,18 +36,27 @@ + (void)registerWithRegistrar:(NSObject *)registrar { return; } - NSNumber *originX = arguments[@"originX"]; - NSNumber *originY = arguments[@"originY"]; - NSNumber *originWidth = arguments[@"originWidth"]; - NSNumber *originHeight = arguments[@"originHeight"]; + [self share:@[ shareText ] + withController:[UIApplication sharedApplication].keyWindow.rootViewController + atSource:originRect]; + result(nil); + } else if ([@"shareFile" isEqualToString:call.method]) { + NSString *path = arguments[@"path"]; + NSString *mimeType = arguments[@"mimeType"]; + NSString *subject = arguments[@"subject"]; + NSString *text = arguments[@"text"]; - CGRect originRect; - if (originX != nil && originY != nil && originWidth != nil && originHeight != nil) { - originRect = CGRectMake([originX doubleValue], [originY doubleValue], - [originWidth doubleValue], [originHeight doubleValue]); + if (path.length == 0) { + result([FlutterError errorWithCode:@"error" + message:@"Non-empty path expected" + details:nil]); + return; } - [self share:shareText + [self shareFile:path + withMimeType:mimeType + withSubject:subject + withText:text withController:[UIApplication sharedApplication].keyWindow.rootViewController atSource:originRect]; result(nil); @@ -46,12 +66,11 @@ + (void)registerWithRegistrar:(NSObject *)registrar { }]; } -+ (void)share:(id)sharedItems ++ (void)share:(NSArray *)shareItems withController:(UIViewController *)controller atSource:(CGRect)origin { UIActivityViewController *activityViewController = - [[UIActivityViewController alloc] initWithActivityItems:@[ sharedItems ] - applicationActivities:nil]; + [[UIActivityViewController alloc] initWithActivityItems:shareItems applicationActivities:nil]; activityViewController.popoverPresentationController.sourceView = controller.view; if (!CGRectIsEmpty(origin)) { activityViewController.popoverPresentationController.sourceRect = origin; @@ -59,4 +78,30 @@ + (void)share:(id)sharedItems [controller presentViewController:activityViewController animated:YES completion:nil]; } ++ (void)shareFile:(id)path + withMimeType:(id)mimeType + withSubject:(NSString *)subject + withText:(NSString *)text + withController:(UIViewController *)controller + atSource:(CGRect)origin { + NSMutableArray *items = [[NSMutableArray alloc] init]; + + if (subject != nil && subject.length != 0) { + [items addObject:subject]; + } + if (text != nil && text.length != 0) { + [items addObject:text]; + } + + if ([mimeType hasPrefix:@"image/"]) { + UIImage *image = [UIImage imageWithContentsOfFile:path]; + [items addObject:image]; + } else { + NSURL *url = [NSURL fileURLWithPath:path]; + [items addObject:url]; + } + + [self share:items withController:controller atSource:origin]; +} + @end diff --git a/packages/share/lib/share.dart b/packages/share/lib/share.dart index a86a8261b5f5..7b26542d3e35 100644 --- a/packages/share/lib/share.dart +++ b/packages/share/lib/share.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:io'; import 'dart:ui'; import 'package:flutter/services.dart'; @@ -46,4 +47,93 @@ class Share { // ignore: strong_mode_implicit_dynamic_method return channel.invokeMethod('share', params); } + + /// Summons the platform's share sheet to share a file. + /// + /// 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 shareFile(File file, + {String mimeType, + String subject, + String text, + Rect sharePositionOrigin}) { + assert(file != null); + assert(file.existsSync()); + final Map params = { + 'path': file.path, + 'mimeType': mimeType ?? _mimeTypeForFile(file), + }; + + 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('shareFile', params); + } + + static String _mimeTypeForFile(File file) { + assert(file != null); + final String path = file.path; + + final int extensionIndex = path.lastIndexOf("\."); + if (extensionIndex == -1 || extensionIndex == 0) { + return null; + } + + final String extension = path.substring(extensionIndex + 1); + switch (extension) { + // image + case 'jpeg': + case 'jpg': + return 'image/jpeg'; + case 'gif': + return 'image/gif'; + case 'png': + return 'image/png'; + case 'svg': + return 'image/svg+xml'; + case 'tif': + case 'tiff': + return 'image/tiff'; + // audio + case 'aac': + return 'audio/aac'; + case 'oga': + return 'audio/ogg'; + // video + case 'avi': + return 'video/x-msvideo'; + case 'mpeg': + return 'video/mpeg'; + case 'ogv': + return 'video/ogg'; + // other + case 'csv': + return 'text/csv'; + case 'htm': + case 'html': + return 'text/html'; + case 'json': + return 'application/json'; + case 'pdf': + return 'application/pdf'; + case 'txt': + return 'text/plain'; + } + return null; + } } diff --git a/packages/share/test/share_test.dart b/packages/share/test/share_test.dart index 66f7af7b5ad9..747943cfd45f 100644 --- a/packages/share/test/share_test.dart +++ b/packages/share/test/share_test.dart @@ -2,14 +2,14 @@ // 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/services.dart'; import 'package:mockito/mockito.dart'; import 'package:share/share.dart'; import 'package:test/test.dart'; -import 'package:flutter/services.dart'; - void main() { MockMethodChannel mockChannel; @@ -58,6 +58,58 @@ void main() { 'originHeight': 4.0, })); }); + + test('sharing null file fails', () { + expect( + () => Share.shareFile(null), + throwsA(const TypeMatcher()), + ); + verifyZeroInteractions(mockChannel); + }); + + test('sharing empty file fails', () { + expect( + () => Share.shareFile(null), + throwsA(const TypeMatcher()), + ); + verifyZeroInteractions(mockChannel); + }); + + test('sharing non existing file fails', () { + expect( + () => Share.shareFile(File('/sdcard/nofile.txt')), + throwsA(const TypeMatcher()), + ); + verifyZeroInteractions(mockChannel); + }); + + test('sharing file sets correct mimeType', () async { + final File file = File('tempfile-83649a.png'); + try { + file.createSync(); + await Share.shareFile(file); + verify(mockChannel.invokeMethod('shareFile', { + 'path': file.path, + 'mimeType': 'image/png', + })); + } finally { + file.deleteSync(); + } + }); + + test('sharing file sets passed mimeType', () async { + final File file = File('tempfile-83649a.png'); + try { + file.createSync(); + await Share.shareFile(file, mimeType: '*/*'); + verify(mockChannel.invokeMethod('shareFile', { + 'path': file.path, + 'mimeType': '*/*', + })); + } finally { + file.deleteSync(); + } + }); } class MockMethodChannel extends Mock implements MethodChannel {}