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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/share_plus.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions packages/share_plus/share_plus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions packages/share_plus/share_plus/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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<Uri>()
private val sourceFolders = mutableListOf<File>()

@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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,12 @@
<action android:name="EXTRA_CHOSEN_COMPONENT" />
</intent-filter>
</receiver>
<!-- Handles the optional Android 14+ Sharesheet Save action. -->
<activity
android:name=".SharePlusSaveActivity"
android:configChanges="orientation|screenSize"
android:excludeFromRecents="true"
android:exported="false"
android:theme="@android:style/Theme.DeviceDefault.Dialog.NoActionBar" />
</application>
</manifest>
Loading
Loading