Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
.packages
.pub/
.dart_tool/
.repo_tool_cache/
pubspec.lock
flutter_export_environment.sh
**/pubspec_overrides.yaml
Expand Down
34 changes: 34 additions & 0 deletions packages/share_plus/share_plus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -124,7 +125,13 @@ internal class Share(
val title = arguments["title"] as String?
val paths = (arguments["paths"] as List<*>?)?.filterIsInstance<String>()
val mimeTypes = (arguments["mimeTypes"] as List<*>?)?.filterIsInstance<String>()
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()
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
107 changes: 77 additions & 30 deletions packages/share_plus/share_plus/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,14 @@ class MyHomePageState extends State<MyHomePage> {
String fileName = '';
List<String> imageNames = [];
List<String> imagePaths = [];
String? previewThumbnailPath;
List<CupertinoActivityType> 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(
Expand Down Expand Up @@ -129,40 +133,53 @@ class MyHomePageState extends State<MyHomePage> {
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: <String>['jpg', 'jpeg', 'png', 'gif'],
);
final file = await openFile(
acceptedTypeGroups: <XTypeGroup>[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: <Widget>[
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))
Expand Down Expand Up @@ -228,6 +245,30 @@ class MyHomePageState extends State<MyHomePage> {
});
}

/// Picks a single image file using the platform-appropriate picker.
Future<XFile?> _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: <String>['jpg', 'jpeg', 'png', 'gif'],
);
final file = await openFile(acceptedTypeGroups: <XTypeGroup>[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(
Expand Down Expand Up @@ -274,6 +315,9 @@ class MyHomePageState extends State<MyHomePage> {
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,
),
Expand All @@ -284,6 +328,9 @@ class MyHomePageState extends State<MyHomePage> {
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,
),
Expand Down
47 changes: 45 additions & 2 deletions packages/share_plus/share_plus/windows/share_plus_plugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>

#include <algorithm>

#include "vector.h"

namespace share_plus_windows {
Expand Down Expand Up @@ -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<WindowsStorage::IStorageFileStatics> factory = nullptr;
HRESULT hr = S_OK;
*file = nullptr;
Expand All @@ -69,8 +78,8 @@ HRESULT SharePlusWindowsPlugin::GetStorageFileFromPath(
WRL::ComPtr<
WindowsFoundation::IAsyncOperation<WindowsStorage::StorageFile *>>
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<IAsyncInfo> info;
hr = async_operation.As(&info);
Expand Down Expand Up @@ -115,6 +124,13 @@ void SharePlusWindowsPlugin::HandleMethodCall(
&args[flutter::EncodableValue("title")])) {
share_title_ = *title_value;
}
if (auto preview_thumbnail_value = std::get_if<std::string>(
&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<flutter::EncodableList>(
&args[flutter::EncodableValue("paths")])) {
paths_.clear();
Expand Down Expand Up @@ -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<wchar_t*>(thumbnail_path.c_str());
WRL::ComPtr<WindowsStorage::IStorageFile> 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<WindowsStorage::IStorageItem*> storage_items;
for (const std::string& path : paths_) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <windows.foundation.collections.h>
#include <windows.foundation.h>
#include <windows.storage.h>
#include <windows.storage.streams.h>
#include <wrl.h>
#include <wrl/client.h>
#include <wrl/event.h>
Expand All @@ -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 {
Expand Down Expand Up @@ -74,6 +76,7 @@ class SharePlusWindowsPlugin : public flutter::Plugin {
std::optional<std::string> share_uri_ = std::nullopt;
std::optional<std::string> share_subject_ = std::nullopt;
std::optional<std::string> share_title_ = std::nullopt;
std::optional<std::string> preview_thumbnail_ = std::nullopt;
std::vector<std::string> paths_ = {};
std::vector<std::string> mime_types_ = {};
};
Expand Down
Loading
Loading