diff --git a/packages/share_plus/share_plus/README.md b/packages/share_plus/share_plus/README.md index 17807e794e..e15811de58 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/MethodCallHandler.kt b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/MethodCallHandler.kt index 9b3341c8be..824b63616b 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 @@ -4,50 +4,45 @@ import android.os.Build import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -/** Handles the method calls for the plugin. */ +/** Handles the method calls for the plugin. */ internal class MethodCallHandler( private val share: Share, private val manager: ShareSuccessManager, ) : MethodChannel.MethodCallHandler { - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - expectMapArguments(call) - - // We don't attempt to return a result if the current API version doesn't support it - val isWithResult = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 - - if (isWithResult) - manager.setCallback(result) - - try { - when (call.method) { - "share" -> { - share.share( - arguments = call.arguments>()!!, - withResult = isWithResult, - ) - success(isWithResult, result) - } - else -> result.notImplemented() - } - } catch (e: Throwable) { - manager.clear() - result.error("Share failed", e.message, e) - } - } + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + expectMapArguments(call) + + // We don't attempt to return a result if the current API version doesn't support it + val isWithResult = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 - private fun success( - isWithResult: Boolean, - result: MethodChannel.Result - ) { - if (!isWithResult) { - result.success("dev.fluttercommunity.plus/share/unavailable") + if (isWithResult) manager.setCallback(result) + + try { + when (call.method) { + "share" -> { + share.share( + arguments = call.arguments>()!!, + withResult = isWithResult, + ) + success(isWithResult, result) } + else -> result.notImplemented() + } + } catch (e: Throwable) { + manager.clear() + result.error("Share failed", e.message, e) } + } - @Throws(IllegalArgumentException::class) - private fun expectMapArguments(call: MethodCall) { - require(call.arguments is Map<*, *>) { "Map arguments expected" } + private fun success(isWithResult: Boolean, result: MethodChannel.Result) { + if (!isWithResult) { + result.success("dev.fluttercommunity.plus/share/unavailable") } + } + + @Throws(IllegalArgumentException::class) + private fun expectMapArguments(call: MethodCall) { + require(call.arguments is Map<*, *>) { "Map arguments expected" } + } } 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 fb6a107988..da167175b1 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 @@ -12,237 +13,254 @@ import java.io.File import java.io.IOException /** - * Handles share intent. The `context` and `activity` are used to start the share - * intent. The `activity` might be null when constructing the [Share] object and set - * to non-null when an activity is available using [.setActivity]. + * Handles share intent. The `context` and `activity` are used to start the share intent. The + * `activity` might be null when constructing the [Share] object and set to non-null when an + * activity is available using [.setActivity]. */ internal class Share( private val context: Context, private var activity: Activity?, private val manager: ShareSuccessManager ) { - private val providerAuthority: String by lazy { - getContext().packageName + ".flutter.share_provider" - } + private val providerAuthority: String by lazy { + getContext().packageName + ".flutter.share_provider" + } - private val shareCacheFolder: File - get() = File(getContext().cacheDir, "share_plus") + private val shareCacheFolder: File + get() = File(getContext().cacheDir, "share_plus") - /** - * Setting mutability flags as API v31+ requires. - */ - private val immutabilityIntentFlags: Int by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_MUTABLE - } else { - 0 - } + /** Setting mutability flags as API v31+ requires. */ + private val immutabilityIntentFlags: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_MUTABLE + } else { + 0 } + } - private fun getContext(): Context { - return if (activity != null) { - activity!! - } else { - context - } + private fun getContext(): Context { + return if (activity != null) { + activity!! + } else { + context } + } - /** - * Sets the activity when an activity is available. When the activity becomes unavailable, use - * this method to set it to null. - */ - fun setActivity(activity: Activity?) { - this.activity = activity - } + /** + * Sets the activity when an activity is available. When the activity becomes unavailable, use + * this method to set it to null. + */ + fun setActivity(activity: Activity?) { + this.activity = activity + } - @Throws(IOException::class) - fun share(arguments: Map, withResult: Boolean) { - clearShareCacheFolder() - - val text = arguments["text"] as String? - val uri = arguments["uri"] as String? - val subject = arguments["subject"] as String? - val title = arguments["title"] as String? - val paths = (arguments["paths"] as List<*>?)?.filterIsInstance() - val mimeTypes = (arguments["mimeTypes"] as List<*>?)?.filterIsInstance() - val fileUris = paths?.let { getUrisForPaths(paths) } - - // Create Share Intent - val shareIntent = Intent() - if (fileUris == null) { - shareIntent.apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, uri ?: text) - if (!subject.isNullOrBlank()) putExtra(Intent.EXTRA_SUBJECT, subject) - if (!title.isNullOrBlank()) putExtra(Intent.EXTRA_TITLE, title) - } - } else { - when { - fileUris.isEmpty() -> { - throw IOException("Error sharing files: No files found") - } - - fileUris.size == 1 -> { - val mimeType = if (!mimeTypes.isNullOrEmpty()) { - mimeTypes.first() - } else { - "*/*" - } - shareIntent.apply { - action = Intent.ACTION_SEND - type = mimeType - putExtra(Intent.EXTRA_STREAM, fileUris.first()) - } - } - - else -> { - shareIntent.apply { - action = Intent.ACTION_SEND_MULTIPLE - type = reduceMimeTypes(mimeTypes) - putParcelableArrayListExtra(Intent.EXTRA_STREAM, fileUris) - } - } - } - - shareIntent.apply { - if (!text.isNullOrBlank()) putExtra(Intent.EXTRA_TEXT, text) - if (!subject.isNullOrBlank()) putExtra(Intent.EXTRA_SUBJECT, subject) - if (!title.isNullOrBlank()) putExtra(Intent.EXTRA_TITLE, title) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - } + @Throws(IOException::class) + fun share(arguments: Map, withResult: Boolean) { + clearShareCacheFolder() + + val text = arguments["text"] as String? + val uri = arguments["uri"] as String? + val subject = arguments["subject"] as String? + 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 the chooser intent - val chooserIntent = - if (withResult && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - // Build chooserIntent with broadcast to ShareSuccessManager on success - Intent.createChooser( - shareIntent, - title, - PendingIntent.getBroadcast( - context, - 0, - Intent(context, SharePlusPendingIntent::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or immutabilityIntentFlags - ).intentSender - ) - } else { - Intent.createChooser(shareIntent, title) - } - - // Grant permissions to all apps that can handle the files shared - if (fileUris != null) { - val resInfoList = getContext().packageManager.queryIntentActivities( - chooserIntent, PackageManager.MATCH_DEFAULT_ONLY - ) - resInfoList.forEach { resolveInfo -> - val packageName = resolveInfo.activityInfo.packageName - fileUris.forEach { fileUri -> - getContext().grantUriPermission( - packageName, - fileUri, - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION, - ) - } - } + // Create Share Intent + val shareIntent = Intent() + if (fileUris == null) { + shareIntent.apply { + action = Intent.ACTION_SEND + type = "text/plain" + 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 { + fileUris.isEmpty() -> { + throw IOException("Error sharing files: No files found") + } + fileUris.size == 1 -> { + val mimeType = + if (!mimeTypes.isNullOrEmpty()) { + mimeTypes.first() + } else { + "*/*" + } + shareIntent.apply { + action = Intent.ACTION_SEND + type = mimeType + putExtra(Intent.EXTRA_STREAM, fileUris.first()) + } + } + else -> { + shareIntent.apply { + action = Intent.ACTION_SEND_MULTIPLE + type = reduceMimeTypes(mimeTypes) + putParcelableArrayListExtra(Intent.EXTRA_STREAM, fileUris) + } } + } - // Launch share intent - startActivity(chooserIntent, withResult) + shareIntent.apply { + if (!text.isNullOrBlank()) putExtra(Intent.EXTRA_TEXT, text) + if (!subject.isNullOrBlank()) putExtra(Intent.EXTRA_SUBJECT, subject) + if (!title.isNullOrBlank()) putExtra(Intent.EXTRA_TITLE, title) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } } - private fun startActivity(intent: Intent, withResult: Boolean) { - if (activity != null) { - if (withResult) { - activity!!.startActivityForResult(intent, ShareSuccessManager.ACTIVITY_CODE) - } else { - activity!!.startActivity(intent) - } + // Create the chooser intent + val chooserIntent = + if (withResult && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + // Build chooserIntent with broadcast to ShareSuccessManager on success + Intent.createChooser( + shareIntent, + title, + PendingIntent.getBroadcast( + context, + 0, + Intent(context, SharePlusPendingIntent::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or immutabilityIntentFlags) + .intentSender) } else { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - if (withResult) { - // We need to cancel the callback to avoid deadlocking on the Dart side - manager.unavailable() - } - context.startActivity(intent) + Intent.createChooser(shareIntent, title) } - } - @Throws(IOException::class) - private fun getUrisForPaths(paths: List): ArrayList { - val uris = ArrayList(paths.size) - paths.forEach { path -> - var file = File(path) - if (fileIsInShareCache(file)) { - // If file is saved in '.../caches/share_plus' it will be erased by 'clearShareCacheFolder()' - throw IOException("Shared file can not be located in '${shareCacheFolder.canonicalPath}'") - } - file = copyToShareCacheFolder(file) - uris.add(FileProvider.getUriForFile(getContext(), providerAuthority, file)) + // 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 + urisToGrant.forEach { uriToGrant -> + getContext() + .grantUriPermission( + packageName, + uriToGrant, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) } - return uris + } } - /** - * Reduces provided MIME types to a common one to provide [Intent] with a correct type to share - * multiple files - */ - private fun reduceMimeTypes(mimeTypes: List?): String { - if (mimeTypes?.isEmpty() != false) return "*/*" - if (mimeTypes.size == 1) return mimeTypes.first() - - var commonMimeType = mimeTypes.first() - for (i in 1..mimeTypes.lastIndex) { - if (commonMimeType != mimeTypes[i]) { - if (getMimeTypeBase(commonMimeType) == getMimeTypeBase(mimeTypes[i])) { - commonMimeType = getMimeTypeBase(mimeTypes[i]) + "/*" - } else { - commonMimeType = "*/*" - break - } - } - } - return commonMimeType + // Launch share intent + startActivity(chooserIntent, withResult) + } + + private fun startActivity(intent: Intent, withResult: Boolean) { + if (activity != null) { + if (withResult) { + activity!!.startActivityForResult(intent, ShareSuccessManager.ACTIVITY_CODE) + } else { + activity!!.startActivity(intent) + } + } else { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (withResult) { + // We need to cancel the callback to avoid deadlocking on the Dart side + manager.unavailable() + } + context.startActivity(intent) } + } + + @Throws(IOException::class) + private fun getUrisForPaths(paths: List): ArrayList { + val uris = ArrayList(paths.size) + paths.forEach { path -> + var file = File(path) + if (fileIsInShareCache(file)) { + // If file is saved in '.../caches/share_plus' it will be erased by + // 'clearShareCacheFolder()' + throw IOException("Shared file can not be located in '${shareCacheFolder.canonicalPath}'") + } + file = copyToShareCacheFolder(file) + uris.add(FileProvider.getUriForFile(getContext(), providerAuthority, file)) + } + return uris + } + + /** + * Reduces provided MIME types to a common one to provide [Intent] with a correct type to share + * multiple files + */ + private fun reduceMimeTypes(mimeTypes: List?): String { + if (mimeTypes?.isEmpty() != false) return "*/*" + if (mimeTypes.size == 1) return mimeTypes.first() - /** - * Returns the first part of provided MIME type, which comes before '/' symbol - */ - private fun getMimeTypeBase(mimeType: String?): String { - return if (mimeType == null || !mimeType.contains("/")) { - "*" + var commonMimeType = mimeTypes.first() + for (i in 1..mimeTypes.lastIndex) { + if (commonMimeType != mimeTypes[i]) { + if (getMimeTypeBase(commonMimeType) == getMimeTypeBase(mimeTypes[i])) { + commonMimeType = getMimeTypeBase(mimeTypes[i]) + "/*" } else { - mimeType.substring(0, mimeType.indexOf("/")) + commonMimeType = "*/*" + break } + } } + return commonMimeType + } - private fun fileIsInShareCache(file: File): Boolean { - return try { - val filePath = file.canonicalPath - filePath.startsWith(shareCacheFolder.canonicalPath) - } catch (e: IOException) { - false - } + /** Returns the first part of provided MIME type, which comes before '/' symbol */ + private fun getMimeTypeBase(mimeType: String?): String { + return if (mimeType == null || !mimeType.contains("/")) { + "*" + } else { + mimeType.substring(0, mimeType.indexOf("/")) } + } - private fun clearShareCacheFolder() { - val folder = shareCacheFolder - val files = folder.listFiles() - if (folder.exists() && !files.isNullOrEmpty()) { - files.forEach { it.delete() } - folder.delete() - } + private fun fileIsInShareCache(file: File): Boolean { + return try { + val filePath = file.canonicalPath + filePath.startsWith(shareCacheFolder.canonicalPath) + } catch (e: IOException) { + false } + } - @Throws(IOException::class) - private fun copyToShareCacheFolder(file: File): File { - val folder = shareCacheFolder - if (!folder.exists()) { - folder.mkdirs() - } - val newFile = File(folder, file.name) - file.copyTo(newFile, true) - return newFile + private fun clearShareCacheFolder() { + val folder = shareCacheFolder + val files = folder.listFiles() + if (folder.exists() && !files.isNullOrEmpty()) { + files.forEach { it.delete() } + folder.delete() + } + } + + @Throws(IOException::class) + private fun copyToShareCacheFolder(file: File): File { + val folder = shareCacheFolder + if (!folder.exists()) { + folder.mkdirs() } + val newFile = File(folder, file.name) + file.copyTo(newFile, true) + return newFile + } } diff --git a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusPendingIntent.kt b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusPendingIntent.kt index 302ad4d874..b29cbff2d6 100644 --- a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusPendingIntent.kt +++ b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusPendingIntent.kt @@ -5,38 +5,35 @@ import android.os.Build import androidx.annotation.RequiresApi /** - * This helper class allows us to use FLAG_MUTABLE on the PendingIntent used in the Share class, - * as it allows us to make the underlying Intent explicit, therefore avoiding any risks an implicit + * This helper class allows us to use FLAG_MUTABLE on the PendingIntent used in the Share class, as + * it allows us to make the underlying Intent explicit, therefore avoiding any risks an implicit * mutable Intent may carry. * - * When the PendingIntent is sent, the system will instantiate this class and call `onReceive` on it. + * When the PendingIntent is sent, the system will instantiate this class and call `onReceive` on + * it. */ -internal class SharePlusPendingIntent: BroadcastReceiver() { - companion object { - /** - * Static member to access the result of the system instantiated instance - */ - var result: String = "" - } +internal class SharePlusPendingIntent : BroadcastReceiver() { + companion object { + /** Static member to access the result of the system instantiated instance */ + var result: String = "" + } - /** - * Handler called after an action was chosen. Called only on success. - */ - @RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) - override fun onReceive(context: Context, intent: Intent) { - // Extract chosen ComponentName - val chosenComponent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // Only available from API level 33 onwards - intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT, ComponentName::class.java) + /** Handler called after an action was chosen. Called only on success. */ + @RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) + override fun onReceive(context: Context, intent: Intent) { + // Extract chosen ComponentName + val chosenComponent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Only available from API level 33 onwards + intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT, ComponentName::class.java) } else { - // Deprecated in API level 33 - @Suppress("DEPRECATION") - intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT) + // Deprecated in API level 33 + @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT) } - // Unambiguously identify the chosen action - if (chosenComponent != null) { - result = chosenComponent.flattenToString() - } + // Unambiguously identify the chosen action + if (chosenComponent != null) { + result = chosenComponent.flattenToString() } + } } diff --git a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusPlugin.kt b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusPlugin.kt index d53a508ddb..582321ace2 100644 --- a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusPlugin.kt +++ b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusPlugin.kt @@ -6,42 +6,42 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodChannel -/** Plugin method host for presenting a share sheet via Intent */ +/** Plugin method host for presenting a share sheet via Intent */ class SharePlusPlugin : FlutterPlugin, ActivityAware { - private lateinit var share: Share - private lateinit var manager: ShareSuccessManager - private lateinit var methodChannel: MethodChannel - - override fun onAttachedToEngine(binding: FlutterPluginBinding) { - methodChannel = MethodChannel(binding.binaryMessenger, CHANNEL) - manager = ShareSuccessManager(binding.applicationContext) - share = Share(context = binding.applicationContext, activity = null, manager = manager) - val handler = MethodCallHandler(share, manager) - methodChannel.setMethodCallHandler(handler) - } - - override fun onDetachedFromEngine(binding: FlutterPluginBinding) { - methodChannel.setMethodCallHandler(null) - } - - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - binding.addActivityResultListener(manager) - share.setActivity(binding.activity) - } - - override fun onDetachedFromActivity() { - share.setActivity(null) - } - - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - onAttachedToActivity(binding) - } - - override fun onDetachedFromActivityForConfigChanges() { - onDetachedFromActivity() - } - - companion object { - private const val CHANNEL = "dev.fluttercommunity.plus/share" - } + private lateinit var share: Share + private lateinit var manager: ShareSuccessManager + private lateinit var methodChannel: MethodChannel + + override fun onAttachedToEngine(binding: FlutterPluginBinding) { + methodChannel = MethodChannel(binding.binaryMessenger, CHANNEL) + manager = ShareSuccessManager(binding.applicationContext) + share = Share(context = binding.applicationContext, activity = null, manager = manager) + val handler = MethodCallHandler(share, manager) + methodChannel.setMethodCallHandler(handler) + } + + override fun onDetachedFromEngine(binding: FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + binding.addActivityResultListener(manager) + share.setActivity(binding.activity) + } + + override fun onDetachedFromActivity() { + share.setActivity(null) + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + + override fun onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity() + } + + companion object { + private const val CHANNEL = "dev.fluttercommunity.plus/share" + } } diff --git a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/ShareSuccessManager.kt b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/ShareSuccessManager.kt index 1e80935119..9a1c4e3041 100644 --- a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/ShareSuccessManager.kt +++ b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/ShareSuccessManager.kt @@ -6,84 +6,77 @@ import io.flutter.plugin.common.PluginRegistry.ActivityResultListener import java.util.concurrent.atomic.AtomicBoolean /** - * Handles the callback based status information about a successful or dismissed - * share. Used to link multiple different callbacks together for easier use. + * Handles the callback based status information about a successful or dismissed share. Used to link + * multiple different callbacks together for easier use. */ internal class ShareSuccessManager(private val context: Context) : ActivityResultListener { - private var callback: MethodChannel.Result? = null - private var isCalledBack: AtomicBoolean = AtomicBoolean(true) + private var callback: MethodChannel.Result? = null + private var isCalledBack: AtomicBoolean = AtomicBoolean(true) - /** - * Set result callback that will wait for the share-sheet to close and get either - * the componentname of the chosen option or an empty string on dismissal. - */ - fun setCallback(callback: MethodChannel.Result) { - return if (isCalledBack.compareAndSet(true, false)) { - // Prepare all state for new share - SharePlusPendingIntent.result = "" - isCalledBack.set(false) - this.callback = callback - } else { - // Ensure no deadlocks. - // Return result of any waiting call. - // e.g. user called to `share` but did not await for result. - this.callback?.success(RESULT_UNAVAILABLE) + /** + * Set result callback that will wait for the share-sheet to close and get either the + * componentname of the chosen option or an empty string on dismissal. + */ + fun setCallback(callback: MethodChannel.Result) { + return if (isCalledBack.compareAndSet(true, false)) { + // Prepare all state for new share + SharePlusPendingIntent.result = "" + isCalledBack.set(false) + this.callback = callback + } else { + // Ensure no deadlocks. + // Return result of any waiting call. + // e.g. user called to `share` but did not await for result. + this.callback?.success(RESULT_UNAVAILABLE) - // Prepare all state for new share - SharePlusPendingIntent.result = "" - isCalledBack.set(false) - this.callback = callback - } + // Prepare all state for new share + SharePlusPendingIntent.result = "" + isCalledBack.set(false) + this.callback = callback } + } - /** - * Must be called if `.startActivityForResult` is not available to avoid deadlocking. - */ - fun unavailable() { - returnResult(RESULT_UNAVAILABLE) - } + /** Must be called if `.startActivityForResult` is not available to avoid deadlocking. */ + fun unavailable() { + returnResult(RESULT_UNAVAILABLE) + } - /** - * Must be called on error to avoid deadlocking. - */ - fun clear() { - isCalledBack.set(true) - callback = null - } + /** Must be called on error to avoid deadlocking. */ + fun clear() { + isCalledBack.set(true) + callback = null + } - /** - * Send the result to flutter by invoking the previously set callback. - */ - private fun returnResult(result: String) { - if (isCalledBack.compareAndSet(false, true) && callback != null) { - callback!!.success(result) - callback = null - } + /** Send the result to flutter by invoking the previously set callback. */ + private fun returnResult(result: String) { + if (isCalledBack.compareAndSet(false, true) && callback != null) { + callback!!.success(result) + callback = null } + } - /** - * Handler called after a share sheet was closed. Called regardless of success or - * dismissal. - */ - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { - return if (requestCode == ACTIVITY_CODE) { - returnResult(SharePlusPendingIntent.result) - true - } else { - false - } + /** Handler called after a share sheet was closed. Called regardless of success or dismissal. */ + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + return if (requestCode == ACTIVITY_CODE) { + returnResult(SharePlusPendingIntent.result) + true + } else { + false } + } + /** + * Companion object holds constants used throughout the plugin when attempting to return the share + * result. + */ + companion object { /** - * Companion object holds constants used throughout the plugin when attempting to return - * the share result. + * When the application's activity is [androidx.fragment.app.FragmentActivity], requestCode can + * only use the lower 16 bits. + * + * @see androidx.fragment.app.FragmentActivity.validateRequestPermissionsRequestCode */ - companion object { - /** - * When the application's activity is [androidx.fragment.app.FragmentActivity], requestCode can only use the lower 16 bits. - * @see androidx.fragment.app.FragmentActivity.validateRequestPermissionsRequestCode - */ - const val ACTIVITY_CODE = 0x5873 - const val RESULT_UNAVAILABLE = "dev.fluttercommunity.plus/share/unavailable" - } + const val ACTIVITY_CODE = 0x5873 + const val RESULT_UNAVAILABLE = "dev.fluttercommunity.plus/share/unavailable" + } } 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..1ae700a77e 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,21 @@ 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/ios/share_plus/Sources/share_plus/FPPSharePlusPlugin.m b/packages/share_plus/share_plus/ios/share_plus/Sources/share_plus/FPPSharePlusPlugin.m index 15230453e2..42f4bc7991 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 @@ -421,10 +421,12 @@ + (void)share:(NSArray *)shareItems CGRectContainsRect(controller.view.frame, origin); // Check if this is actually an iPad - BOOL isIpad = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad); + BOOL isIpad = + ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad); - // Before Xcode 26 hasPopoverPresentationController is true for iPads and false for iPhones. - // Since Xcode 26 is true both for iPads and iPhones, so additional check was added above. + // Before Xcode 26 hasPopoverPresentationController is true for iPads and + // false for iPhones. Since Xcode 26 is true both for iPads and iPhones, so + // additional check was added above. BOOL hasPopoverPresentationController = [activityViewController popoverPresentationController] != NULL; if (isIpad && hasPopoverPresentationController && 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..a380b848ed 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); @@ -98,34 +107,42 @@ void SharePlusWindowsPlugin::HandleMethodCall( auto data_transfer_manager = GetDataTransferManager(); auto args = std::get(*method_call.arguments()); - // Extract the text, subject, uri, title, paths and mimeTypes from the arguments - if (auto text_value = std::get_if( - &args[flutter::EncodableValue("text")])) { + // Extract the text, subject, uri, title, paths and mimeTypes from the + // arguments + if (auto text_value = + std::get_if(&args[flutter::EncodableValue("text")])) { share_text_ = *text_value; } if (auto subject_value = std::get_if( &args[flutter::EncodableValue("subject")])) { share_subject_ = *subject_value; } - if (auto uri_value = std::get_if( - &args[flutter::EncodableValue("uri")])) { + if (auto uri_value = + std::get_if(&args[flutter::EncodableValue("uri")])) { share_uri_ = *uri_value; } - if (auto title_value = std::get_if( - &args[flutter::EncodableValue("title")])) { + if (auto title_value = + std::get_if(&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")])) { + &args[flutter::EncodableValue("paths")])) { paths_.clear(); - for (auto& path : *paths) { + for (auto &path : *paths) { paths_.emplace_back(std::get(path)); } } if (auto mime_types = std::get_if( - &args[flutter::EncodableValue("mimeTypes")])) { + &args[flutter::EncodableValue("mimeTypes")])) { mime_types_.clear(); - for (auto& mime_type : *mime_types) { + for (auto &mime_type : *mime_types) { mime_types_.emplace_back(std::get(mime_type)); } } @@ -133,64 +150,85 @@ void SharePlusWindowsPlugin::HandleMethodCall( // Create the share callback auto callback = WRL::Callback>( - [&](auto &&, DataTransfer::IDataRequestedEventArgs *e) { - using Microsoft::WRL::Wrappers::HStringReference; - WRL::ComPtr request; - e->get_Request(&request); - WRL::ComPtr data; - request->get_Data(&data); - WRL::ComPtr properties; - data->get_Properties(&properties); - - // Set the title of the share dialog - // Prefer the title, then the subject, then the text - // Setting a title is mandatory for Windows - if (share_title_ && !share_title_.value_or("").empty()) { - auto title = Utf16FromUtf8(share_title_.value_or("")); - properties->put_Title(HStringReference(title.c_str()).Get()); - } - else if (share_subject_ && !share_subject_.value_or("").empty()) { - auto title = Utf16FromUtf8(share_subject_.value_or("")); - properties->put_Title(HStringReference(title.c_str()).Get()); - } - else { - auto title = Utf16FromUtf8(share_text_.value_or("")); - properties->put_Title(HStringReference(title.c_str()).Get()); - } + DataTransfer::DataRequestedEventArgs + *>>([&](auto &&, DataTransfer::IDataRequestedEventArgs *e) { + using Microsoft::WRL::Wrappers::HStringReference; + WRL::ComPtr request; + e->get_Request(&request); + WRL::ComPtr data; + request->get_Data(&data); + WRL::ComPtr properties; + data->get_Properties(&properties); - // Set the text of the share dialog - if (share_text_ && !share_text_.value_or("").empty()) { - auto text = Utf16FromUtf8(share_text_.value_or("")); - properties->put_Description( - HStringReference(text.c_str()).Get()); - data->SetText(HStringReference(text.c_str()).Get()); - } + // Set the title of the share dialog + // Prefer the title, then the subject, then the text + // Setting a title is mandatory for Windows + if (share_title_ && !share_title_.value_or("").empty()) { + auto title = Utf16FromUtf8(share_title_.value_or("")); + properties->put_Title(HStringReference(title.c_str()).Get()); + } else if (share_subject_ && !share_subject_.value_or("").empty()) { + auto title = Utf16FromUtf8(share_subject_.value_or("")); + properties->put_Title(HStringReference(title.c_str()).Get()); + } else { + auto title = Utf16FromUtf8(share_text_.value_or("")); + properties->put_Title(HStringReference(title.c_str()).Get()); + } - // If URI provided, set the URI to share - if (share_uri_ && !share_uri_.value_or("").empty()) { - auto uri = Utf16FromUtf8(share_uri_.value_or("")); - properties->put_Description( - HStringReference(uri.c_str()).Get()); - data->SetText(HStringReference(uri.c_str()).Get()); - } + // Set the text of the share dialog + if (share_text_ && !share_text_.value_or("").empty()) { + auto text = Utf16FromUtf8(share_text_.value_or("")); + properties->put_Description(HStringReference(text.c_str()).Get()); + data->SetText(HStringReference(text.c_str()).Get()); + } + + // If URI provided, set the URI to share + if (share_uri_ && !share_uri_.value_or("").empty()) { + auto uri = Utf16FromUtf8(share_uri_.value_or("")); + properties->put_Description(HStringReference(uri.c_str()).Get()); + data->SetText(HStringReference(uri.c_str()).Get()); + } - // Add files to the data. - Vector storage_items; - for (const std::string& path : paths_) { - auto str = Utf16FromUtf8(path); - wchar_t* ptr = const_cast(str.c_str()); - WindowsStorage::IStorageFile* file = nullptr; - if (SUCCEEDED(GetStorageFileFromPath(ptr, &file)) && - file != nullptr) { - storage_items.Append( - reinterpret_cast(file)); + // 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 + stream_ref; + if (SUCCEEDED(stream_ref_statics->CreateFromFile( + thumbnail_file.Get(), &stream_ref))) { + properties->put_Thumbnail(stream_ref.Get()); } } - data->SetStorageItemsReadOnly(&storage_items); + } + } + + // Add files to the data. + Vector storage_items; + for (const std::string &path : paths_) { + auto str = Utf16FromUtf8(path); + wchar_t *ptr = const_cast(str.c_str()); + WindowsStorage::IStorageFile *file = nullptr; + if (SUCCEEDED(GetStorageFileFromPath(ptr, &file)) && file != nullptr) { + storage_items.Append( + reinterpret_cast(file)); + } + } + data->SetStorageItemsReadOnly(&storage_items); - return S_OK; - }); + return S_OK; + }); // Add the callback to the data transfer manager data_transfer_manager->add_DataRequested(callback.Get(), 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..c3d6f6d97a 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 { @@ -45,7 +47,7 @@ class SharePlusWindowsPlugin : public flutter::Plugin { "dev.fluttercommunity.plus/share/unavailable"; static constexpr auto kShare = "share"; - //static constexpr auto kShareFiles = "shareFiles"; + // static constexpr auto kShareFiles = "shareFiles"; HWND GetWindow(); @@ -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/windows/vector.h b/packages/share_plus/share_plus/windows/vector.h index 105e42c222..8bf96d80ec 100644 --- a/packages/share_plus/share_plus/windows/vector.h +++ b/packages/share_plus/share_plus/windows/vector.h @@ -65,8 +65,8 @@ __declspec(noreturn) __declspec(noinline) inline void ThrowHR(HRESULT hr, ThrowHR(hr); } -__declspec(noreturn) __declspec(noinline) inline void ThrowHR( - HRESULT hr, wchar_t const *message) { +__declspec(noreturn) __declspec(noinline) inline void +ThrowHR(HRESULT hr, wchar_t const *message) { using ::Microsoft::WRL::Wrappers::HStringReference; ThrowHR(hr, HStringReference(message).Get()); 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 40ceee27b6..43982a3500 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',