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',