diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
new file mode 100644
index 0000000..95d62c7
--- /dev/null
+++ b/.github/workflows/publish.yaml
@@ -0,0 +1,15 @@
+on:
+ push:
+ branches:
+ - release
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ - uses: dart-lang/setup-dart@v1
+
+ - name: Run Pub Publish
+ run: dart pub publish
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index fe5a19e..d754b83 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,8 @@
.history
.svn/
+*/**/pubspec.lock
+
# IntelliJ related
*.iml
*.ipr
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5dda2d..8ab6a6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 0.2.0
+
+Add basic support for `Storage Access Framework` and `targetSdk 31`
+
+- The package now supports basic intents from `Storage Access Framework`
+- Your App needs update the `build.gradle` by targeting the current sdk to `31`
+
## 0.1.1
Minor improvements on `pub.dev` documentation
diff --git a/README.md b/README.md
index b6ea8a2..c25fa95 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ Plugin to fetch Android shared storage/folders info
- _**Android Only**_
- _**Alpha version**_
- _**Supports Android 4.1+ (API Level 16+)**_
+- _**The `targetSdk` should be set to `31`**_
### Features
@@ -19,8 +20,8 @@ This plugin allow us to get path of top-level shared folder (Downloads, DCIM, Vi
```dart
/// Get Android [downloads] top-level shared folder
/// You can also create a reference to a custom directory as: `EnvironmentDirectory.custom('Custom Folder')`
-final sharedDirectory =
- await getExternalStoragePublicDirectory(EnvironmentDirectory.downloads);
+final sharedDirectory =
+ await getExternalStoragePublicDirectory(EnvironmentDirectory.downloads);
print(sharedDirectory.path); /// `/storage/emulated/0/Download`
```
@@ -29,26 +30,70 @@ print(sharedDirectory.path); /// `/storage/emulated/0/Download`
```dart
/// Get Android [downloads] shared folder for Android 9+
-final sharedDirectory =
+final sharedDirectory =
await getMediaStoreContentDirectory(MediaStoreCollection.downloads);
print(sharedDirectory.path); /// `/external/downloads`
```
-- Get root Android path, note that is a read-only folder
+- Start `OPEN_DOCUMENT_TREE` activity to prompt user to select an folder to enable write and read access to be used by the `Storage Access Framework` API
```dart
-/// Get Android root folder
-final sharedDirectory = await getRootDirectory();
+/// Get permissions to manage an Android directory
+final selectedUriDir = await openDocumentTree();
-print(sharedDirectory.path); /// `/system`
+print(selectedUriDir);
+```
+
+- Create a new file using the `SAF` API
+
+```dart
+/// Create a new file using the `SAF` API
+final newDocumentFile = await createDocumentFile(
+ mimeType: ' text/plain',
+ content: 'My Plain Text Comment Created by shared_storage plugin',
+ displayName: 'CreatedBySharedStorageFlutterPlugin',
+ directory: anySelectedUriByTheOpenDocumentTreeAPI,
+);
+
+print(newDocumentFile);
+```
+
+- Get all persisted [URI]s by the `openDocumentTree` API, from `SAF` API
+
+```dart
+/// You have [write] and [read] access to all persisted [URI]s
+final listOfPersistedUris = await persistedUriPermissions();
+
+print(listOfPersistedUris);
+```
+
+- Revoke a current persisted [URI], from `SAF` API
+
+```dart
+/// Can be any [URI] returned by the `persistedUriPermissions`
+final uri = ...;
+
+/// After calling this, you no longer has access to the [uri]
+await releasePersistableUriPermission(uri);
+```
+
+- Convenient method to know if a given [uri] is a persisted `uri` ("persisted uri" means that you have `write` and `read` access to the `uri` even if devices reboot)
+
+```dart
+/// Can be any [URI], but the method will only return [true] if the [uri]
+/// is also present in the list returned by `persistedUriPermissions`
+final uri = ...;
+
+/// Verify if you have [write] and [read] access to a given [uri]
+final isPersisted = await isPersistedUri(uri);
```
### Android API's
Most Flutter plugins uses Android API's under the hood. So this plugin do the same, and to retrieve Android shared folder paths the following API's are being used:
-[`🔗 android.os.Environment`](https://developer.android.com/reference/android/os/Environment#summary) [`🔗 android.provider.MediaStore`](https://developer.android.com/reference/android/provider/MediaStore#summary)
+[`🔗android.os.Environment`](https://developer.android.com/reference/android/os/Environment#summary) [`🔗android.provider.MediaStore`](https://developer.android.com/reference/android/provider/MediaStore#summary) [`🔗android.provider.DocumentsProvider`](https://developer.android.com/guide/topics/providers/document-provider)
diff --git a/android/build.gradle b/android/build.gradle
index 76c12f3..3b36289 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -1,4 +1,4 @@
-group 'com.pinciat.external_path'
+group 'io.lakscastro.sharedstorage'
version '1.0-SNAPSHOT'
buildscript {
@@ -37,4 +37,5 @@ android {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+ implementation "androidx.documentfile:documentfile:1.0.1"
}
diff --git a/android/settings.gradle b/android/settings.gradle
index e688d1d..0f44934 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -1 +1 @@
-rootProject.name = 'external_path'
+rootProject.name = 'sharedstorage'
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 4f813cd..d8a8d77 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -1,3 +1,3 @@
+ package="io.lakscastro.sharedstorage">
diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/EnvironmentDirectoryOf.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/EnvironmentDirectoryOf.kt
index f65e94c..344e9b7 100644
--- a/android/src/main/kotlin/io/lakscastro/sharedstorage/EnvironmentDirectoryOf.kt
+++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/EnvironmentDirectoryOf.kt
@@ -1,8 +1,9 @@
package io.lakscastro.sharedstorage
import android.os.Environment
+import java.io.File
-fun environmentDirectoryOf(directory: String): String {
+fun environmentDirectoryOf(directory: String): File {
val mapper = mapOf(
"EnvironmentDirectory.Alarms" to Environment.DIRECTORY_ALARMS,
"EnvironmentDirectory.DCIM" to Environment.DIRECTORY_DCIM,
@@ -15,5 +16,5 @@ fun environmentDirectoryOf(directory: String): String {
"EnvironmentDirectory.Ringtones" to Environment.DIRECTORY_RINGTONES
)
- return mapper[directory] ?: directory
+ return Environment.getExternalStoragePublicDirectory(mapper[directory] ?: directory)
}
\ No newline at end of file
diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/SharedStoragePlugin.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/SharedStoragePlugin.kt
index 1ad70a2..ca4973e 100644
--- a/android/src/main/kotlin/io/lakscastro/sharedstorage/SharedStoragePlugin.kt
+++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/SharedStoragePlugin.kt
@@ -1,19 +1,30 @@
package io.lakscastro.sharedstorage
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
import androidx.annotation.NonNull
-
+import androidx.annotation.RequiresApi
+import androidx.documentfile.provider.DocumentFile
import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding
+import io.flutter.embedding.engine.plugins.activity.ActivityAware
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
-import android.content.Context
-import android.os.Build
-import android.os.Environment
-import androidx.annotation.RequiresApi
+import io.flutter.plugin.common.PluginRegistry
/** SharedStoragePlugin */
-class SharedStoragePlugin : FlutterPlugin, MethodCallHandler {
+class SharedStoragePlugin :
+ FlutterPlugin,
+ MethodCallHandler,
+ ActivityAware,
+ PluginRegistry.ActivityResultListener {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
@@ -21,37 +32,232 @@ class SharedStoragePlugin : FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var context: Context
+ private var binding: ActivityPluginBinding? = null
+
+ private val activity
+ get(): Activity? = binding?.activity
+
+ private val pendingResults: MutableMap = mutableMapOf()
+
companion object {
const val METHOD_CHANNEL_NAME = "io.lakscastro.plugins/sharedstorage"
- const val GET_EXTERNAL_STORAGE_PUBLIC_DIRECTORY = "getExternalStoragePublicDirectory"
- const val GET_MEDIA_STORE_CONTENT_DIRECTORY = "getMediaStoreContentDirectory"
+ const val GET_EXTERNAL_STORAGE_PUBLIC_DIRECTORY =
+ "getExternalStoragePublicDirectory"
+ const val GET_EXTERNAL_STORAGE_DIRECTORY = "getExternalStorageDirectory"
+
+ const val GET_MEDIA_STORE_CONTENT_DIRECTORY =
+ "getMediaStoreContentDirectory"
const val GET_ROOT_DIRECTORY = "getRootDirectory"
+
+ const val OPEN_DOCUMENT_TREE = "openDocumentTree"
+ const val OPEN_DOCUMENT_TREE_CODE = 10
+
+ const val CREATE_DOCUMENT_FILE = "createDocumentFile"
+ const val PERSISTED_URI_PERMISSIONS = "persistedUriPermissions"
+ const val RELEASE_PERSISTABLE_URI_PERMISSION =
+ "releasePersistableUriPermission"
}
- override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
+ override fun onAttachedToEngine(
+ @NonNull flutterPluginBinding: FlutterPluginBinding
+ ) {
context = flutterPluginBinding.applicationContext
- channel = MethodChannel(flutterPluginBinding.binaryMessenger, METHOD_CHANNEL_NAME)
+ channel =
+ MethodChannel(
+ flutterPluginBinding.binaryMessenger,
+ METHOD_CHANNEL_NAME
+ )
channel.setMethodCallHandler(this)
}
- override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
+ override fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ this.binding = binding
+
+ binding.addActivityResultListener(this)
+ }
+
+ override fun onDetachedFromEngine(@NonNull binding: FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
- override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
+ /// Allow usage of [this] as [MethodCallHandler]
+ override fun onMethodCall(
+ @NonNull call: MethodCall,
+ @NonNull result: Result
+ ) {
when (call.method) {
- GET_EXTERNAL_STORAGE_PUBLIC_DIRECTORY -> getExternalStoragePublicDirectory(result, call.argument("directory") as String)
- GET_MEDIA_STORE_CONTENT_DIRECTORY -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getMediaStoreContentDirectory(result, call.argument("collection") as String) else result.notImplemented()
+ GET_EXTERNAL_STORAGE_PUBLIC_DIRECTORY ->
+ getExternalStoragePublicDirectory(
+ result,
+ call.argument("directory") as String
+ )
+ GET_EXTERNAL_STORAGE_DIRECTORY ->
+ getExternalStorageDirectory(result)
+ GET_MEDIA_STORE_CONTENT_DIRECTORY ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ getMediaStoreContentDirectory(
+ result,
+ call.argument("collection") as String
+ )
+ else result.notImplemented()
GET_ROOT_DIRECTORY -> getRootDirectory(result)
+ OPEN_DOCUMENT_TREE ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+ openDocumentTree(result)
+ CREATE_DOCUMENT_FILE ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ createDocumentFile(
+ result,
+ call.argument("mimeType") as String,
+ call.argument("displayName") as String,
+ call.argument("directoryUri") as String,
+ call.argument("content") as String
+ )
+ }
+ PERSISTED_URI_PERMISSIONS ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
+ persistedUriPermissions(result)
+ RELEASE_PERSISTABLE_URI_PERMISSION ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
+ releasePersistableUriPermission(
+ result,
+ call.argument("directoryUri") as String
+ )
else -> result.notImplemented()
}
}
- private fun getExternalStoragePublicDirectory(result: Result, directory: String) = result.success(Environment.getExternalStoragePublicDirectory(environmentDirectoryOf(directory)).absolutePath)
+ private fun getExternalStoragePublicDirectory(
+ result: Result,
+ directory: String
+ ) = result.success(environmentDirectoryOf(directory).absolutePath)
+
+ private fun getExternalStorageDirectory(result: Result) =
+ result.success(Environment.getExternalStorageDirectory().absolutePath)
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ private fun getMediaStoreContentDirectory(
+ result: Result,
+ collection: String
+ ) = result.success(mediaStoreOf(collection))
+
+ private fun getRootDirectory(result: Result) =
+ result.success(Environment.getRootDirectory().absolutePath)
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun openDocumentTree(result: Result) {
+ val intent =
+ Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
+ addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
+ }
+
+ if (pendingResults[OPEN_DOCUMENT_TREE_CODE] != null) return
+
+ pendingResults[OPEN_DOCUMENT_TREE_CODE] = result
+
+ activity?.startActivityForResult(intent, OPEN_DOCUMENT_TREE_CODE)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.KITKAT)
+ private fun createDocumentFile(
+ result: Result,
+ mimeType: String,
+ displayName: String,
+ directory: String,
+ content: String
+ ) {
+ val parentUri = Uri.parse(directory)
+
+ val parentDocumentDirectory =
+ DocumentFile.fromTreeUri(context, parentUri)
+
+ val createdFile =
+ parentDocumentDirectory?.createFile(mimeType, displayName)
+
+ createdFile?.uri?.apply {
+ context.contentResolver.openOutputStream(this)?.apply {
+ write(content.toByteArray())
+ flush()
+ result.success(path)
+ }
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.KITKAT)
+ private fun persistedUriPermissions(result: Result) {
+ val persistedUriPermissions =
+ context.contentResolver.persistedUriPermissions
+
+ result.success(
+ persistedUriPermissions
+ .map {
+ mapOf(
+ "isReadPermission" to it.isReadPermission,
+ "isWritePermission" to it.isWritePermission,
+ "persistedTime" to it.persistedTime,
+ "uri" to "${it.uri}"
+ )
+ }
+ .toList()
+ )
+ }
+
+ @RequiresApi(Build.VERSION_CODES.KITKAT)
+ private fun releasePersistableUriPermission(
+ result: Result,
+ directoryUri: String
+ ) {
+ context.contentResolver.releasePersistableUriPermission(
+ Uri.parse(directoryUri),
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+
+ result.success(null)
+ }
+
+ override fun onDetachedFromActivityForConfigChanges() {
+ binding?.removeActivityResultListener(this)
+ }
+
+ override fun onReattachedToActivityForConfigChanges(
+ binding: ActivityPluginBinding
+ ) {
+ this.binding = binding
+ }
+
+ override fun onDetachedFromActivity() {
+ binding = null
+ }
@RequiresApi(Build.VERSION_CODES.Q)
- private fun getMediaStoreContentDirectory(result: Result, collection: String) = result.success(mediaStoreOf(collection))
+ override fun onActivityResult(
+ requestCode: Int,
+ resultCode: Int,
+ data: Intent?
+ ): Boolean {
+ when (requestCode) {
+ OPEN_DOCUMENT_TREE_CODE -> {
+ try {
+ val uri = data?.data
+
+ if (uri != null) {
+ context.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+
+ pendingResults[OPEN_DOCUMENT_TREE_CODE]?.success("$uri")
- private fun getRootDirectory(result: Result) = result.success(Environment.getRootDirectory().absolutePath)
+ return true
+ }
+ } finally {
+ pendingResults.remove(OPEN_DOCUMENT_TREE_CODE)
+ }
+ }
+ }
+
+ return false
+ }
}
diff --git a/example/.gitignore b/example/.gitignore
index 0fa6b67..84dfd94 100644
--- a/example/.gitignore
+++ b/example/.gitignore
@@ -8,7 +8,7 @@
.buildlog/
.history
.svn/
-
+pubspec.lock
# IntelliJ related
*.iml
*.ipr
diff --git a/example/README.md b/example/README.md
index 9649278..8bdf14f 100644
--- a/example/README.md
+++ b/example/README.md
@@ -1,6 +1,6 @@
-# external_path_example
+# sharedstorage_example
-Demonstrates how to use the external_path plugin.
+Demonstrates how to use the shared_storage plugin.
## Getting Started
diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle
index 51b8236..80b1c86 100644
--- a/example/android/app/build.gradle
+++ b/example/android/app/build.gradle
@@ -26,7 +26,7 @@ apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android {
- compileSdkVersion 30
+ compileSdkVersion 31
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
@@ -35,7 +35,7 @@ android {
defaultConfig {
applicationId "io.lakscastro.sharedstorage.example"
minSdkVersion 16
- targetSdkVersion 30
+ targetSdkVersion 31
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml
index 537c76b..6bf72fe 100644
--- a/example/android/app/src/main/AndroidManifest.xml
+++ b/example/android/app/src/main/AndroidManifest.xml
@@ -1,36 +1,35 @@
-
+
+
+ android:name="io.flutter.embedding.android.NormalTheme"
+ android:resource="@style/NormalTheme" />
+ android:name="io.flutter.embedding.android.SplashScreenDrawable"
+ android:resource="@drawable/launch_background" />
-
-
+
+