diff --git a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/Share.kt b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/Share.kt index cdfc4aabb2..9775f4bfc1 100644 --- a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/Share.kt +++ b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/Share.kt @@ -132,7 +132,7 @@ internal class Share( shareIntent.apply { action = Intent.ACTION_SEND type = "text/plain" - putExtra(Intent.EXTRA_TEXT, uri ?: text) + putExtra(Intent.EXTRA_TEXT, text ?: uri) if (!subject.isNullOrBlank()) putExtra(Intent.EXTRA_SUBJECT, subject) if (!title.isNullOrBlank()) putExtra(Intent.EXTRA_TITLE, title) } diff --git a/packages/share_plus/share_plus/ios/share_plus/Sources/share_plus/FPPSharePlusPlugin.m b/packages/share_plus/share_plus/ios/share_plus/Sources/share_plus/FPPSharePlusPlugin.m index fe1ebc3dbf..70f54cebbf 100644 --- a/packages/share_plus/share_plus/ios/share_plus/Sources/share_plus/FPPSharePlusPlugin.m +++ b/packages/share_plus/share_plus/ios/share_plus/Sources/share_plus/FPPSharePlusPlugin.m @@ -6,6 +6,23 @@ #import "LinkPresentation/LPMetadataProvider.h" static NSString *const PLATFORM_CHANNEL = @"dev.fluttercommunity.plus/share"; +static NSString *const kSharePlusPlaceholderActivityType = + @"dev.fluttercommunity.share_plus.placeholder"; + +/// Apps that open or save a file. Text and multimedia apps are everything else. +static BOOL FPPSharePlusIsFileDestination(NSString *activityType) { + if (activityType.length == 0) { + return NO; + } + return [activityType isEqualToString:UIActivityTypeAirDrop] || + [activityType isEqualToString:UIActivityTypeCopyToPasteboard] || + [activityType isEqualToString:UIActivityTypePrint] || + [activityType isEqualToString:UIActivityTypeMarkupAsPDF] || + [activityType isEqualToString:UIActivityTypeOpenInIBooks] || + [activityType isEqualToString:UIActivityTypeSaveToCameraRoll] || + [activityType + isEqualToString:@"com.apple.DocumentManagerUICore.SaveToFiles"]; +} static UIViewController *RootViewController(void) { if (@available(iOS 13, *)) { // UIApplication.keyWindow is deprecated @@ -108,10 +125,22 @@ @interface SharePlusData : NSObject @property(readonly, nonatomic, copy) NSString *text; @property(readonly, nonatomic, copy) NSString *path; @property(readonly, nonatomic, copy) NSString *mimeType; +@property(readonly, nonatomic, copy) NSString *url; +@property(readonly, nonatomic, assign) BOOL airDropAsUrl; +@property(readonly, nonatomic, assign) BOOL messageAndMailOnly; - (instancetype)initWithSubject:(NSString *)subject text:(NSString *)text NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithSubject:(NSString *)subject + text:(NSString *)text + messageAndMailOnly:(BOOL)messageAndMailOnly NS_DESIGNATED_INITIALIZER; + +- (instancetype)initWithSubject:(NSString *)subject + text:(NSString *)text + url:(NSString *)url + airDropAsUrl:(BOOL)airDropAsUrl NS_DESIGNATED_INITIALIZER; + - (instancetype)initWithFile:(NSString *)path mimeType:(NSString *)mimeType NS_DESIGNATED_INITIALIZER; @@ -132,10 +161,29 @@ - (instancetype)init { } - (instancetype)initWithSubject:(NSString *)subject text:(NSString *)text { + return [self initWithSubject:subject text:text messageAndMailOnly:NO]; +} + +- (instancetype)initWithSubject:(NSString *)subject + text:(NSString *)text + messageAndMailOnly:(BOOL)messageAndMailOnly { + self = [self initWithSubject:subject text:text url:nil airDropAsUrl:NO]; + if (self) { + _messageAndMailOnly = messageAndMailOnly; + } + return self; +} + +- (instancetype)initWithSubject:(NSString *)subject + text:(NSString *)text + url:(NSString *)url + airDropAsUrl:(BOOL)airDropAsUrl { self = [super init]; if (self) { _subject = [subject isKindOfClass:NSNull.class] ? @"" : subject; _text = text; + _url = url; + _airDropAsUrl = airDropAsUrl; } return self; } @@ -165,19 +213,38 @@ - (id)activityViewControllerPlaceholderItem: (UIActivityViewController *)activityViewController { return [self activityViewController:activityViewController - itemForActivityType:@"dev.fluttercommunity.share_plus.placeholder"]; + itemForActivityType:kSharePlusPlaceholderActivityType]; } - (id)activityViewController:(UIActivityViewController *)activityViewController itemForActivityType:(UIActivityType)activityType { if (!_path || !_mimeType) { + // Caption for text and multimedia apps. nil (not NSNull) so the header + // and file apps do not count a Plain Text item. Unknown types, including + // Notes, still receive the string. + if (_messageAndMailOnly) { + if (activityType == nil || + [activityType isEqualToString:kSharePlusPlaceholderActivityType] || + FPPSharePlusIsFileDestination(activityType)) { + return nil; + } + return _text; + } + // Placeholder stays text so the sheet uses the host app icon, not Safari. + // AirDrop gets the URL so the other device opens the link. Messages, + // Mail, and the placeholder stay on text so the sheet keeps the host + // app icon and SMS/email still receive the sentence. + if (_airDropAsUrl && _url.length > 0 && + [activityType isEqualToString:UIActivityTypeAirDrop]) { + return [NSURL URLWithString:_url]; + } return _text; } // If the shared file is an image return an UIImage for the placeholder // to show a preview. if ([activityType - isEqualToString:@"dev.fluttercommunity.share_plus.placeholder"] && + isEqualToString:kSharePlusPlaceholderActivityType] && [_mimeType hasPrefix:@"image/"]) { UIImage *image = [UIImage imageWithContentsOfFile:_path]; return image; @@ -292,6 +359,7 @@ + (void)registerWithRegistrar:(NSObject *)registrar { NSArray *paths = arguments[@"paths"]; NSArray *mimeTypes = arguments[@"mimeTypes"]; NSString *uri = arguments[@"uri"]; + NSString *airDrop = arguments[@"airDrop"]; // Use title field for consistency with Android. // Subject field should only be used on email subjects. @@ -367,6 +435,9 @@ + (void)registerWithRegistrar:(NSObject *)registrar { if (uri) { [self shareUri:uri + airDrop:airDrop + text:shareText + subject:shareTitle excludedActivityTypes:excludedActivityTypes withController:topViewController atSource:originRect @@ -462,13 +533,46 @@ + (void)share:(NSArray *)shareItems } + (void)shareUri:(NSString *)uri + airDrop:(NSString *)airDrop + text:(NSString *)text + subject:(NSString *)subject excludedActivityTypes:(NSArray *)excludedActivityTypes withController:(UIViewController *)controller atSource:(CGRect)origin toResult:(FlutterResult)result { + if ([airDrop isEqualToString:@"url"]) { + NSString *body = text; + if (!body || [body isKindOfClass:[NSNull class]] || body.length == 0) { + body = subject; + } + if (!body || [body isKindOfClass:[NSNull class]] || body.length == 0) { + body = uri; + } + NSObject *data = [[SharePlusData alloc] initWithSubject:subject + text:body + url:uri + airDropAsUrl:YES]; + [self share:@[ data ] + withSubject:subject + excludedActivityTypes:excludedActivityTypes + withController:controller + atSource:origin + toResult:result]; + return; + } + if ([airDrop isEqualToString:@"text"]) { + NSObject *data = [[SharePlusData alloc] initWithSubject:subject text:uri]; + [self share:@[ data ] + withSubject:subject + excludedActivityTypes:excludedActivityTypes + withController:controller + atSource:origin + toResult:result]; + return; + } NSURL *data = [NSURL URLWithString:uri]; [self share:@[ data ] - withSubject:nil + withSubject:subject excludedActivityTypes:excludedActivityTypes withController:controller atSource:origin @@ -509,7 +613,9 @@ + (void)shareFiles:(NSArray *)paths subject:subject]]; } if (text != nil) { - NSObject *data = [[SharePlusData alloc] initWithSubject:subject text:text]; + NSObject *data = [[SharePlusData alloc] initWithSubject:subject + text:text + messageAndMailOnly:YES]; [items addObject:data]; } diff --git a/packages/share_plus/share_plus/lib/share_plus.dart b/packages/share_plus/share_plus/lib/share_plus.dart index b6e055e3de..7228b5584f 100644 --- a/packages/share_plus/share_plus/lib/share_plus.dart +++ b/packages/share_plus/share_plus/lib/share_plus.dart @@ -13,6 +13,7 @@ export 'package:share_plus_platform_interface/share_plus_platform_interface.dart ShareResultStatus, XFile, ShareParams, + ShareAirDropAs, CupertinoActivityType; export 'src/share_plus_linux.dart'; @@ -79,8 +80,12 @@ class SharePlus { ); } - if (params.uri != null && params.text != null) { - throw ArgumentError('uri and text cannot be provided at the same time'); + if (params.uri != null && + params.text != null && + params.airDrop != ShareAirDropAs.url) { + throw ArgumentError( + 'uri and text cannot be provided at the same time unless airDrop is ShareAirDropAs.url', + ); } if (params.text != null && params.text!.isEmpty) { diff --git a/packages/share_plus/share_plus/lib/src/share_plus_web.dart b/packages/share_plus/share_plus/lib/src/share_plus_web.dart index 1eb79e1942..62bda80656 100644 --- a/packages/share_plus/share_plus/lib/src/share_plus_web.dart +++ b/packages/share_plus/share_plus/lib/src/share_plus_web.dart @@ -88,11 +88,11 @@ class SharePlusWebPlugin extends SharePlatform { ); } - if (uri != null && text != null) { - throw ArgumentError('Only one of uri or text can be provided'); - } - - if (uri != null) { + if (uri != null && text != null && title != null) { + data = ShareData(text: text, title: title); + } else if (uri != null && text != null) { + data = ShareData(text: text); + } else if (uri != null) { data = ShareData(url: uri); } else if (webFiles.isNotEmpty && text != null && title != null) { data = ShareData(text: text, title: title, files: webFiles.toJS); diff --git a/packages/share_plus/share_plus/test/share_plus_test.dart b/packages/share_plus/share_plus/test/share_plus_test.dart index eb1cba6570..dc6f88ad91 100644 --- a/packages/share_plus/share_plus/test/share_plus_test.dart +++ b/packages/share_plus/share_plus/test/share_plus_test.dart @@ -31,6 +31,23 @@ void main() { }, ); + test( + 'share allows uri and text when airDrop is url', + () async { + final params = ShareParams( + uri: Uri.parse('https://example.com'), + text: 'Open this link https://example.com', + title: 'Open this link', + airDrop: ShareAirDropAs.url, + ); + final result = await sharePlus.share(params); + expect(result, ShareResult.unavailable); + expect(fakePlatform.lastParams?.uri, params.uri); + expect(fakePlatform.lastParams?.text, params.text); + expect(fakePlatform.lastParams?.airDrop, ShareAirDropAs.url); + }, + ); + test('share throws ArgumentError if text is empty', () async { expect( () => sharePlus.share(ShareParams(text: '')), diff --git a/packages/share_plus/share_plus_platform_interface/lib/method_channel/method_channel_share.dart b/packages/share_plus/share_plus_platform_interface/lib/method_channel/method_channel_share.dart index 80d6c97e51..822a9acec2 100644 --- a/packages/share_plus/share_plus_platform_interface/lib/method_channel/method_channel_share.dart +++ b/packages/share_plus/share_plus_platform_interface/lib/method_channel/method_channel_share.dart @@ -43,6 +43,8 @@ class MethodChannelShare extends SharePlatform { if (params.subject != null) 'subject': params.subject, if (params.title != null) 'title': params.title, if (params.uri != null) 'uri': params.uri.toString(), + if (params.airDrop != ShareAirDropAs.inherit) + 'airDrop': params.airDrop.name, }; if (params.sharePositionOrigin != null) { diff --git a/packages/share_plus/share_plus_platform_interface/lib/platform_interface/share_plus_platform.dart b/packages/share_plus/share_plus_platform_interface/lib/platform_interface/share_plus_platform.dart index c5e8f0b934..bb0e60bc7d 100644 --- a/packages/share_plus/share_plus_platform_interface/lib/platform_interface/share_plus_platform.dart +++ b/packages/share_plus/share_plus_platform_interface/lib/platform_interface/share_plus_platform.dart @@ -39,13 +39,15 @@ class SharePlatform extends PlatformInterface { class ShareParams { /// The text to share /// - /// Cannot be provided at the same time as [uri], - /// as the share method will use one or the other. + /// Can be provided with [uri] when [airDrop] is [ShareAirDropAs.url]: + /// Messages, Mail, and other text destinations receive this string, while + /// AirDrop still receives the URL. /// - /// Can be used together with [files], - /// but it depends on the receiving app if they support - /// loading files and text from a share action. - /// Some apps only support one or the other. + /// Can be used together with [files]. On iOS that string is a caption for + /// text and multimedia destinations (Messages, Mail, Notes, and other + /// share extensions). File destinations (Save to Files, AirDrop, Copy, + /// Print, Books, Camera Roll, Markup as PDF) and the sheet header do not + /// receive it. Android puts it in EXTRA_TEXT beside the file. /// /// * Supported platforms: All final String? text; @@ -94,13 +96,17 @@ class ShareParams { /// /// On other platforms it behaves like sharing text. /// - /// Cannot be used in combination with [text]. + /// Can be used with [text] when [airDrop] is [ShareAirDropAs.url]. /// /// * Supported platforms: iOS, Android /// Falls back to sharing the URI as text on other platforms. final Uri? uri; - /// Share multiple files, can be used in combination with [text] + /// Share multiple files, can be used in combination with [text]. + /// + /// On iOS, [text] goes to text and multimedia destinations. Save to Files, + /// AirDrop, Copy, Print, Books, Camera Roll, Markup as PDF, and the sheet + /// header stay file-only. Unknown activity types receive [text]. /// /// Android supports all natively available MIME types (wildcards like image/* /// are also supported) and it's considered best practice to avoid mixing @@ -145,6 +151,22 @@ class ShareParams { /// Parameter ignored on other platforms. final List? excludedCupertinoActivities; + /// How AirDrop should deliver [uri] on iOS. + /// + /// * [ShareAirDropAs.inherit] keeps the current URI share (raw `NSURL`, + /// Safari / page preview on the sheet). + /// * [ShareAirDropAs.url] wraps the URI like a text share so the host app + /// icon stays in the header. AirDrop receives the URL. Messages, Mail, + /// and other destinations receive [text] when it is set. + /// * [ShareAirDropAs.text] sends the URI string as plain text (Notes). + /// + /// Android has no AirDrop. [uri] + [title] already go out as EXTRA_TEXT / + /// EXTRA_TITLE (Nearby Share equivalent). This parameter is ignored there. + /// + /// * Supported platforms: iOS + /// Parameter ignored on other platforms. + final ShareAirDropAs airDrop; + ShareParams({ this.text, this.subject, @@ -157,6 +179,7 @@ class ShareParams { this.downloadFallbackEnabled = true, this.mailToFallbackEnabled = true, this.excludedCupertinoActivities, + this.airDrop = ShareAirDropAs.inherit, }); } @@ -222,6 +245,18 @@ enum ShareResultStatus { /// /// See also: /// [UIActivity.ActivityType](https://developer.apple.com/documentation/uikit/uiactivity/activitytype) +/// How iOS AirDrop should deliver a shared [ShareParams.uri]. +enum ShareAirDropAs { + /// Keep the current URI-share behaviour (raw URL item). + inherit, + + /// Deliver the URI as a URL (opens the link instead of Notes). + url, + + /// Deliver the URI string as plain text. + text, +} + enum CupertinoActivityType { postToFacebook, postToTwitter, diff --git a/packages/share_plus/share_plus_platform_interface/lib/share_plus_platform_interface.dart b/packages/share_plus/share_plus_platform_interface/lib/share_plus_platform_interface.dart index f1e38b83bc..3ee169f9a1 100644 --- a/packages/share_plus/share_plus_platform_interface/lib/share_plus_platform_interface.dart +++ b/packages/share_plus/share_plus_platform_interface/lib/share_plus_platform_interface.dart @@ -3,4 +3,5 @@ // found in the LICENSE file. export 'package:cross_file/cross_file.dart'; + export 'platform_interface/share_plus_platform.dart'; diff --git a/packages/share_plus/share_plus_platform_interface/test/share_plus_platform_interface_test.dart b/packages/share_plus/share_plus_platform_interface/test/share_plus_platform_interface_test.dart index 67c8bff9a9..41f6423abf 100644 --- a/packages/share_plus/share_plus_platform_interface/test/share_plus_platform_interface_test.dart +++ b/packages/share_plus/share_plus_platform_interface/test/share_plus_platform_interface_test.dart @@ -58,6 +58,9 @@ void main() { await sharePlatform.share( ShareParams( uri: Uri.parse('https://pub.dev/packages/share_plus'), + text: 'Open this link https://pub.dev/packages/share_plus', + title: 'Open this link', + airDrop: ShareAirDropAs.url, sharePositionOrigin: const Rect.fromLTWH(1.0, 2.0, 3.0, 4.0), excludedCupertinoActivities: [CupertinoActivityType.airDrop], ), @@ -65,6 +68,9 @@ void main() { verify( mockChannel.invokeMethod('share', { 'uri': 'https://pub.dev/packages/share_plus', + 'text': 'Open this link https://pub.dev/packages/share_plus', + 'title': 'Open this link', + 'airDrop': 'url', 'originX': 1.0, 'originY': 2.0, 'originWidth': 3.0, @@ -223,8 +229,7 @@ class MockMethodChannel extends Mock implements MethodChannel { @override Future invokeMethod(String method, [dynamic arguments]) async { return super.noSuchMethod( - Invocation.method(#invokeMethod, [method, arguments]), - ) - as dynamic; + Invocation.method(#invokeMethod, [method, arguments]), + ) as dynamic; } }