diff --git a/.gitignore b/.gitignore index 2d6710764e..57df2c1c21 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .packages .pub/ .dart_tool/ +.repo_tool_cache/ pubspec.lock flutter_export_environment.sh **/pubspec_overrides.yaml diff --git a/packages/share_plus/share_plus/README.md b/packages/share_plus/share_plus/README.md index fbb7c1c6b1..943f862e80 100644 --- a/packages/share_plus/share_plus/README.md +++ b/packages/share_plus/share_plus/README.md @@ -222,6 +222,40 @@ ShareParams( ) ``` +#### Preview Thumbnail + +Sets a preview thumbnail shown in the share UI when sharing `text` or `uri`. + +- On **Android**, rendered by the system Sharesheet (API 29+). It is ignored + for file shares, where the system builds its own preview from the files. +- On **Windows**, set as the `DataPackage` thumbnail. +- Ignored on other platforms. + +> **Important:** the `XFile` **must carry a correct image MIME type**, or the +> platform treats it as a generic binary file and shows **no preview** (the +> share itself still succeeds). The plugin does **not** infer the type from the +> file contents — it is the caller's responsibility to set it. This is the most +> common reason a thumbnail does not appear. + +Provide the MIME type via `XFile.mimeType`, or via a file name/path that ends in +a matching image extension. An `XFile.fromData(bytes)` created **without** a +`mimeType` falls back to `application/octet-stream` and will not render a preview. + +```dart +// From in-memory bytes — you MUST pass mimeType (set it to the actual image +// type, e.g. image/jpeg or image/webp): +ShareParams( + text: 'Check this out', + previewThumbnail: XFile.fromData(bytes, mimeType: 'image/png'), +) + +// From a file path — make sure the path/name has an image extension: +ShareParams( + text: 'Check this out', + previewThumbnail: XFile('/path/to/thumbnail.png'), +) +``` + ## Known Issues ### Sharing data created with XFile.fromData 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..7c06edc9a3 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,6 +2,7 @@ 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 @@ -124,7 +125,13 @@ internal class Share( val title = arguments["title"] as String? val paths = (arguments["paths"] as List<*>?)?.filterIsInstance() val mimeTypes = (arguments["mimeTypes"] as List<*>?)?.filterIsInstance() + val previewThumbnail = arguments["previewThumbnail"] as String? val fileUris = paths?.let { getUrisForPaths(paths) } + // Preview thumbnail is only rendered by the system Sharesheet (API 29+) for + // text/URL shares; file shares build their own preview from EXTRA_STREAM. + val previewThumbnailUri = previewThumbnail + ?.takeIf { fileUris == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q } + ?.let { getUrisForPaths(listOf(it)).first() } // Create Share Intent val shareIntent = Intent() @@ -135,6 +142,18 @@ internal class Share( putExtra(Intent.EXTRA_TEXT, uri ?: text) if (!subject.isNullOrBlank()) putExtra(Intent.EXTRA_SUBJECT, subject) if (!title.isNullOrBlank()) putExtra(Intent.EXTRA_TITLE, title) + if (previewThumbnailUri != null) { + // Attach the thumbnail so the system Sharesheet shows a rich preview. + // The content URI must be readable by the chooser; ClipData propagates + // the temporary read grant to the selected target. + // + // Note: do NOT call setData() here. setData() clears the intent + // type ("text/plain"), which breaks Direct Share suggestions + // (recommended people) since those are matched by MIME type. + // ClipData alone carries the thumbnail for the preview. + clipData = ClipData.newRawUri(null, previewThumbnailUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } } } else { when { @@ -190,17 +209,18 @@ internal class Share( Intent.createChooser(shareIntent, title) } - // Grant permissions to all apps that can handle the files shared - if (fileUris != null) { + // Grant permissions to all apps that can handle the files or thumbnail shared + val urisToGrant = (fileUris ?: emptyList()) + listOfNotNull(previewThumbnailUri) + if (urisToGrant.isNotEmpty()) { val resInfoList = getContext().packageManager.queryIntentActivities( chooserIntent, PackageManager.MATCH_DEFAULT_ONLY ) resInfoList.forEach { resolveInfo -> val packageName = resolveInfo.activityInfo.packageName - fileUris.forEach { fileUri -> + urisToGrant.forEach { uriToGrant -> getContext().grantUriPermission( packageName, - fileUri, + uriToGrant, Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION, ) } diff --git a/packages/share_plus/share_plus/example/integration_test/share_plus_test.dart b/packages/share_plus/share_plus/example/integration_test/share_plus_test.dart index 4c63a0f421..76aa1b8d04 100644 --- a/packages/share_plus/share_plus/example/integration_test/share_plus_test.dart +++ b/packages/share_plus/share_plus/example/integration_test/share_plus_test.dart @@ -37,4 +37,20 @@ void main() { final params = ShareParams(files: [file], text: 'message'); expect(SharePlus.instance.share(params), isNotNull); }); + + testWidgets('Can share with previewThumbnail', (WidgetTester tester) async { + final bytes = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]); + final XFile thumbnail = XFile.fromData( + bytes, + name: 'thumbnail.jpg', + mimeType: 'image/jpeg', + ); + + final params = ShareParams( + text: 'message', + previewThumbnail: thumbnail, + ); + // Check isNotNull because we cannot wait for ShareResult + expect(SharePlus.instance.share(params), isNotNull); + }, skip: !Platform.isAndroid && !Platform.isWindows); } diff --git a/packages/share_plus/share_plus/example/lib/main.dart b/packages/share_plus/share_plus/example/lib/main.dart index d172097da9..70a4ee32f9 100644 --- a/packages/share_plus/share_plus/example/lib/main.dart +++ b/packages/share_plus/share_plus/example/lib/main.dart @@ -53,10 +53,14 @@ class MyHomePageState extends State { String fileName = ''; List imageNames = []; List imagePaths = []; + String? previewThumbnailPath; List excludedCupertinoActivityType = []; @override Widget build(BuildContext context) { + // previewThumbnail is only honored on Android and Windows. + final supportsPreviewThumbnail = + !kIsWeb && (Platform.isAndroid || Platform.isWindows); return Scaffold( appBar: AppBar(title: const Text('Share Plus Plugin Demo'), elevation: 4), body: SingleChildScrollView( @@ -129,40 +133,53 @@ class MyHomePageState 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); - }); - } + final file = await _pickImage(); + if (file != null) { + setState(() { + imagePaths.add(file.path); + imageNames.add(file.name); + }); } }, icon: const Icon(Icons.add), ), + const SizedBox(height: 16), + // Preview thumbnail: shown at the top of the share sheet for + // text/URI shares (Android API 29+ and Windows). Ignored for file + // shares and on other platforms. + if (previewThumbnailPath != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 100, + maxHeight: 100, + ), + child: Image.file(File(previewThumbnailPath!)), + ), + IconButton( + color: Colors.red, + onPressed: () => + setState(() => previewThumbnailPath = null), + icon: const Icon(Icons.delete), + ), + ], + ), + ), + ElevatedButton.icon( + label: const Text('Add preview thumbnail'), + onPressed: supportsPreviewThumbnail + ? () async { + final file = await _pickImage(); + if (file != null) { + setState(() => previewThumbnailPath = file.path); + } + } + : null, + icon: const Icon(Icons.image), + ), if (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) const SizedBox(height: 16), if (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) @@ -228,6 +245,30 @@ class MyHomePageState extends State { }); } + /// Picks a single image file using the platform-appropriate picker. + Future _pickImage() 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]); + return file == null ? null : XFile(file.path, name: file.name); + } else { + final imagePicker = ImagePicker(); + final pickedFile = await imagePicker.pickImage( + source: ImageSource.gallery, + ); + return pickedFile == null + ? null + : XFile(pickedFile.path, name: pickedFile.name); + } + } + void _onSelectExcludedActivityType() async { final result = await Navigator.of(context).push( MaterialPageRoute( @@ -274,6 +315,9 @@ class MyHomePageState extends State { uri: Uri.parse(uri), subject: subject.isEmpty ? null : subject, title: title.isEmpty ? null : title, + previewThumbnail: previewThumbnailPath == null + ? null + : XFile(previewThumbnailPath!), sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, excludedCupertinoActivities: excludedCupertinoActivityType, ), @@ -284,6 +328,9 @@ class MyHomePageState extends State { text: text.isEmpty ? null : text, subject: subject.isEmpty ? null : subject, title: title.isEmpty ? null : title, + previewThumbnail: previewThumbnailPath == null + ? null + : XFile(previewThumbnailPath!), sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, excludedCupertinoActivities: excludedCupertinoActivityType, ), diff --git a/packages/share_plus/share_plus/windows/share_plus_plugin.cpp b/packages/share_plus/share_plus/windows/share_plus_plugin.cpp index fed28d46cb..f63f14ce8a 100644 --- a/packages/share_plus/share_plus/windows/share_plus_plugin.cpp +++ b/packages/share_plus/share_plus/windows/share_plus_plugin.cpp @@ -4,6 +4,8 @@ #include #include +#include + #include "vector.h" namespace share_plus_windows { @@ -57,6 +59,13 @@ SharePlusWindowsPlugin::GetDataTransferManager() { HRESULT SharePlusWindowsPlugin::GetStorageFileFromPath( wchar_t *path, WindowsStorage::IStorageFile **file) { using Microsoft::WRL::Wrappers::HStringReference; + // GetFileFromPathAsync requires a fully-qualified path using backslash + // separators. Paths produced on the Dart side can contain mixed separators + // (e.g. in-memory XFile.fromData temp files combine a backslash temp root + // with forward-slash subpaths), which would otherwise fail with + // ERROR_FILE_NOT_FOUND. Normalize forward slashes to backslashes. + std::wstring normalized_path(path); + std::replace(normalized_path.begin(), normalized_path.end(), L'/', L'\\'); WRL::ComPtr factory = nullptr; HRESULT hr = S_OK; *file = nullptr; @@ -69,8 +78,8 @@ HRESULT SharePlusWindowsPlugin::GetStorageFileFromPath( WRL::ComPtr< WindowsFoundation::IAsyncOperation> async_operation; - hr = factory->GetFileFromPathAsync(HStringReference(path).Get(), - &async_operation); + hr = factory->GetFileFromPathAsync( + HStringReference(normalized_path.c_str()).Get(), &async_operation); if (SUCCEEDED(hr)) { WRL::ComPtr info; hr = async_operation.As(&info); @@ -115,6 +124,13 @@ void SharePlusWindowsPlugin::HandleMethodCall( &args[flutter::EncodableValue("title")])) { share_title_ = *title_value; } + if (auto preview_thumbnail_value = std::get_if( + &args[flutter::EncodableValue("previewThumbnail")])) { + preview_thumbnail_ = *preview_thumbnail_value; + } else { + // Reset to avoid carrying over a thumbnail from a previous share. + preview_thumbnail_ = std::nullopt; + } if (auto paths = std::get_if( &args[flutter::EncodableValue("paths")])) { paths_.clear(); @@ -175,6 +191,33 @@ void SharePlusWindowsPlugin::HandleMethodCall( data->SetText(HStringReference(uri.c_str()).Get()); } + // Set the preview thumbnail shown in the Windows share UI. + if (preview_thumbnail_ && !preview_thumbnail_.value_or("").empty()) { + auto thumbnail_path = Utf16FromUtf8(preview_thumbnail_.value_or("")); + wchar_t* ptr = const_cast(thumbnail_path.c_str()); + WRL::ComPtr thumbnail_file; + if (SUCCEEDED(GetStorageFileFromPath( + ptr, thumbnail_file.GetAddressOf())) && + thumbnail_file != nullptr) { + WRL::ComPtr< + WindowsStorageStreams::IRandomAccessStreamReferenceStatics> + stream_ref_statics; + if (SUCCEEDED(WindowsFoundation::GetActivationFactory( + HStringReference( + RuntimeClass_Windows_Storage_Streams_RandomAccessStreamReference) + .Get(), + &stream_ref_statics))) { + WRL::ComPtr< + WindowsStorageStreams::IRandomAccessStreamReference> + stream_ref; + if (SUCCEEDED(stream_ref_statics->CreateFromFile( + thumbnail_file.Get(), &stream_ref))) { + properties->put_Thumbnail(stream_ref.Get()); + } + } + } + } + // Add files to the data. Vector storage_items; for (const std::string& path : paths_) { diff --git a/packages/share_plus/share_plus/windows/share_plus_windows_plugin.h b/packages/share_plus/share_plus/windows/share_plus_windows_plugin.h index ca7d2aaa14..8b1bf38df6 100644 --- a/packages/share_plus/share_plus/windows/share_plus_windows_plugin.h +++ b/packages/share_plus/share_plus/windows/share_plus_windows_plugin.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,7 @@ namespace WRL = Microsoft::WRL; namespace WindowsFoundation = ABI::Windows::Foundation; namespace WindowsStorage = ABI::Windows::Storage; +namespace WindowsStorageStreams = ABI::Windows::Storage::Streams; namespace DataTransfer = ABI::Windows::ApplicationModel::DataTransfer; namespace share_plus_windows { @@ -74,6 +76,7 @@ class SharePlusWindowsPlugin : public flutter::Plugin { std::optional share_uri_ = std::nullopt; std::optional share_subject_ = std::nullopt; std::optional share_title_ = std::nullopt; + std::optional preview_thumbnail_ = std::nullopt; std::vector paths_ = {}; std::vector mime_types_ = {}; }; 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..c86d339b2d 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 @@ -71,6 +71,12 @@ class MethodChannelShare extends SharePlatform { map['mimeTypes'] = mimeTypes; } + if (params.previewThumbnail != null) { + final thumbnail = await _getFile(params.previewThumbnail!); + assert(thumbnail.path.isNotEmpty); + map['previewThumbnail'] = thumbnail.path; + } + if (params.excludedCupertinoActivities != null && params.excludedCupertinoActivities!.isNotEmpty) { final excludedActivityTypes = params.excludedCupertinoActivities! 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..27df84ae16 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 @@ -67,11 +67,26 @@ class ShareParams { /// * Supported platforms: All final String? subject; - /// Preview thumbnail + /// Preview thumbnail shown in the share UI. /// - /// TODO: https://github.com/fluttercommunity/plus_plugins/pull/3372 + /// On Android, rendered by the system Sharesheet (API 29+) when sharing + /// [text] or [uri]. For file shares the system builds its own preview from + /// the shared files, so this is ignored. /// - /// * Supported platforms: Android + /// On Windows, set as the [DataPackage] thumbnail in the share UI. + /// + /// IMPORTANT: the [XFile] must carry a correct image MIME type, otherwise + /// the platform treats it as a generic binary file and shows no preview + /// (the share itself still succeeds). The caller is responsible for setting + /// it — this plugin does not infer the type from the file contents. Provide + /// it via one of: + /// * [XFile.mimeType] (e.g. `image/png`, `image/jpeg`, `image/webp`), or + /// * a file name/path ending in a matching image extension (e.g. + /// `thumbnail.png`). + /// In particular, an [XFile.fromData] created without a `mimeType` falls + /// back to `application/octet-stream` and will not render a preview. + /// + /// * Supported platforms: Android, Windows /// Parameter ignored on other platforms. final XFile? previewThumbnail; 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..bca6178a0b 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 @@ -143,6 +143,23 @@ void main() { }); }); + test('sharing previewThumbnail sets the right param', () async { + await withFile('tempfile-83649f.png', (File fd) async { + await sharePlatform.share( + ShareParams( + text: 'some text to share', + previewThumbnail: XFile(fd.path), + ), + ); + verify( + mockChannel.invokeMethod('share', { + 'text': 'some text to share', + 'previewThumbnail': fd.path, + }), + ); + }); + }); + test('withResult methods return unavailable on non IOS & Android', () async { const resultUnavailable = ShareResult( 'dev.fluttercommunity.plus/share/unavailable',