From 293c3d881ee81f630cc61792dc38f6cb341b6cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Somogyi?= Date: Wed, 27 Nov 2024 11:11:06 +0100 Subject: [PATCH] [share_plus] add support for thumbnail and title --- packages/share_plus/share_plus/README.md | 16 ++++ .../plus/share/MethodCallHandler.kt | 2 + .../dev/fluttercommunity/plus/share/Share.kt | 34 ++++++- .../share_plus/example/lib/main.dart | 95 +++++++++++++------ .../share_plus/share_plus/lib/share_plus.dart | 12 +++ .../share_plus/lib/src/share_plus_linux.dart | 2 + .../share_plus/lib/src/share_plus_web.dart | 8 +- .../lib/src/share_plus_windows.dart | 2 + .../method_channel/method_channel_share.dart | 11 ++- .../share_plus_platform.dart | 4 + .../share_plus_platform_interface_test.dart | 31 ++++-- 11 files changed, 175 insertions(+), 42 deletions(-) diff --git a/packages/share_plus/share_plus/README.md b/packages/share_plus/share_plus/README.md index ce58588779..66ad01d40c 100644 --- a/packages/share_plus/share_plus/README.md +++ b/packages/share_plus/share_plus/README.md @@ -60,6 +60,22 @@ sharing to email. Share.share('check out my website https://example.com', subject: 'Look what I made!'); ``` +The optional `title` and `thumbnail` parameters enable +[rich content preview](https://developer.android.com/training/sharing/send#adding-rich-content-previews) +on Android when sharing text. + +On the web the `title` or the `subject` (when the `title` is omitted) is passed to the +[Web Share API](https://web.dev/web-share/)'s title parameter. + +```dart +Share.share('Content which will be shared', title: 'Preview title', thumbnail: XFile('path/to/thumbnail.png')); +``` + +> [!CAUTION] +> For the `thumbnail` parameter the +> [Sharing data created with XFile.fromData](#sharing-data-created-with-xfilefromdata) +> limitation has to be considered. + `share()` returns `status` object that allows to check the result of user action in the share sheet. ```dart diff --git a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/MethodCallHandler.kt b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/MethodCallHandler.kt index d4488922e5..767ef9c74c 100644 --- a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/MethodCallHandler.kt +++ b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/MethodCallHandler.kt @@ -38,6 +38,8 @@ internal class MethodCallHandler( call.argument("text") as String, call.argument("subject") as String?, isWithResult, + title = call.argument("title"), + thumbnailPath = call.argument("thumbnailPath"), ) success(isWithResult, result) } 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 59bdb1e914..c6b402a354 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 @@ -2,11 +2,13 @@ package dev.fluttercommunity.plus.share import android.app.Activity import android.app.PendingIntent +import android.content.ClipData import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.util.Log import androidx.core.content.FileProvider import java.io.File import java.io.IOException @@ -21,6 +23,10 @@ internal class Share( private var activity: Activity?, private val manager: ShareSuccessManager ) { + companion object { + const val TAG = "FlutterSharePlus" + } + private val providerAuthority: String by lazy { getContext().packageName + ".flutter.share_provider" } @@ -55,7 +61,13 @@ internal class Share( this.activity = activity } - fun share(text: String, subject: String?, withResult: Boolean) { + fun share( + text: String, + subject: String?, + withResult: Boolean, + title: String? = null, + thumbnailPath: String? = null, + ) { val shareIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" @@ -63,6 +75,12 @@ internal class Share( if (subject != null) { putExtra(Intent.EXTRA_SUBJECT, subject) } + if (title != null) { + putExtra(Intent.EXTRA_TITLE, title) + } + if (thumbnailPath != null) { + addThumbnail(this, thumbnailPath) + } } // If we dont want the result we use the old 'createChooser' val chooserIntent = @@ -251,4 +269,18 @@ internal class Share( file.copyTo(newFile, true) return newFile } + + private fun addThumbnail(intent: Intent, thumbnailPath: String) { + try { + clearShareCacheFolder() + val uri = getUrisForPaths(listOf(thumbnailPath)).first() + intent.apply { + clipData = ClipData.newUri(getContext().contentResolver, null, uri) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + } catch (e: IOException) { + // do not prevent sharing if the thumbnail cannot be added + Log.e(TAG, "Failed to add thumbnail", e) + } + } } diff --git a/packages/share_plus/share_plus/example/lib/main.dart b/packages/share_plus/share_plus/example/lib/main.dart index 704e2cf955..10deff1f53 100644 --- a/packages/share_plus/share_plus/example/lib/main.dart +++ b/packages/share_plus/share_plus/example/lib/main.dart @@ -32,10 +32,12 @@ class DemoApp extends StatefulWidget { class DemoAppState extends State { String text = ''; String subject = ''; + String title = ''; String uri = ''; String fileName = ''; List imageNames = []; List imagePaths = []; + XFile? thumbnail; @override Widget build(BuildContext context) { @@ -80,6 +82,18 @@ class DemoAppState extends State { }), ), const SizedBox(height: 16), + TextField( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Share title', + hintText: 'Enter title to share (optional)', + ), + maxLines: null, + onChanged: (String value) => setState(() { + title = value; + }), + ), + const SizedBox(height: 16), TextField( decoration: const InputDecoration( border: OutlineInputBorder(), @@ -108,39 +122,21 @@ class DemoAppState extends State { ElevatedButton.icon( label: const Text('Add image'), onPressed: () async { - // Using `package:image_picker` to get image from gallery. - if (!kIsWeb && - (Platform.isMacOS || - Platform.isLinux || - Platform.isWindows)) { - // Using `package:file_selector` on windows, macos & Linux, since `package:image_picker` is not supported. - const XTypeGroup typeGroup = XTypeGroup( - label: 'images', - extensions: ['jpg', 'jpeg', 'png', 'gif'], - ); - final file = await openFile( - acceptedTypeGroups: [typeGroup]); - if (file != null) { - setState(() { - imagePaths.add(file.path); - imageNames.add(file.name); - }); - } - } else { - final imagePicker = ImagePicker(); - final pickedFile = await imagePicker.pickImage( - source: ImageSource.gallery, - ); - if (pickedFile != null) { - setState(() { - imagePaths.add(pickedFile.path); - imageNames.add(pickedFile.name); - }); - } - } + await _pickImage(); }, icon: const Icon(Icons.add), ), + const SizedBox(height: 16), + if (thumbnail != null) + ImagePreviews([thumbnail!.path], onDelete: _onDeleteThumbnail) + else + ElevatedButton.icon( + label: const Text('Add thumbnail'), + icon: const Icon(Icons.image), + onPressed: () async { + await _pickImage(pickThumbnail: true); + }, + ), const SizedBox(height: 32), Builder( builder: (BuildContext context) { @@ -200,6 +196,12 @@ class DemoAppState extends State { }); } + void _onDeleteThumbnail(int position) { + setState(() { + thumbnail = null; + }); + } + void _onShareWithResult(BuildContext context) async { // A builder is used to retrieve the context immediately // surrounding the ElevatedButton. @@ -232,6 +234,8 @@ class DemoAppState extends State { shareResult = await Share.share( text, subject: subject, + title: title, + thumbnail: thumbnail, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, ); } @@ -276,6 +280,37 @@ class DemoAppState extends State { scaffoldMessenger.showSnackBar(getResultSnackBar(shareResult)); } + Future _pickImage({bool pickThumbnail = false}) async { + // Using `package:image_picker` to get image from gallery. + late final XFile? pickedFile; + if (!kIsWeb && + (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) { + // Using `package:file_selector` on windows, macos & Linux, since `package:image_picker` is not supported. + const XTypeGroup typeGroup = XTypeGroup( + label: 'images', + extensions: ['jpg', 'jpeg', 'png', 'gif'], + ); + pickedFile = await openFile(acceptedTypeGroups: [typeGroup]); + } else { + final imagePicker = ImagePicker(); + pickedFile = await imagePicker.pickImage( + source: ImageSource.gallery, + ); + } + + setState(() { + if (pickedFile == null) { + return; + } + if (pickThumbnail) { + thumbnail = pickedFile; + } else { + imagePaths.add(pickedFile.path); + imageNames.add(pickedFile.name); + } + }); + } + SnackBar getResultSnackBar(ShareResult result) { return SnackBar( content: Column( diff --git a/packages/share_plus/share_plus/lib/share_plus.dart b/packages/share_plus/share_plus/lib/share_plus.dart index 52e50d624d..d5fd6d9312 100644 --- a/packages/share_plus/share_plus/lib/share_plus.dart +++ b/packages/share_plus/share_plus/lib/share_plus.dart @@ -57,6 +57,14 @@ class Share { /// origin rect for the share sheet to popover from on iPads and Macs. It has no effect /// on other devices. /// + /// The optional [title] parameter can be used to specify a title for the shared text. + /// This works only on Android and on the Web for text only sharing as additional context. + /// It is not part of the shared data. + /// + /// The optional [thumbnail] parameter can be used to specify a thumbnail for + /// the shared text on Android. This is only displayed on the share sheet + /// for additional context, it is not part of the shared data. + /// /// May throw [PlatformException] or [FormatException] /// from [MethodChannel]. /// @@ -82,13 +90,17 @@ class Share { static Future share( String text, { String? subject, + String? title, Rect? sharePositionOrigin, + XFile? thumbnail, }) async { assert(text.isNotEmpty); return _platform.share( text, subject: subject, sharePositionOrigin: sharePositionOrigin, + title: title, + thumbnail: thumbnail, ); } diff --git a/packages/share_plus/share_plus/lib/src/share_plus_linux.dart b/packages/share_plus/share_plus/lib/src/share_plus_linux.dart index 03132bddc0..881dd5d729 100644 --- a/packages/share_plus/share_plus/lib/src/share_plus_linux.dart +++ b/packages/share_plus/share_plus/lib/src/share_plus_linux.dart @@ -34,7 +34,9 @@ class SharePlusLinuxPlugin extends SharePlatform { Future share( String text, { String? subject, + String? title, Rect? sharePositionOrigin, + XFile? thumbnail, }) async { final queryParameters = { if (subject != null) 'subject': subject, 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 45d7968aee..41e1f18595 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 @@ -75,12 +75,16 @@ class SharePlusWebPlugin extends SharePlatform { Future share( String text, { String? subject, + String? title, Rect? sharePositionOrigin, + XFile? thumbnail, }) async { final ShareData data; - if (subject != null && subject.isNotEmpty) { + final hasSubject = subject != null && subject.isNotEmpty; + final hasTitle = title != null && title.isNotEmpty; + if (hasTitle || hasSubject) { data = ShareData( - title: subject, + title: hasTitle ? title : subject!, text: text, ); } else { diff --git a/packages/share_plus/share_plus/lib/src/share_plus_windows.dart b/packages/share_plus/share_plus/lib/src/share_plus_windows.dart index 5a660e4763..e2c1965c72 100644 --- a/packages/share_plus/share_plus/lib/src/share_plus_windows.dart +++ b/packages/share_plus/share_plus/lib/src/share_plus_windows.dart @@ -39,7 +39,9 @@ class SharePlusWindowsPlugin extends SharePlatform { Future share( String text, { String? subject, + String? title, Rect? sharePositionOrigin, + XFile? thumbnail, }) async { final queryParameters = { if (subject != null) 'subject': subject, 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 bb7fa696c6..28c6d0f425 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 @@ -4,7 +4,6 @@ import 'dart:async'; import 'dart:io'; - // Keep dart:ui for retrocompatiblity with Flutter <3.3.0 // ignore: unnecessary_import import 'dart:ui'; @@ -12,8 +11,8 @@ import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:meta/meta.dart' show visibleForTesting; import 'package:mime/mime.dart' show extensionFromMime, lookupMimeType; -import 'package:share_plus_platform_interface/share_plus_platform_interface.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:share_plus_platform_interface/share_plus_platform_interface.dart'; import 'package:uuid/uuid.dart'; /// Plugin for summoning a platform share sheet. @@ -48,12 +47,15 @@ class MethodChannelShare extends SharePlatform { Future share( String text, { String? subject, + String? title, Rect? sharePositionOrigin, + XFile? thumbnail, }) async { assert(text.isNotEmpty); final params = { 'text': text, 'subject': subject, + 'title': title, }; if (sharePositionOrigin != null) { @@ -63,6 +65,11 @@ class MethodChannelShare extends SharePlatform { params['originHeight'] = sharePositionOrigin.height; } + if (thumbnail != null) { + final thumbnailFile = await _getFile(thumbnail); + params['thumbnailPath'] = thumbnailFile.path; + } + final result = await channel.invokeMethod('share', params) ?? 'dev.fluttercommunity.plus/share/unavailable'; 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 f1840624ef..f63eef13a2 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 @@ -46,12 +46,16 @@ class SharePlatform extends PlatformInterface { Future share( String text, { String? subject, + String? title, Rect? sharePositionOrigin, + XFile? thumbnail, }) async { return await _instance.share( text, subject: subject, sharePositionOrigin: sharePositionOrigin, + title: title, + thumbnail: thumbnail, ); } 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 03c796bddc..88d9350720 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 @@ -76,18 +76,33 @@ void main() { await sharePlatform.share( 'some text to share', - subject: 'some subject to share', - sharePositionOrigin: const Rect.fromLTWH(1.0, 2.0, 3.0, 4.0), ); verify(mockChannel.invokeMethod('share', { 'text': 'some text to share', - 'subject': 'some subject to share', - 'originX': 1.0, - 'originY': 2.0, - 'originWidth': 3.0, - 'originHeight': 4.0, + 'subject': null, + 'title': null, })); + await withFile('tempfile-thumbnail123.png', (File fd) async { + await sharePlatform.share( + 'some text to share', + subject: 'some subject to share', + title: 'some title to share', + sharePositionOrigin: const Rect.fromLTWH(1.0, 2.0, 3.0, 4.0), + thumbnail: XFile(fd.path), + ); + verify(mockChannel.invokeMethod('share', { + 'text': 'some text to share', + 'subject': 'some subject to share', + 'title': 'some title to share', + 'originX': 1.0, + 'originY': 2.0, + 'originWidth': 3.0, + 'originHeight': 4.0, + 'thumbnailPath': fd.path, + })); + }); + await withFile('tempfile-83649a.png', (File fd) async { await sharePlatform.shareXFiles( [XFile(fd.path)], @@ -154,11 +169,13 @@ void main() { await sharePlatform.share( 'some text to share', subject: 'some subject to share', + title: 'some title to share', sharePositionOrigin: const Rect.fromLTWH(1.0, 2.0, 3.0, 4.0), ); verify(mockChannel.invokeMethod('share', { 'text': 'some text to share', 'subject': 'some subject to share', + 'title': 'some title to share', 'originX': 1.0, 'originY': 2.0, 'originWidth': 3.0,