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" /> - - + +