From 4bc721159bec2a5d1cfa8f85abe447b25a70185e Mon Sep 17 00:00:00 2001 From: Christopher Banck Date: Sun, 28 Jun 2026 22:21:05 +0200 Subject: [PATCH] feat(share_plus): add Android Sharesheet Save action Add an opt-in Save action when sharing files through the Android 14+ Sharesheet. Use Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS because it is Android's closest native equivalent to the Save option provided by the iOS share sheet. Save images to Pictures, videos to Movies, and other files to Downloads through MediaStore without requiring storage permission. Support localized progress and result labels, preserve request-scoped cache files during asynchronous saves, and roll back partially saved batches on failure. Ignore the option on older Android versions, other platforms, and shares without files. Document the API and add platform-interface and Android instrumentation coverage. --- .github/workflows/share_plus.yaml | 8 +- packages/share_plus/share_plus/README.md | 38 ++ .../share_plus/android/build.gradle.kts | 3 + .../share/MediaStoreWriterInstrumentedTest.kt | 134 +++++++ .../android/src/main/AndroidManifest.xml | 7 + .../dev/fluttercommunity/plus/share/Share.kt | 140 +++++++- .../plus/share/SharePlusPendingIntent.kt | 4 + .../plus/share/SharePlusSaveActivity.kt | 339 ++++++++++++++++++ .../main/res/drawable/share_plus_ic_save.xml | 10 + .../share_plus/example/lib/main.dart | 3 + .../share_plus/share_plus/lib/share_plus.dart | 1 + .../method_channel/method_channel_share.dart | 8 + .../share_plus_platform.dart | 44 +++ .../share_plus_platform_interface_test.dart | 51 +++ 14 files changed, 771 insertions(+), 19 deletions(-) create mode 100644 packages/share_plus/share_plus/android/src/androidTest/kotlin/dev/fluttercommunity/plus/share/MediaStoreWriterInstrumentedTest.kt create mode 100644 packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusSaveActivity.kt create mode 100644 packages/share_plus/share_plus/android/src/main/res/drawable/share_plus_ic_save.xml diff --git a/.github/workflows/share_plus.yaml b/.github/workflows/share_plus.yaml index f48d468617..cb14674347 100644 --- a/.github/workflows/share_plus.yaml +++ b/.github/workflows/share_plus.yaml @@ -84,7 +84,13 @@ jobs: arch: x86_64 force-avd-creation: false profile: pixel_7_pro - script: ./.github/workflows/scripts/integration-test.sh android share_plus_example + script: | + ./.github/workflows/scripts/integration-test.sh android share_plus_example + if [ "${{ matrix.android-api-level }}" -ge 34 ]; then + ./packages/share_plus/share_plus/example/android/gradlew \ + -p ./packages/share_plus/share_plus/example/android \ + :share_plus:connectedDebugAndroidTest + fi ios_example_build: runs-on: macos-26 diff --git a/packages/share_plus/share_plus/README.md b/packages/share_plus/share_plus/README.md index 17807e794e..66bfc81976 100644 --- a/packages/share_plus/share_plus/README.md +++ b/packages/share_plus/share_plus/README.md @@ -209,6 +209,44 @@ ShareParams( ) ``` +#### Android Save Action + +On Android 14 (API level 34) and later, a **Save** action can be added to the +Android Sharesheet when sharing files: + +```dart +ShareParams( + files: [XFile('${directory.path}/image.jpg')], + androidIncludeSaveAction: true, +) +``` + +The labels default to English. To localize or customize them, resolve the +strings in Flutter and pass them with the share request: + +```dart +final localizations = AppLocalizations.of(context)!; + +ShareParams( + files: [XFile('${directory.path}/image.jpg')], + androidIncludeSaveAction: true, + androidSaveActionLabels: AndroidSaveActionLabels( + save: localizations.save, + saving: localizations.saving, + success: localizations.saved, + failure: localizations.saveFailed, + ), +) +``` + +When selected, images are saved to Pictures, videos to Movies, and all other +file types to Downloads using Android's MediaStore. No storage permission is +required. The parameter is ignored on older Android versions, on other +platforms, and when no files are shared. + +The returned `ShareResult` reports the Sharesheet interaction, not the outcome +of the file copy. Android displays a confirmation after the save finishes. + #### Excluded Cupertino Activities On iOS or macOS, if you want to exclude certain options from appearing in your share sheet, you can set the `excludedCupertinoActivities` array. diff --git a/packages/share_plus/share_plus/android/build.gradle.kts b/packages/share_plus/share_plus/android/build.gradle.kts index 9a05b0dee6..3f5aec935a 100644 --- a/packages/share_plus/share_plus/android/build.gradle.kts +++ b/packages/share_plus/share_plus/android/build.gradle.kts @@ -60,5 +60,8 @@ android { dependencies { implementation("androidx.core:core-ktx:1.16.0") implementation("androidx.annotation:annotation:1.9.1") + + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.test.ext:junit:1.3.0") } } diff --git a/packages/share_plus/share_plus/android/src/androidTest/kotlin/dev/fluttercommunity/plus/share/MediaStoreWriterInstrumentedTest.kt b/packages/share_plus/share_plus/android/src/androidTest/kotlin/dev/fluttercommunity/plus/share/MediaStoreWriterInstrumentedTest.kt new file mode 100644 index 0000000000..cef4406654 --- /dev/null +++ b/packages/share_plus/share_plus/android/src/androidTest/kotlin/dev/fluttercommunity/plus/share/MediaStoreWriterInstrumentedTest.kt @@ -0,0 +1,134 @@ +package dev.fluttercommunity.plus.share + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import java.util.UUID +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = 34) +class MediaStoreWriterInstrumentedTest { + private val context: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private val createdMedia = mutableListOf() + private val sourceFolders = mutableListOf() + + @After + fun cleanUp() { + createdMedia.forEach { context.contentResolver.delete(it, null, null) } + sourceFolders.forEach { it.deleteRecursively() } + } + + @Test + fun savesImageToPictures() { + val sourceBytes = byteArrayOf(1, 2, 3, 4, 5) + val source = createSourceFile("png", sourceBytes) + + MediaStoreWriter.save( + context, + listOf(uriFor(source)), + listOf("image/png"), + ) + + val foundDestination = findByDisplayName( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + source.name, + ) + assertNotNull(foundDestination) + val destination = foundDestination!! + createdMedia.add(destination) + + context.contentResolver.query( + destination, + arrayOf( + MediaStore.MediaColumns.MIME_TYPE, + MediaStore.MediaColumns.RELATIVE_PATH, + ), + null, + null, + null, + )!!.use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("image/png", cursor.getString(0)) + assertEquals( + Environment.DIRECTORY_PICTURES, + cursor.getString(1).trimEnd('/'), + ) + } + val savedBytes = context.contentResolver.openInputStream(destination)!!.use { + it.readBytes() + } + assertArrayEquals(sourceBytes, savedBytes) + } + + @Test + fun rollsBackEarlierFilesWhenAnotherFileCannotBeRead() { + val source = createSourceFile("png", byteArrayOf(1, 2, 3)) + val missingSource = Uri.parse( + "content://${context.packageName}.missing/${UUID.randomUUID()}", + ) + + val result = runCatching { + MediaStoreWriter.save( + context, + listOf(uriFor(source), missingSource), + listOf("image/png", "image/png"), + ) + } + + val remainingDestination = findByDisplayName( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + source.name, + ) + if (remainingDestination != null) createdMedia.add(remainingDestination) + + assertTrue(result.isFailure) + assertNull(remainingDestination) + } + + private fun createSourceFile(extension: String, bytes: ByteArray): File { + val folder = File(context.cacheDir, "share_plus/test-${UUID.randomUUID()}") + .apply { mkdirs() } + sourceFolders.add(folder) + return File(folder, "share-plus-test-${UUID.randomUUID()}.$extension").apply { + writeBytes(bytes) + } + } + + private fun uriFor(file: File): Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.flutter.share_provider", + file, + ) + + private fun findByDisplayName(collection: Uri, displayName: String): Uri? { + context.contentResolver.query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + arrayOf(displayName), + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + return ContentUris.withAppendedId(collection, cursor.getLong(0)) + } + } + return null + } +} diff --git a/packages/share_plus/share_plus/android/src/main/AndroidManifest.xml b/packages/share_plus/share_plus/android/src/main/AndroidManifest.xml index 6644612b05..e55ee59758 100644 --- a/packages/share_plus/share_plus/android/src/main/AndroidManifest.xml +++ b/packages/share_plus/share_plus/android/src/main/AndroidManifest.xml @@ -19,5 +19,12 @@ + + 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..dd97aeca20 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 @@ -5,11 +5,15 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.graphics.drawable.Icon import android.net.Uri import android.os.Build +import android.service.chooser.ChooserAction +import androidx.annotation.RequiresApi import androidx.core.content.FileProvider import java.io.File import java.io.IOException +import java.util.UUID /** * Handles share intent. The `context` and `activity` are used to start the share @@ -65,7 +69,14 @@ internal class Share( 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) } + val includeSaveAction = arguments["androidIncludeSaveAction"] as? Boolean ?: false + val saveActionLabels = if (includeSaveAction) { + getSaveActionLabels(arguments) + } else { + null + } + val sharedFiles = paths?.let { getUrisForPaths(paths) } + val fileUris = sharedFiles?.uris // Create Share Intent val shareIntent = Intent() @@ -131,6 +142,20 @@ internal class Share( Intent.createChooser(shareIntent, title) } + if ( + includeSaveAction && + !fileUris.isNullOrEmpty() && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE + ) { + addSaveAction( + chooserIntent, + fileUris, + mimeTypes, + sharedFiles.cacheFolder, + requireNotNull(saveActionLabels), + ) + } + // Grant permissions to all apps that can handle the files shared if (fileUris != null) { val resInfoList = getContext().packageManager.queryIntentActivities( @@ -170,18 +195,77 @@ internal class Share( } @Throws(IOException::class) - private fun getUrisForPaths(paths: List): ArrayList { + private fun getUrisForPaths(paths: List): SharedFiles { 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}'") + val requestFolder = File(shareCacheFolder, UUID.randomUUID().toString()) + try { + paths.forEach { path -> + var file = File(path) + if (fileIsInShareCache(file)) { + throw IOException("Shared file can not be located in '${shareCacheFolder.canonicalPath}'") + } + file = copyToShareCacheFolder(file, requestFolder) + uris.add(FileProvider.getUriForFile(getContext(), providerAuthority, file)) } - file = copyToShareCacheFolder(file) - uris.add(FileProvider.getUriForFile(getContext(), providerAuthority, file)) + } catch (error: Throwable) { + requestFolder.deleteRecursively() + throw error + } + return SharedFiles(uris, requestFolder) + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private fun addSaveAction( + chooserIntent: Intent, + fileUris: ArrayList, + mimeTypes: List?, + cacheFolder: File, + labels: SaveActionLabels, + ) { + val saveIntent = Intent(getContext(), SharePlusSaveActivity::class.java).apply { + action = "${getContext().packageName}.share_plus.SAVE.${UUID.randomUUID()}" + putParcelableArrayListExtra(SharePlusSaveActivity.EXTRA_URIS, fileUris) + putStringArrayListExtra( + SharePlusSaveActivity.EXTRA_MIME_TYPES, + ArrayList(mimeTypes.orEmpty()), + ) + putExtra(SharePlusSaveActivity.EXTRA_CACHE_FOLDER, cacheFolder.absolutePath) + putExtra(SharePlusSaveActivity.EXTRA_SAVING_LABEL, labels.saving) + putExtra(SharePlusSaveActivity.EXTRA_SUCCESS_LABEL, labels.success) + putExtra(SharePlusSaveActivity.EXTRA_FAILURE_LABEL, labels.failure) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - return uris + val savePendingIntent = PendingIntent.getActivity( + getContext(), + 0, + saveIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val saveAction = ChooserAction.Builder( + Icon.createWithResource(getContext(), R.drawable.share_plus_ic_save), + labels.save, + savePendingIntent, + ).build() + + chooserIntent.putExtra( + Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, + arrayOf(saveAction), + ) + } + + private fun getSaveActionLabels(arguments: Map): SaveActionLabels { + val labels = arguments["androidSaveActionLabels"] as? Map<*, *> + ?: throw IllegalArgumentException("androidSaveActionLabels must be provided") + + fun getLabel(name: String): String = labels[name] as? String + ?: throw IllegalArgumentException("androidSaveActionLabels.$name must be provided") + + return SaveActionLabels( + save = getLabel("save"), + saving = getLabel("saving"), + success = getLabel("success"), + failure = getLabel("failure"), + ) } /** @@ -220,7 +304,8 @@ internal class Share( private fun fileIsInShareCache(file: File): Boolean { return try { val filePath = file.canonicalPath - filePath.startsWith(shareCacheFolder.canonicalPath) + val cachePath = shareCacheFolder.canonicalPath + filePath == cachePath || filePath.startsWith(cachePath + File.separator) } catch (e: IOException) { false } @@ -228,21 +313,40 @@ internal class Share( private fun clearShareCacheFolder() { val folder = shareCacheFolder - val files = folder.listFiles() - if (folder.exists() && !files.isNullOrEmpty()) { - files.forEach { it.delete() } - folder.delete() + folder.listFiles()?.forEach { file -> + if (!SharePlusSaveActivity.isCacheFolderActive(file)) { + file.deleteRecursively() + } } + if (folder.listFiles().isNullOrEmpty()) folder.delete() } @Throws(IOException::class) - private fun copyToShareCacheFolder(file: File): File { - val folder = shareCacheFolder + private fun copyToShareCacheFolder(file: File, folder: File): File { if (!folder.exists()) { folder.mkdirs() } - val newFile = File(folder, file.name) + var newFile = File(folder, file.name) + var suffix = 1 + while (newFile.exists()) { + val baseName = file.nameWithoutExtension + val extension = file.extension.takeIf { it.isNotEmpty() }?.let { ".$it" }.orEmpty() + newFile = File(folder, "$baseName ($suffix)$extension") + suffix++ + } file.copyTo(newFile, true) return newFile } + + private data class SharedFiles( + val uris: ArrayList, + val cacheFolder: File, + ) + + private data class SaveActionLabels( + val save: String, + val saving: String, + val success: String, + val failure: String, + ) } 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..028576998c 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 @@ -16,7 +16,11 @@ internal class SharePlusPendingIntent: BroadcastReceiver() { /** * Static member to access the result of the system instantiated instance */ + @Volatile var result: String = "" + + /** Raw share result used when the Android 14+ custom Save action is selected. */ + const val SAVE_ACTION_RESULT = "dev.fluttercommunity.plus/share/save" } /** diff --git a/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusSaveActivity.kt b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusSaveActivity.kt new file mode 100644 index 0000000000..5a0185c396 --- /dev/null +++ b/packages/share_plus/share_plus/android/src/main/kotlin/dev/fluttercommunity/plus/share/SharePlusSaveActivity.kt @@ -0,0 +1,339 @@ +package dev.fluttercommunity.plus.share + +import android.app.Activity +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.util.Log +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.RequiresApi +import java.io.File +import java.io.IOException +import java.util.Collections +import java.util.concurrent.Executors + +/** Saves files selected through the Android 14+ Sharesheet action to MediaStore. */ +class SharePlusSaveActivity : Activity() { + private var operationKey: String? = null + private var saveOperation: SaveOperation? = null + private lateinit var successLabel: String + private lateinit var failureLabel: String + private val saveObserver: (Result) -> Unit = { result -> + handleSaveResult(result) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Custom ChooserActions do not populate EXTRA_CHOSEN_COMPONENT. Record + // the selection explicitly so ShareResult reports success rather than + // treating the completed chooser interaction as a dismissal. + SharePlusPendingIntent.result = SharePlusPendingIntent.SAVE_ACTION_RESULT + setFinishOnTouchOutside(false) + + val savingLabel = intent.getStringExtra(EXTRA_SAVING_LABEL) + val successLabel = intent.getStringExtra(EXTRA_SUCCESS_LABEL) + val failureLabel = intent.getStringExtra(EXTRA_FAILURE_LABEL) + if (savingLabel == null || successLabel == null || failureLabel == null) { + finish() + return + } + this.successLabel = successLabel + this.failureLabel = failureLabel + showProgress(savingLabel) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + finish() + return + } + + val uris = intent.getParcelableArrayListExtra(EXTRA_URIS, Uri::class.java).orEmpty() + val mimeTypes = intent.getStringArrayListExtra(EXTRA_MIME_TYPES).orEmpty() + val cacheFolder = intent.getStringExtra(EXTRA_CACHE_FOLDER) + markCacheFolderActive(cacheFolder) + if (uris.isEmpty()) { + deleteCacheFolder(applicationContext, cacheFolder) + finish() + return + } + + val key = intent.action ?: cacheFolder ?: uris.joinToString() + operationKey = key + saveOperation = getOrStartSaveOperation( + key, + applicationContext, + uris, + mimeTypes, + cacheFolder, + ).also { it.observe(saveObserver) } + } + + override fun onDestroy() { + saveOperation?.let { operation -> + operation.removeObserver(saveObserver) + if (!isChangingConfigurations) { + operationKey?.let { releaseSaveOperation(it, operation) } + } + } + super.onDestroy() + } + + private fun handleSaveResult(result: Result) { + val operation = saveOperation ?: return + operation.removeObserver(saveObserver) + operationKey?.let { releaseSaveOperation(it, operation) } + saveOperation = null + + if (isFinishing || isDestroyed) return + if (result.isSuccess) { + Toast.makeText(this, successLabel, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, failureLabel, Toast.LENGTH_LONG).show() + } + finish() + } + + private fun showProgress(savingLabel: String) { + val density = resources.displayMetrics.density + val padding = (24 * density).toInt() + val content = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(padding, padding, padding, padding) + addView(ProgressBar(this@SharePlusSaveActivity)) + addView(TextView(this@SharePlusSaveActivity).apply { + text = savingLabel + setPadding(padding, 0, 0, 0) + }) + } + setContentView(content) + } + + companion object { + const val EXTRA_URIS = "dev.fluttercommunity.plus.share.extra.SAVE_URIS" + const val EXTRA_MIME_TYPES = "dev.fluttercommunity.plus.share.extra.SAVE_MIME_TYPES" + const val EXTRA_CACHE_FOLDER = "dev.fluttercommunity.plus.share.extra.SAVE_CACHE_FOLDER" + const val EXTRA_SAVING_LABEL = "dev.fluttercommunity.plus.share.extra.SAVING_LABEL" + const val EXTRA_SUCCESS_LABEL = "dev.fluttercommunity.plus.share.extra.SUCCESS_LABEL" + const val EXTRA_FAILURE_LABEL = "dev.fluttercommunity.plus.share.extra.FAILURE_LABEL" + private const val TAG = "SharePlusSave" + private val activeCacheFolders: MutableSet = + Collections.synchronizedSet(HashSet()) + // Keeps one save job alive while Android recreates its progress activity. + private val saveOperations = mutableMapOf() + + private fun markCacheFolderActive(path: String?) { + if (path != null) activeCacheFolders.add(File(path).absolutePath) + } + + fun isCacheFolderActive(folder: File): Boolean = + activeCacheFolders.contains(folder.absolutePath) + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private fun getOrStartSaveOperation( + key: String, + context: Context, + uris: List, + mimeTypes: List, + cacheFolder: String?, + ): SaveOperation = synchronized(saveOperations) { + saveOperations[key] ?: SaveOperation( + context, + uris, + mimeTypes, + cacheFolder, + ).also { operation -> + saveOperations[key] = operation + operation.start() + } + } + + private fun releaseSaveOperation(key: String, operation: SaveOperation) { + synchronized(saveOperations) { + if (saveOperations[key] === operation) saveOperations.remove(key) + } + } + + private fun deleteCacheFolder(context: Context, path: String?) { + if (path == null) return + try { + runCatching { + val shareCache = File(context.cacheDir, "share_plus").canonicalFile + val folder = File(path).canonicalFile + if (folder.parentFile == shareCache) folder.deleteRecursively() + } + } finally { + activeCacheFolders.remove(File(path).absolutePath) + } + } + + private class SaveOperation( + private val context: Context, + private val uris: List, + private val mimeTypes: List, + private val cacheFolder: String?, + ) { + private val executor = Executors.newSingleThreadExecutor() + private val mainHandler = Handler(Looper.getMainLooper()) + private var observer: ((Result) -> Unit)? = null + private var completedResult: Result? = null + + @RequiresApi(Build.VERSION_CODES.Q) + fun start() { + executor.execute { + val result = runCatching { + MediaStoreWriter.save(context, uris, mimeTypes) + } + if (result.isFailure) { + Log.e(TAG, "Unable to save shared files", result.exceptionOrNull()) + } + deleteCacheFolder(context, cacheFolder) + complete(result) + executor.shutdown() + } + } + + fun observe(observer: (Result) -> Unit) { + val result = synchronized(this) { + this.observer = observer + completedResult + } + if (result != null) dispatch(observer, result) + } + + fun removeObserver(observer: (Result) -> Unit) { + synchronized(this) { + if (this.observer === observer) this.observer = null + } + } + + private fun complete(result: Result) { + val currentObserver = synchronized(this) { + completedResult = result + observer + } + if (currentObserver != null) dispatch(currentObserver, result) + } + + private fun dispatch( + observer: (Result) -> Unit, + result: Result, + ) { + mainHandler.post { + val isAttached = synchronized(this@SaveOperation) { + this@SaveOperation.observer === observer + } + if (isAttached) observer(result) + } + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.Q) +internal object MediaStoreWriter { + fun save(context: Context, sourceUris: List, providedMimeTypes: List) { + val resolver = context.contentResolver + val createdUris = mutableListOf() + + try { + sourceUris.forEachIndexed { index, sourceUri -> + val displayName = resolveDisplayName(context, sourceUri) + val mimeType = resolveMimeType( + context, + sourceUri, + providedMimeTypes.getOrNull(index), + displayName, + ) + val (collection, relativePath) = destinationFor(mimeType) + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val destinationUri = resolver.insert(collection, values) + ?: throw IOException("MediaStore did not create a destination") + createdUris.add(destinationUri) + + resolver.openInputStream(sourceUri).use { input -> + if (input == null) throw IOException("Unable to read $sourceUri") + resolver.openOutputStream(destinationUri, "w").use { output -> + if (output == null) throw IOException("Unable to write $destinationUri") + input.copyTo(output) + } + } + + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + if (resolver.update(destinationUri, values, null, null) != 1) { + throw IOException("Unable to publish $destinationUri") + } + } + } catch (error: Throwable) { + createdUris.forEach { resolver.delete(it, null, null) } + throw error + } + } + + private fun destinationFor(mimeType: String): Pair = when { + mimeType.startsWith("image/", ignoreCase = true) -> Pair( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + Environment.DIRECTORY_PICTURES, + ) + mimeType.startsWith("video/", ignoreCase = true) -> Pair( + MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + Environment.DIRECTORY_MOVIES, + ) + else -> Pair( + MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + Environment.DIRECTORY_DOWNLOADS, + ) + } + + private fun resolveDisplayName(context: Context, uri: Uri): String { + var name: String? = null + context.contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) name = cursor.getString(0) + } + val fallback = uri.lastPathSegment?.substringAfterLast('/') ?: "shared_file" + return (name ?: fallback) + .replace('/', '_') + .replace('\\', '_') + .takeUnless { it.isBlank() || it == "." || it == ".." } + ?: "shared_file" + } + + private fun resolveMimeType( + context: Context, + uri: Uri, + providedMimeType: String?, + displayName: String, + ): String { + if (!providedMimeType.isNullOrBlank() && !providedMimeType.contains('*')) { + return providedMimeType + } + context.contentResolver.getType(uri)?.takeUnless { it.contains('*') }?.let { return it } + val extension = File(displayName).extension.lowercase() + android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)?.let { + return it + } + return providedMimeType?.takeUnless { it.isBlank() } ?: "application/octet-stream" + } +} diff --git a/packages/share_plus/share_plus/android/src/main/res/drawable/share_plus_ic_save.xml b/packages/share_plus/share_plus/android/src/main/res/drawable/share_plus_ic_save.xml new file mode 100644 index 0000000000..86374f7d4e --- /dev/null +++ b/packages/share_plus/share_plus/android/src/main/res/drawable/share_plus_ic_save.xml @@ -0,0 +1,10 @@ + + + + diff --git a/packages/share_plus/share_plus/example/lib/main.dart b/packages/share_plus/share_plus/example/lib/main.dart index d172097da9..4fa4b05e33 100644 --- a/packages/share_plus/share_plus/example/lib/main.dart +++ b/packages/share_plus/share_plus/example/lib/main.dart @@ -264,6 +264,7 @@ class MyHomePageState extends State { subject: subject.isEmpty ? null : subject, title: title.isEmpty ? null : title, files: files, + androidIncludeSaveAction: true, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, excludedCupertinoActivities: [CupertinoActivityType.airDrop], ), @@ -307,6 +308,7 @@ class MyHomePageState extends State { mimeType: 'image/png', ), ], + androidIncludeSaveAction: true, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, downloadFallbackEnabled: true, excludedCupertinoActivities: excludedCupertinoActivityType, @@ -332,6 +334,7 @@ class MyHomePageState extends State { mimeType: 'text/plain', ), ], + androidIncludeSaveAction: true, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, fileNameOverrides: [fileName], downloadFallbackEnabled: true, diff --git a/packages/share_plus/share_plus/lib/share_plus.dart b/packages/share_plus/share_plus/lib/share_plus.dart index b6e055e3de..3c494e980a 100644 --- a/packages/share_plus/share_plus/lib/share_plus.dart +++ b/packages/share_plus/share_plus/lib/share_plus.dart @@ -13,6 +13,7 @@ export 'package:share_plus_platform_interface/share_plus_platform_interface.dart ShareResultStatus, XFile, ShareParams, + AndroidSaveActionLabels, CupertinoActivityType; export 'src/share_plus_linux.dart'; 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..a444dd7925 100644 --- a/packages/share_plus/share_plus_platform_interface/lib/method_channel/method_channel_share.dart +++ b/packages/share_plus/share_plus_platform_interface/lib/method_channel/method_channel_share.dart @@ -43,6 +43,14 @@ class MethodChannelShare extends SharePlatform { if (params.subject != null) 'subject': params.subject, if (params.title != null) 'title': params.title, if (params.uri != null) 'uri': params.uri.toString(), + if (params.androidIncludeSaveAction) 'androidIncludeSaveAction': true, + if (params.androidIncludeSaveAction) + 'androidSaveActionLabels': { + 'save': params.androidSaveActionLabels.save, + 'saving': params.androidSaveActionLabels.saving, + 'success': params.androidSaveActionLabels.success, + 'failure': params.androidSaveActionLabels.failure, + }, }; if (params.sharePositionOrigin != null) { diff --git a/packages/share_plus/share_plus_platform_interface/lib/platform_interface/share_plus_platform.dart b/packages/share_plus/share_plus_platform_interface/lib/platform_interface/share_plus_platform.dart index 40ceee27b6..8af9563d93 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 @@ -36,6 +36,29 @@ class SharePlatform extends PlatformInterface { } } +/// Labels displayed by the Android Sharesheet Save action. +class AndroidSaveActionLabels { + /// Creates labels for the Android Sharesheet Save action. + const AndroidSaveActionLabels({ + this.save = 'Save', + this.saving = 'Saving…', + this.success = 'Saved', + this.failure = 'Couldn’t save the file', + }); + + /// Label for the Save action in the Android Sharesheet. + final String save; + + /// Label displayed while the files are being saved. + final String saving; + + /// Message displayed after the files have been saved. + final String success; + + /// Message displayed when the files could not be saved. + final String failure; +} + class ShareParams { /// The text to share /// @@ -75,6 +98,25 @@ class ShareParams { /// Parameter ignored on other platforms. final XFile? previewThumbnail; + /// Whether to add a Save action to the Android share sheet. + /// + /// When selected, image and video files are saved to their corresponding + /// media collections. Other files are saved to Downloads. + /// + /// * Supported platforms: Android 14 (API level 34) and later. + /// The parameter is ignored on other platforms and older Android versions. + /// The action is only shown when [files] are provided. + final bool androidIncludeSaveAction; + + /// Labels displayed by the Android Sharesheet Save action. + /// + /// Resolve localized values in Flutter and provide them here to customize + /// the action, progress, success, and failure text. + /// + /// * Supported platforms: Android 14 (API level 34) and later. + /// The parameter is ignored on other platforms and older Android versions. + final AndroidSaveActionLabels androidSaveActionLabels; + /// The optional [sharePositionOrigin] parameter can be used to specify a global /// origin rect for the share sheet to popover from on iPads and Macs. It has no effect /// on other devices. @@ -147,6 +189,8 @@ class ShareParams { this.subject, this.title, this.previewThumbnail, + this.androidIncludeSaveAction = false, + this.androidSaveActionLabels = const AndroidSaveActionLabels(), this.sharePositionOrigin, this.uri, this.files, 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..d129b6a155 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,57 @@ void main() { }); }); + test('enabling the Android Save action sends its default labels', () async { + await withFile('tempfile-83649-save.png', (File fd) async { + await sharePlatform.share( + ShareParams(files: [XFile(fd.path)], androidIncludeSaveAction: true), + ); + verify( + mockChannel.invokeMethod('share', { + 'paths': [fd.path], + 'mimeTypes': ['image/png'], + 'androidIncludeSaveAction': true, + 'androidSaveActionLabels': { + 'save': 'Save', + 'saving': 'Saving…', + 'success': 'Saved', + 'failure': 'Couldn’t save the file', + }, + }), + ); + }); + }); + + test('the Android Save action sends customized labels', () async { + await withFile('tempfile-83649-save-custom.png', (File fd) async { + await sharePlatform.share( + ShareParams( + files: [XFile(fd.path)], + androidIncludeSaveAction: true, + androidSaveActionLabels: const AndroidSaveActionLabels( + save: 'Speichern', + saving: 'Wird gespeichert…', + success: 'Gespeichert', + failure: 'Datei konnte nicht gespeichert werden', + ), + ), + ); + verify( + mockChannel.invokeMethod('share', { + 'paths': [fd.path], + 'mimeTypes': ['image/png'], + 'androidIncludeSaveAction': true, + 'androidSaveActionLabels': { + 'save': 'Speichern', + 'saving': 'Wird gespeichert…', + 'success': 'Gespeichert', + 'failure': 'Datei konnte nicht gespeichert werden', + }, + }), + ); + }); + }); + test('withResult methods return unavailable on non IOS & Android', () async { const resultUnavailable = ShareResult( 'dev.fluttercommunity.plus/share/unavailable',