From 88f78afb8e1092375ec78f85a1d41a0c2d99fd98 Mon Sep 17 00:00:00 2001 From: Marcelo Milhomem Date: Thu, 24 Sep 2026 10:58:46 +1000 Subject: [PATCH] feat(share_plus): AirDrop a link that opens the app Sharing a URI through the iOS sheet currently hands AirDrop a payload the other device opens in Notes, so a super link or deep link never reaches the app. Callers can now set ShareAirDropAs.url and AirDrop receives a real NSURL. The receiving device opens that URL, which is what launches the app through the universal link or deep link. The sheet placeholder stays plain text so the header keeps the host app icon instead of a Safari page preview. Messages, Mail, and other text destinations still get ShareParams.text, which is why uri and text are allowed together only for this mode. Android has no AirDrop, so the message body wins over the bare URI in EXTRA_TEXT and the title stays EXTRA_TITLE. File captions return nil for the placeholder and for file destinations (Save to Files, AirDrop, Copy, and the rest) so they do not show up as a second item or get sent as a text file. inherit keeps the old raw-NSURL share, and the channel omits airDrop unless the caller opts in. Co-authored-by: Cursor --- .../dev/fluttercommunity/plus/share/Share.kt | 2 +- .../Sources/share_plus/FPPSharePlusPlugin.m | 114 +++++++++++++++++- .../share_plus/share_plus/lib/share_plus.dart | 9 +- .../share_plus/lib/src/share_plus_web.dart | 10 +- .../share_plus/test/share_plus_test.dart | 17 +++ .../method_channel/method_channel_share.dart | 2 + .../share_plus_platform.dart | 51 ++++++-- .../lib/share_plus_platform_interface.dart | 1 + .../share_plus_platform_interface_test.dart | 11 +- 9 files changed, 194 insertions(+), 23 deletions(-) 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; } }