From f6be63003c27b9fcc2e8d668d6ca51c0c4b1ddac Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Sun, 4 Jun 2023 23:05:32 -0300 Subject: [PATCH 01/18] Add support for [REQUEST_INSTALL_PACKAGE] use-case --- .../DocumentFileHelperApi.kt | 129 +++++++++++++----- .../android/app/src/main/AndroidManifest.xml | 15 +- example/lib/utils/document_file_utils.dart | 6 - 3 files changed, 109 insertions(+), 41 deletions(-) diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt index f7b23f7..bdc0000 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt @@ -1,16 +1,24 @@ package io.alexrintt.sharedstorage.storageaccessframework +import android.Manifest import android.content.ActivityNotFoundException import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.util.Log -import io.flutter.plugin.common.* -import io.flutter.plugin.common.EventChannel.StreamHandler +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import com.anggrayudi.storage.file.isTreeDocumentFile import io.alexrintt.sharedstorage.ROOT_CHANNEL import io.alexrintt.sharedstorage.SharedStoragePlugin import io.alexrintt.sharedstorage.plugin.ActivityListener import io.alexrintt.sharedstorage.plugin.Listenable import io.alexrintt.sharedstorage.storageaccessframework.lib.* +import io.flutter.plugin.common.* +import io.flutter.plugin.common.EventChannel.StreamHandler +import java.security.AccessController.getContext + /** * Aimed to be a class which takes the `DocumentFile` API and implement some APIs not supported @@ -23,13 +31,13 @@ import io.alexrintt.sharedstorage.storageaccessframework.lib.* * globally without modifying the strict APIs. */ internal class DocumentFileHelperApi(private val plugin: SharedStoragePlugin) : - MethodChannel.MethodCallHandler, - PluginRegistry.ActivityResultListener, - Listenable, - ActivityListener, - StreamHandler { + MethodChannel.MethodCallHandler, + PluginRegistry.ActivityResultListener, + Listenable, + ActivityListener, + StreamHandler { private val pendingResults: MutableMap> = - mutableMapOf() + mutableMapOf() private var channel: MethodChannel? = null private var eventChannel: EventChannel? = null private var eventSink: EventChannel.EventSink? = null @@ -45,44 +53,103 @@ internal class DocumentFileHelperApi(private val plugin: SharedStoragePlugin) : } } - private fun openDocumentFile(call: MethodCall, result: MethodChannel.Result) { - val uri = Uri.parse(call.argument("uri")!!) - val type = call.argument("type") ?: plugin.context.contentResolver.getType(uri) + private fun openDocumentAsSimpleFile(uri: Uri, type: String?) { + val isApk: Boolean = type == "application/vnd.android.package-archive" + + Log.d("sharedstorage", "Trying to open uri $uri with type $type") val intent = - Intent(Intent.ACTION_VIEW).apply { - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - data = uri - } + Intent(Intent.ACTION_VIEW).apply { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + + setDataAndType(uri, type) + setFlags(flags) + } + + plugin.binding?.activity?.startActivity(intent, null) + + Log.d( + "sharedstorage", + "Successfully launched uri $uri as single|file uri." + ) + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private fun openDocumentAsTree(uri: Uri) { + val file = documentFromUri(plugin.context, uri) + if (file?.isTreeDocumentFile == true) { + val intent = Intent(Intent.ACTION_VIEW) + + intent.setDataAndType(uri, "vnd.android.document/root") - try { plugin.binding?.activity?.startActivity(intent, null) - Log.d("sharedstorage", "Successfully launched uri $uri ") + Log.d( + "sharedstorage", + "Successfully launched uri $uri as tree uri." + ) + } else { + throw Exception("Not a document tree URI") + } + } + + private fun openDocumentFile(call: MethodCall, result: MethodChannel.Result) { + val uri = Uri.parse(call.argument("uri")!!) + val type = + call.argument("type") ?: plugin.context.contentResolver.getType( + uri + ) + fun successfullyOpenedUri() { result.success(true) + } + + try { + openDocumentAsSimpleFile(uri, type) + return successfullyOpenedUri() } catch (e: ActivityNotFoundException) { - result.error( - EXCEPTION_ACTIVITY_NOT_FOUND, - "There's no activity handler that can process the uri $uri of type $type", - mapOf("uri" to "$uri", "type" to type) + Log.d( + "sharedstorage", + "No activity is defined to handle $uri, trying to recover from error and interpret as tree." + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + try { + openDocumentAsTree(uri) + return successfullyOpenedUri() + } catch (e: Throwable) { + Log.d( + "sharedstorage", + "Tried to recover from missing activity exception but did not work, exception: $e" + ) + } + } + + return result.error( + EXCEPTION_ACTIVITY_NOT_FOUND, + "There's no activity handler that can process the uri $uri of type $type", + mapOf("uri" to "$uri", "type" to type) ) } catch (e: SecurityException) { - result.error( - EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, - "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity", - mapOf("uri" to "$uri", "type" to "$type") + return result.error( + EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, + "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity", + mapOf("uri" to "$uri", "type" to "$type") ) } catch (e: Throwable) { - result.error( - EXCEPTION_CANT_OPEN_DOCUMENT_FILE, - "Couldn't start activity to open document file for uri: $uri", - mapOf("uri" to "$uri") + return result.error( + EXCEPTION_CANT_OPEN_DOCUMENT_FILE, + "Couldn't start activity to open document file for uri: $uri", + mapOf("uri" to "$uri") ) } } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + data: Intent? + ): Boolean { when (requestCode) { /** TODO(@alexrintt): Implement if required */ else -> return true @@ -125,7 +192,7 @@ internal class DocumentFileHelperApi(private val plugin: SharedStoragePlugin) : eventSink = events when (args["event"]) { - /** TODO(@alexrintt): Implement if required */ + /** TODO(@alexrintt): Implement if required */ } } diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 9b5789e..d8efeeb 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,19 +1,26 @@ - + + - + - + - + diff --git a/example/lib/utils/document_file_utils.dart b/example/lib/utils/document_file_utils.dart index ee27d72..179f3c9 100644 --- a/example/lib/utils/document_file_utils.dart +++ b/example/lib/utils/document_file_utils.dart @@ -46,12 +46,6 @@ extension ShowDocumentFileContents on DocumentFile { if (!mimeTypeOrEmpty.startsWith(kTextMime) && !mimeTypeOrEmpty.startsWith(kImageMime)) { - if (mimeTypeOrEmpty == kApkMime) { - return context.showToast( - 'Requesting to install a package (.apk) is not currently supported, to request this feature open an issue at github.com/alexrintt/shared-storage/issues', - ); - } - return uri.openWithExternalApp(); } From 2652dc6b176ac6d36f4e065d169824e7dba78ac5 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Sun, 11 Jun 2023 10:32:36 -0300 Subject: [PATCH 02/18] Impr error handling and upgrade [compileSdkVersion] --- analysis_options.yaml | 2 + android/build.gradle | 2 +- .../storageaccessframework/DocumentFileApi.kt | 162 +++++++++---- .../DocumentFileHelperApi.kt | 5 +- .../DocumentsContractApi.kt | 227 +++++++++++++++--- .../lib/DocumentCommon.kt | 14 +- .../lib/DocumentFileColumn.kt | 43 +++- example/android/app/build.gradle | 2 +- lib/src/channels.dart | 17 +- lib/src/common/functional_extender.dart | 2 +- lib/src/environment/common.dart | 3 +- lib/src/environment/environment.dart | 20 +- .../environment/environment_directory.dart | 28 ++- lib/src/media_store/media_store.dart | 10 +- .../media_store/media_store_collection.dart | 14 +- lib/src/saf/api/content.dart | 8 +- lib/src/saf/api/copy.dart | 5 +- lib/src/saf/api/create.dart | 11 +- lib/src/saf/api/grant.dart | 20 +- lib/src/saf/api/info.dart | 8 +- lib/src/saf/api/persisted.dart | 18 +- lib/src/saf/api/search.dart | 2 +- lib/src/saf/api/tree.dart | 16 +- lib/src/saf/api/write.dart | 4 +- lib/src/saf/common/method_channel_helper.dart | 2 +- lib/src/saf/models/document_bitmap.dart | 4 +- lib/src/saf/models/document_file.dart | 7 +- lib/src/saf/models/document_file_column.dart | 2 +- lib/src/saf/models/uri_permission.dart | 2 +- 29 files changed, 470 insertions(+), 190 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index d6312c6..f40ab3a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -19,3 +19,5 @@ linter: always_use_package_imports: false avoid_relative_lib_imports: false avoid_print: false + always_specify_types: true + avoid_classes_with_only_static_members: false diff --git a/android/build.gradle b/android/build.gradle index d9dd1c5..4dd5e0f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -27,7 +27,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { - compileSdkVersion 30 + compileSdkVersion 33 sourceSets { main.java.srcDirs += 'src/main/kotlin' diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt index eedfab9..159ad16 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt @@ -13,15 +13,16 @@ import io.flutter.plugin.common.* import io.flutter.plugin.common.EventChannel.StreamHandler import io.alexrintt.sharedstorage.ROOT_CHANNEL import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.deprecated.lib.* import io.alexrintt.sharedstorage.plugin.* +import io.alexrintt.sharedstorage.storageaccessframework.* import io.alexrintt.sharedstorage.storageaccessframework.lib.* +import io.alexrintt.sharedstorage.plugin.ActivityListener +import io.alexrintt.sharedstorage.plugin.Listenable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.io.FileNotFoundException -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream +import java.io.* /** * Aimed to implement strictly only the APIs already available from the native and original @@ -58,12 +59,15 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : result.notSupported(call.method, API_21) } } + OPEN_DOCUMENT -> if (Build.VERSION.SDK_INT >= API_21) { openDocument(call, result) } + OPEN_DOCUMENT_TREE -> if (Build.VERSION.SDK_INT >= API_21) { openDocumentTree(call, result) } + CREATE_FILE -> if (Build.VERSION.SDK_INT >= API_21) { createFile( result, @@ -73,16 +77,20 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : call.argument("content")!! ) } + WRITE_TO_FILE -> writeToFile( result, call.argument("uri")!!, call.argument("content")!!, call.argument("mode")!! ) + PERSISTED_URI_PERMISSIONS -> persistedUriPermissions(result) + RELEASE_PERSISTABLE_URI_PERMISSION -> releasePersistableUriPermission( result, call.argument("uri") as String ) + FROM_TREE_URI -> if (Build.VERSION.SDK_INT >= API_21) { result.success( createDocumentFileMap( @@ -92,6 +100,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : ) ) } + CAN_WRITE -> if (Build.VERSION.SDK_INT >= API_21) { result.success( documentFromUri( @@ -99,11 +108,13 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : )?.canWrite() ) } + CAN_READ -> if (Build.VERSION.SDK_INT >= API_21) { val uri = call.argument("uri") as String result.success(documentFromUri(plugin.context, uri)?.canRead()) } + LENGTH -> if (Build.VERSION.SDK_INT >= API_21) { result.success( documentFromUri( @@ -111,6 +122,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : )?.length() ) } + EXISTS -> if (Build.VERSION.SDK_INT >= API_21) { result.success( documentFromUri( @@ -118,13 +130,36 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : )?.exists() ) } + DELETE -> if (Build.VERSION.SDK_INT >= API_21) { - result.success( - documentFromUri( - plugin.context, call.argument("uri") as String - )?.delete() - ) + try { + result.success( + documentFromUri( + plugin.context, call.argument("uri") as String + )?.delete() + ) + } catch (e: FileNotFoundException) { + // File is already deleted. + result.success(null) + } catch (e: IllegalStateException) { + // File is already deleted. + result.success(null) + } catch (e: IllegalArgumentException) { + // File is already deleted. + result.success(null) + } catch (e: IOException) { + // Unknown, can be anything. + result.success(null) + } catch (e: Throwable) { + Log.d( + "sharedstorage", + "Unknown error when calling [delete] method with [uri]." + ) + // Unknown, can be anything. + result.success(null) + } } + LAST_MODIFIED -> if (Build.VERSION.SDK_INT >= API_21) { val document = documentFromUri( plugin.context, call.argument("uri") as String @@ -132,6 +167,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : result.success(document?.lastModified()) } + CREATE_DIRECTORY -> { if (Build.VERSION.SDK_INT >= API_21) { val uri = call.argument("uri") as String @@ -146,6 +182,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : result.notSupported(call.method, API_21) } } + FIND_FILE -> { if (Build.VERSION.SDK_INT >= API_21) { val uri = call.argument("uri") as String @@ -160,20 +197,30 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : ) } } + COPY -> { val uri = Uri.parse(call.argument("uri")!!) val destination = Uri.parse(call.argument("destination")!!) if (Build.VERSION.SDK_INT >= API_21) { - if (Build.VERSION.SDK_INT >= API_24) { - DocumentsContract.copyDocument( - plugin.context.contentResolver, uri, destination - ) - } else { - val inputStream = openInputStream(uri) - val outputStream = openOutputStream(destination) + val isContentUri: Boolean = + uri.scheme == "content" && destination.scheme == "content" + + CoroutineScope(Dispatchers.IO).launch { + if (Build.VERSION.SDK_INT >= API_24 && isContentUri) { + DocumentsContract.copyDocument( + plugin.context.contentResolver, uri, destination + ) + } else { + val inputStream = openInputStream(uri) + val outputStream = openOutputStream(destination) - outputStream?.let { inputStream?.copyTo(it) } + outputStream?.let { inputStream?.copyTo(it) } + } + + launch(Dispatchers.Main) { + result.success(null) + } } } else { result.notSupported( @@ -183,6 +230,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : ) } } + RENAME_TO -> { val uri = call.argument("uri") as String val displayName = call.argument("displayName") as String @@ -206,6 +254,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : ) } } + PARENT_FILE -> { val uri = call.argument("uri")!! @@ -217,6 +266,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : result.notSupported(PARENT_FILE, API_21, mapOf("uri" to uri)) } } + CHILD -> { val uri = call.argument("uri")!! val path = call.argument("path")!! @@ -233,6 +283,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : result.notSupported(CHILD, API_21, mapOf("uri" to uri)) } } + else -> result.notImplemented() } } @@ -344,20 +395,25 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : content: ByteArray, block: DocumentFile?.() -> Unit ) { - val createdFile = documentFromUri(plugin.context, treeUri)!!.createFile( - mimeType, displayName - ) + CoroutineScope(Dispatchers.IO).launch { + val createdFile = documentFromUri(plugin.context, treeUri)!!.createFile( + mimeType, displayName + ) - createdFile?.uri?.apply { - plugin.context.contentResolver.openOutputStream(this)?.apply { - write(content) - flush() - close() + createdFile?.uri?.apply { + kotlin.runCatching { + plugin.context.contentResolver.openOutputStream(this)?.use { + it.write(content) + it.flush() - val createdFileDocument = - documentFromUri(plugin.context, createdFile.uri) + val createdFileDocument = + documentFromUri(plugin.context, createdFile.uri) - block(createdFileDocument) + launch(Dispatchers.Main) { + block(createdFileDocument) + } + } + } } } } @@ -379,23 +435,21 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : } } - @RequiresApi(API_19) private fun persistedUriPermissions(result: MethodChannel.Result) { val persistedUriPermissions = plugin.context.contentResolver.persistedUriPermissions result.success(persistedUriPermissions.map { - mapOf( - "isReadPermission" to it.isReadPermission, - "isWritePermission" to it.isWritePermission, - "persistedTime" to it.persistedTime, - "uri" to "${it.uri}", - "isTreeDocumentFile" to it.uri.isTreeDocumentFile - ) - }.toList()) + mapOf( + "isReadPermission" to it.isReadPermission, + "isWritePermission" to it.isWritePermission, + "persistedTime" to it.persistedTime, + "uri" to "${it.uri}", + "isTreeDocumentFile" to it.uri.isTreeDocumentFile + ) + }.toList()) } - @RequiresApi(API_19) private fun releasePersistableUriPermission( result: MethodChannel.Result, directoryUri: String ) { @@ -417,8 +471,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : } plugin.context.contentResolver.releasePersistableUriPermission( - targetUri, - flags + targetUri, flags ) } } @@ -426,7 +479,6 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : result.success(null) } - @RequiresApi(API_19) override fun onActivityResult( requestCode: Int, resultCode: Int, resultIntent: Intent? ): Boolean { @@ -467,6 +519,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : pendingResults.remove(OPEN_DOCUMENT_TREE_CODE) } } + OPEN_DOCUMENT_CODE -> { val pendingResult = pendingResults[OPEN_DOCUMENT_CODE] ?: return false @@ -579,7 +632,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : ) { if (eventSink == null) return - val columns = args["columns"] as List<*> + val userProvidedColumns = args["columns"] as List<*> val uri = Uri.parse(args["uri"] as String) val document = DocumentFile.fromTreeUri(plugin.context, uri) @@ -589,6 +642,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : "Android SDK must be greater or equal than [Build.VERSION_CODES.N]", "Got (Build.VERSION.SDK_INT): ${Build.VERSION.SDK_INT}" ) + eventSink.endOfStream() } else { if (!document.canRead()) { val error = "You cannot read a URI that you don't have read permissions" @@ -604,14 +658,15 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { CoroutineScope(Dispatchers.IO).launch { try { - traverseDirectoryEntries( - plugin.context.contentResolver, + traverseDirectoryEntries(plugin.context.contentResolver, rootOnly = true, targetUri = document.uri, - columns = columns.map { - parseDocumentFileColumn(parseDocumentFileColumn(it as String)!!) - }.toTypedArray() - ) { data, _ -> + columns = userProvidedColumns.map { + // Convert the user provided column string to documentscontract column ID. + documentFileColumnToActualDocumentsContractEnumString( + deserializeDocumentFileColumn(it as String)!! + ) + }.toTypedArray()) { data, _ -> launch(Dispatchers.Main) { eventSink.success( data @@ -622,6 +677,8 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : launch(Dispatchers.Main) { eventSink.endOfStream() } } } + } else { + eventSink.endOfStream() } } } @@ -649,13 +706,22 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : bytes } catch (e: FileNotFoundException) { + // Probably the file was already deleted and now you are trying to read. null } catch (e: IOException) { + // Unknown, can be anything. + null + } catch (e: IllegalArgumentException) { + // Probably the file was already deleted and now you are trying to read. + null + } catch (e: IllegalStateException) { + // Probably you ran [delete] and [readDocumentContent] at the same time. null } } override fun onCancel(arguments: Any?) { + eventSink?.endOfStream() eventSink = null } } diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt index bdc0000..e4f020c 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt @@ -1,23 +1,20 @@ package io.alexrintt.sharedstorage.storageaccessframework -import android.Manifest import android.content.ActivityNotFoundException import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.util.Log import androidx.annotation.RequiresApi -import androidx.core.content.ContextCompat import com.anggrayudi.storage.file.isTreeDocumentFile import io.alexrintt.sharedstorage.ROOT_CHANNEL import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.deprecated.lib.documentFromUri import io.alexrintt.sharedstorage.plugin.ActivityListener import io.alexrintt.sharedstorage.plugin.Listenable import io.alexrintt.sharedstorage.storageaccessframework.lib.* import io.flutter.plugin.common.* import io.flutter.plugin.common.EventChannel.StreamHandler -import java.security.AccessController.getContext /** diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt index 9b8848b..2f78716 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt @@ -1,73 +1,169 @@ package io.alexrintt.sharedstorage.storageaccessframework +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Point +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build import android.provider.DocumentsContract +import android.util.Log +import io.alexrintt.sharedstorage.ROOT_CHANNEL +import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.plugin.* +import io.alexrintt.sharedstorage.storageaccessframework.* +import io.alexrintt.sharedstorage.storageaccessframework.lib.* import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import io.alexrintt.sharedstorage.ROOT_CHANNEL -import io.alexrintt.sharedstorage.SharedStoragePlugin -import io.alexrintt.sharedstorage.plugin.API_21 -import io.alexrintt.sharedstorage.plugin.ActivityListener -import io.alexrintt.sharedstorage.plugin.Listenable -import io.alexrintt.sharedstorage.plugin.notSupported -import io.alexrintt.sharedstorage.storageaccessframework.lib.GET_DOCUMENT_THUMBNAIL -import io.alexrintt.sharedstorage.storageaccessframework.lib.bitmapToBase64 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.nio.ByteBuffer +import java.util.* + +const val APK_MIME_TYPE = "application/vnd.android.package-archive" internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : - MethodChannel.MethodCallHandler, Listenable, ActivityListener { + MethodChannel.MethodCallHandler, Listenable, ActivityListener { private var channel: MethodChannel? = null companion object { private const val CHANNEL = "documentscontract" } + private fun createTempUriFile(sourceUri: Uri, callback: (File) -> Unit) { + val destinationFilename: String = UUID.randomUUID().toString() + + val tempDestinationFile = + File(plugin.context.cacheDir.path, destinationFilename) + + plugin.context.contentResolver.openInputStream(sourceUri)?.use { + createFileFromStream(it, tempDestinationFile) + } + + callback(tempDestinationFile) + } + + private fun createFileFromStream(ins: InputStream, destination: File?) { + FileOutputStream(destination).use { fileOutputStream -> + val buffer = ByteArray(4096) + var length: Int + while (ins.read(buffer).also { length = it } > 0) { + fileOutputStream.write(buffer, 0, length) + } + fileOutputStream.flush() + } + } + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { GET_DOCUMENT_THUMBNAIL -> { - if (Build.VERSION.SDK_INT >= API_21) { - try { - val uri = Uri.parse(call.argument("uri")) - val width = call.argument("width")!! - val height = call.argument("height")!! - - val bitmap = DocumentsContract.getDocumentThumbnail( - plugin.context.contentResolver, - uri, - Point(width, height), - null - ) + val uri = Uri.parse(call.argument("uri")) + val mimeType: String? = plugin.context.contentResolver.getType(uri) - if (bitmap != null) { - CoroutineScope(Dispatchers.Default).launch { - val base64 = bitmapToBase64(bitmap) - - val data = - mapOf( - "base64" to base64, - "uri" to "$uri", - "width" to bitmap.width, - "height" to bitmap.height, - "byteCount" to bitmap.byteCount, - "density" to bitmap.density + if (mimeType == APK_MIME_TYPE) { + CoroutineScope(Dispatchers.IO).launch { + createTempUriFile(uri) { + val packageManager: PackageManager = plugin.context.packageManager + val packageInfo: PackageInfo? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageArchiveInfo( + it.path, + PackageManager.PackageInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageArchiveInfo( + it.path, + 0 ) + } - launch(Dispatchers.Main) { result.success(data) } + if (packageInfo == null) { + if (it.exists()) it.delete() + return@createTempUriFile result.success(null) } - } else { - result.success(null) + + // the secret is these two lines.... + packageInfo.applicationInfo.sourceDir = it.path + packageInfo.applicationInfo.publicSourceDir = it.path + + val apkIcon: Drawable = + packageInfo.applicationInfo.loadIcon(packageManager) + + val bitmap: Bitmap = drawableToBitmap(apkIcon) + + val bytes: ByteArray = bitmap.convertToByteArray() + + val data = + mapOf( + "bytes" to bytes, + "uri" to "$uri", + "width" to bitmap.width, + "height" to bitmap.height, + "byteCount" to bitmap.byteCount, + "density" to bitmap.density + ) + + if (it.exists()) it.delete() + + launch(Dispatchers.Main) { result.success(data) } } - } catch(e: IllegalArgumentException) { - // Tried to load thumbnail of a folder. - result.success(null) } } else { - result.notSupported(call.method, API_21) + if (Build.VERSION.SDK_INT >= API_21) { + getThumbnailForApi24(call, result) + } else { + result.notSupported(call.method, API_21) + } + } + } + } + } + + private fun getThumbnailForApi24( + call: MethodCall, + result: MethodChannel.Result + ) { + CoroutineScope(Dispatchers.IO).launch { + val uri = Uri.parse(call.argument("uri")) + val width = call.argument("width")!! + val height = call.argument("height")!! + + // run catching because [DocumentsContract.getDocumentThumbnail] + // can throw a [FileNotFoundException]. + kotlin.runCatching { + val bitmap = DocumentsContract.getDocumentThumbnail( + plugin.context.contentResolver, + uri, + Point(width, height), + null + ) + + if (bitmap != null) { + val byteArray: ByteArray = bitmap.convertToByteArray() + + val data = + mapOf( + "bytes" to byteArray, + "uri" to "$uri", + "width" to bitmap.width, + "height" to bitmap.height, + "byteCount" to bitmap.byteCount, + "density" to bitmap.density + ) + + launch(Dispatchers.Main) { result.success(data) } + } else { + Log.d("GET_DOCUMENT_THUMBNAIL", "bitmap is null") + launch(Dispatchers.Main) { result.success(null) } } } } @@ -95,3 +191,54 @@ internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : /** Implement if needed */ } } + +fun drawableToBitmap(drawable: Drawable): Bitmap { + if (drawable is BitmapDrawable) { + val bitmapDrawable: BitmapDrawable = drawable + if (bitmapDrawable.bitmap != null) { + return bitmapDrawable.bitmap + } + } + val bitmap: Bitmap = + if (drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) { + Bitmap.createBitmap( + 1, + 1, + Bitmap.Config.ARGB_8888 + ) // Single color bitmap will be created of 1x1 pixel + } else { + Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + } + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap +} + +/** + * Convert bitmap to byte array using ByteBuffer. + */ +fun Bitmap.convertToByteArray(): ByteArray { + //minimum number of bytes that can be used to store this bitmap's pixels + val size: Int = this.byteCount + + //allocate new instances which will hold bitmap + val buffer = ByteBuffer.allocate(size) + val bytes = ByteArray(size) + + // copy the bitmap's pixels into the specified buffer + this.copyPixelsToBuffer(buffer) + + // rewinds buffer (buffer position is set to zero and the mark is discarded) + buffer.rewind() + + // transfer bytes from buffer into the given destination array + buffer.get(bytes) + + // return bitmap's pixels + return bytes +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt index 00ddd6a..8a6419a 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt @@ -1,4 +1,4 @@ -package io.alexrintt.sharedstorage.storageaccessframework.lib +package io.alexrintt.sharedstorage.deprecated.lib import android.content.ContentResolver import android.content.Context @@ -9,9 +9,8 @@ import android.provider.DocumentsContract import android.util.Base64 import androidx.annotation.RequiresApi import androidx.documentfile.provider.DocumentFile -import io.alexrintt.sharedstorage.plugin.API_19 -import io.alexrintt.sharedstorage.plugin.API_21 -import io.alexrintt.sharedstorage.plugin.API_24 +import io.alexrintt.sharedstorage.plugin.* +import io.alexrintt.sharedstorage.storageaccessframework.* import java.io.ByteArrayOutputStream import java.io.Closeable @@ -168,13 +167,15 @@ fun traverseDirectoryEntries( } while (cursor.moveToNext()) { - val data = mutableMapOf() + val data = mutableMapOf() for (column in projection) { - data[column] = cursorHandlerOf(typeOfColumn(column)!!)( + val columnValue: Any? = cursorHandlerOf(typeOfColumn(column)!!)( cursor, cursor.getColumnIndexOrThrow(column) ) + + data[column] = columnValue } val mimeType = @@ -230,7 +231,6 @@ fun traverseDirectoryEntries( return true } -@RequiresApi(API_19) private fun isDirectory(mimeType: String): Boolean { return DocumentsContract.Document.MIME_TYPE_DIR == mimeType } diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt index a1b7523..c473bbc 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt @@ -1,7 +1,8 @@ -package io.alexrintt.sharedstorage.storageaccessframework.lib +package io.alexrintt.sharedstorage.deprecated.lib import android.database.Cursor import android.provider.DocumentsContract +import java.lang.NullPointerException private const val PREFIX = "DocumentFileColumn" @@ -20,7 +21,7 @@ enum class DocumentFileColumnType { INT } -fun parseDocumentFileColumn(column: String): DocumentFileColumn? { +fun deserializeDocumentFileColumn(column: String): DocumentFileColumn? { val values = mapOf( "$PREFIX.COLUMN_DOCUMENT_ID" to DocumentFileColumn.ID, "$PREFIX.COLUMN_DISPLAY_NAME" to DocumentFileColumn.DISPLAY_NAME, @@ -33,7 +34,7 @@ fun parseDocumentFileColumn(column: String): DocumentFileColumn? { return values[column] } -fun documentFileColumnToRawString(column: DocumentFileColumn): String? { +fun serializeDocumentFileColumn(column: DocumentFileColumn): String? { val values = mapOf( DocumentFileColumn.ID to "$PREFIX.COLUMN_DOCUMENT_ID", DocumentFileColumn.DISPLAY_NAME to "$PREFIX.COLUMN_DISPLAY_NAME", @@ -46,7 +47,7 @@ fun documentFileColumnToRawString(column: DocumentFileColumn): String? { return values[column] } -fun parseDocumentFileColumn(column: DocumentFileColumn): String { +fun documentFileColumnToActualDocumentsContractEnumString(column: DocumentFileColumn): String { val values = mapOf( DocumentFileColumn.ID to DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentFileColumn.DISPLAY_NAME to DocumentsContract.Document.COLUMN_DISPLAY_NAME, @@ -74,10 +75,34 @@ fun typeOfColumn(column: String): DocumentFileColumnType? { return values[column] } -fun cursorHandlerOf(type: DocumentFileColumnType): (Cursor, Int) -> Any { - when(type) { - DocumentFileColumnType.LONG -> { return { cursor, index -> cursor.getLong(index) } } - DocumentFileColumnType.STRING -> { return { cursor, index -> cursor.getString(index) } } - DocumentFileColumnType.INT -> { return { cursor, index -> cursor.getInt(index) } } +fun cursorHandlerOf(type: DocumentFileColumnType): (Cursor, Int) -> Any? { + when (type) { + DocumentFileColumnType.LONG -> { + return { cursor, index -> + try { + cursor.getLong(index) + } catch (e: NullPointerException) { + null + } + } + } + DocumentFileColumnType.STRING -> { + return { cursor, index -> + try { + cursor.getString(index) + } catch (e: NullPointerException) { + null + } + } + } + DocumentFileColumnType.INT -> { + return { cursor, index -> + try { + cursor.getInt(index) + } catch (e: NullPointerException) { + null + } + } + } } } diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index e9d9fce..180d76f 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 31 + compileSdkVersion 33 sourceSets { main.java.srcDirs += "src/main/kotlin" diff --git a/lib/src/channels.dart b/lib/src/channels.dart index 2c29515..10263b5 100644 --- a/lib/src/channels.dart +++ b/lib/src/channels.dart @@ -1,28 +1,31 @@ import 'package:flutter/services.dart'; -const kRootChannel = 'io.alexrintt.plugins/sharedstorage'; +const String kRootChannel = 'io.alexrintt.plugins/sharedstorage'; /// `MethodChannels` of this plugin. Flutter use this to communicate with native Android /// Target [Environment] Android API (Legacy and you should avoid it) -const kEnvironmentChannel = MethodChannel('$kRootChannel/environment'); +const MethodChannel kEnvironmentChannel = + MethodChannel('$kRootChannel/environment'); /// Target [MediaStore] Android API -const kMediaStoreChannel = MethodChannel('$kRootChannel/mediastore'); +const MethodChannel kMediaStoreChannel = + MethodChannel('$kRootChannel/mediastore'); /// Target [DocumentFile] from `SAF` Android API (New Android APIs use it) -const kDocumentFileChannel = MethodChannel('$kRootChannel/documentfile'); +const MethodChannel kDocumentFileChannel = + MethodChannel('$kRootChannel/documentfile'); /// Target [DocumentsContract] from `SAF` Android API (New Android APIs use it) -const kDocumentsContractChannel = +const MethodChannel kDocumentsContractChannel = MethodChannel('$kRootChannel/documentscontract'); /// Target [DocumentFileHelper] Shared Storage plugin class (SAF Based) -const kDocumentFileHelperChannel = +const MethodChannel kDocumentFileHelperChannel = MethodChannel('$kRootChannel/documentfilehelper'); /// `EventChannels` of this plugin. Flutter use this to communicate with native Android /// Target [DocumentFile] from `SAF` Android API (New Android APIs use it) -const kDocumentFileEventChannel = +const EventChannel kDocumentFileEventChannel = EventChannel('$kRootChannel/event/documentfile'); diff --git a/lib/src/common/functional_extender.dart b/lib/src/common/functional_extender.dart index 91b1f70..7e0bd65 100644 --- a/lib/src/common/functional_extender.dart +++ b/lib/src/common/functional_extender.dart @@ -29,6 +29,6 @@ extension FunctionalExtender on T? { } } -const willbemovedsoon = Deprecated( +const Deprecated willbemovedsoon = Deprecated( 'This method will be moved to another package in a next release.\nBe aware this method will not be removed but moved to another module outside of [saf].', ); diff --git a/lib/src/environment/common.dart b/lib/src/environment/common.dart index 6dca670..7e9f4f0 100644 --- a/lib/src/environment/common.dart +++ b/lib/src/environment/common.dart @@ -4,7 +4,8 @@ import '../channels.dart'; /// Util method to call a given `Environment.` method without arguments Future invokeVoidEnvironmentMethod(String method) async { - final directory = await kEnvironmentChannel.invokeMethod(method); + final String? directory = + await kEnvironmentChannel.invokeMethod(method); if (directory == null) return null; diff --git a/lib/src/environment/environment.dart b/lib/src/environment/environment.dart index e36df37..8152414 100644 --- a/lib/src/environment/environment.dart +++ b/lib/src/environment/environment.dart @@ -11,7 +11,7 @@ import 'environment_directory.dart'; 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) Future getRootDirectory() async { - const kGetRootDirectory = 'getRootDirectory'; + const String kGetRootDirectory = 'getRootDirectory'; return invokeVoidEnvironmentMethod(kGetRootDirectory); } @@ -33,13 +33,15 @@ Future getRootDirectory() async { Future getExternalStoragePublicDirectory( EnvironmentDirectory directory, ) async { - const kGetExternalStoragePublicDirectory = + const String kGetExternalStoragePublicDirectory = 'getExternalStoragePublicDirectory'; - const kDirectoryArg = 'directory'; + const String kDirectoryArg = 'directory'; - final args = {kDirectoryArg: '$directory'}; + final Map args = { + kDirectoryArg: '$directory' + }; - final publicDir = await kEnvironmentChannel.invokeMethod( + final String? publicDir = await kEnvironmentChannel.invokeMethod( kGetExternalStoragePublicDirectory, args, ); @@ -56,7 +58,7 @@ Future getExternalStoragePublicDirectory( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) Future getExternalStorageDirectory() async { - const kGetExternalStorageDirectory = 'getExternalStorageDirectory'; + const String kGetExternalStorageDirectory = 'getExternalStorageDirectory'; return invokeVoidEnvironmentMethod(kGetExternalStorageDirectory); } @@ -68,7 +70,7 @@ Future getExternalStorageDirectory() async { 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) Future getDataDirectory() async { - const kGetDataDirectory = 'getDataDirectory'; + const String kGetDataDirectory = 'getDataDirectory'; return invokeVoidEnvironmentMethod(kGetDataDirectory); } @@ -80,7 +82,7 @@ Future getDataDirectory() async { 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) Future getDownloadCacheDirectory() async { - const kGetDownloadCacheDirectory = 'getDownloadCacheDirectory'; + const String kGetDownloadCacheDirectory = 'getDownloadCacheDirectory'; return invokeVoidEnvironmentMethod(kGetDownloadCacheDirectory); } @@ -92,7 +94,7 @@ Future getDownloadCacheDirectory() async { 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) Future getStorageDirectory() { - const kGetStorageDirectory = 'getStorageDirectory'; + const String kGetStorageDirectory = 'getStorageDirectory'; return invokeVoidEnvironmentMethod(kGetStorageDirectory); } diff --git a/lib/src/environment/environment_directory.dart b/lib/src/environment/environment_directory.dart index 2e1657c..9f27d16 100644 --- a/lib/src/environment/environment_directory.dart +++ b/lib/src/environment/environment_directory.dart @@ -15,7 +15,7 @@ class EnvironmentDirectory { final String id; - static const _kPrefix = 'EnvironmentDirectory'; + static const String _kPrefix = 'EnvironmentDirectory'; /// Available for Android [4.1 to 9.0] /// @@ -23,7 +23,8 @@ class EnvironmentDirectory { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const alarms = EnvironmentDirectory._('$_kPrefix.Alarms'); + static const EnvironmentDirectory alarms = + EnvironmentDirectory._('$_kPrefix.Alarms'); /// Available for Android [4.1 to 9] /// @@ -32,7 +33,8 @@ class EnvironmentDirectory { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const dcim = EnvironmentDirectory._('$_kPrefix.DCIM'); + static const EnvironmentDirectory dcim = + EnvironmentDirectory._('$_kPrefix.DCIM'); /// Available for Android [4.1 to 9] /// @@ -41,7 +43,8 @@ class EnvironmentDirectory { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const downloads = EnvironmentDirectory._('$_kPrefix.Downloads'); + static const EnvironmentDirectory downloads = + EnvironmentDirectory._('$_kPrefix.Downloads'); /// Available for Android [4.1 to 9] /// @@ -49,7 +52,8 @@ class EnvironmentDirectory { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const movies = EnvironmentDirectory._('$_kPrefix.Movies'); + static const EnvironmentDirectory movies = + EnvironmentDirectory._('$_kPrefix.Movies'); /// Available for Android [4.1 to 9] /// @@ -57,7 +61,8 @@ class EnvironmentDirectory { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const music = EnvironmentDirectory._('$_kPrefix.Music'); + static const EnvironmentDirectory music = + EnvironmentDirectory._('$_kPrefix.Music'); /// Available for Android [4.1 to 9] /// @@ -65,7 +70,7 @@ class EnvironmentDirectory { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const notifications = + static const EnvironmentDirectory notifications = EnvironmentDirectory._('$_kPrefix.Notifications'); /// Available for Android [4.1 to 9] @@ -74,7 +79,8 @@ class EnvironmentDirectory { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const pictures = EnvironmentDirectory._('$_kPrefix.Pictures'); + static const EnvironmentDirectory pictures = + EnvironmentDirectory._('$_kPrefix.Pictures'); /// Available for Android [4.1 to 9] /// @@ -82,7 +88,8 @@ class EnvironmentDirectory { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const podcasts = EnvironmentDirectory._('$_kPrefix.Podcasts'); + static const EnvironmentDirectory podcasts = + EnvironmentDirectory._('$_kPrefix.Podcasts'); /// Available for Android [4.1 to 9] /// @@ -90,7 +97,8 @@ class EnvironmentDirectory { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const ringtones = EnvironmentDirectory._('$_kPrefix.Ringtones'); + static const EnvironmentDirectory ringtones = + EnvironmentDirectory._('$_kPrefix.Ringtones'); @override bool operator ==(Object other) { diff --git a/lib/src/media_store/media_store.dart b/lib/src/media_store/media_store.dart index 08a12df..4d5a107 100644 --- a/lib/src/media_store/media_store.dart +++ b/lib/src/media_store/media_store.dart @@ -12,12 +12,14 @@ import 'media_store_collection.dart'; Future getMediaStoreContentDirectory( MediaStoreCollection collection, ) async { - const kGetMediaStoreContentDirectory = 'getMediaStoreContentDirectory'; - const kCollectionArg = 'collection'; + const String kGetMediaStoreContentDirectory = 'getMediaStoreContentDirectory'; + const String kCollectionArg = 'collection'; - final args = {kCollectionArg: '$collection'}; + final Map args = { + kCollectionArg: '$collection' + }; - final publicDir = await kMediaStoreChannel.invokeMethod( + final String? publicDir = await kMediaStoreChannel.invokeMethod( kGetMediaStoreContentDirectory, args, ); diff --git a/lib/src/media_store/media_store_collection.dart b/lib/src/media_store/media_store_collection.dart index ed694cc..c960009 100644 --- a/lib/src/media_store/media_store_collection.dart +++ b/lib/src/media_store/media_store_collection.dart @@ -6,7 +6,7 @@ class MediaStoreCollection { final String id; - static const _kPrefix = 'MediaStoreCollection'; + static const String _kPrefix = 'MediaStoreCollection'; /// Available for Android [10 to 12] /// @@ -15,7 +15,8 @@ class MediaStoreCollection { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const audio = MediaStoreCollection._('$_kPrefix.Audio'); + static const MediaStoreCollection audio = + MediaStoreCollection._('$_kPrefix.Audio'); /// Available for Android [10 to 12] /// @@ -24,7 +25,8 @@ class MediaStoreCollection { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const downloads = MediaStoreCollection._('$_kPrefix.Downloads'); + static const MediaStoreCollection downloads = + MediaStoreCollection._('$_kPrefix.Downloads'); /// Available for Android [10 to 12] /// @@ -33,7 +35,8 @@ class MediaStoreCollection { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const images = MediaStoreCollection._('$_kPrefix.Images'); + static const MediaStoreCollection images = + MediaStoreCollection._('$_kPrefix.Images'); /// Available for Android [10 to 12] /// @@ -42,7 +45,8 @@ class MediaStoreCollection { @Deprecated( 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', ) - static const video = MediaStoreCollection._('$_kPrefix.Video'); + static const MediaStoreCollection video = + MediaStoreCollection._('$_kPrefix.Video'); @override bool operator ==(Object other) { diff --git a/lib/src/saf/api/content.dart b/lib/src/saf/api/content.dart index 15e9f10..338b2c3 100644 --- a/lib/src/saf/api/content.dart +++ b/lib/src/saf/api/content.dart @@ -12,7 +12,7 @@ Future getDocumentContentAsString( Uri uri, { bool throwIfError = false, }) async { - final bytes = await getDocumentContent(uri); + final Uint8List? bytes = await getDocumentContent(uri); if (bytes == null) return null; @@ -42,14 +42,14 @@ Future getDocumentThumbnail({ required double width, required double height, }) async { - final args = { + final Map args = { 'uri': '$uri', 'width': width, 'height': height, }; - final bitmap = await kDocumentsContractChannel + final Map? bitmap = await kDocumentsContractChannel .invokeMapMethod('getDocumentThumbnail', args); - return bitmap?.apply((b) => DocumentBitmap.fromMap(b)); + return bitmap?.apply((Map b) => DocumentBitmap.fromMap(b)); } diff --git a/lib/src/saf/api/copy.dart b/lib/src/saf/api/copy.dart index 830abc6..527cc18 100644 --- a/lib/src/saf/api/copy.dart +++ b/lib/src/saf/api/copy.dart @@ -7,7 +7,10 @@ import '../models/barrel.dart'; /// This API uses the `createFile` and `getDocumentContent` API's behind the scenes. /// {@endtemplate} Future copy(Uri uri, Uri destination) async { - final args = {'uri': '$uri', 'destination': '$destination'}; + final Map args = { + 'uri': '$uri', + 'destination': '$destination' + }; return invokeMapMethod('copy', args); } diff --git a/lib/src/saf/api/create.dart b/lib/src/saf/api/create.dart index 4ec1638..64d09a6 100644 --- a/lib/src/saf/api/create.dart +++ b/lib/src/saf/api/create.dart @@ -13,15 +13,16 @@ import '../models/barrel.dart'; /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createDirectory%28java.lang.String%29). /// {@endtemplate} Future createDirectory(Uri parentUri, String displayName) async { - final args = { + final Map args = { 'uri': '$parentUri', 'displayName': displayName, }; - final createdDocumentFile = await kDocumentFileChannel + final Map? createdDocumentFile = await kDocumentFileChannel .invokeMapMethod('createDirectory', args); - return createdDocumentFile?.apply((c) => DocumentFile.fromMap(c)); + return createdDocumentFile + ?.apply((Map c) => DocumentFile.fromMap(c)); } /// {@template sharedstorage.saf.createFile} @@ -70,9 +71,9 @@ Future createFileAsBytes( required String displayName, required Uint8List bytes, }) async { - final directoryUri = '$parentUri'; + final String directoryUri = '$parentUri'; - final args = { + final Map args = { 'mimeType': mimeType, 'content': bytes, 'displayName': displayName, diff --git a/lib/src/saf/api/grant.dart b/lib/src/saf/api/grant.dart index db7b559..6efb59c 100644 --- a/lib/src/saf/api/grant.dart +++ b/lib/src/saf/api/grant.dart @@ -17,18 +17,18 @@ Future openDocumentTree({ bool persistablePermission = true, Uri? initialUri, }) async { - const kOpenDocumentTree = 'openDocumentTree'; + const String kOpenDocumentTree = 'openDocumentTree'; - final args = { + final Map args = { 'grantWritePermission': grantWritePermission, 'persistablePermission': persistablePermission, if (initialUri != null) 'initialUri': '$initialUri', }; - final selectedDirectoryUri = + final String? selectedDirectoryUri = await kDocumentFileChannel.invokeMethod(kOpenDocumentTree, args); - return selectedDirectoryUri?.apply((e) => Uri.parse(e)); + return selectedDirectoryUri?.apply((String e) => Uri.parse(e)); } /// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT). @@ -39,9 +39,9 @@ Future?> openDocument({ String mimeType = '*/*', bool multiple = false, }) async { - const kOpenDocument = 'openDocument'; + const String kOpenDocument = 'openDocument'; - final args = { + final Map args = { if (initialUri != null) 'initialUri': '$initialUri', 'grantWritePermission': grantWritePermission, 'persistablePermission': persistablePermission, @@ -49,9 +49,11 @@ Future?> openDocument({ 'multiple': multiple, }; - final selectedUriList = + final List? selectedUriList = await kDocumentFileChannel.invokeListMethod(kOpenDocument, args); - return selectedUriList - ?.apply((e) => e.map((e) => Uri.parse(e as String)).toList()); + return selectedUriList?.apply( + (List list) => + list.map((dynamic e) => Uri.parse(e as String)).toList(), + ); } diff --git a/lib/src/saf/api/info.dart b/lib/src/saf/api/info.dart index 00535c9..1da646c 100644 --- a/lib/src/saf/api/info.dart +++ b/lib/src/saf/api/info.dart @@ -17,14 +17,14 @@ Future documentLength(Uri uri) async => kDocumentFileChannel /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#lastModified%28%29). /// {@endtemplate} Future lastModified(Uri uri) async { - const kLastModified = 'lastModified'; + const String kLastModified = 'lastModified'; - final inMillisecondsSinceEpoch = await kDocumentFileChannel + final int? inMillisecondsSinceEpoch = await kDocumentFileChannel .invokeMethod(kLastModified, {'uri': '$uri'}); return inMillisecondsSinceEpoch - ?.takeIf((i) => i > 0) - ?.apply((i) => DateTime.fromMillisecondsSinceEpoch(i)); + ?.takeIf((int i) => i > 0) + ?.apply((int i) => DateTime.fromMillisecondsSinceEpoch(i)); } /// {@template sharedstorage.saf.canRead} diff --git a/lib/src/saf/api/persisted.dart b/lib/src/saf/api/persisted.dart index e3a62d1..8fee0c0 100644 --- a/lib/src/saf/api/persisted.dart +++ b/lib/src/saf/api/persisted.dart @@ -10,11 +10,19 @@ import '../models/barrel.dart'; /// To remove an persisted [Uri] call `releasePersistableUriPermission`. /// {@endtemplate} Future?> persistedUriPermissions() async { - final persistedUriPermissions = + final List? persistedUriPermissions = await kDocumentFileChannel.invokeListMethod('persistedUriPermissions'); return persistedUriPermissions?.apply( - (p) => p.map((e) => UriPermission.fromMap(Map.from(e as Map))).toList(), + (List p) => p + .map( + (dynamic e) => UriPermission.fromMap( + Map.from( + e as Map, + ), + ), + ) + .toList(), ); } @@ -26,9 +34,11 @@ Future?> persistedUriPermissions() async { /// of allowed [Uri]s then will verify if the [uri] is included in. /// {@endtemplate} Future isPersistedUri(Uri uri) async { - final persistedUris = await persistedUriPermissions(); + final List? persistedUris = await persistedUriPermissions(); - return persistedUris?.any((persistedUri) => persistedUri.uri == uri) ?? false; + return persistedUris + ?.any((UriPermission persistedUri) => persistedUri.uri == uri) ?? + false; } /// {@template sharedstorage.saf.releasePersistableUriPermission} diff --git a/lib/src/saf/api/search.dart b/lib/src/saf/api/search.dart index 397408f..09f963e 100644 --- a/lib/src/saf/api/search.dart +++ b/lib/src/saf/api/search.dart @@ -9,7 +9,7 @@ import '../common/barrel.dart'; /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#findFile%28java.lang.String%29). /// {@endtemplate} Future findFile(Uri directoryUri, String displayName) async { - final args = { + final Map args = { 'uri': '$directoryUri', 'displayName': displayName, }; diff --git a/lib/src/saf/api/tree.dart b/lib/src/saf/api/tree.dart index 4624f78..cce58ef 100644 --- a/lib/src/saf/api/tree.dart +++ b/lib/src/saf/api/tree.dart @@ -32,16 +32,22 @@ Stream listFiles( Uri uri, { required List columns, }) { - final args = { + final Map args = { 'uri': '$uri', 'event': 'listFiles', - 'columns': columns.map((e) => '$e').toList(), + 'columns': columns.map((DocumentFileColumn e) => '$e').toList(), }; - final onCursorRowResult = + final Stream onCursorRowResult = kDocumentFileEventChannel.receiveBroadcastStream(args); - return onCursorRowResult.map((e) => DocumentFile.fromMap(Map.from(e as Map))); + return onCursorRowResult.map( + (dynamic e) => DocumentFile.fromMap( + Map.from( + e as Map, + ), + ), + ); } /// {@template sharedstorage.saf.child} @@ -60,7 +66,7 @@ Future child( String path, { bool requiresWriteAccess = false, }) async { - final args = { + final Map args = { 'uri': '$uri', 'path': path, 'requiresWriteAccess': requiresWriteAccess, diff --git a/lib/src/saf/api/write.dart b/lib/src/saf/api/write.dart index 1c801ec..c4b3e00 100644 --- a/lib/src/saf/api/write.dart +++ b/lib/src/saf/api/write.dart @@ -16,10 +16,10 @@ Future writeToFileAsBytes( required Uint8List bytes, FileMode? mode, }) async { - final writeMode = + final String writeMode = mode == FileMode.append || mode == FileMode.writeOnlyAppend ? 'wa' : 'wt'; - final args = { + final Map args = { 'uri': '$uri', 'content': bytes, 'mode': writeMode, diff --git a/lib/src/saf/common/method_channel_helper.dart b/lib/src/saf/common/method_channel_helper.dart index 7fd2511..f0f68f8 100644 --- a/lib/src/saf/common/method_channel_helper.dart +++ b/lib/src/saf/common/method_channel_helper.dart @@ -7,7 +7,7 @@ Future invokeMapMethod( String method, Map args, ) async { - final documentMap = + final Map? documentMap = await kDocumentFileChannel.invokeMapMethod(method, args); if (documentMap == null) return null; diff --git a/lib/src/saf/models/document_bitmap.dart b/lib/src/saf/models/document_bitmap.dart index 40aec2c..ed5decf 100644 --- a/lib/src/saf/models/document_bitmap.dart +++ b/lib/src/saf/models/document_bitmap.dart @@ -23,7 +23,7 @@ class DocumentBitmap { factory DocumentBitmap.fromMap(Map map) { return DocumentBitmap( uri: (() { - final uri = map['uri'] as String?; + final String? uri = map['uri'] as String?; if (uri == null) return null; @@ -58,7 +58,7 @@ class DocumentBitmap { Uint8List? get bytes { if (base64 == null) return null; - const codec = Base64Codec(); + const Base64Codec codec = Base64Codec(); return codec.decode(base64!); } diff --git a/lib/src/saf/models/document_file.dart b/lib/src/saf/models/document_file.dart index 3304c26..8066062 100644 --- a/lib/src/saf/models/document_file.dart +++ b/lib/src/saf/models/document_file.dart @@ -31,7 +31,8 @@ class DocumentFile { factory DocumentFile.fromMap(Map map) { return DocumentFile( - parentUri: (map['parentUri'] as String?)?.apply((p) => Uri.parse(p)), + parentUri: + (map['parentUri'] as String?)?.apply((String p) => Uri.parse(p)), id: map['id'] as String?, isDirectory: map['isDirectory'] as bool?, isFile: map['isFile'] as bool?, @@ -41,7 +42,7 @@ class DocumentFile { uri: Uri.parse(map['uri'] as String), size: map['size'] as int?, lastModified: (map['lastModified'] as int?) - ?.apply((l) => DateTime.fromMillisecondsSinceEpoch(l)), + ?.apply((int l) => DateTime.fromMillisecondsSinceEpoch(l)), ); } @@ -226,7 +227,7 @@ class DocumentFile { Future parentFile() => saf.parentFile(uri); Map toMap() { - return { + return { 'id': id, 'uri': '$uri', 'parentUri': '$parentUri', diff --git a/lib/src/saf/models/document_file_column.dart b/lib/src/saf/models/document_file_column.dart index e939c86..2acbe20 100644 --- a/lib/src/saf/models/document_file_column.dart +++ b/lib/src/saf/models/document_file_column.dart @@ -22,7 +22,7 @@ enum DocumentFileColumn { const DocumentFileColumn(this.androidEnumItemName); - static const _kAndroidEnumTypeName = 'DocumentFileColumn'; + static const String _kAndroidEnumTypeName = 'DocumentFileColumn'; final String androidEnumItemName; diff --git a/lib/src/saf/models/uri_permission.dart b/lib/src/saf/models/uri_permission.dart index 5ec93d9..9cfd1e2 100644 --- a/lib/src/saf/models/uri_permission.dart +++ b/lib/src/saf/models/uri_permission.dart @@ -61,7 +61,7 @@ class UriPermission { @override int get hashCode => Object.hashAll( - [ + [ isReadPermission, isWritePermission, persistedTime, From e7ce56a23cee470640eadfa819945172e5c534fb Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Sun, 11 Jun 2023 10:33:16 -0300 Subject: [PATCH 03/18] Bump v9 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index d332239..075afe0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: shared_storage description: "Flutter plugin to work with external storage and privacy-friendly APIs." -version: 0.8.0 +version: 0.9.0 homepage: https://github.com/alexrintt/shared-storage repository: https://github.com/alexrintt/shared-storage issue_tracker: https://github.com/alexrintt/shared-storage/issues From aa58f9f266cf3684a97fc627bf949572846da5c3 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Sun, 11 Jun 2023 13:48:56 -0300 Subject: [PATCH 04/18] Fix typing exception due [always_specify_types] lint rule --- lib/src/saf/api/persisted.dart | 8 +++----- lib/src/saf/api/tree.dart | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/src/saf/api/persisted.dart b/lib/src/saf/api/persisted.dart index 8fee0c0..f055203 100644 --- a/lib/src/saf/api/persisted.dart +++ b/lib/src/saf/api/persisted.dart @@ -10,16 +10,14 @@ import '../models/barrel.dart'; /// To remove an persisted [Uri] call `releasePersistableUriPermission`. /// {@endtemplate} Future?> persistedUriPermissions() async { - final List? persistedUriPermissions = - await kDocumentFileChannel.invokeListMethod('persistedUriPermissions'); + final List? persistedUriPermissions = await kDocumentFileChannel + .invokeListMethod('persistedUriPermissions'); return persistedUriPermissions?.apply( (List p) => p .map( (dynamic e) => UriPermission.fromMap( - Map.from( - e as Map, - ), + Map.from(e as Map), ), ) .toList(), diff --git a/lib/src/saf/api/tree.dart b/lib/src/saf/api/tree.dart index cce58ef..04edbfa 100644 --- a/lib/src/saf/api/tree.dart +++ b/lib/src/saf/api/tree.dart @@ -44,7 +44,7 @@ Stream listFiles( return onCursorRowResult.map( (dynamic e) => DocumentFile.fromMap( Map.from( - e as Map, + e as Map, ), ), ); From 6e4894a09e93edb8d8a60bf8e1ea9b51e2208c3c Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Sun, 11 Jun 2023 13:50:19 -0300 Subject: [PATCH 05/18] [getDocumentThumbnail] Add support for apk icon thumbnail --- .../DocumentsContractApi.kt | 184 ++++++++++-------- .../file_explorer/file_explorer_card.dart | 14 +- lib/src/saf/models/document_bitmap.dart | 23 +-- 3 files changed, 120 insertions(+), 101 deletions(-) diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt index 2f78716..38eaad4 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt @@ -22,12 +22,15 @@ import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream import java.io.File +import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.InputStream -import java.nio.ByteBuffer +import java.io.Serializable import java.util.* + const val APK_MIME_TYPE = "application/vnd.android.package-archive" internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : @@ -38,17 +41,20 @@ internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : private const val CHANNEL = "documentscontract" } - private fun createTempUriFile(sourceUri: Uri, callback: (File) -> Unit) { - val destinationFilename: String = UUID.randomUUID().toString() + private fun createTempUriFile(sourceUri: Uri, callback: (File?) -> Unit) { + try { + val destinationFilename: String = UUID.randomUUID().toString() - val tempDestinationFile = - File(plugin.context.cacheDir.path, destinationFilename) + val tempDestinationFile = + File(plugin.context.cacheDir.path, destinationFilename) - plugin.context.contentResolver.openInputStream(sourceUri)?.use { - createFileFromStream(it, tempDestinationFile) + plugin.context.contentResolver.openInputStream(sourceUri)?.use { + createFileFromStream(it, tempDestinationFile) + } + callback(tempDestinationFile) + } catch (_: FileNotFoundException) { + callback(null) } - - callback(tempDestinationFile) } private fun createFileFromStream(ins: InputStream, destination: File?) { @@ -69,54 +75,7 @@ internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : val mimeType: String? = plugin.context.contentResolver.getType(uri) if (mimeType == APK_MIME_TYPE) { - CoroutineScope(Dispatchers.IO).launch { - createTempUriFile(uri) { - val packageManager: PackageManager = plugin.context.packageManager - val packageInfo: PackageInfo? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getPackageArchiveInfo( - it.path, - PackageManager.PackageInfoFlags.of(0) - ) - } else { - @Suppress("DEPRECATION") - packageManager.getPackageArchiveInfo( - it.path, - 0 - ) - } - - if (packageInfo == null) { - if (it.exists()) it.delete() - return@createTempUriFile result.success(null) - } - - // the secret is these two lines.... - packageInfo.applicationInfo.sourceDir = it.path - packageInfo.applicationInfo.publicSourceDir = it.path - - val apkIcon: Drawable = - packageInfo.applicationInfo.loadIcon(packageManager) - - val bitmap: Bitmap = drawableToBitmap(apkIcon) - - val bytes: ByteArray = bitmap.convertToByteArray() - - val data = - mapOf( - "bytes" to bytes, - "uri" to "$uri", - "width" to bitmap.width, - "height" to bitmap.height, - "byteCount" to bitmap.byteCount, - "density" to bitmap.density - ) - - if (it.exists()) it.delete() - - launch(Dispatchers.Main) { result.success(data) } - } - } + getThumbnailForApkFile(call, result, uri) } else { if (Build.VERSION.SDK_INT >= API_21) { getThumbnailForApi24(call, result) @@ -128,6 +87,65 @@ internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : } } + private fun getThumbnailForApkFile( + call: MethodCall, + result: MethodChannel.Result, + uri: Uri + ) { + CoroutineScope(Dispatchers.IO).launch { + createTempUriFile(uri) { + if (it == null) { + launch(Dispatchers.Main) { result.success(null) } + return@createTempUriFile + } + + kotlin.runCatching { + val packageManager: PackageManager = + plugin.context.packageManager + val packageInfo: PackageInfo? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageArchiveInfo( + it.path, + PackageManager.PackageInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageArchiveInfo( + it.path, + 0 + ) + } + + if (packageInfo == null) { + if (it.exists()) it.delete() + return@createTempUriFile result.success(null) + } + + // Parse the apk and to get the icon later on + packageInfo.applicationInfo.sourceDir = it.path + packageInfo.applicationInfo.publicSourceDir = it.path + + val apkIcon: Drawable = + packageInfo.applicationInfo.loadIcon(packageManager) + + val bitmap: Bitmap = drawableToBitmap(apkIcon) + + val data = bitmap.generateSerializableBitmapData(uri) + + if (it.exists()) it.delete() + + launch(Dispatchers.Main) { result.success(data) } + } + + try { + } catch (e: FileNotFoundException) { + // The target file apk is invalid + launch(Dispatchers.Main) { result.success(null) } + } + } + } + } + private fun getThumbnailForApi24( call: MethodCall, result: MethodChannel.Result @@ -148,17 +166,7 @@ internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : ) if (bitmap != null) { - val byteArray: ByteArray = bitmap.convertToByteArray() - - val data = - mapOf( - "bytes" to byteArray, - "uri" to "$uri", - "width" to bitmap.width, - "height" to bitmap.height, - "byteCount" to bitmap.byteCount, - "density" to bitmap.density - ) + val data = bitmap.generateSerializableBitmapData(uri) launch(Dispatchers.Main) { result.success(data) } } else { @@ -221,24 +229,38 @@ fun drawableToBitmap(drawable: Drawable): Bitmap { /** * Convert bitmap to byte array using ByteBuffer. + * + * This method calls [Bitmap.recycle] so this function will make the bitmap unusable after that. */ fun Bitmap.convertToByteArray(): ByteArray { - //minimum number of bytes that can be used to store this bitmap's pixels - val size: Int = this.byteCount + val stream = ByteArrayOutputStream() - //allocate new instances which will hold bitmap - val buffer = ByteBuffer.allocate(size) - val bytes = ByteArray(size) + // Very important, see https://stackoverflow.com/questions/51516310/sending-bitmap-to-flutter-from-android-platform + // Without compressing the raw bitmap, Flutter Image widget cannot decode it correctly and will throw a error. + this.compress(Bitmap.CompressFormat.PNG, 100, stream) - // copy the bitmap's pixels into the specified buffer - this.copyPixelsToBuffer(buffer) + val byteArray = stream.toByteArray() - // rewinds buffer (buffer position is set to zero and the mark is discarded) - buffer.rewind() + this.recycle() - // transfer bytes from buffer into the given destination array - buffer.get(bytes) + return byteArray +} - // return bitmap's pixels - return bytes +fun Bitmap.generateSerializableBitmapData( + uri: Uri, + additional: Map = mapOf() +): Map { + val metadata = mapOf( + "uri" to "$uri", + "width" to this.width, + "height" to this.height, + "byteCount" to this.byteCount, + "density" to this.density + ) + + val bytes: ByteArray = this.convertToByteArray() + + return metadata + additional + mapOf( + "bytes" to bytes + ) } diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index baf2f03..b403e1f 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -36,7 +36,7 @@ class FileExplorerCard extends StatefulWidget { class _FileExplorerCardState extends State { DocumentFile get _file => widget.documentFile; - static const _expandedThumbnailSize = Size.square(150); + static const _kExpandedThumbnailSize = Size.square(150); Uint8List? _thumbnailImageBytes; Size? _thumbnailSize; @@ -51,8 +51,8 @@ class _FileExplorerCardState extends State { final bitmap = await getDocumentThumbnail( uri: uri, - width: _expandedThumbnailSize.width, - height: _expandedThumbnailSize.height, + width: _kExpandedThumbnailSize.width, + height: _kExpandedThumbnailSize.height, ); if (bitmap == null) { @@ -177,7 +177,7 @@ class _FileExplorerCardState extends State { return random.nextInt(1000); } - Widget _buildThumbnail({double? size}) { + Widget _buildThumbnailImage({double? size}) { late Widget thumbnail; if (_thumbnailImageBytes == null) { @@ -207,6 +207,12 @@ class _FileExplorerCardState extends State { } } + return thumbnail; + } + + Widget _buildThumbnail({double? size}) { + final Widget thumbnail = _buildThumbnailImage(size: size); + List children; if (_expanded) { diff --git a/lib/src/saf/models/document_bitmap.dart b/lib/src/saf/models/document_bitmap.dart index ed5decf..b798551 100644 --- a/lib/src/saf/models/document_bitmap.dart +++ b/lib/src/saf/models/document_bitmap.dart @@ -1,23 +1,22 @@ -import 'dart:convert'; import 'dart:typed_data'; /// Represent the bitmap/image of a document. /// /// Usually the thumbnail of the document. /// -/// The bitmap is represented as a base64 string. +/// The bitmap is represented as a byte array [Uint8List]. /// /// Should be used to show a list/grid preview of a file list. /// /// See also [getDocumentThumbnail]. class DocumentBitmap { const DocumentBitmap({ - required this.base64, required this.uri, required this.width, required this.height, required this.byteCount, required this.density, + required this.bytes, }); factory DocumentBitmap.fromMap(Map map) { @@ -31,38 +30,30 @@ class DocumentBitmap { })(), width: map['width'] as int?, height: map['height'] as int?, - base64: map['base64'] as String?, + bytes: map['bytes'] as Uint8List?, byteCount: map['byteCount'] as int?, density: map['density'] as int?, ); } - final String? base64; final Uri? uri; final int? width; final int? height; final int? byteCount; final int? density; + final Uint8List? bytes; Map toMap() { return { 'uri': '$uri', 'width': width, 'height': height, - 'base64': base64, + 'bytes': bytes, 'byteCount': byteCount, 'density': density, }; } - Uint8List? get bytes { - if (base64 == null) return null; - - const Base64Codec codec = Base64Codec(); - - return codec.decode(base64!); - } - @override bool operator ==(Object other) { if (other is! DocumentBitmap) return false; @@ -72,10 +63,10 @@ class DocumentBitmap { other.height == height && other.uri == uri && other.density == density && - other.base64 == base64; + other.bytes == bytes; } @override int get hashCode => - Object.hash(width, height, uri, density, byteCount, base64); + Object.hash(width, height, uri, density, byteCount, bytes); } From fed35843bbbead7adc912057718493a6df582cb2 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Mon, 12 Jun 2023 01:21:17 -0300 Subject: [PATCH 06/18] Add [shareUri] related APIs --- CHANGELOG.md | 8 ++ .../DocumentFileHelperApi.kt | 57 ++++++++++++- .../lib/StorageAccessFrameworkConstant.kt | 1 + .../file_explorer/file_explorer_card.dart | 8 ++ lib/src/common/functional_extender.dart | 4 - lib/src/saf/api/barrel.dart | 1 + lib/src/saf/api/share.dart | 81 +++++++++++++++++++ lib/src/saf/api/tree.dart | 1 - lib/src/saf/models/document_file.dart | 4 +- 9 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 lib/src/saf/api/share.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6efc703..512f012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.9.0 + +### New + +- Calling [openDocumentFile] on apk files triggers the installation. +- [getDocumentThumbnail] it's now supports decoding apk file icons. +- [shareUri] is a new API to trigger share intent using Uris from SAF (files through File class are also supported). + ## 0.8.0 New SAF API and Gradle version upgrade. diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt index e4f020c..c04861e 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt @@ -6,7 +6,9 @@ import android.net.Uri import android.os.Build import android.util.Log import androidx.annotation.RequiresApi +import androidx.core.app.ShareCompat import com.anggrayudi.storage.file.isTreeDocumentFile +import com.anggrayudi.storage.file.mimeType import io.alexrintt.sharedstorage.ROOT_CHANNEL import io.alexrintt.sharedstorage.SharedStoragePlugin import io.alexrintt.sharedstorage.deprecated.lib.documentFromUri @@ -15,6 +17,7 @@ import io.alexrintt.sharedstorage.plugin.Listenable import io.alexrintt.sharedstorage.storageaccessframework.lib.* import io.flutter.plugin.common.* import io.flutter.plugin.common.EventChannel.StreamHandler +import java.net.URLConnection /** @@ -46,13 +49,63 @@ internal class DocumentFileHelperApi(private val plugin: SharedStoragePlugin) : override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { OPEN_DOCUMENT_FILE -> openDocumentFile(call, result) + SHARE_URI -> shareUri(call, result) else -> result.notImplemented() } } - private fun openDocumentAsSimpleFile(uri: Uri, type: String?) { - val isApk: Boolean = type == "application/vnd.android.package-archive" + private fun shareUri(call: MethodCall, result: MethodChannel.Result) { + val uri = Uri.parse(call.argument("uri")!!) + val type = + call.argument("type") + ?: try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + documentFromUri(plugin.context, uri)?.mimeType + } else { + null + } + } catch (e: Throwable) { + null + } + ?: plugin.binding!!.activity.contentResolver.getType(uri) + ?: URLConnection.guessContentTypeFromName(uri.lastPathSegment) + ?: "application/octet-stream" + + try { + Log.d("sharedstorage", "Trying to share uri $uri with type $type") + ShareCompat + .IntentBuilder(plugin.binding!!.activity) + .setChooserTitle("Share") + .setType(type) + .setStream(uri) + .startChooser() + + Log.d("sharedstorage", "Successfully shared uri $uri of type $type.") + + result.success(null) + } catch (e: ActivityNotFoundException) { + result.error( + EXCEPTION_ACTIVITY_NOT_FOUND, + "There's no activity handler that can process the uri $uri of type $type, error: $e.", + mapOf("uri" to "$uri", "type" to type) + ) + } catch (e: SecurityException) { + result.error( + EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, + "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity, error: $e.", + mapOf("uri" to "$uri", "type" to type) + ) + } catch (e: Throwable) { + result.error( + EXCEPTION_CANT_OPEN_DOCUMENT_FILE, + "Couldn't start activity to open document file for uri: $uri, error: $e.", + mapOf("uri" to "$uri") + ) + } + } + + private fun openDocumentAsSimpleFile(uri: Uri, type: String?) { Log.d("sharedstorage", "Trying to open uri $uri with type $type") val intent = diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt index ad61abf..bc52e8f 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt @@ -44,6 +44,7 @@ const val CHILD = "child" * Available DocumentFileHelper Method Channel APIs */ const val OPEN_DOCUMENT_FILE = "openDocumentFile" +const val SHARE_URI = "shareUri" /** * Available Event Channels APIs diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index b403e1f..7edbcd4 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -295,6 +295,10 @@ class _FileExplorerCardState extends State { ); } + Future _shareDocument() async { + await widget.documentFile.share(); + } + Widget _buildAvailableActions() { return Wrap( children: [ @@ -315,6 +319,10 @@ class _FileExplorerCardState extends State { : _fileConfirmation('Delete', _deleteDocument), ), if (!_isDirectory) ...[ + ActionButton( + 'Share Document', + onTap: _shareDocument, + ), DangerButton( 'Write to File', onTap: _fileConfirmation('Overwite', _overwriteFileContents), diff --git a/lib/src/common/functional_extender.dart b/lib/src/common/functional_extender.dart index 7e0bd65..734ef78 100644 --- a/lib/src/common/functional_extender.dart +++ b/lib/src/common/functional_extender.dart @@ -28,7 +28,3 @@ extension FunctionalExtender on T? { return self != null && f(self) ? self : null; } } - -const Deprecated willbemovedsoon = Deprecated( - 'This method will be moved to another package in a next release.\nBe aware this method will not be removed but moved to another module outside of [saf].', -); diff --git a/lib/src/saf/api/barrel.dart b/lib/src/saf/api/barrel.dart index 1bf0c16..275d5f7 100644 --- a/lib/src/saf/api/barrel.dart +++ b/lib/src/saf/api/barrel.dart @@ -8,6 +8,7 @@ export './open.dart'; export './persisted.dart'; export './rename.dart'; export './search.dart'; +export './share.dart'; export './tree.dart'; export './utility.dart'; export './write.dart'; diff --git a/lib/src/saf/api/share.dart b/lib/src/saf/api/share.dart new file mode 100644 index 0000000..dd04c4f --- /dev/null +++ b/lib/src/saf/api/share.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import '../../channels.dart'; + +/// {@template sharedstorage.saf.share} +/// Start share intent for the given [uri]. +/// +/// To share a file, use [Uri.parse] passing the file absolute path as argument. +/// +/// Note that this method can only share files that your app has permission over, +/// either by being in your app domain (e.g file from your app cache) or that is granted by [openDocumentTree]. +/// +/// Usage: +/// +/// ```dart +/// try { +/// await shareUriOrFile( +/// uri: uri, +/// filePath: path, +/// file: file, +/// ); +/// } on PlatformException catch (e) { +/// // The user clicked twice too fast, which created 2 share requests and the second one failed. +/// // Unhandled Exception: PlatformException(Share callback error, prior share-sheet did not call back, did you await it? Maybe use non-result variant, null, null). +/// log('Error when calling [shareFile]: $e'); +/// return; +/// } +/// ``` +/// {@endtemplate} +Future shareUri( + Uri uri, { + String? type, +}) { + final Map args = { + 'uri': '$uri', + 'type': type, + }; + + return kDocumentFileHelperChannel.invokeMethod('shareUri', args); +} + +/// Alias for [shareUri]. +Future shareFile({File? file, String? path}) { + return shareUriOrFile(filePath: path, file: file); +} + +/// Alias for [shareUri]. +Future shareUriOrFile({String? filePath, File? file, Uri? uri}) { + return shareUri( + _getShareableUriFrom(file: file, filePath: filePath, uri: uri), + ); +} + +/// Helper function to get the shareable URI from [file], [filePath] or the [uri] itself. +/// +/// Usage: +/// +/// ```dart +/// shareUri(getShareableUri(...)); +/// ``` +Uri _getShareableUriFrom({String? filePath, File? file, Uri? uri}) { + if (filePath == null && file == null && uri == null) { + throw ArgumentError.value( + null, + 'getShareableUriFrom', + 'Tried to call [getShareableUriFrom] or with all arguments ({String? filePath, File? file, Uri? uri}) set to [null].', + ); + } + + late Uri target; + + if (uri != null) { + target = uri; + } else if (filePath != null) { + target = Uri.parse(filePath); + } else if (file != null) { + target = Uri.parse(file.absolute.path); + } + + return target; +} diff --git a/lib/src/saf/api/tree.dart b/lib/src/saf/api/tree.dart index 04edbfa..296e6b9 100644 --- a/lib/src/saf/api/tree.dart +++ b/lib/src/saf/api/tree.dart @@ -60,7 +60,6 @@ Stream listFiles( /// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29) /// {@endtemplate} -@willbemovedsoon Future child( Uri uri, String path, { diff --git a/lib/src/saf/models/document_file.dart b/lib/src/saf/models/document_file.dart index 8066062..4e3ba7e 100644 --- a/lib/src/saf/models/document_file.dart +++ b/lib/src/saf/models/document_file.dart @@ -96,7 +96,6 @@ class DocumentFile { static Future fromTreeUri(Uri uri) => saf.fromTreeUri(uri); /// {@macro sharedstorage.saf.child} - @willbemovedsoon Future child( String path, { bool requiresWriteAccess = false, @@ -114,6 +113,9 @@ class DocumentFile { /// {@macro sharedstorage.saf.canRead} Future canRead() async => saf.canRead(uri); + /// {@macro sharedstorage.saf.share} + Future share() async => saf.shareUri(uri); + /// {@macro sharedstorage.saf.canWrite} Future canWrite() async => saf.canWrite(uri); From 4e9bedde5a3fdfc9050fef6d7a81cb6b0b694d87 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Mon, 12 Jun 2023 01:23:27 -0300 Subject: [PATCH 07/18] Remove unused import --- lib/src/saf/api/tree.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/saf/api/tree.dart b/lib/src/saf/api/tree.dart index 296e6b9..c7c12e8 100644 --- a/lib/src/saf/api/tree.dart +++ b/lib/src/saf/api/tree.dart @@ -1,5 +1,4 @@ import '../../channels.dart'; -import '../../common/functional_extender.dart'; import '../common/barrel.dart'; import '../models/barrel.dart'; From c6151e7996dd28cd47473005eaf7a0bea756495e Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Mon, 12 Jun 2023 07:50:28 -0300 Subject: [PATCH 08/18] Close input and output stream when copying a file without SAF API --- .../storageaccessframework/DocumentFileApi.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt index 159ad16..e82fe45 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt @@ -22,6 +22,7 @@ import io.alexrintt.sharedstorage.plugin.Listenable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.* /** @@ -212,10 +213,15 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : plugin.context.contentResolver, uri, destination ) } else { - val inputStream = openInputStream(uri) - val outputStream = openOutputStream(destination) + withContext(Dispatchers.IO) { + val inputStream = openInputStream(uri) + val outputStream = openOutputStream(destination) - outputStream?.let { inputStream?.copyTo(it) } + outputStream?.let { inputStream?.copyTo(it) } + + inputStream?.close() + outputStream?.close() + } } launch(Dispatchers.Main) { From d18bd42ce874cda7225d47901a3c67e3fd0299d3 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Mon, 12 Jun 2023 10:35:04 -0300 Subject: [PATCH 09/18] [DocumentsContract] Simplify copyTo implementation by removing it and calling and IS and OS directly --- .../storageaccessframework/DocumentFileApi.kt | 22 ++++++----------- .../file_explorer/file_explorer_card.dart | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt index e82fe45..ac88afe 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt @@ -204,24 +204,16 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : val destination = Uri.parse(call.argument("destination")!!) if (Build.VERSION.SDK_INT >= API_21) { - val isContentUri: Boolean = - uri.scheme == "content" && destination.scheme == "content" - CoroutineScope(Dispatchers.IO).launch { - if (Build.VERSION.SDK_INT >= API_24 && isContentUri) { - DocumentsContract.copyDocument( - plugin.context.contentResolver, uri, destination - ) - } else { - withContext(Dispatchers.IO) { - val inputStream = openInputStream(uri) - val outputStream = openOutputStream(destination) + withContext(Dispatchers.IO) { + val inputStream = openInputStream(uri) + val outputStream = openOutputStream(destination) - outputStream?.let { inputStream?.copyTo(it) } + // TODO: Implement progress indicator by re-writing the [copyTo] impl with an optional callback fn. + outputStream?.let { inputStream?.copyTo(it) } - inputStream?.close() - outputStream?.close() - } + inputStream?.close() + outputStream?.close() } launch(Dispatchers.Main) { diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index 7edbcd4..319c003 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -299,6 +299,26 @@ class _FileExplorerCardState extends State { await widget.documentFile.share(); } + Future _copyTo() async { + final Uri? parentUri = await openDocumentTree(persistablePermission: false); + + if (parentUri != null) { + final DocumentFile? parentDocumentFile = await parentUri.toDocumentFile(); + + if (widget.documentFile.type != null && + widget.documentFile.name != null) { + final DocumentFile? recipient = await parentDocumentFile?.createFile( + mimeType: widget.documentFile.type!, + displayName: widget.documentFile.name!, + ); + + if (recipient != null) { + widget.documentFile.copy(recipient.uri); + } + } + } + } + Widget _buildAvailableActions() { return Wrap( children: [ @@ -319,6 +339,10 @@ class _FileExplorerCardState extends State { : _fileConfirmation('Delete', _deleteDocument), ), if (!_isDirectory) ...[ + ActionButton( + 'Copy to', + onTap: _copyTo, + ), ActionButton( 'Share Document', onTap: _shareDocument, From 9099d0ee0c5f83877d199eae24abeefa994488ea Mon Sep 17 00:00:00 2001 From: kent <38088995+ken-tn@users.noreply.github.com> Date: Tue, 16 May 2023 23:37:26 +0100 Subject: [PATCH 10/18] Update Storage Access Framework docs - persistedUriPermissions example.md Fix null check for granted uris --- docs/Usage/Storage Access Framework.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Usage/Storage Access Framework.md b/docs/Usage/Storage Access Framework.md index f64855b..c882050 100644 --- a/docs/Usage/Storage Access Framework.md +++ b/docs/Usage/Storage Access Framework.md @@ -219,7 +219,7 @@ Basically this allow get the **granted** `Uri`s permissions after the app restar ```dart final List? grantedUris = await persistedUriPermissions(); -if (grantedUris != null) { +if (grantedUris == null) { print('There is no granted Uris'); } else { print('My granted Uris: $grantedUris'); From e3da8262f00adfe49e02d7b0a73231d0f56ed064 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Mon, 12 Jun 2023 14:57:56 -0300 Subject: [PATCH 11/18] Add kent as contrib --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1395f3e..aab84ff 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ All other branches are derivated from issues, new features or bug fixes. ## Contributors +- [kent](https://github.com/ken-tn) fixed documentation typo. - [iamcosmin](https://github.com/iamcosmin), [limshengli](https://github.com/limshengli) reported a issue with Gradle and Kotlin version [#124](https://github.com/alexrintt/shared-storage/issues/124). - [mx1up](https://github.com/mx1up) reported a issue with `openDocumentFile` API [#121](https://github.com/alexrintt/shared-storage/issues/121). - [Tamerlanchiques](https://github.com/Tamerlanchiques) reported a bug which the persisted URI wasn't being properly persisted across device reboots [#118](https://github.com/alexrintt/shared-storage/issues/118). From 8ff85740f1747afe0047630f2c20b1fa947c2932 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Mon, 12 Jun 2023 14:59:52 -0300 Subject: [PATCH 12/18] Add LICENSE current year --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index d7e44f7..7d8e012 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Alex Rintt +Copyright (c) 2021-2023 Alex Rintt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 71662cd8e0c2a02b08d282cddba288c9849550bb Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Mon, 12 Jun 2023 16:17:33 -0300 Subject: [PATCH 13/18] Add permissions doc section and remove Android APIs doc section since it's not a important as I thought it was --- docs/index.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/index.md b/docs/index.md index ffc4537..b24d9d6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,6 +30,21 @@ Import: import 'package:shared_storage/shared_storage.dart' as shared_storage; ``` +## Permissions (optional) + +The following APIs require the `REQUEST_INSTALL_PACKAGES` permission in order to prompt the user to install arbitrary APKs: + +- `openDocumentFile` when trying to open APK files. + +If your want to display APK files inside your app and let users install it, then you need this permission, if that's not the case then you can just skip this step. + +```xml + +``` + +> Warning! In some cases the app can become ineligible in the Play Store when using this permission, be sure you need it. Most cases where you think you don't need it you are goddamn right. + + ## Plugin This plugin include **partial** support for the following APIs: @@ -54,14 +69,8 @@ All these APIs are module based, which means they are implemented separadely and ## Support -If you have ideas to share, bugs to report or need support, you can either open an issue or join our [Discord server](https://discord.gg/86GDERXZNS). - -## Android APIs - -Most Flutter plugins use Android API's under the hood. So this plugin does the same, and to call native Android storage APIs 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.provider.DocumentsProvider`](https://developer.android.com/guide/topics/providers/document-provider) +If you have ideas to share, bugs to report or need support, you can either open an issue or join our [Discord server](https://discord.alexrintt.io). --- -Thanks to all [contributors](https://github.com/alexrintt/shared-storage/tree/release#contributors). \ No newline at end of file +Last but not least, [thanks to all contributors](https://github.com/alexrintt/shared-storage/tree/release#contributors) that makes this plugin a better tool. \ No newline at end of file From da4bd1f16efa043dcdc10a451220629b30bed041 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Sun, 18 Jun 2023 18:12:37 -0300 Subject: [PATCH 14/18] Implement [getDocumentContentAsStream] and fix crash when reading large files #91 --- .../storageaccessframework/DocumentFileApi.kt | 141 ++++++++++----- .../lib/StorageAccessFrameworkConstant.kt | 4 + example/lib/main.dart | 2 +- .../file_explorer/file_explorer_card.dart | 20 +- .../file_explorer/file_explorer_page.dart | 4 +- .../granted_uris/granted_uri_card.dart | 4 +- .../granted_uris/granted_uris_page.dart | 2 +- .../screens/large_file/large_file_screen.dart | 105 +++++++++++ example/lib/utils/document_file_utils.dart | 171 ++++++++++++++---- example/lib/widgets/buttons.dart | 12 +- example/lib/widgets/confirmation_dialog.dart | 7 +- example/lib/widgets/key_value_text.dart | 2 +- example/lib/widgets/light_text.dart | 2 +- example/lib/widgets/simple_card.dart | 3 +- example/lib/widgets/text_field_dialog.dart | 4 +- example/pubspec.yaml | 2 +- lib/src/saf/api/barrel.dart | 29 +-- lib/src/saf/api/content.dart | 118 ++++++++++-- lib/src/saf/api/exception.dart | 25 +++ lib/src/saf/common/barrel.dart | 3 +- lib/src/saf/common/generate_id.dart | 6 + lib/src/saf/common/method_call_handler.dart | 74 ++++++++ lib/src/saf/models/barrel.dart | 10 +- lib/src/saf/models/document_file.dart | 3 + 24 files changed, 620 insertions(+), 133 deletions(-) create mode 100644 example/lib/screens/large_file/large_file_screen.dart create mode 100644 lib/src/saf/api/exception.dart create mode 100644 lib/src/saf/common/generate_id.dart create mode 100644 lib/src/saf/common/method_call_handler.dart diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt index ac88afe..425131f 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt @@ -9,22 +9,21 @@ import androidx.annotation.RequiresApi import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.extension.isTreeDocumentFile import com.anggrayudi.storage.file.child -import io.flutter.plugin.common.* -import io.flutter.plugin.common.EventChannel.StreamHandler import io.alexrintt.sharedstorage.ROOT_CHANNEL import io.alexrintt.sharedstorage.SharedStoragePlugin import io.alexrintt.sharedstorage.deprecated.lib.* import io.alexrintt.sharedstorage.plugin.* import io.alexrintt.sharedstorage.storageaccessframework.* import io.alexrintt.sharedstorage.storageaccessframework.lib.* -import io.alexrintt.sharedstorage.plugin.ActivityListener -import io.alexrintt.sharedstorage.plugin.Listenable +import io.flutter.plugin.common.* +import io.flutter.plugin.common.EventChannel.StreamHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.* + /** * Aimed to implement strictly only the APIs already available from the native and original * `DocumentFile` API @@ -41,23 +40,84 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : private var eventChannel: EventChannel? = null private var eventSink: EventChannel.EventSink? = null + private val openInputStreams = mutableMapOf() + companion object { private const val CHANNEL = "documentfile" } + private fun readBytes( + inputStream: InputStream, + offset: Int, + bufferSize: Int = 1024, + ): Pair { + val buffer = ByteArray(bufferSize) + val readBufferSize = inputStream.read(buffer, offset, bufferSize) + return Pair(buffer, readBufferSize) + } + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - GET_DOCUMENT_CONTENT -> { + OPEN_INPUT_STREAM -> { val uri = Uri.parse(call.argument("uri")!!) + val callId = call.argument("callId")!! - if (Build.VERSION.SDK_INT >= API_21) { - CoroutineScope(Dispatchers.IO).launch { - val content = readDocumentContent(uri) - - launch(Dispatchers.Main) { result.success(content) } + if (openInputStreams[callId] != null) { + // There is already a open input stream for this URI + result.success(null) + } else { + val (inputStream, exception) = openInputStream(uri) + if (exception != null) { + result.error( + exception.code, exception.details, mapOf("uri" to uri.toString()) + ) + } else if (inputStream != null) { + openInputStreams[callId] = inputStream + result.success(null) + } else { + result.error( + "INTERNAL_ERROR", + "[openInputStream] doesn't returned errors neither success", + mapOf("uri" to uri.toString(), "callId" to callId) + ) } + } + } + + CLOSE_INPUT_STREAM -> { + val callId = call.argument("callId")!! + openInputStreams[callId]?.close() + openInputStreams.remove(callId) + result.success(null) + } + + READ_INPUT_STREAM -> { + val callId = call.argument("callId")!! + val offset = call.argument("offset")!! + val bufferSize = call.argument("bufferSize")!! + val inputStream = openInputStreams[callId] + + if (inputStream == null) { + result.error( + "NOT_FOUND", + "Input stream was not found opened, please, call [openInputStream] method before", + mapOf("callId" to callId) + ) } else { - result.notSupported(call.method, API_21) + CoroutineScope(Dispatchers.IO).launch { + try { + val (bytes, readBufferSize) = readBytes( + inputStream, offset, bufferSize + ) + launch(Dispatchers.Main) { + result.success( + mapOf("bytes" to bytes, "readBufferSize" to readBufferSize) + ) + } + } catch (e: IOException) { + // Concurrent reads or too fast reads (opening, closing, opening, closing, opening, ...) + } + } } } @@ -206,7 +266,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : if (Build.VERSION.SDK_INT >= API_21) { CoroutineScope(Dispatchers.IO).launch { withContext(Dispatchers.IO) { - val inputStream = openInputStream(uri) + val (inputStream) = openInputStream(uri) val outputStream = openOutputStream(destination) // TODO: Implement progress indicator by re-writing the [copyTo] impl with an optional callback fn. @@ -438,14 +498,14 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : plugin.context.contentResolver.persistedUriPermissions result.success(persistedUriPermissions.map { - mapOf( - "isReadPermission" to it.isReadPermission, - "isWritePermission" to it.isWritePermission, - "persistedTime" to it.persistedTime, - "uri" to "${it.uri}", - "isTreeDocumentFile" to it.uri.isTreeDocumentFile - ) - }.toList()) + mapOf( + "isReadPermission" to it.isReadPermission, + "isWritePermission" to it.isWritePermission, + "persistedTime" to it.persistedTime, + "uri" to "${it.uri}", + "isTreeDocumentFile" to it.uri.isTreeDocumentFile + ) + }.toList()) } private fun releasePersistableUriPermission( @@ -656,15 +716,17 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { CoroutineScope(Dispatchers.IO).launch { try { - traverseDirectoryEntries(plugin.context.contentResolver, + traverseDirectoryEntries( + plugin.context.contentResolver, rootOnly = true, targetUri = document.uri, columns = userProvidedColumns.map { - // Convert the user provided column string to documentscontract column ID. - documentFileColumnToActualDocumentsContractEnumString( - deserializeDocumentFileColumn(it as String)!! - ) - }.toTypedArray()) { data, _ -> + // Convert the user provided column string to documentscontract column ID. + documentFileColumnToActualDocumentsContractEnumString( + deserializeDocumentFileColumn(it as String)!! + ) + }.toTypedArray() + ) { data, _ -> launch(Dispatchers.Main) { eventSink.success( data @@ -682,39 +744,28 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : } } + /** Alias for `plugin.context.contentResolver.openOutputStream(uri)` */ private fun openOutputStream(uri: Uri): OutputStream? { return plugin.context.contentResolver.openOutputStream(uri) } /** Alias for `plugin.context.contentResolver.openInputStream(uri)` */ - private fun openInputStream(uri: Uri): InputStream? { - return plugin.context.contentResolver.openInputStream(uri) - } - - /** Get a document content as `ByteArray` equivalent to `Uint8List` in Dart */ - @RequiresApi(API_21) - private fun readDocumentContent(uri: Uri): ByteArray? { + private fun openInputStream(uri: Uri): Pair { return try { - val inputStream = openInputStream(uri) - - val bytes = inputStream?.readBytes() - - inputStream?.close() - - bytes + Pair(plugin.context.contentResolver.openInputStream(uri), null) } catch (e: FileNotFoundException) { // Probably the file was already deleted and now you are trying to read. - null + Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) } catch (e: IOException) { // Unknown, can be anything. - null + Pair(null, PlatformException("INTERNAL_ERROR", e.toString())) } catch (e: IllegalArgumentException) { // Probably the file was already deleted and now you are trying to read. - null + Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) } catch (e: IllegalStateException) { // Probably you ran [delete] and [readDocumentContent] at the same time. - null + Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) } } @@ -723,3 +774,5 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : eventSink = null } } + +data class PlatformException(val code: String, val details: String) diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt index bc52e8f..62c4145 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt @@ -57,3 +57,7 @@ const val GET_DOCUMENT_CONTENT = "getDocumentContent" */ const val OPEN_DOCUMENT_TREE_CODE = 10 const val OPEN_DOCUMENT_CODE = 11 + +const val OPEN_INPUT_STREAM = "openInputStream" +const val CLOSE_INPUT_STREAM = "closeInputStream" +const val READ_INPUT_STREAM = "readInputStream" diff --git a/example/lib/main.dart b/example/lib/main.dart index 03f0aec..25cd0d3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -5,7 +5,7 @@ import 'screens/granted_uris/granted_uris_page.dart'; void main() => runApp(const Root()); class Root extends StatefulWidget { - const Root({Key? key}) : super(key: key); + const Root({super.key}); @override _RootState createState() => _RootState(); diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index 319c003..16de3b4 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -17,14 +17,15 @@ import '../../widgets/buttons.dart'; import '../../widgets/key_value_text.dart'; import '../../widgets/simple_card.dart'; import '../../widgets/text_field_dialog.dart'; +import '../large_file/large_file_screen.dart'; import 'file_explorer_page.dart'; class FileExplorerCard extends StatefulWidget { const FileExplorerCard({ - Key? key, + super.key, required this.documentFile, required this.didUpdateDocument, - }) : super(key: key); + }); final DocumentFile documentFile; final void Function(DocumentFile?) didUpdateDocument; @@ -319,6 +320,17 @@ class _FileExplorerCardState extends State { } } + void _openLargeFileScreen() { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) { + return LargeFileScreen(uri: widget.documentFile.uri); + }, + ), + ); + } + Widget _buildAvailableActions() { return Wrap( children: [ @@ -339,6 +351,10 @@ class _FileExplorerCardState extends State { : _fileConfirmation('Delete', _deleteDocument), ), if (!_isDirectory) ...[ + ActionButton( + 'Lazy load its content', + onTap: _openLargeFileScreen, + ), ActionButton( 'Copy to', onTap: _copyTo, diff --git a/example/lib/screens/file_explorer/file_explorer_page.dart b/example/lib/screens/file_explorer/file_explorer_page.dart index 85ee49b..dfd9567 100644 --- a/example/lib/screens/file_explorer/file_explorer_page.dart +++ b/example/lib/screens/file_explorer/file_explorer_page.dart @@ -13,9 +13,9 @@ import 'file_explorer_card.dart'; class FileExplorerPage extends StatefulWidget { const FileExplorerPage({ - Key? key, + super.key, required this.uri, - }) : super(key: key); + }); final Uri uri; diff --git a/example/lib/screens/granted_uris/granted_uri_card.dart b/example/lib/screens/granted_uris/granted_uri_card.dart index 38e87e8..c023f85 100644 --- a/example/lib/screens/granted_uris/granted_uri_card.dart +++ b/example/lib/screens/granted_uris/granted_uri_card.dart @@ -12,10 +12,10 @@ import '../file_explorer/file_explorer_page.dart'; class GrantedUriCard extends StatefulWidget { const GrantedUriCard({ - Key? key, + super.key, required this.permissionUri, required this.onChange, - }) : super(key: key); + }); final UriPermission permissionUri; final VoidCallback onChange; diff --git a/example/lib/screens/granted_uris/granted_uris_page.dart b/example/lib/screens/granted_uris/granted_uris_page.dart index 133759c..cdd248c 100644 --- a/example/lib/screens/granted_uris/granted_uris_page.dart +++ b/example/lib/screens/granted_uris/granted_uris_page.dart @@ -7,7 +7,7 @@ import '../../widgets/light_text.dart'; import 'granted_uri_card.dart'; class GrantedUrisPage extends StatefulWidget { - const GrantedUrisPage({Key? key}) : super(key: key); + const GrantedUrisPage({super.key}); @override _GrantedUrisPageState createState() => _GrantedUrisPageState(); diff --git a/example/lib/screens/large_file/large_file_screen.dart b/example/lib/screens/large_file/large_file_screen.dart new file mode 100644 index 0000000..461fe77 --- /dev/null +++ b/example/lib/screens/large_file/large_file_screen.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import '../../theme/spacing.dart'; +import '../../widgets/key_value_text.dart'; + +class LargeFileScreen extends StatefulWidget { + const LargeFileScreen({super.key, required this.uri}); + + final Uri uri; + + @override + State createState() => _LargeFileScreenState(); +} + +class _LargeFileScreenState extends State { + DocumentFile? _documentFile; + StreamSubscription? _subscription; + int _bytesLoaded = 0; + + @override + void initState() { + super.initState(); + _loadDocFile(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + Future _loadDocFile() async { + _documentFile = await widget.uri.toDocumentFile(); + + setState(() {}); + + _startLoadingFile(); + } + + Future _startLoadingFile() async { + final Stream byteStream = getDocumentContentAsStream(widget.uri); + + _subscription = byteStream.listen( + (bytes) { + _bytesLoaded += bytes.length; + // debounce2s(() => setState(() {})); + setState(() {}); + }, + cancelOnError: true, + onError: (_) => _unsubscribe(), + onDone: _unsubscribe, + ); + } + + void _unsubscribe() { + _subscription?.cancel(); + } + + @override + void setState(VoidCallback fn) { + if (mounted) super.setState(fn); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + _documentFile?.name ?? 'Loading...', + ), + ), + body: Center( + child: ContentSizeCard(bytes: _bytesLoaded), + ), + ); + } +} + +class ContentSizeCard extends StatelessWidget { + const ContentSizeCard({super.key, required this.bytes}); + + final int bytes; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: k4dp.all, + child: KeyValueText( + entries: { + 'In bytes': '$bytes B', + 'In kilobytes': '${bytes ~/ 1024} KB', + 'In megabytes': '${bytes / 1024 ~/ 1024} MB', + 'In gigabytes': '${bytes / 1024 / 1024 ~/ 1024} GB', + 'In terabytes': '${bytes / 1024 / 1024 / 1024 ~/ 1014} TB', + }, + ), + ), + ); + } +} diff --git a/example/lib/utils/document_file_utils.dart b/example/lib/utils/document_file_utils.dart index 179f3c9..ee191f6 100644 --- a/example/lib/utils/document_file_utils.dart +++ b/example/lib/utils/document_file_utils.dart @@ -1,9 +1,14 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:fl_toast/fl_toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_storage/shared_storage.dart'; +import '../screens/large_file/large_file_screen.dart'; import '../theme/spacing.dart'; +import '../widgets/buttons.dart'; import 'disabled_text_style.dart'; import 'mime_types.dart'; @@ -39,48 +44,150 @@ extension OpenUriWithExternalApp on Uri { extension ShowDocumentFileContents on DocumentFile { Future showContents(BuildContext context) async { - final mimeTypeOrEmpty = type ?? ''; - final sizeInBytes = size ?? 0; + if (context.mounted) { + final mimeTypeOrEmpty = type ?? ''; - const k10mb = 1024 * 1024 * 10; + if (!mimeTypeOrEmpty.startsWith(kTextMime) && + !mimeTypeOrEmpty.startsWith(kImageMime)) { + return uri.openWithExternalApp(); + } - if (!mimeTypeOrEmpty.startsWith(kTextMime) && - !mimeTypeOrEmpty.startsWith(kImageMime)) { - return uri.openWithExternalApp(); + await showModalBottomSheet( + context: context, + builder: (context) => DocumentContentViewer(documentFile: this), + ); } + } +} - // Too long, will take too much time to read - if (sizeInBytes > k10mb) { - return context.showToast('File too long to open'); - } +class DocumentContentViewer extends StatefulWidget { + const DocumentContentViewer({super.key, required this.documentFile}); + + final DocumentFile documentFile; - final content = await getDocumentContent(uri); + @override + State createState() => _DocumentContentViewerState(); +} + +class _DocumentContentViewerState extends State { + Uint8List _bytes = Uint8List.fromList([]); + StreamSubscription? _subscription; + int _bytesLoaded = 0; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _startLoadingFile(); + } - if (content != null) { - final isImage = mimeTypeOrEmpty.startsWith(kImageMime); + @override + void dispose() { + _unsubscribe(); + super.dispose(); + } - if (context.mounted) { - await showModalBottomSheet( - context: context, - builder: (context) { - if (isImage) { - return Image.memory(content); - } + void _unsubscribe() { + _subscription?.cancel(); + } - final contentAsString = String.fromCharCodes(content); + Future _startLoadingFile() async { + // The implementation of [getDocumentContent] is no longer blocking! + // It now just merges all events of [getDocumentContentAsStream]. + // Basically: lazy loaded -> No performance issues. + final Stream byteStream = + getDocumentContentAsStream(widget.documentFile.uri); - final fileIsEmpty = contentAsString.isEmpty; + _subscription = byteStream.listen( + (Uint8List chunk) { + _bytesLoaded += chunk.length; + if (_bytesLoaded <= k1KB * 10) { + // Load file + _bytes = Uint8List.fromList(_bytes + chunk); + } else { + // otherwise just bump we are not going to display a large file + _bytes = Uint8List.fromList([]); + } - return Container( - padding: k8dp.all, - child: Text( - fileIsEmpty ? 'This file is empty' : contentAsString, - style: fileIsEmpty ? disabledTextStyle() : null, - ), - ); - }, - ); - } + setState(() {}); + }, + cancelOnError: true, + onError: (_) => _unsubscribe(), + onDone: () { + _loaded = true; + _unsubscribe(); + setState(() {}); + }, + ); + } + + @override + void setState(VoidCallback fn) { + if (mounted) super.setState(fn); + } + + @override + Widget build(BuildContext context) { + if (_bytesLoaded >= k1MB * 10) { + // The ideal approach is to implement a backpressure using: + // - Pause: _subscription!.pause(); + // - Resume: _subscription!.resume(); + // 'Backpressure' is a short term for 'loading only when the user asks for'. + // This happens because there is no way to load a 5GB file into a variable and expect you app doesn't crash. + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('File too long to show: ${widget.documentFile.name}'), + ContentSizeCard(bytes: _bytesLoaded), + Wrap( + children: [ + ActionButton( + 'Pause', + onTap: () { + if (_subscription?.isPaused == false) { + _subscription?.pause(); + } + }, + ), + ActionButton( + 'Resume', + onTap: () { + if (_subscription?.isPaused == true) { + _subscription?.resume(); + } + }, + ), + ], + ) + ], + ), + ); + } + + if (!_loaded) { + return const Center(child: CircularProgressIndicator()); + } + + final type = widget.documentFile.type; + final mimeTypeOrEmpty = type ?? ''; + + final isImage = mimeTypeOrEmpty.startsWith(kImageMime); + + if (isImage) { + return Image.memory(_bytes); } + + final contentAsString = utf8.decode(_bytes); + + final fileIsEmpty = contentAsString.isEmpty; + + return Container( + padding: k8dp.all, + child: Text( + fileIsEmpty ? 'This file is empty' : contentAsString, + style: fileIsEmpty ? disabledTextStyle() : null, + ), + ); } } diff --git a/example/lib/widgets/buttons.dart b/example/lib/widgets/buttons.dart index 7f0b4c3..6a8da09 100644 --- a/example/lib/widgets/buttons.dart +++ b/example/lib/widgets/buttons.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; class Button extends StatelessWidget { const Button( this.text, { - Key? key, + super.key, this.color, required this.onTap, - }) : super(key: key); + }); final Color? color; final String text; @@ -25,9 +25,9 @@ class Button extends StatelessWidget { class DangerButton extends StatelessWidget { const DangerButton( this.text, { - Key? key, + super.key, required this.onTap, - }) : super(key: key); + }); final String text; final VoidCallback onTap; @@ -41,9 +41,9 @@ class DangerButton extends StatelessWidget { class ActionButton extends StatelessWidget { const ActionButton( this.text, { - Key? key, + super.key, required this.onTap, - }) : super(key: key); + }); final String text; final VoidCallback onTap; diff --git a/example/lib/widgets/confirmation_dialog.dart b/example/lib/widgets/confirmation_dialog.dart index 8fe9c1e..8c5690a 100644 --- a/example/lib/widgets/confirmation_dialog.dart +++ b/example/lib/widgets/confirmation_dialog.dart @@ -4,16 +4,15 @@ import 'buttons.dart'; class ConfirmationDialog extends StatefulWidget { const ConfirmationDialog({ - Key? key, + super.key, required this.color, this.message, this.body, required this.actionName, - }) : assert( + }) : assert( message != null || body != null, '''You should at least provde [message] or body to explain to the user the context of this confirmation''', - ), - super(key: key); + ); final Color color; final String? message; diff --git a/example/lib/widgets/key_value_text.dart b/example/lib/widgets/key_value_text.dart index db600fc..cdd921e 100644 --- a/example/lib/widgets/key_value_text.dart +++ b/example/lib/widgets/key_value_text.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; /// Use the entry value as [Widget] to use a [WidgetSpan] and [Text] to use a [TextSpan] class KeyValueText extends StatefulWidget { - const KeyValueText({Key? key, required this.entries}) : super(key: key); + const KeyValueText({super.key, required this.entries}); final Map entries; diff --git a/example/lib/widgets/light_text.dart b/example/lib/widgets/light_text.dart index fff581c..6d5def1 100644 --- a/example/lib/widgets/light_text.dart +++ b/example/lib/widgets/light_text.dart @@ -1,7 +1,7 @@ import 'package:flutter/cupertino.dart'; class LightText extends StatelessWidget { - const LightText(this.text, {Key? key}) : super(key: key); + const LightText(this.text, {super.key}); final String text; diff --git a/example/lib/widgets/simple_card.dart b/example/lib/widgets/simple_card.dart index 588e4a0..9bcd49a 100644 --- a/example/lib/widgets/simple_card.dart +++ b/example/lib/widgets/simple_card.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; class SimpleCard extends StatefulWidget { - const SimpleCard({Key? key, required this.onTap, required this.children}) - : super(key: key); + const SimpleCard({super.key, required this.onTap, required this.children}); final VoidCallback onTap; final List children; diff --git a/example/lib/widgets/text_field_dialog.dart b/example/lib/widgets/text_field_dialog.dart index d38a4dc..a30b72d 100644 --- a/example/lib/widgets/text_field_dialog.dart +++ b/example/lib/widgets/text_field_dialog.dart @@ -5,12 +5,12 @@ import 'buttons.dart'; class TextFieldDialog extends StatefulWidget { const TextFieldDialog({ - Key? key, + super.key, required this.labelText, required this.hintText, this.suffixText, required this.actionText, - }) : super(key: key); + }); final String labelText; final String hintText; diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 63e59e8..9708e1c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -6,7 +6,7 @@ description: Demonstrates how to use the shared_storage plugin. publish_to: "none" # Remove this line if you wish to publish to pub.dev environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.17.0 <3.0.0" dependencies: fl_toast: ^3.1.0 diff --git a/lib/src/saf/api/barrel.dart b/lib/src/saf/api/barrel.dart index 275d5f7..dd73717 100644 --- a/lib/src/saf/api/barrel.dart +++ b/lib/src/saf/api/barrel.dart @@ -1,14 +1,15 @@ -export './content.dart'; -export './copy.dart'; -export './create.dart'; -export './delete.dart'; -export './grant.dart'; -export './info.dart'; -export './open.dart'; -export './persisted.dart'; -export './rename.dart'; -export './search.dart'; -export './share.dart'; -export './tree.dart'; -export './utility.dart'; -export './write.dart'; +export 'content.dart'; +export 'copy.dart'; +export 'create.dart'; +export 'delete.dart'; +export 'exception.dart'; +export 'grant.dart'; +export 'info.dart'; +export 'open.dart'; +export 'persisted.dart'; +export 'rename.dart'; +export 'search.dart'; +export 'share.dart'; +export 'tree.dart'; +export 'utility.dart'; +export 'write.dart'; diff --git a/lib/src/saf/api/content.dart b/lib/src/saf/api/content.dart index 338b2c3..1de295a 100644 --- a/lib/src/saf/api/content.dart +++ b/lib/src/saf/api/content.dart @@ -1,7 +1,10 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import '../../channels.dart'; import '../../common/functional_extender.dart'; +import '../common/generate_id.dart'; import '../models/barrel.dart'; /// {@template sharedstorage.saf.getDocumentContentAsString} @@ -12,25 +15,116 @@ Future getDocumentContentAsString( Uri uri, { bool throwIfError = false, }) async { - final Uint8List? bytes = await getDocumentContent(uri); - - if (bytes == null) return null; - - return String.fromCharCodes(bytes); + return utf8.decode(await getDocumentContent(uri)); } /// {@template sharedstorage.saf.getDocumentContent} -/// Get content of a given document `uri`. +/// Get content of a given document [uri]. /// -/// Equivalent to `contentDescriptor` usage. +/// This method is an alias for [getDocumentContentAsStream] that merges every file chunk into the memory. /// -/// [Refer to details](https://developer.android.com/training/data-storage/shared/documents-files#input_stream). +/// Be careful: this method crashes the app if the target [uri] is a large file, prefer [getDocumentContentAsStream] instead. /// {@endtemplate} -Future getDocumentContent(Uri uri) async => - kDocumentFileChannel.invokeMethod( - 'getDocumentContent', - {'uri': '$uri'}, +Future getDocumentContent(Uri uri) { + return getDocumentContentAsStream(uri).reduce( + (Uint8List previous, Uint8List element) => Uint8List.fromList( + [ + ...previous, + ...element, + ], + ), + ); +} + +const int k1B = 1; +const int k1KB = k1B * 1024; +const int k512KB = k1B * 512; +const int k1MB = k1KB * 1024; +const int k512MB = k1MB * 512; +const int k1GB = k1MB * 1024; +const int k1TB = k1GB * 1024; +const int k1PB = k1TB * 1024; + +/// {@template sharedstorage.getDocumentContentAsStream} +/// Read the given [uri] contents with lazy-strategy using [Stream]s. +/// +/// Each [Stream] event contains only a small fraction of the [uri] bytes of size [bufferSize]. +/// +/// e.g let target [uri] be a 500MB file and [bufferSize] is 1MB, the returned [Stream] will emit 500 events, each one containing a [Uint8List] of size 1MB (may vary but that's the idea). +/// +/// Since only chunks of the files are actually loaded, there are no performance gaps or the risk of app crash. +/// +/// If that happens, provide the [bufferSize] with a lower limit. +/// +/// Greater [bufferSize] values will speed-up reading but will increase [OutOfMemoryError] chances. +/// {@endtemplate} +Stream getDocumentContentAsStream( + Uri uri, { + int bufferSize = k1MB, +}) { + final String callId = generateTimeBasedId(); + late final StreamController controller; + + bool paused = false; + + Stream readFileInputStream() async* { + int readBufferSize = 0; + + while (readBufferSize != -1 && !paused) { + final Map? result = + await kDocumentFileChannel.invokeMapMethod( + 'readInputStream', + { + 'callId': callId, + 'offset': 0, + 'bufferSize': bufferSize, + }, + ); + + if (result != null) { + readBufferSize = result['readBufferSize'] as int; + yield result['bytes'] as Uint8List; + } + } + } + + void onListen() { + // Platform code is optimized to not create a new input stream if + // a same [callId] is provided, so there are no problems in calling this several times. + kDocumentFileChannel.invokeMethod( + 'openInputStream', + {'uri': uri.toString(), 'callId': callId}, + ); + + controller.addStream(readFileInputStream()); + } + + FutureOr onCancel() { + kDocumentFileChannel.invokeMethod( + 'closeInputStream', + {'callId': callId}, ); + controller.close(); + } + + void onPause() { + paused = true; + } + + void onResume() { + paused = false; + readFileInputStream(); + } + + controller = StreamController( + onCancel: onCancel, + onListen: onListen, + onPause: onPause, + onResume: onResume, + ); + + return controller.stream; +} /// {@template sharedstorage.saf.getDocumentThumbnail} /// Equivalent to `DocumentsContract.getDocumentThumbnail`. diff --git a/lib/src/saf/api/exception.dart b/lib/src/saf/api/exception.dart new file mode 100644 index 0000000..292343c --- /dev/null +++ b/lib/src/saf/api/exception.dart @@ -0,0 +1,25 @@ +/// Exception thrown when the provided URI is invalid, possible reasons: +/// +/// - The file was deleted and you are trying to read. +/// - [delete] and [readDocumentContent] ran at the same time. +class SharedStorageFileNotFoundException extends SharedStorageException { + const SharedStorageFileNotFoundException(super.message, super.stackTrace); +} + +/// Exception thrown in the platform-side and that cannot be addressed by the client. +/// +/// You can continue the program flow ignoring this exception or open a issue if it's fatal. +class SharedStorageInternalException extends SharedStorageException { + const SharedStorageInternalException(super.message, super.stackTrace); +} + +/// Custom type for exceptions of [shared_storage] package. +class SharedStorageException implements Exception { + const SharedStorageException(this.message, this.stackTrace); + + final String message; + final StackTrace stackTrace; + + @override + String toString() => '$message $stackTrace'; +} diff --git a/lib/src/saf/common/barrel.dart b/lib/src/saf/common/barrel.dart index ea0e2ff..61b5227 100644 --- a/lib/src/saf/common/barrel.dart +++ b/lib/src/saf/common/barrel.dart @@ -1 +1,2 @@ -export './method_channel_helper.dart'; +export 'method_call_handler.dart'; +export 'method_channel_helper.dart'; diff --git a/lib/src/saf/common/generate_id.dart b/lib/src/saf/common/generate_id.dart new file mode 100644 index 0000000..243bfdd --- /dev/null +++ b/lib/src/saf/common/generate_id.dart @@ -0,0 +1,6 @@ +int currentMicrosecondsSinceEpoch() { + final DateTime now = DateTime.now(); + return now.microsecondsSinceEpoch; +} + +String generateTimeBasedId() => currentMicrosecondsSinceEpoch().toString(); diff --git a/lib/src/saf/common/method_call_handler.dart b/lib/src/saf/common/method_call_handler.dart new file mode 100644 index 0000000..f14b94d --- /dev/null +++ b/lib/src/saf/common/method_call_handler.dart @@ -0,0 +1,74 @@ +import 'dart:developer'; + +import 'package:flutter/services.dart'; + +import '../../channels.dart'; + +typedef MethodCallHandler = void Function( + T arguments, + UnsubscribeFn unsubscribe, +); + +final Map _listeners = {}; + +Future _methodCallHandler(MethodCall call) async { + final MethodCallHandler? handler = _listeners[call.method]; + + handler?.call(call.arguments, () => removeMethodCallListener(call.method)); + + if (handler == null) { + log('Tried to invoke undefined handler: ${call.method} with args ${call.arguments}'); + } + + return null; +} + +void _setupMethodCallHandler() { + kDocumentFileChannel.setMethodCallHandler(_methodCallHandler); +} + +void _removeMethodCallHandler() { + kDocumentFileChannel.setMethodCallHandler(null); +} + +void _removeMethodCallHandlerIfThereAreNoMoreListeners() { + if (_listeners.isEmpty) { + _removeMethodCallHandler(); + } +} + +typedef UnsubscribeFn = void Function(); + +UnsubscribeFn addMethodCallListener(String method, MethodCallHandler fn) { + void unsubscribe() => removeMethodCallListener(method); + + _listeners[method] = (dynamic arguments, UnsubscribeFn unsubscribe) { + if (arguments is T) { + fn(arguments, unsubscribe); + } + }; + + _setupMethodCallHandler(); + + return unsubscribe; +} + +void removeMethodCallListener(String method) { + _listeners.remove(method); + _removeMethodCallHandlerIfThereAreNoMoreListeners(); +} + +void addMapMethodCallListener( + String method, + MethodCallHandler> fn, +) { + addMethodCallListener(method, (dynamic arguments, UnsubscribeFn unsubscribe) { + if (arguments is Map) { + try { + fn(Map.from(arguments), unsubscribe); + } finally {} + } + }); + + _setupMethodCallHandler(); +} diff --git a/lib/src/saf/models/barrel.dart b/lib/src/saf/models/barrel.dart index 4d8e5e8..8853e79 100644 --- a/lib/src/saf/models/barrel.dart +++ b/lib/src/saf/models/barrel.dart @@ -1,5 +1,5 @@ -export './document_bitmap.dart'; -export './document_file.dart'; -export './document_file_column.dart'; -export './open_document_file_result.dart'; -export './uri_permission.dart'; +export 'document_bitmap.dart'; +export 'document_file.dart'; +export 'document_file_column.dart'; +export 'open_document_file_result.dart'; +export 'uri_permission.dart'; diff --git a/lib/src/saf/models/document_file.dart b/lib/src/saf/models/document_file.dart index 4e3ba7e..f0b04ce 100644 --- a/lib/src/saf/models/document_file.dart +++ b/lib/src/saf/models/document_file.dart @@ -134,6 +134,9 @@ class DocumentFile { /// {@macro sharedstorage.saf.getContentAsString} Future getContentAsString() => saf.getDocumentContentAsString(uri); + /// {@macro sharedstorage.getDocumentContentAsStream} + Stream getContentAsStream() => saf.getDocumentContentAsStream(uri); + /// {@macro sharedstorage.saf.createDirectory} Future createDirectory(String displayName) => saf.createDirectory(uri, displayName); From cf0d3b49245361960b434036d38c5577cf75d885 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Mon, 19 Jun 2023 13:46:36 -0300 Subject: [PATCH 15/18] Close stream controller when platform input stream signals -1 --- example/lib/utils/document_file_utils.dart | 16 ++++++----- lib/src/saf/api/content.dart | 31 ++++++++++++---------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/example/lib/utils/document_file_utils.dart b/example/lib/utils/document_file_utils.dart index ee191f6..58e9c18 100644 --- a/example/lib/utils/document_file_utils.dart +++ b/example/lib/utils/document_file_utils.dart @@ -112,7 +112,11 @@ class _DocumentContentViewerState extends State { setState(() {}); }, cancelOnError: true, - onError: (_) => _unsubscribe(), + onError: (_) { + _loaded = true; + _unsubscribe(); + setState(() {}); + }, onDone: () { _loaded = true; _unsubscribe(); @@ -128,7 +132,7 @@ class _DocumentContentViewerState extends State { @override Widget build(BuildContext context) { - if (_bytesLoaded >= k1MB * 10) { + if (!_loaded || _bytesLoaded >= k1MB * 10) { // The ideal approach is to implement a backpressure using: // - Pause: _subscription!.pause(); // - Resume: _subscription!.resume(); @@ -138,7 +142,9 @@ class _DocumentContentViewerState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('File too long to show: ${widget.documentFile.name}'), + Text('Is done: $_loaded'), + if (_bytesLoaded >= k1MB * 10) + Text('File too long to show: ${widget.documentFile.name}'), ContentSizeCard(bytes: _bytesLoaded), Wrap( children: [ @@ -165,10 +171,6 @@ class _DocumentContentViewerState extends State { ); } - if (!_loaded) { - return const Center(child: CircularProgressIndicator()); - } - final type = widget.documentFile.type; final mimeTypeOrEmpty = type ?? ''; diff --git a/lib/src/saf/api/content.dart b/lib/src/saf/api/content.dart index 1de295a..d3dbd53 100644 --- a/lib/src/saf/api/content.dart +++ b/lib/src/saf/api/content.dart @@ -67,10 +67,17 @@ Stream getDocumentContentAsStream( bool paused = false; + FutureOr onCancel() async { + await kDocumentFileChannel.invokeMethod( + 'closeInputStream', + {'callId': callId}, + ); + } + Stream readFileInputStream() async* { int readBufferSize = 0; - while (readBufferSize != -1 && !paused) { + while (true && !paused) { final Map? result = await kDocumentFileChannel.invokeMapMethod( 'readInputStream', @@ -83,28 +90,25 @@ Stream getDocumentContentAsStream( if (result != null) { readBufferSize = result['readBufferSize'] as int; - yield result['bytes'] as Uint8List; + if (readBufferSize == -1) { + controller.close(); + break; + } else { + yield result['bytes'] as Uint8List; + } } } } - void onListen() { + Future onListen() async { // Platform code is optimized to not create a new input stream if // a same [callId] is provided, so there are no problems in calling this several times. - kDocumentFileChannel.invokeMethod( + await kDocumentFileChannel.invokeMethod( 'openInputStream', {'uri': uri.toString(), 'callId': callId}, ); - controller.addStream(readFileInputStream()); - } - - FutureOr onCancel() { - kDocumentFileChannel.invokeMethod( - 'closeInputStream', - {'callId': callId}, - ); - controller.close(); + await controller.addStream(readFileInputStream()); } void onPause() { @@ -113,7 +117,6 @@ Stream getDocumentContentAsStream( void onResume() { paused = false; - readFileInputStream(); } controller = StreamController( From 4ae24175b37772fa0dd7cc056cac44b401460795 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Wed, 21 Jun 2023 19:39:20 -0300 Subject: [PATCH 16/18] Simplify stream impl mechanism and fix null bytes at the EOF --- example/lib/utils/document_file_utils.dart | 22 +++--- lib/src/saf/api/content.dart | 82 ++++++++-------------- 2 files changed, 43 insertions(+), 61 deletions(-) diff --git a/example/lib/utils/document_file_utils.dart b/example/lib/utils/document_file_utils.dart index 58e9c18..93aa5ac 100644 --- a/example/lib/utils/document_file_utils.dart +++ b/example/lib/utils/document_file_utils.dart @@ -70,14 +70,17 @@ class DocumentContentViewer extends StatefulWidget { } class _DocumentContentViewerState extends State { - Uint8List _bytes = Uint8List.fromList([]); + late Uint8List _bytes; StreamSubscription? _subscription; - int _bytesLoaded = 0; - bool _loaded = false; + late int _bytesLoaded; + late bool _loaded = false; @override void initState() { super.initState(); + _bytes = Uint8List.fromList([]); + _bytesLoaded = 0; + _loaded = false; _startLoadingFile(); } @@ -91,6 +94,8 @@ class _DocumentContentViewerState extends State { _subscription?.cancel(); } + static const double _kLargerFileSupported = k1MB * 10; + Future _startLoadingFile() async { // The implementation of [getDocumentContent] is no longer blocking! // It now just merges all events of [getDocumentContentAsStream]. @@ -101,23 +106,24 @@ class _DocumentContentViewerState extends State { _subscription = byteStream.listen( (Uint8List chunk) { _bytesLoaded += chunk.length; - if (_bytesLoaded <= k1KB * 10) { + if (_bytesLoaded < _kLargerFileSupported) { // Load file _bytes = Uint8List.fromList(_bytes + chunk); } else { // otherwise just bump we are not going to display a large file _bytes = Uint8List.fromList([]); } - setState(() {}); }, - cancelOnError: true, - onError: (_) { + cancelOnError: false, + onError: (e, stackTrace) { + print('Error: $e, st: $stackTrace'); _loaded = true; _unsubscribe(); setState(() {}); }, onDone: () { + print('Done'); _loaded = true; _unsubscribe(); setState(() {}); @@ -132,7 +138,7 @@ class _DocumentContentViewerState extends State { @override Widget build(BuildContext context) { - if (!_loaded || _bytesLoaded >= k1MB * 10) { + if (!_loaded || _bytesLoaded >= _kLargerFileSupported) { // The ideal approach is to implement a backpressure using: // - Pause: _subscription!.pause(); // - Resume: _subscription!.resume(); diff --git a/lib/src/saf/api/content.dart b/lib/src/saf/api/content.dart index d3dbd53..f704e79 100644 --- a/lib/src/saf/api/content.dart +++ b/lib/src/saf/api/content.dart @@ -61,72 +61,48 @@ const int k1PB = k1TB * 1024; Stream getDocumentContentAsStream( Uri uri, { int bufferSize = k1MB, -}) { + int offset = 0, +}) async* { final String callId = generateTimeBasedId(); - late final StreamController controller; - bool paused = false; + await kDocumentFileChannel.invokeMethod( + 'openInputStream', + {'uri': uri.toString(), 'callId': callId}, + ); - FutureOr onCancel() async { - await kDocumentFileChannel.invokeMethod( - 'closeInputStream', - {'callId': callId}, + int readBufferSize = 0; + + while (true) { + final Map? result = + await kDocumentFileChannel.invokeMapMethod( + 'readInputStream', + { + 'callId': callId, + 'offset': offset, + 'bufferSize': bufferSize, + }, ); - } - - Stream readFileInputStream() async* { - int readBufferSize = 0; - - while (true && !paused) { - final Map? result = - await kDocumentFileChannel.invokeMapMethod( - 'readInputStream', - { - 'callId': callId, - 'offset': 0, - 'bufferSize': bufferSize, - }, - ); - if (result != null) { - readBufferSize = result['readBufferSize'] as int; - if (readBufferSize == -1) { - controller.close(); - break; + if (result != null) { + readBufferSize = result['readBufferSize'] as int; + if (readBufferSize < 0) { + break; + } else { + if (readBufferSize != bufferSize) { + // Slice the buffer to the actual read size. + yield (result['bytes'] as Uint8List).sublist(0, readBufferSize); } else { + // No need to slice the buffer, just yield it. yield result['bytes'] as Uint8List; } } } } - Future onListen() async { - // Platform code is optimized to not create a new input stream if - // a same [callId] is provided, so there are no problems in calling this several times. - await kDocumentFileChannel.invokeMethod( - 'openInputStream', - {'uri': uri.toString(), 'callId': callId}, - ); - - await controller.addStream(readFileInputStream()); - } - - void onPause() { - paused = true; - } - - void onResume() { - paused = false; - } - - controller = StreamController( - onCancel: onCancel, - onListen: onListen, - onPause: onPause, - onResume: onResume, + await kDocumentFileChannel.invokeMethod( + 'closeInputStream', + {'callId': callId}, ); - - return controller.stream; } /// {@template sharedstorage.saf.getDocumentThumbnail} From 61766a213e601acc2526eec077782dd76c717152 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Sun, 16 Jul 2023 23:26:52 -0300 Subject: [PATCH 17/18] Add initial refactor to scoped storage and make API easier to use --- android/build.gradle | 108 +- .../sharedstorage/mediastore/MediaStoreApi.kt | 318 +++- .../storageaccessframework/DocumentFileApi.kt | 1557 +++++++++-------- .../DocumentFileHelperApi.kt | 495 +++--- .../DocumentsContractApi.kt | 533 +++--- .../lib/DocumentCommon.kt | 659 ++++--- .../lib/DocumentFileColumn.kt | 182 +- example/android/app/build.gradle | 120 +- .../android/app/src/main/AndroidManifest.xml | 103 +- example/android/build.gradle | 60 +- .../file_explorer/file_explorer_card.dart | 1021 +++++------ .../file_explorer/file_explorer_page.dart | 488 +++--- .../granted_uris/granted_uri_card.dart | 405 ++--- .../granted_uris/granted_uris_page.dart | 380 ++-- .../screens/large_file/large_file_screen.dart | 12 +- example/lib/utils/document_file_utils.dart | 396 +++-- example/pubspec.yaml | 135 +- lib/src/media_store/barrel.dart | 4 +- lib/src/media_store/media_store.dart | 298 +++- .../media_store/media_store_collection.dart | 64 - lib/src/media_store/models/barrel.dart | 3 + .../media_store/models/scoped_directory.dart | 229 +++ lib/src/media_store/models/scoped_file.dart | 433 +++++ .../models/scoped_file_system_entity.dart | 34 + .../shared_storage_platform_interface.dart | 50 + lib/src/saf/api/content.dart | 254 ++- lib/src/saf/api/copy.dart | 32 +- lib/src/saf/api/create.dart | 204 +-- lib/src/saf/api/exception.dart | 39 + lib/src/saf/api/grant.dart | 118 +- lib/src/saf/api/info.dart | 55 +- lib/src/saf/api/open.dart | 84 +- lib/src/saf/api/rename.dart | 40 +- lib/src/saf/api/search.dart | 36 +- lib/src/saf/api/share.dart | 80 - lib/src/saf/api/tree.dart | 164 +- lib/src/saf/api/utility.dart | 24 +- lib/src/saf/common/method_channel_helper.dart | 32 +- lib/src/saf/models/document_file.dart | 532 +++--- lib/src/saf/models/document_file_column.dart | 66 +- pubspec.yaml | 62 +- test/scoped_file_test.dart | 5 + 42 files changed, 5600 insertions(+), 4314 deletions(-) delete mode 100644 lib/src/media_store/media_store_collection.dart create mode 100644 lib/src/media_store/models/barrel.dart create mode 100644 lib/src/media_store/models/scoped_directory.dart create mode 100644 lib/src/media_store/models/scoped_file.dart create mode 100644 lib/src/media_store/models/scoped_file_system_entity.dart create mode 100644 lib/src/media_store/shared_storage_platform_interface.dart create mode 100644 test/scoped_file_test.dart diff --git a/android/build.gradle b/android/build.gradle index 4dd5e0f..3620e01 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,54 +1,54 @@ -group 'io.alexrintt.sharedstorage' -version '1.0-SNAPSHOT' - -buildscript { - ext.kotlin_version = '1.8.21' - ext.gradle_version = '7.4.2' - repositories { - google() - jcenter() - maven { url "https://oss.sonatype.org/content/repositories/snapshots" } - } - - dependencies { - classpath "com.android.tools.build:gradle:$gradle_version" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -rootProject.allprojects { - repositories { - google() - jcenter() - } -} - -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' - -android { - compileSdkVersion 33 - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - defaultConfig { - minSdkVersion 19 - } -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "androidx.documentfile:documentfile:1.0.1" - - /** - * Allow usage of `CoroutineScope` to run heavy - * computation and queries outside the Main (UI) Thread - */ - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1" - - /** - * `SimpleStorage` library - */ - implementation "com.anggrayudi:storage:1.3.0" -} +group 'io.alexrintt.sharedstorage' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.8.21' + ext.gradle_version = '7.4.2' + repositories { + google() + jcenter() + maven { url "https://oss.sonatype.org/content/repositories/snapshots" } + } + + dependencies { + classpath "com.android.tools.build:gradle:$gradle_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + jcenter() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 33 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + defaultConfig { + minSdkVersion 19 + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "androidx.documentfile:documentfile:1.0.1" + + /** + * Allow usage of `CoroutineScope` to run heavy + * computation and queries outside the Main (UI) Thread + */ + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1" + + /** + * `SimpleStorage` library + */ + implementation "com.anggrayudi:storage:1.3.0" +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/mediastore/MediaStoreApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/mediastore/MediaStoreApi.kt index ad2c2c7..3ed3db1 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/mediastore/MediaStoreApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/mediastore/MediaStoreApi.kt @@ -1,70 +1,248 @@ -package io.alexrintt.sharedstorage.mediastore - -import android.os.Build -import android.provider.MediaStore -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.alexrintt.sharedstorage.ROOT_CHANNEL -import io.alexrintt.sharedstorage.SharedStoragePlugin -import io.alexrintt.sharedstorage.plugin.API_29 -import io.alexrintt.sharedstorage.plugin.Listenable - -class MediaStoreApi(val plugin: SharedStoragePlugin) : MethodChannel.MethodCallHandler, Listenable { - private var channel: MethodChannel? = null - - companion object { - private const val GET_MEDIA_STORE_CONTENT_DIRECTORY = "getMediaStoreContentDirectory" - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - GET_MEDIA_STORE_CONTENT_DIRECTORY -> { - getMediaStoreContentDirectory( - result, - call.argument("collection") as String - ) - } - else -> result.notImplemented() - } - } - - private fun getMediaStoreContentDirectory( - result: MethodChannel.Result, - collection: String - ) = result.success(mediaStoreOf(collection)) - - /** - * Returns the [EXTERNAL_CONTENT_URI] of [MediaStore.] equivalent to [collection] - */ - private fun mediaStoreOf(collection: String): String? { - val mapper = mutableMapOf( - "MediaStoreCollection.Audio" to MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.path, - "MediaStoreCollection.Video" to MediaStore.Video.Media.EXTERNAL_CONTENT_URI.path, - "MediaStoreCollection.Images" to MediaStore.Images.Media.EXTERNAL_CONTENT_URI.path - ) - - if (Build.VERSION.SDK_INT >= API_29) { - mapper["MediaStoreCollection.Downloads"] = - MediaStore.Downloads.EXTERNAL_CONTENT_URI.path - } - - return mapper[collection] - } - - override fun startListening(binaryMessenger: BinaryMessenger) { - if (channel != null) { - stopListening() - } - - channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/mediastore") - channel?.setMethodCallHandler(this) - } - - override fun stopListening() { - if (channel == null) return - - channel?.setMethodCallHandler(null) - channel = null - } -} +package io.alexrintt.sharedstorage.mediastore + +import android.content.Context +import android.database.Cursor +import android.database.SQLException +import android.database.sqlite.SQLiteException +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns +import androidx.core.database.getStringOrNull +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.extension.isMediaDocument +import com.anggrayudi.storage.extension.isMediaFile +import com.anggrayudi.storage.extension.isTreeDocumentFile +import com.anggrayudi.storage.file.DocumentFileCompat +import com.anggrayudi.storage.file.id +import com.anggrayudi.storage.file.mimeType +import com.anggrayudi.storage.file.mimeTypeByFileName +import com.anggrayudi.storage.media.MediaStoreCompat +import io.alexrintt.sharedstorage.ROOT_CHANNEL +import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.deprecated.lib.getDocumentsContractColumns +import io.alexrintt.sharedstorage.plugin.Listenable +import io.flutter.Log +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + + +data class ScopedFileSystemEntity( + val id: String, + val mimeType: String?, + val length: Long, + val displayName: String, + val uri: Uri, + val parentUri: Uri?, + val lastModified: Long, + val entityType: String +) { + fun toMap(): Map { + return mapOf( + "id" to id, + "mimeType" to mimeType, + "length" to length, + "displayName" to displayName, + "uri" to uri.toString(), + "lastModified" to lastModified, + "parentUri" to parentUri?.toString(), + "entityType" to entityType, + ) + } +} + +fun getScopedFileSystemEntityFromMediaStoreUri( + context: Context, uri: Uri +): ScopedFileSystemEntity? { + val projection: MutableList = mutableListOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DOCUMENT_ID, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.SIZE, + MediaStore.MediaColumns.MIME_TYPE, + MediaStore.MediaColumns.DATE_MODIFIED, + // TODO: Add support for mime type specific files (e.g [ALBUM_ARTIST] when the file is a mp3) but all inside a [extra] map field to not pollute/modify the [ScopedFileSystemEntity] interface. + ) + + var cursor: Cursor? = try { + context.contentResolver.query( + uri, projection.toTypedArray(), null, null, null + ) + } catch (e: SQLiteException) { + // Some android 8.0 devices throw "DOCUMENT_ID is not a column" + projection.remove(MediaStore.MediaColumns.DOCUMENT_ID) + context.contentResolver.query( + uri, projection.toTypedArray(), null, null, null + ) + } + + if (cursor != null && cursor.moveToFirst()) { + val id: String? + val idColumn = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + id = cursor.getStringOrNull(idColumn) + + val documentId: String? + val documentIdColumn = + cursor.getColumnIndex(MediaStore.MediaColumns.DOCUMENT_ID) + documentId = cursor.getStringOrNull(documentIdColumn) + + val dateModified: Long + val dateModifiedColumn = + cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED) + dateModified = cursor.getLong(dateModifiedColumn) + + val displayName: String + val displayNameColumn = + cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) + displayName = cursor.getString(displayNameColumn) + + val mimeType: String + val mimeTypeColumn = + cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) + mimeType = cursor.getString(mimeTypeColumn) + + val size: Long + val sizeColumn = cursor.getColumnIndex(MediaStore.MediaColumns.SIZE) + size = cursor.getLong(sizeColumn) + + cursor.close() + + return ScopedFileSystemEntity( + id = documentId ?: id ?: uri.toString(), + mimeType = mimeType, + length = size, + displayName = displayName, + uri = uri, + parentUri = null, + lastModified = dateModified, + entityType = "file" + ) + } + + return null +} + +fun getOpenableUriNameAndLength( + context: Context, uri: Uri +): Pair { + context.contentResolver.query(uri, null, null, null, null)?.use { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + it.moveToFirst() + return Pair(it.getString(nameIndex), it.getLong(sizeIndex)) + } + + return Pair(null, null) +} + +fun getScopedFileSystemEntityFromSafUri( + context: Context, uri: Uri +): ScopedFileSystemEntity? { + val documentTree = DocumentFileCompat.fromUri(context, uri) ?: return null + + return ScopedFileSystemEntity( + id = documentTree.id, + displayName = documentTree.name ?: Uri.decode(uri.lastPathSegment), + uri = uri, + length = documentTree.length(), + parentUri = documentTree.parentFile?.uri, + mimeType = documentTree.mimeType ?: documentTree.mimeTypeByFileName, + lastModified = documentTree.lastModified(), + entityType = if (documentTree.isDirectory) "directory" else "file" + ) +} + +fun getScopedFileSystemEntityFromUri( + context: Context, uri: Uri +): ScopedFileSystemEntity? { + Log.d("getScopedFileSystemEntityFromUri1", uri.isMediaFile.toString()) + Log.d("getScopedFileSystemEntityFromUri1", uri.authority.toString()) + + // Some devices do return "0@media" as URI authority when "sharing with" + // so we need try to parse this URI using everything we can and know because scoped storage (SAF cof cof) is just about it: + // parse unknown URIs using unknown columns by unknown providers with unknown or behavior, accept it. + return getScopedFileSystemEntityFromUriUsingPredeterminedConstantConditionStrategy( + context, + uri + ) ?: getScopedFileSystemEntityFromUriUsingTryCatchStrategy(context, uri) +} + +fun getScopedFileSystemEntityFromUriUsingPredeterminedConstantConditionStrategy( + context: Context, + uri: Uri +): ScopedFileSystemEntity? { + try { + return when { + uri.isMediaFile || uri.isMediaDocument -> getScopedFileSystemEntityFromMediaStoreUri( + context, + uri + ) + + else -> getScopedFileSystemEntityFromSafUri(context, uri) + } + } catch (e: Throwable) { + Log.d( + "URI PARSE FAILED", + "[getScopedFileSystemEntityFromUriUsingPredeterminedConstantConditionStrategy] failed to parse URI $uri. Error: $e" + ) + return null + } +} + +fun getScopedFileSystemEntityFromUriUsingTryCatchStrategy( + context: Context, + uri: Uri +): ScopedFileSystemEntity? { + return try { + getScopedFileSystemEntityFromMediaStoreUri(context, uri) + } catch (e: Throwable) { + getScopedFileSystemEntityFromSafUri(context, uri) + } +} + + +class MediaStoreApi(val plugin: SharedStoragePlugin) : + MethodChannel.MethodCallHandler, Listenable { + private var channel: MethodChannel? = null + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getScopedFileSystemEntityFromUri" -> { + val uri = Uri.parse(call.argument("uri")!!) + + val scopedFile = getScopedFileSystemEntityFromUri( + plugin.context.applicationContext, uri + ) + + if (scopedFile == null) { + result.error( + "NOT_FOUND", + "The URI $uri was not found: did not return any results", + mapOf("uri" to uri.toString()) + ) + } else { + result.success(scopedFile.toMap()) + } + } + + else -> result.notImplemented() + } + } + + override fun startListening(binaryMessenger: BinaryMessenger) { + if (channel != null) { + stopListening() + } + + channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/mediastore") + channel?.setMethodCallHandler(this) + } + + override fun stopListening() { + if (channel == null) return + + channel?.setMethodCallHandler(null) + channel = null + } +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt index 425131f..a3e6dfd 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt @@ -1,778 +1,779 @@ -package io.alexrintt.sharedstorage.storageaccessframework - -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.provider.DocumentsContract -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.extension.isTreeDocumentFile -import com.anggrayudi.storage.file.child -import io.alexrintt.sharedstorage.ROOT_CHANNEL -import io.alexrintt.sharedstorage.SharedStoragePlugin -import io.alexrintt.sharedstorage.deprecated.lib.* -import io.alexrintt.sharedstorage.plugin.* -import io.alexrintt.sharedstorage.storageaccessframework.* -import io.alexrintt.sharedstorage.storageaccessframework.lib.* -import io.flutter.plugin.common.* -import io.flutter.plugin.common.EventChannel.StreamHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.* - - -/** - * Aimed to implement strictly only the APIs already available from the native and original - * `DocumentFile` API - * - * Basically, this is just an adapter of the native `DocumentFile` class to a Flutter Plugin class, - * without any modifications or abstractions - */ -internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : - MethodChannel.MethodCallHandler, PluginRegistry.ActivityResultListener, - Listenable, ActivityListener, StreamHandler { - private val pendingResults: MutableMap> = - mutableMapOf() - private var channel: MethodChannel? = null - private var eventChannel: EventChannel? = null - private var eventSink: EventChannel.EventSink? = null - - private val openInputStreams = mutableMapOf() - - companion object { - private const val CHANNEL = "documentfile" - } - - private fun readBytes( - inputStream: InputStream, - offset: Int, - bufferSize: Int = 1024, - ): Pair { - val buffer = ByteArray(bufferSize) - val readBufferSize = inputStream.read(buffer, offset, bufferSize) - return Pair(buffer, readBufferSize) - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - OPEN_INPUT_STREAM -> { - val uri = Uri.parse(call.argument("uri")!!) - val callId = call.argument("callId")!! - - if (openInputStreams[callId] != null) { - // There is already a open input stream for this URI - result.success(null) - } else { - val (inputStream, exception) = openInputStream(uri) - if (exception != null) { - result.error( - exception.code, exception.details, mapOf("uri" to uri.toString()) - ) - } else if (inputStream != null) { - openInputStreams[callId] = inputStream - result.success(null) - } else { - result.error( - "INTERNAL_ERROR", - "[openInputStream] doesn't returned errors neither success", - mapOf("uri" to uri.toString(), "callId" to callId) - ) - } - } - } - - CLOSE_INPUT_STREAM -> { - val callId = call.argument("callId")!! - openInputStreams[callId]?.close() - openInputStreams.remove(callId) - result.success(null) - } - - READ_INPUT_STREAM -> { - val callId = call.argument("callId")!! - val offset = call.argument("offset")!! - val bufferSize = call.argument("bufferSize")!! - val inputStream = openInputStreams[callId] - - if (inputStream == null) { - result.error( - "NOT_FOUND", - "Input stream was not found opened, please, call [openInputStream] method before", - mapOf("callId" to callId) - ) - } else { - CoroutineScope(Dispatchers.IO).launch { - try { - val (bytes, readBufferSize) = readBytes( - inputStream, offset, bufferSize - ) - launch(Dispatchers.Main) { - result.success( - mapOf("bytes" to bytes, "readBufferSize" to readBufferSize) - ) - } - } catch (e: IOException) { - // Concurrent reads or too fast reads (opening, closing, opening, closing, opening, ...) - } - } - } - } - - OPEN_DOCUMENT -> if (Build.VERSION.SDK_INT >= API_21) { - openDocument(call, result) - } - - OPEN_DOCUMENT_TREE -> if (Build.VERSION.SDK_INT >= API_21) { - openDocumentTree(call, result) - } - - CREATE_FILE -> if (Build.VERSION.SDK_INT >= API_21) { - createFile( - result, - call.argument("mimeType")!!, - call.argument("displayName")!!, - call.argument("directoryUri")!!, - call.argument("content")!! - ) - } - - WRITE_TO_FILE -> writeToFile( - result, - call.argument("uri")!!, - call.argument("content")!!, - call.argument("mode")!! - ) - - PERSISTED_URI_PERMISSIONS -> persistedUriPermissions(result) - - RELEASE_PERSISTABLE_URI_PERMISSION -> releasePersistableUriPermission( - result, call.argument("uri") as String - ) - - FROM_TREE_URI -> if (Build.VERSION.SDK_INT >= API_21) { - result.success( - createDocumentFileMap( - documentFromUri( - plugin.context, call.argument("uri") as String - ) - ) - ) - } - - CAN_WRITE -> if (Build.VERSION.SDK_INT >= API_21) { - result.success( - documentFromUri( - plugin.context, call.argument("uri") as String - )?.canWrite() - ) - } - - CAN_READ -> if (Build.VERSION.SDK_INT >= API_21) { - val uri = call.argument("uri") as String - - result.success(documentFromUri(plugin.context, uri)?.canRead()) - } - - LENGTH -> if (Build.VERSION.SDK_INT >= API_21) { - result.success( - documentFromUri( - plugin.context, call.argument("uri") as String - )?.length() - ) - } - - EXISTS -> if (Build.VERSION.SDK_INT >= API_21) { - result.success( - documentFromUri( - plugin.context, call.argument("uri") as String - )?.exists() - ) - } - - DELETE -> if (Build.VERSION.SDK_INT >= API_21) { - try { - result.success( - documentFromUri( - plugin.context, call.argument("uri") as String - )?.delete() - ) - } catch (e: FileNotFoundException) { - // File is already deleted. - result.success(null) - } catch (e: IllegalStateException) { - // File is already deleted. - result.success(null) - } catch (e: IllegalArgumentException) { - // File is already deleted. - result.success(null) - } catch (e: IOException) { - // Unknown, can be anything. - result.success(null) - } catch (e: Throwable) { - Log.d( - "sharedstorage", - "Unknown error when calling [delete] method with [uri]." - ) - // Unknown, can be anything. - result.success(null) - } - } - - LAST_MODIFIED -> if (Build.VERSION.SDK_INT >= API_21) { - val document = documentFromUri( - plugin.context, call.argument("uri") as String - ) - - result.success(document?.lastModified()) - } - - CREATE_DIRECTORY -> { - if (Build.VERSION.SDK_INT >= API_21) { - val uri = call.argument("uri") as String - val displayName = call.argument("displayName") as String - - val createdDirectory = - documentFromUri(plugin.context, uri)?.createDirectory(displayName) - ?: return - - result.success(createDocumentFileMap(createdDirectory)) - } else { - result.notSupported(call.method, API_21) - } - } - - FIND_FILE -> { - if (Build.VERSION.SDK_INT >= API_21) { - val uri = call.argument("uri") as String - val displayName = call.argument("displayName") as String - - result.success( - createDocumentFileMap( - documentFromUri( - plugin.context, uri - )?.findFile(displayName) - ) - ) - } - } - - COPY -> { - val uri = Uri.parse(call.argument("uri")!!) - val destination = Uri.parse(call.argument("destination")!!) - - if (Build.VERSION.SDK_INT >= API_21) { - CoroutineScope(Dispatchers.IO).launch { - withContext(Dispatchers.IO) { - val (inputStream) = openInputStream(uri) - val outputStream = openOutputStream(destination) - - // TODO: Implement progress indicator by re-writing the [copyTo] impl with an optional callback fn. - outputStream?.let { inputStream?.copyTo(it) } - - inputStream?.close() - outputStream?.close() - } - - launch(Dispatchers.Main) { - result.success(null) - } - } - } else { - result.notSupported( - RENAME_TO, - API_21, - mapOf("uri" to "$uri", "destination" to "$destination") - ) - } - } - - RENAME_TO -> { - val uri = call.argument("uri") as String - val displayName = call.argument("displayName") as String - - if (Build.VERSION.SDK_INT >= API_21) { - documentFromUri(plugin.context, uri)?.apply { - val success = renameTo(displayName) - - result.success( - if (success) createDocumentFileMap( - documentFromUri( - plugin.context, this.uri - )!! - ) - else null - ) - } - } else { - result.notSupported( - RENAME_TO, API_21, mapOf("uri" to uri, "displayName" to displayName) - ) - } - } - - PARENT_FILE -> { - val uri = call.argument("uri")!! - - if (Build.VERSION.SDK_INT >= API_21) { - val parent = documentFromUri(plugin.context, uri)?.parentFile - - result.success(if (parent != null) createDocumentFileMap(parent) else null) - } else { - result.notSupported(PARENT_FILE, API_21, mapOf("uri" to uri)) - } - } - - CHILD -> { - val uri = call.argument("uri")!! - val path = call.argument("path")!! - val requiresWriteAccess = - call.argument("requiresWriteAccess") ?: false - - if (Build.VERSION.SDK_INT >= API_21) { - val document = documentFromUri(plugin.context, uri) - val childDocument = - document?.child(plugin.context, path, requiresWriteAccess) - - result.success(createDocumentFileMap(childDocument)) - } else { - result.notSupported(CHILD, API_21, mapOf("uri" to uri)) - } - } - - else -> result.notImplemented() - } - } - - @RequiresApi(API_21) - private fun openDocument(call: MethodCall, result: MethodChannel.Result) { - val initialUri = call.argument("initialUri") - val grantWritePermission = call.argument("grantWritePermission")!! - val persistablePermission = - call.argument("persistablePermission")!! - - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - if (persistablePermission) { - addFlags( - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - ) - } - - if (grantWritePermission) { - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - } - - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - - if (initialUri != null) { - val tree = - DocumentFile.fromTreeUri(plugin.context, Uri.parse(initialUri)) - if (Build.VERSION.SDK_INT >= API_26) { - putExtra(DocumentsContract.EXTRA_INITIAL_URI, tree?.uri) - } - } - - type = call.argument("mimeType") ?: "*/*" - putExtra( - Intent.EXTRA_ALLOW_MULTIPLE, call.argument("multiple") ?: false - ) - } - - if (pendingResults[OPEN_DOCUMENT_CODE] != null) return - - pendingResults[OPEN_DOCUMENT_CODE] = Pair(call, result) - - plugin.binding?.activity?.startActivityForResult(intent, OPEN_DOCUMENT_CODE) - } - - @RequiresApi(API_21) - private fun openDocumentTree(call: MethodCall, result: MethodChannel.Result) { - val initialUri = call.argument("initialUri") - val grantWritePermission = call.argument("grantWritePermission")!! - val persistablePermission = - call.argument("persistablePermission")!! - - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { - if (persistablePermission) { - addFlags( - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - ) - } - - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - - if (grantWritePermission) { - addFlags( - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } - - if (initialUri != null) { - val tree = - DocumentFile.fromTreeUri(plugin.context, Uri.parse(initialUri)) - - if (Build.VERSION.SDK_INT >= API_26) { - putExtra( - if (Build.VERSION.SDK_INT >= API_26) DocumentsContract.EXTRA_INITIAL_URI - else DOCUMENTS_CONTRACT_EXTRA_INITIAL_URI, tree?.uri - ) - } - } - } - - if (pendingResults[OPEN_DOCUMENT_TREE_CODE] != null) return - - pendingResults[OPEN_DOCUMENT_TREE_CODE] = Pair(call, result) - - plugin.binding?.activity?.startActivityForResult( - intent, OPEN_DOCUMENT_TREE_CODE - ) - } - - @RequiresApi(API_21) - private fun createFile( - result: MethodChannel.Result, - mimeType: String, - displayName: String, - directory: String, - content: ByteArray - ) { - createFile(Uri.parse(directory), mimeType, displayName, content) { - result.success(createDocumentFileMap(this)) - } - } - - @RequiresApi(Build.VERSION_CODES.LOLLIPOP) - private fun createFile( - treeUri: Uri, - mimeType: String, - displayName: String, - content: ByteArray, - block: DocumentFile?.() -> Unit - ) { - CoroutineScope(Dispatchers.IO).launch { - val createdFile = documentFromUri(plugin.context, treeUri)!!.createFile( - mimeType, displayName - ) - - createdFile?.uri?.apply { - kotlin.runCatching { - plugin.context.contentResolver.openOutputStream(this)?.use { - it.write(content) - it.flush() - - val createdFileDocument = - documentFromUri(plugin.context, createdFile.uri) - - launch(Dispatchers.Main) { - block(createdFileDocument) - } - } - } - } - } - } - - private fun writeToFile( - result: MethodChannel.Result, uri: String, content: ByteArray, mode: String - ) { - try { - plugin.context.contentResolver.openOutputStream(Uri.parse(uri), mode) - ?.apply { - write(content) - flush() - close() - - result.success(true) - } - } catch (e: Exception) { - result.success(false) - } - } - - private fun persistedUriPermissions(result: MethodChannel.Result) { - val persistedUriPermissions = - plugin.context.contentResolver.persistedUriPermissions - - result.success(persistedUriPermissions.map { - mapOf( - "isReadPermission" to it.isReadPermission, - "isWritePermission" to it.isWritePermission, - "persistedTime" to it.persistedTime, - "uri" to "${it.uri}", - "isTreeDocumentFile" to it.uri.isTreeDocumentFile - ) - }.toList()) - } - - private fun releasePersistableUriPermission( - result: MethodChannel.Result, directoryUri: String - ) { - val targetUri = Uri.parse(directoryUri) - - val persistedUriPermissions = - plugin.context.contentResolver.persistedUriPermissions - - for (persistedUriPermission in persistedUriPermissions) { - if (persistedUriPermission.uri == targetUri) { - var flags = 0 - - if (persistedUriPermission.isReadPermission) { - flags = flags or Intent.FLAG_GRANT_READ_URI_PERMISSION - } - - if (persistedUriPermission.isWritePermission) { - flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - } - - plugin.context.contentResolver.releasePersistableUriPermission( - targetUri, flags - ) - } - } - - result.success(null) - } - - override fun onActivityResult( - requestCode: Int, resultCode: Int, resultIntent: Intent? - ): Boolean { - when (requestCode) { - OPEN_DOCUMENT_TREE_CODE -> { - val pendingResult = - pendingResults[OPEN_DOCUMENT_TREE_CODE] ?: return false - - val grantWritePermission = - pendingResult.first.argument("grantWritePermission")!! - val persistablePermission = - pendingResult.first.argument("persistablePermission")!! - - try { - val uri = resultIntent?.data - - if (uri != null) { - if (persistablePermission) { - if (grantWritePermission) { - plugin.context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } else { - plugin.context.contentResolver.takePersistableUriPermission( - uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } - } - - pendingResult.second.success("$uri") - - return true - } - - pendingResult.second.success(null) - } finally { - pendingResults.remove(OPEN_DOCUMENT_TREE_CODE) - } - } - - OPEN_DOCUMENT_CODE -> { - val pendingResult = pendingResults[OPEN_DOCUMENT_CODE] ?: return false - - val grantWritePermission = - pendingResult.first.argument("grantWritePermission")!! - val persistablePermission = - pendingResult.first.argument("persistablePermission")!! - - try { - // if data.clipData not null, uriList from data.clipData, else uriList is data.data - val uriList = resultIntent?.clipData?.let { - (0 until it.itemCount).map { i -> it.getItemAt(i).uri } - } ?: resultIntent?.data?.let { listOf(it) } - - // After some experiments, I noticed that you need grant both (read and write permission) - // otherwise, when rebooting the read permission will fail). - fun persistUriListWithWritePermissionAndReadPermission() { - for (uri in uriList!!) { - plugin.context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - } - - fun persistUriListWithReadPermissionOnly() { - for (uri in uriList!!) { - plugin.context.contentResolver.takePersistableUriPermission( - uri, Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - } - - fun persistUriList() { - if (grantWritePermission) { - persistUriListWithWritePermissionAndReadPermission() - } else { - persistUriListWithReadPermissionOnly() - } - } - - if (uriList != null) { - if (persistablePermission) { - persistUriList() - } - - pendingResult.second.success(uriList.map { "$it" }) - - return true - } - - pendingResult.second.success(null) - } finally { - pendingResults.remove(OPEN_DOCUMENT_CODE) - } - } - } - - return false - } - - override fun startListening(binaryMessenger: BinaryMessenger) { - if (channel != null) stopListening() - - channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") - channel?.setMethodCallHandler(this) - - eventChannel = EventChannel(binaryMessenger, "$ROOT_CHANNEL/event/$CHANNEL") - eventChannel?.setStreamHandler(this) - } - - override fun stopListening() { - if (channel == null) return - - channel?.setMethodCallHandler(null) - channel = null - - eventChannel?.setStreamHandler(null) - eventChannel = null - } - - override fun startListeningToActivity() { - plugin.binding?.addActivityResultListener(this) - } - - override fun stopListeningToActivity() { - plugin.binding?.removeActivityResultListener(this) - } - - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - val args = arguments as Map<*, *> - - eventSink = events - - when (args["event"]) { - LIST_FILES -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - listFilesEvent(eventSink, args) - } - } - } - - /** - * Read files of a given `uri` and dispatches all files under it through the `eventSink` and - * closes the stream after the last record - * - * Useful to read files under a `uri` with a large set of children - */ - private fun listFilesEvent( - eventSink: EventChannel.EventSink?, args: Map<*, *> - ) { - if (eventSink == null) return - - val userProvidedColumns = args["columns"] as List<*> - val uri = Uri.parse(args["uri"] as String) - val document = DocumentFile.fromTreeUri(plugin.context, uri) - - if (document == null) { - eventSink.error( - EXCEPTION_NOT_SUPPORTED, - "Android SDK must be greater or equal than [Build.VERSION_CODES.N]", - "Got (Build.VERSION.SDK_INT): ${Build.VERSION.SDK_INT}" - ) - eventSink.endOfStream() - } else { - if (!document.canRead()) { - val error = "You cannot read a URI that you don't have read permissions" - - Log.d("NO PERMISSION!!!", error) - - eventSink.error( - EXCEPTION_MISSING_PERMISSIONS, error, mapOf("uri" to args["uri"]) - ) - - eventSink.endOfStream() - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - CoroutineScope(Dispatchers.IO).launch { - try { - traverseDirectoryEntries( - plugin.context.contentResolver, - rootOnly = true, - targetUri = document.uri, - columns = userProvidedColumns.map { - // Convert the user provided column string to documentscontract column ID. - documentFileColumnToActualDocumentsContractEnumString( - deserializeDocumentFileColumn(it as String)!! - ) - }.toTypedArray() - ) { data, _ -> - launch(Dispatchers.Main) { - eventSink.success( - data - ) - } - } - } finally { - launch(Dispatchers.Main) { eventSink.endOfStream() } - } - } - } else { - eventSink.endOfStream() - } - } - } - } - - - /** Alias for `plugin.context.contentResolver.openOutputStream(uri)` */ - private fun openOutputStream(uri: Uri): OutputStream? { - return plugin.context.contentResolver.openOutputStream(uri) - } - - /** Alias for `plugin.context.contentResolver.openInputStream(uri)` */ - private fun openInputStream(uri: Uri): Pair { - return try { - Pair(plugin.context.contentResolver.openInputStream(uri), null) - } catch (e: FileNotFoundException) { - // Probably the file was already deleted and now you are trying to read. - Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) - } catch (e: IOException) { - // Unknown, can be anything. - Pair(null, PlatformException("INTERNAL_ERROR", e.toString())) - } catch (e: IllegalArgumentException) { - // Probably the file was already deleted and now you are trying to read. - Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) - } catch (e: IllegalStateException) { - // Probably you ran [delete] and [readDocumentContent] at the same time. - Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) - } - } - - override fun onCancel(arguments: Any?) { - eventSink?.endOfStream() - eventSink = null - } -} - -data class PlatformException(val code: String, val details: String) +package io.alexrintt.sharedstorage.storageaccessframework + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.extension.isTreeDocumentFile +import com.anggrayudi.storage.file.child +import io.alexrintt.sharedstorage.ROOT_CHANNEL +import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.deprecated.lib.* +import io.alexrintt.sharedstorage.plugin.* +import io.alexrintt.sharedstorage.storageaccessframework.* +import io.alexrintt.sharedstorage.storageaccessframework.lib.* +import io.flutter.plugin.common.* +import io.flutter.plugin.common.EventChannel.StreamHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.* + + +/** + * Aimed to implement strictly only the APIs already available from the native and original + * `DocumentFile` API + * + * Basically, this is just an adapter of the native `DocumentFile` class to a Flutter Plugin class, + * without any modifications or abstractions + */ +internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : + MethodChannel.MethodCallHandler, PluginRegistry.ActivityResultListener, + Listenable, ActivityListener, StreamHandler { + private val pendingResults: MutableMap> = + mutableMapOf() + private var channel: MethodChannel? = null + private var eventChannel: EventChannel? = null + private var eventSink: EventChannel.EventSink? = null + + private val openInputStreams = mutableMapOf() + + companion object { + private const val CHANNEL = "documentfile" + } + + private fun readBytes( + inputStream: InputStream, + offset: Int, + bufferSize: Int = 1024, + ): Pair { + var buffer = ByteArray(bufferSize) + val readBufferSize = inputStream.read(buffer, offset, bufferSize) + + if (readBufferSize == -1) { + buffer = ByteArray(0) + } else if (readBufferSize < bufferSize) { + buffer = buffer.copyOfRange(0, readBufferSize) + } + + return Pair(buffer, readBufferSize) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + OPEN_INPUT_STREAM -> { + val uri = Uri.parse(call.argument("uri")!!) + val callId = call.argument("callId")!! + + if (openInputStreams[callId] != null) { + // There is already a open input stream for this URI + result.success(null) + } else { + val (inputStream, exception) = openInputStream(uri) + if (exception != null) { + result.error( + exception.code, exception.details, mapOf("uri" to uri.toString()) + ) + } else if (inputStream != null) { + openInputStreams[callId] = inputStream + result.success(null) + } else { + result.error( + "INTERNAL_ERROR", + "[openInputStream] doesn't returned errors neither success", + mapOf("uri" to uri.toString(), "callId" to callId) + ) + } + } + } + + CLOSE_INPUT_STREAM -> { + val callId = call.argument("callId")!! + openInputStreams[callId]?.close() + openInputStreams.remove(callId) + result.success(null) + } + + READ_INPUT_STREAM -> { + val callId = call.argument("callId")!! + val offset = call.argument("offset")!! + val bufferSize = call.argument("bufferSize")!! + val inputStream = openInputStreams[callId] + + if (inputStream == null) { + result.error( + "NOT_FOUND", + "Input stream was not found opened, please, call [openInputStream] method before", + mapOf("callId" to callId) + ) + } else { + CoroutineScope(Dispatchers.IO).launch { + try { + val (bytes, readBufferSize) = readBytes( + inputStream, offset, bufferSize + ) + launch(Dispatchers.Main) { + result.success( + mapOf("bytes" to bytes, "readBufferSize" to readBufferSize) + ) + } + } catch (e: IOException) { + // Concurrent reads or too fast reads (opening, closing, opening, closing, opening, ...) + } + } + } + } + + OPEN_DOCUMENT -> if (Build.VERSION.SDK_INT >= API_21) { + openDocument(call, result) + } + + OPEN_DOCUMENT_TREE -> if (Build.VERSION.SDK_INT >= API_21) { + openDocumentTree(call, result) + } + + CREATE_FILE -> if (Build.VERSION.SDK_INT >= API_21) { + createFile( + result, + call.argument("mimeType")!!, + call.argument("displayName")!!, + call.argument("directoryUri")!!, + call.argument("content")!! + ) + } + + WRITE_TO_FILE -> writeToFile( + result, + call.argument("uri")!!, + call.argument("content")!!, + call.argument("mode")!! + ) + + PERSISTED_URI_PERMISSIONS -> persistedUriPermissions(result) + + RELEASE_PERSISTABLE_URI_PERMISSION -> releasePersistableUriPermission( + result, call.argument("uri") as String + ) + + FROM_TREE_URI -> if (Build.VERSION.SDK_INT >= API_21) { + result.success( + createDocumentFileMap( + documentFromUri( + plugin.context, call.argument("uri") as String + ) + ) + ) + } + + CAN_WRITE -> if (Build.VERSION.SDK_INT >= API_21) { + result.success( + documentFromUri( + plugin.context, call.argument("uri") as String + )?.canWrite() + ) + } + + CAN_READ -> if (Build.VERSION.SDK_INT >= API_21) { + val uri = call.argument("uri") as String + + result.success(documentFromUri(plugin.context, uri)?.canRead()) + } + + LENGTH -> if (Build.VERSION.SDK_INT >= API_21) { + result.success( + documentFromUri( + plugin.context, call.argument("uri") as String + )?.length() + ) + } + + EXISTS -> if (Build.VERSION.SDK_INT >= API_21) { + result.success( + documentFromUri( + plugin.context, call.argument("uri") as String + )?.exists() + ) + } + + DELETE -> if (Build.VERSION.SDK_INT >= API_21) { + try { + result.success( + documentFromUri( + plugin.context, call.argument("uri") as String + )?.delete() + ) + } catch (e: FileNotFoundException) { + // File is already deleted. + result.success(null) + } catch (e: IllegalStateException) { + // File is already deleted. + result.success(null) + } catch (e: IllegalArgumentException) { + // File is already deleted. + result.success(null) + } catch (e: IOException) { + // Unknown, can be anything. + result.success(null) + } catch (e: Throwable) { + Log.d( + "sharedstorage", + "Unknown error when calling [delete] method with [uri]." + ) + // Unknown, can be anything. + result.success(null) + } + } + + LAST_MODIFIED -> if (Build.VERSION.SDK_INT >= API_21) { + val document = documentFromUri( + plugin.context, call.argument("uri") as String + ) + + result.success(document?.lastModified()) + } + + CREATE_DIRECTORY -> { + if (Build.VERSION.SDK_INT >= API_21) { + val uri = call.argument("uri") as String + val displayName = call.argument("displayName") as String + + val createdDirectory = + documentFromUri(plugin.context, uri)?.createDirectory(displayName) + ?: return + + result.success(createDocumentFileMap(createdDirectory)) + } else { + result.notSupported(call.method, API_21) + } + } + + FIND_FILE -> { + if (Build.VERSION.SDK_INT >= API_21) { + val uri = call.argument("uri") as String + val displayName = call.argument("displayName") as String + + result.success( + createDocumentFileMap( + documentFromUri( + plugin.context, uri + )?.findFile(displayName) + ) + ) + } + } + + COPY -> { + val uri = Uri.parse(call.argument("uri")!!) + val destination = Uri.parse(call.argument("destination")!!) + + if (Build.VERSION.SDK_INT >= API_21) { + CoroutineScope(Dispatchers.IO).launch { + withContext(Dispatchers.IO) { + val (inputStream) = openInputStream(uri) + val outputStream = openOutputStream(destination) + + // TODO: Implement progress indicator by re-writing the [copyTo] impl with an optional callback fn. + outputStream?.let { inputStream?.copyTo(it) } + + inputStream?.close() + outputStream?.close() + } + + launch(Dispatchers.Main) { + result.success(null) + } + } + } else { + result.notSupported( + RENAME_TO, + API_21, + mapOf("uri" to "$uri", "destination" to "$destination") + ) + } + } + + RENAME_TO -> { + val uri = call.argument("uri") as String + val displayName = call.argument("displayName") as String + + if (Build.VERSION.SDK_INT >= API_21) { + documentFromUri(plugin.context, uri)?.apply { + val success = renameTo(displayName) + + result.success( + if (success) createDocumentFileMap( + documentFromUri( + plugin.context, this.uri + )!! + ) + else null + ) + } + } else { + result.notSupported( + RENAME_TO, API_21, mapOf("uri" to uri, "displayName" to displayName) + ) + } + } + + PARENT_FILE -> { + val uri = call.argument("uri")!! + + if (Build.VERSION.SDK_INT >= API_21) { + val parent = documentFromUri(plugin.context, uri)?.parentFile + + result.success(if (parent != null) createDocumentFileMap(parent) else null) + } else { + result.notSupported(PARENT_FILE, API_21, mapOf("uri" to uri)) + } + } + + CHILD -> { + val uri = call.argument("uri")!! + val path = call.argument("path")!! + val requiresWriteAccess = + call.argument("requiresWriteAccess") ?: false + + if (Build.VERSION.SDK_INT >= API_21) { + val document = documentFromUri(plugin.context, uri) + val childDocument = + document?.child(plugin.context, path, requiresWriteAccess) + + result.success(createDocumentFileMap(childDocument)) + } else { + result.notSupported(CHILD, API_21, mapOf("uri" to uri)) + } + } + + else -> result.notImplemented() + } + } + + @RequiresApi(API_21) + private fun openDocument(call: MethodCall, result: MethodChannel.Result) { + val initialUri = call.argument("initialUri") + val grantWritePermission = call.argument("grantWritePermission")!! + val persistablePermission = + call.argument("persistablePermission")!! + + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + if (persistablePermission) { + addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + } + + if (grantWritePermission) { + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + if (initialUri != null) { + val tree = + DocumentFile.fromTreeUri(plugin.context, Uri.parse(initialUri)) + if (Build.VERSION.SDK_INT >= API_26) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, tree?.uri) + } + } + + type = call.argument("mimeType") ?: "*/*" + putExtra( + Intent.EXTRA_ALLOW_MULTIPLE, call.argument("multiple") ?: false + ) + } + + if (pendingResults[OPEN_DOCUMENT_CODE] != null) return + + pendingResults[OPEN_DOCUMENT_CODE] = Pair(call, result) + + plugin.binding?.activity?.startActivityForResult(intent, OPEN_DOCUMENT_CODE) + } + + @RequiresApi(API_21) + private fun openDocumentTree(call: MethodCall, result: MethodChannel.Result) { + val initialUri = call.argument("initialUri") + val grantWritePermission = call.argument("grantWritePermission")!! + val persistablePermission = + call.argument("persistablePermission")!! + + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + if (persistablePermission) { + addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + } + + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + if (grantWritePermission) { + addFlags( + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + + if (initialUri != null) { + val tree = + DocumentFile.fromTreeUri(plugin.context, Uri.parse(initialUri)) + + if (Build.VERSION.SDK_INT >= API_26) { + putExtra( + if (Build.VERSION.SDK_INT >= API_26) DocumentsContract.EXTRA_INITIAL_URI + else DOCUMENTS_CONTRACT_EXTRA_INITIAL_URI, tree?.uri + ) + } + } + } + + if (pendingResults[OPEN_DOCUMENT_TREE_CODE] != null) return + + pendingResults[OPEN_DOCUMENT_TREE_CODE] = Pair(call, result) + + plugin.binding?.activity?.startActivityForResult( + intent, OPEN_DOCUMENT_TREE_CODE + ) + } + + @RequiresApi(API_21) + private fun createFile( + result: MethodChannel.Result, + mimeType: String, + displayName: String, + directory: String, + content: ByteArray + ) { + createFile(Uri.parse(directory), mimeType, displayName, content) { + result.success(createDocumentFileMap(this)) + } + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private fun createFile( + treeUri: Uri, + mimeType: String, + displayName: String, + content: ByteArray, + block: DocumentFile?.() -> Unit + ) { + CoroutineScope(Dispatchers.IO).launch { + val createdFile = documentFromUri(plugin.context, treeUri)!!.createFile( + mimeType, displayName + ) + + createdFile?.uri?.apply { + kotlin.runCatching { + plugin.context.contentResolver.openOutputStream(this)?.use { + it.write(content) + it.flush() + + val createdFileDocument = + documentFromUri(plugin.context, createdFile.uri) + + launch(Dispatchers.Main) { + block(createdFileDocument) + } + } + } + } + } + } + + private fun writeToFile( + result: MethodChannel.Result, uri: String, content: ByteArray, mode: String + ) { + try { + plugin.context.contentResolver.openOutputStream(Uri.parse(uri), mode) + ?.apply { + write(content) + flush() + close() + + result.success(true) + } + } catch (e: Exception) { + result.success(false) + } + } + + private fun persistedUriPermissions(result: MethodChannel.Result) { + val persistedUriPermissions = + plugin.context.contentResolver.persistedUriPermissions + + result.success(persistedUriPermissions.map { + mapOf( + "isReadPermission" to it.isReadPermission, + "isWritePermission" to it.isWritePermission, + "persistedTime" to it.persistedTime, + "uri" to "${it.uri}", + "isTreeDocumentFile" to it.uri.isTreeDocumentFile + ) + }.toList()) + } + + private fun releasePersistableUriPermission( + result: MethodChannel.Result, directoryUri: String + ) { + val targetUri = Uri.parse(directoryUri) + + val persistedUriPermissions = + plugin.context.contentResolver.persistedUriPermissions + + for (persistedUriPermission in persistedUriPermissions) { + if (persistedUriPermission.uri == targetUri) { + var flags = 0 + + if (persistedUriPermission.isReadPermission) { + flags = flags or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + + if (persistedUriPermission.isWritePermission) { + flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + } + + plugin.context.contentResolver.releasePersistableUriPermission( + targetUri, flags + ) + } + } + + result.success(null) + } + + override fun onActivityResult( + requestCode: Int, resultCode: Int, resultIntent: Intent? + ): Boolean { + when (requestCode) { + OPEN_DOCUMENT_TREE_CODE -> { + val pendingResult = + pendingResults[OPEN_DOCUMENT_TREE_CODE] ?: return false + + val grantWritePermission = + pendingResult.first.argument("grantWritePermission")!! + val persistablePermission = + pendingResult.first.argument("persistablePermission")!! + + try { + val uri = resultIntent?.data + + if (uri != null) { + if (persistablePermission) { + if (grantWritePermission) { + plugin.context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } else { + plugin.context.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + } + + pendingResult.second.success("$uri") + + return true + } + + pendingResult.second.success(null) + } finally { + pendingResults.remove(OPEN_DOCUMENT_TREE_CODE) + } + } + + OPEN_DOCUMENT_CODE -> { + val pendingResult = pendingResults[OPEN_DOCUMENT_CODE] ?: return false + + val grantWritePermission = + pendingResult.first.argument("grantWritePermission")!! + val persistablePermission = + pendingResult.first.argument("persistablePermission")!! + + try { + // if data.clipData not null, uriList from data.clipData, else uriList is data.data + val uriList = resultIntent?.clipData?.let { + (0 until it.itemCount).map { i -> it.getItemAt(i).uri } + } ?: resultIntent?.data?.let { listOf(it) } + + // After some experiments, I noticed that you need grant both (read and write permission) + // otherwise, when rebooting the read permission will fail). + fun persistUriListWithWritePermissionAndReadPermission() { + for (uri in uriList!!) { + plugin.context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + + fun persistUriListWithReadPermissionOnly() { + for (uri in uriList!!) { + plugin.context.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + + fun persistUriList() { + if (grantWritePermission) { + persistUriListWithWritePermissionAndReadPermission() + } else { + persistUriListWithReadPermissionOnly() + } + } + + if (uriList != null) { + if (persistablePermission) { + persistUriList() + } + + pendingResult.second.success(uriList.map { "$it" }) + + return true + } + + pendingResult.second.success(null) + } finally { + pendingResults.remove(OPEN_DOCUMENT_CODE) + } + } + } + + return false + } + + override fun startListening(binaryMessenger: BinaryMessenger) { + if (channel != null) stopListening() + + channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") + channel?.setMethodCallHandler(this) + + eventChannel = EventChannel(binaryMessenger, "$ROOT_CHANNEL/event/$CHANNEL") + eventChannel?.setStreamHandler(this) + } + + override fun stopListening() { + if (channel == null) return + + channel?.setMethodCallHandler(null) + channel = null + + eventChannel?.setStreamHandler(null) + eventChannel = null + } + + override fun startListeningToActivity() { + plugin.binding?.addActivityResultListener(this) + } + + override fun stopListeningToActivity() { + plugin.binding?.removeActivityResultListener(this) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + val args = arguments as Map<*, *> + + eventSink = events + + when (args["event"]) { + LIST_FILES -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + listFilesEvent(eventSink, args) + } + } + } + + /** + * Read files of a given `uri` and dispatches all files under it through the `eventSink` and + * closes the stream after the last record + * + * Useful to read files under a `uri` with a large set of children + */ + private fun listFilesEvent( + eventSink: EventChannel.EventSink?, args: Map<*, *> + ) { + if (eventSink == null) return + + val uri = Uri.parse(args["uri"] as String) + val document = DocumentFile.fromTreeUri(plugin.context, uri) + + if (document == null) { + eventSink.error( + EXCEPTION_NOT_SUPPORTED, + "Android SDK must be greater or equal than [Build.VERSION_CODES.N]", + "Got (Build.VERSION.SDK_INT): ${Build.VERSION.SDK_INT}" + ) + eventSink.endOfStream() + } else { + if (!document.canRead()) { + val error = "You cannot read a URI that you don't have read permissions" + + Log.d("NO PERMISSION!!!", error) + + eventSink.error( + EXCEPTION_MISSING_PERMISSIONS, error, mapOf("uri" to args["uri"]) + ) + + eventSink.endOfStream() + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + CoroutineScope(Dispatchers.IO).launch { + try { + traverseDirectoryEntries( + plugin.context, + rootOnly = true, + targetUri = document.uri, + columns = getDocumentsContractColumns().toTypedArray() + ) { data, _ -> + launch(Dispatchers.Main) { + eventSink.success( + data + ) + } + } + } finally { + launch(Dispatchers.Main) { eventSink.endOfStream() } + } + } + } else { + eventSink.endOfStream() + } + } + } + } + + + /** Alias for `plugin.context.contentResolver.openOutputStream(uri)` */ + private fun openOutputStream(uri: Uri): OutputStream? { + return plugin.context.contentResolver.openOutputStream(uri) + } + + /** Alias for `plugin.context.contentResolver.openInputStream(uri)` */ + private fun openInputStream(uri: Uri): Pair { + return try { + Pair(plugin.context.contentResolver.openInputStream(uri), null) + } catch (e: FileNotFoundException) { + // Probably the file was already deleted and now you are trying to read. + Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) + } catch (e: IOException) { + // Unknown, can be anything. + Pair(null, PlatformException("INTERNAL_ERROR", e.toString())) + } catch (e: IllegalArgumentException) { + // Probably the file was already deleted and now you are trying to read. + Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) + } catch (e: IllegalStateException) { + // Probably you ran [delete] and [readDocumentContent] at the same time. + Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) + } + } + + override fun onCancel(arguments: Any?) { + eventSink?.endOfStream() + eventSink = null + } +} + +data class PlatformException(val code: String, val details: String) diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt index c04861e..83ca4cd 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt @@ -1,252 +1,243 @@ -package io.alexrintt.sharedstorage.storageaccessframework - -import android.content.ActivityNotFoundException -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.core.app.ShareCompat -import com.anggrayudi.storage.file.isTreeDocumentFile -import com.anggrayudi.storage.file.mimeType -import io.alexrintt.sharedstorage.ROOT_CHANNEL -import io.alexrintt.sharedstorage.SharedStoragePlugin -import io.alexrintt.sharedstorage.deprecated.lib.documentFromUri -import io.alexrintt.sharedstorage.plugin.ActivityListener -import io.alexrintt.sharedstorage.plugin.Listenable -import io.alexrintt.sharedstorage.storageaccessframework.lib.* -import io.flutter.plugin.common.* -import io.flutter.plugin.common.EventChannel.StreamHandler -import java.net.URLConnection - - -/** - * Aimed to be a class which takes the `DocumentFile` API and implement some APIs not supported - * natively by Android. - * - * This is why it is separated from the original and raw `DocumentFileApi` which is the class that - * only exposes the APIs without modifying them (Mirror API). - * - * Then here is where we can implement the main abstractions/use-cases which would be available - * globally without modifying the strict APIs. - */ -internal class DocumentFileHelperApi(private val plugin: SharedStoragePlugin) : - MethodChannel.MethodCallHandler, - PluginRegistry.ActivityResultListener, - Listenable, - ActivityListener, - StreamHandler { - private val pendingResults: MutableMap> = - mutableMapOf() - private var channel: MethodChannel? = null - private var eventChannel: EventChannel? = null - private var eventSink: EventChannel.EventSink? = null - - companion object { - private const val CHANNEL = "documentfilehelper" - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - OPEN_DOCUMENT_FILE -> openDocumentFile(call, result) - SHARE_URI -> shareUri(call, result) - else -> result.notImplemented() - } - } - - private fun shareUri(call: MethodCall, result: MethodChannel.Result) { - val uri = Uri.parse(call.argument("uri")!!) - val type = - call.argument("type") - ?: try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - documentFromUri(plugin.context, uri)?.mimeType - } else { - null - } - } catch (e: Throwable) { - null - } - ?: plugin.binding!!.activity.contentResolver.getType(uri) - ?: URLConnection.guessContentTypeFromName(uri.lastPathSegment) - ?: "application/octet-stream" - - try { - Log.d("sharedstorage", "Trying to share uri $uri with type $type") - - ShareCompat - .IntentBuilder(plugin.binding!!.activity) - .setChooserTitle("Share") - .setType(type) - .setStream(uri) - .startChooser() - - Log.d("sharedstorage", "Successfully shared uri $uri of type $type.") - - result.success(null) - } catch (e: ActivityNotFoundException) { - result.error( - EXCEPTION_ACTIVITY_NOT_FOUND, - "There's no activity handler that can process the uri $uri of type $type, error: $e.", - mapOf("uri" to "$uri", "type" to type) - ) - } catch (e: SecurityException) { - result.error( - EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, - "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity, error: $e.", - mapOf("uri" to "$uri", "type" to type) - ) - } catch (e: Throwable) { - result.error( - EXCEPTION_CANT_OPEN_DOCUMENT_FILE, - "Couldn't start activity to open document file for uri: $uri, error: $e.", - mapOf("uri" to "$uri") - ) - } - } - - private fun openDocumentAsSimpleFile(uri: Uri, type: String?) { - Log.d("sharedstorage", "Trying to open uri $uri with type $type") - - val intent = - Intent(Intent.ACTION_VIEW).apply { - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - - setDataAndType(uri, type) - setFlags(flags) - } - - plugin.binding?.activity?.startActivity(intent, null) - - Log.d( - "sharedstorage", - "Successfully launched uri $uri as single|file uri." - ) - } - - @RequiresApi(Build.VERSION_CODES.LOLLIPOP) - private fun openDocumentAsTree(uri: Uri) { - val file = documentFromUri(plugin.context, uri) - if (file?.isTreeDocumentFile == true) { - val intent = Intent(Intent.ACTION_VIEW) - - intent.setDataAndType(uri, "vnd.android.document/root") - - plugin.binding?.activity?.startActivity(intent, null) - - Log.d( - "sharedstorage", - "Successfully launched uri $uri as tree uri." - ) - } else { - throw Exception("Not a document tree URI") - } - } - - private fun openDocumentFile(call: MethodCall, result: MethodChannel.Result) { - val uri = Uri.parse(call.argument("uri")!!) - val type = - call.argument("type") ?: plugin.context.contentResolver.getType( - uri - ) - - fun successfullyOpenedUri() { - result.success(true) - } - - try { - openDocumentAsSimpleFile(uri, type) - return successfullyOpenedUri() - } catch (e: ActivityNotFoundException) { - Log.d( - "sharedstorage", - "No activity is defined to handle $uri, trying to recover from error and interpret as tree." - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - try { - openDocumentAsTree(uri) - return successfullyOpenedUri() - } catch (e: Throwable) { - Log.d( - "sharedstorage", - "Tried to recover from missing activity exception but did not work, exception: $e" - ) - } - } - - return result.error( - EXCEPTION_ACTIVITY_NOT_FOUND, - "There's no activity handler that can process the uri $uri of type $type", - mapOf("uri" to "$uri", "type" to type) - ) - } catch (e: SecurityException) { - return result.error( - EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, - "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity", - mapOf("uri" to "$uri", "type" to "$type") - ) - } catch (e: Throwable) { - return result.error( - EXCEPTION_CANT_OPEN_DOCUMENT_FILE, - "Couldn't start activity to open document file for uri: $uri", - mapOf("uri" to "$uri") - ) - } - } - - override fun onActivityResult( - requestCode: Int, - resultCode: Int, - data: Intent? - ): Boolean { - when (requestCode) { - /** TODO(@alexrintt): Implement if required */ - else -> return true - } - - return false - } - - override fun startListening(binaryMessenger: BinaryMessenger) { - if (channel != null) stopListening() - - channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") - channel?.setMethodCallHandler(this) - - eventChannel = EventChannel(binaryMessenger, "$ROOT_CHANNEL/event/$CHANNEL") - eventChannel?.setStreamHandler(this) - } - - override fun stopListening() { - if (channel == null) return - - channel?.setMethodCallHandler(null) - channel = null - - eventChannel?.setStreamHandler(null) - eventChannel = null - } - - override fun startListeningToActivity() { - plugin.binding?.addActivityResultListener(this) - } - - override fun stopListeningToActivity() { - plugin.binding?.removeActivityResultListener(this) - } - - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - val args = arguments as Map<*, *> - - eventSink = events - - when (args["event"]) { - /** TODO(@alexrintt): Implement if required */ - } - } - - override fun onCancel(arguments: Any?) { - eventSink = null - } -} +package io.alexrintt.sharedstorage.storageaccessframework + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.ShareCompat +import com.anggrayudi.storage.file.isTreeDocumentFile +import com.anggrayudi.storage.file.mimeType +import io.alexrintt.sharedstorage.ROOT_CHANNEL +import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.storageaccessframework.lib.documentFromUri +import io.alexrintt.sharedstorage.plugin.ActivityListener +import io.alexrintt.sharedstorage.plugin.Listenable +import io.alexrintt.sharedstorage.storageaccessframework.lib.* +import io.flutter.plugin.common.* +import io.flutter.plugin.common.EventChannel.StreamHandler +import java.net.URLConnection + + +/** + * Aimed to be a class which takes the `DocumentFile` API and implement some APIs not supported + * natively by Android. + * + * This is why it is separated from the original and raw `DocumentFileApi` which is the class that + * only exposes the APIs without modifying them (Mirror API). + * + * Then here is where we can implement the main abstractions/use-cases which would be available + * globally without modifying the strict APIs. + */ +internal class DocumentFileHelperApi(private val plugin: SharedStoragePlugin) : + MethodChannel.MethodCallHandler, + PluginRegistry.ActivityResultListener, + Listenable, + ActivityListener, + StreamHandler { + private val pendingResults: MutableMap> = + mutableMapOf() + private var channel: MethodChannel? = null + private var eventChannel: EventChannel? = null + private var eventSink: EventChannel.EventSink? = null + + companion object { + private const val CHANNEL = "documentfilehelper" + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + OPEN_DOCUMENT_FILE -> openDocumentFile(call, result) + SHARE_URI -> shareUri(call, result) + else -> result.notImplemented() + } + } + + private fun shareUri(call: MethodCall, result: MethodChannel.Result) { + val uri = Uri.parse(call.argument("uri")!!) + val type = + call.argument("type") + ?: plugin.binding!!.activity.contentResolver.getType(uri) + ?: URLConnection.guessContentTypeFromName(uri.lastPathSegment) + ?: "application/octet-stream" + + try { + Log.d("sharedstorage", "Trying to share uri $uri with type $type") + + ShareCompat + .IntentBuilder(plugin.binding!!.activity) + .setChooserTitle("Share") + .setType(type) + .setStream(uri) + .startChooser() + + Log.d("sharedstorage", "Successfully shared uri $uri of type $type.") + + result.success(null) + } catch (e: ActivityNotFoundException) { + result.error( + EXCEPTION_ACTIVITY_NOT_FOUND, + "There's no activity handler that can process the uri $uri of type $type, error: $e.", + mapOf("uri" to "$uri", "type" to type) + ) + } catch (e: SecurityException) { + result.error( + EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, + "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity, error: $e.", + mapOf("uri" to "$uri", "type" to type) + ) + } catch (e: Throwable) { + result.error( + EXCEPTION_CANT_OPEN_DOCUMENT_FILE, + "Couldn't start activity to open document file for uri: $uri, error: $e.", + mapOf("uri" to "$uri") + ) + } + } + + private fun openDocumentAsSimpleFile(uri: Uri, type: String?) { + Log.d("sharedstorage", "Trying to open uri $uri with type $type") + + val intent = + Intent(Intent.ACTION_VIEW).apply { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + + setDataAndType(uri, type) + setFlags(flags) + } + + plugin.binding?.activity?.startActivity(intent, null) + + Log.d( + "sharedstorage", + "Successfully launched uri $uri as single|file uri." + ) + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private fun openDocumentAsTree(uri: Uri) { + val file = documentFromUri(plugin.context, uri) + if (file?.isTreeDocumentFile == true) { + val intent = Intent(Intent.ACTION_VIEW) + + intent.setDataAndType(uri, "vnd.android.document/root") + + plugin.binding?.activity?.startActivity(intent, null) + + Log.d( + "sharedstorage", + "Successfully launched uri $uri as tree uri." + ) + } else { + throw Exception("Not a document tree URI") + } + } + + private fun openDocumentFile(call: MethodCall, result: MethodChannel.Result) { + val uri = Uri.parse(call.argument("uri")!!) + val type = + call.argument("type") ?: plugin.context.contentResolver.getType( + uri + ) + + fun successfullyOpenedUri() { + result.success(true) + } + + try { + openDocumentAsSimpleFile(uri, type) + return successfullyOpenedUri() + } catch (e: ActivityNotFoundException) { + Log.d( + "sharedstorage", + "No activity is defined to handle $uri, trying to recover from error and interpret as tree." + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + try { + openDocumentAsTree(uri) + return successfullyOpenedUri() + } catch (e: Throwable) { + Log.d( + "sharedstorage", + "Tried to recover from missing activity exception but did not work, exception: $e" + ) + } + } + + return result.error( + EXCEPTION_ACTIVITY_NOT_FOUND, + "There's no activity handler that can process the uri $uri of type $type", + mapOf("uri" to "$uri", "type" to type) + ) + } catch (e: SecurityException) { + return result.error( + EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, + "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity", + mapOf("uri" to "$uri", "type" to "$type") + ) + } catch (e: Throwable) { + return result.error( + EXCEPTION_CANT_OPEN_DOCUMENT_FILE, + "Couldn't start activity to open document file for uri: $uri", + mapOf("uri" to "$uri") + ) + } + } + + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + data: Intent? + ): Boolean { + when (requestCode) { + /** TODO(@alexrintt): Implement if required */ + else -> return true + } + + return false + } + + override fun startListening(binaryMessenger: BinaryMessenger) { + if (channel != null) stopListening() + + channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") + channel?.setMethodCallHandler(this) + + eventChannel = EventChannel(binaryMessenger, "$ROOT_CHANNEL/event/$CHANNEL") + eventChannel?.setStreamHandler(this) + } + + override fun stopListening() { + if (channel == null) return + + channel?.setMethodCallHandler(null) + channel = null + + eventChannel?.setStreamHandler(null) + eventChannel = null + } + + override fun startListeningToActivity() { + plugin.binding?.addActivityResultListener(this) + } + + override fun stopListeningToActivity() { + plugin.binding?.removeActivityResultListener(this) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + val args = arguments as Map<*, *> + + eventSink = events + + when (args["event"]) { + /** TODO(@alexrintt): Implement if required */ + } + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt index 38eaad4..dd13786 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt @@ -1,266 +1,267 @@ -package io.alexrintt.sharedstorage.storageaccessframework - -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Point -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.net.Uri -import android.os.Build -import android.provider.DocumentsContract -import android.util.Log -import io.alexrintt.sharedstorage.ROOT_CHANNEL -import io.alexrintt.sharedstorage.SharedStoragePlugin -import io.alexrintt.sharedstorage.plugin.* -import io.alexrintt.sharedstorage.storageaccessframework.* -import io.alexrintt.sharedstorage.storageaccessframework.lib.* -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.FileNotFoundException -import java.io.FileOutputStream -import java.io.InputStream -import java.io.Serializable -import java.util.* - - -const val APK_MIME_TYPE = "application/vnd.android.package-archive" - -internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : - MethodChannel.MethodCallHandler, Listenable, ActivityListener { - private var channel: MethodChannel? = null - - companion object { - private const val CHANNEL = "documentscontract" - } - - private fun createTempUriFile(sourceUri: Uri, callback: (File?) -> Unit) { - try { - val destinationFilename: String = UUID.randomUUID().toString() - - val tempDestinationFile = - File(plugin.context.cacheDir.path, destinationFilename) - - plugin.context.contentResolver.openInputStream(sourceUri)?.use { - createFileFromStream(it, tempDestinationFile) - } - callback(tempDestinationFile) - } catch (_: FileNotFoundException) { - callback(null) - } - } - - private fun createFileFromStream(ins: InputStream, destination: File?) { - FileOutputStream(destination).use { fileOutputStream -> - val buffer = ByteArray(4096) - var length: Int - while (ins.read(buffer).also { length = it } > 0) { - fileOutputStream.write(buffer, 0, length) - } - fileOutputStream.flush() - } - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - GET_DOCUMENT_THUMBNAIL -> { - val uri = Uri.parse(call.argument("uri")) - val mimeType: String? = plugin.context.contentResolver.getType(uri) - - if (mimeType == APK_MIME_TYPE) { - getThumbnailForApkFile(call, result, uri) - } else { - if (Build.VERSION.SDK_INT >= API_21) { - getThumbnailForApi24(call, result) - } else { - result.notSupported(call.method, API_21) - } - } - } - } - } - - private fun getThumbnailForApkFile( - call: MethodCall, - result: MethodChannel.Result, - uri: Uri - ) { - CoroutineScope(Dispatchers.IO).launch { - createTempUriFile(uri) { - if (it == null) { - launch(Dispatchers.Main) { result.success(null) } - return@createTempUriFile - } - - kotlin.runCatching { - val packageManager: PackageManager = - plugin.context.packageManager - val packageInfo: PackageInfo? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getPackageArchiveInfo( - it.path, - PackageManager.PackageInfoFlags.of(0) - ) - } else { - @Suppress("DEPRECATION") - packageManager.getPackageArchiveInfo( - it.path, - 0 - ) - } - - if (packageInfo == null) { - if (it.exists()) it.delete() - return@createTempUriFile result.success(null) - } - - // Parse the apk and to get the icon later on - packageInfo.applicationInfo.sourceDir = it.path - packageInfo.applicationInfo.publicSourceDir = it.path - - val apkIcon: Drawable = - packageInfo.applicationInfo.loadIcon(packageManager) - - val bitmap: Bitmap = drawableToBitmap(apkIcon) - - val data = bitmap.generateSerializableBitmapData(uri) - - if (it.exists()) it.delete() - - launch(Dispatchers.Main) { result.success(data) } - } - - try { - } catch (e: FileNotFoundException) { - // The target file apk is invalid - launch(Dispatchers.Main) { result.success(null) } - } - } - } - } - - private fun getThumbnailForApi24( - call: MethodCall, - result: MethodChannel.Result - ) { - CoroutineScope(Dispatchers.IO).launch { - val uri = Uri.parse(call.argument("uri")) - val width = call.argument("width")!! - val height = call.argument("height")!! - - // run catching because [DocumentsContract.getDocumentThumbnail] - // can throw a [FileNotFoundException]. - kotlin.runCatching { - val bitmap = DocumentsContract.getDocumentThumbnail( - plugin.context.contentResolver, - uri, - Point(width, height), - null - ) - - if (bitmap != null) { - val data = bitmap.generateSerializableBitmapData(uri) - - launch(Dispatchers.Main) { result.success(data) } - } else { - Log.d("GET_DOCUMENT_THUMBNAIL", "bitmap is null") - launch(Dispatchers.Main) { result.success(null) } - } - } - } - } - - override fun startListening(binaryMessenger: BinaryMessenger) { - if (channel != null) stopListening() - - channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") - channel?.setMethodCallHandler(this) - } - - override fun stopListening() { - if (channel == null) return - - channel?.setMethodCallHandler(null) - channel = null - } - - override fun startListeningToActivity() { - /** Implement if needed */ - } - - override fun stopListeningToActivity() { - /** Implement if needed */ - } -} - -fun drawableToBitmap(drawable: Drawable): Bitmap { - if (drawable is BitmapDrawable) { - val bitmapDrawable: BitmapDrawable = drawable - if (bitmapDrawable.bitmap != null) { - return bitmapDrawable.bitmap - } - } - val bitmap: Bitmap = - if (drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) { - Bitmap.createBitmap( - 1, - 1, - Bitmap.Config.ARGB_8888 - ) // Single color bitmap will be created of 1x1 pixel - } else { - Bitmap.createBitmap( - drawable.intrinsicWidth, - drawable.intrinsicHeight, - Bitmap.Config.ARGB_8888 - ) - } - val canvas = Canvas(bitmap) - drawable.setBounds(0, 0, canvas.width, canvas.height) - drawable.draw(canvas) - return bitmap -} - -/** - * Convert bitmap to byte array using ByteBuffer. - * - * This method calls [Bitmap.recycle] so this function will make the bitmap unusable after that. - */ -fun Bitmap.convertToByteArray(): ByteArray { - val stream = ByteArrayOutputStream() - - // Very important, see https://stackoverflow.com/questions/51516310/sending-bitmap-to-flutter-from-android-platform - // Without compressing the raw bitmap, Flutter Image widget cannot decode it correctly and will throw a error. - this.compress(Bitmap.CompressFormat.PNG, 100, stream) - - val byteArray = stream.toByteArray() - - this.recycle() - - return byteArray -} - -fun Bitmap.generateSerializableBitmapData( - uri: Uri, - additional: Map = mapOf() -): Map { - val metadata = mapOf( - "uri" to "$uri", - "width" to this.width, - "height" to this.height, - "byteCount" to this.byteCount, - "density" to this.density - ) - - val bytes: ByteArray = this.convertToByteArray() - - return metadata + additional + mapOf( - "bytes" to bytes - ) -} +package io.alexrintt.sharedstorage.storageaccessframework + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Point +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.util.Log +import io.alexrintt.sharedstorage.ROOT_CHANNEL +import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.plugin.* +import io.alexrintt.sharedstorage.storageaccessframework.* +import io.alexrintt.sharedstorage.storageaccessframework.lib.* +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.InputStream +import java.io.Serializable +import java.util.* + + +const val APK_MIME_TYPE = "application/vnd.android.package-archive" + +internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : + MethodChannel.MethodCallHandler, Listenable, ActivityListener { + private var channel: MethodChannel? = null + + companion object { + private const val CHANNEL = "documentscontract" + } + + private fun createTempUriFile(sourceUri: Uri, callback: (File?) -> Unit) { + try { + val destinationFilename: String = UUID.randomUUID().toString() + + val tempDestinationFile = + File(plugin.context.cacheDir.path, destinationFilename) + + plugin.context.contentResolver.openInputStream(sourceUri)?.use { + createFileFromStream(it, tempDestinationFile) + } + callback(tempDestinationFile) + } catch (_: FileNotFoundException) { + callback(null) + } + } + + private fun createFileFromStream(ins: InputStream, destination: File?) { + FileOutputStream(destination).use { fileOutputStream -> + val buffer = ByteArray(4096) + var length: Int + while (ins.read(buffer).also { length = it } > 0) { + fileOutputStream.write(buffer, 0, length) + } + fileOutputStream.flush() + } + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + GET_DOCUMENT_THUMBNAIL -> { + val uri = Uri.parse(call.argument("uri")) + val mimeType: String? = plugin.context.contentResolver.getType(uri) + + if (mimeType == APK_MIME_TYPE) { + return result.success(null) +// getThumbnailForApkFile(call, result, uri) + } else { + if (Build.VERSION.SDK_INT >= API_21) { + getThumbnailForApi24(call, result) + } else { + result.notSupported(call.method, API_21) + } + } + } + } + } + + private fun getThumbnailForApkFile( + call: MethodCall, + result: MethodChannel.Result, + uri: Uri + ) { + CoroutineScope(Dispatchers.IO).launch { + createTempUriFile(uri) { + if (it == null) { + launch(Dispatchers.Main) { result.success(null) } + return@createTempUriFile + } + + kotlin.runCatching { + val packageManager: PackageManager = + plugin.context.packageManager + val packageInfo: PackageInfo? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageArchiveInfo( + it.path, + PackageManager.PackageInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageArchiveInfo( + it.path, + 0 + ) + } + + if (packageInfo == null) { + if (it.exists()) it.delete() + return@createTempUriFile result.success(null) + } + + // Parse the apk and to get the icon later on + packageInfo.applicationInfo.sourceDir = it.path + packageInfo.applicationInfo.publicSourceDir = it.path + + val apkIcon: Drawable = + packageInfo.applicationInfo.loadIcon(packageManager) + + val bitmap: Bitmap = drawableToBitmap(apkIcon) + + val data = bitmap.generateSerializableBitmapData(uri) + + if (it.exists()) it.delete() + + launch(Dispatchers.Main) { result.success(data) } + } + + try { + } catch (e: FileNotFoundException) { + // The target file apk is invalid + launch(Dispatchers.Main) { result.success(null) } + } + } + } + } + + private fun getThumbnailForApi24( + call: MethodCall, + result: MethodChannel.Result + ) { + CoroutineScope(Dispatchers.IO).launch { + val uri = Uri.parse(call.argument("uri")) + val width = call.argument("width")!! + val height = call.argument("height")!! + + // run catching because [DocumentsContract.getDocumentThumbnail] + // can throw a [FileNotFoundException]. + kotlin.runCatching { + val bitmap = DocumentsContract.getDocumentThumbnail( + plugin.context.contentResolver, + uri, + Point(width, height), + null + ) + + if (bitmap != null) { + val data = bitmap.generateSerializableBitmapData(uri) + + launch(Dispatchers.Main) { result.success(data) } + } else { + Log.d("GET_DOCUMENT_THUMBNAIL", "bitmap is null") + launch(Dispatchers.Main) { result.success(null) } + } + } + } + } + + override fun startListening(binaryMessenger: BinaryMessenger) { + if (channel != null) stopListening() + + channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") + channel?.setMethodCallHandler(this) + } + + override fun stopListening() { + if (channel == null) return + + channel?.setMethodCallHandler(null) + channel = null + } + + override fun startListeningToActivity() { + /** Implement if needed */ + } + + override fun stopListeningToActivity() { + /** Implement if needed */ + } +} + +fun drawableToBitmap(drawable: Drawable): Bitmap { + if (drawable is BitmapDrawable) { + val bitmapDrawable: BitmapDrawable = drawable + if (bitmapDrawable.bitmap != null) { + return bitmapDrawable.bitmap + } + } + val bitmap: Bitmap = + if (drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) { + Bitmap.createBitmap( + 1, + 1, + Bitmap.Config.ARGB_8888 + ) // Single color bitmap will be created of 1x1 pixel + } else { + Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + } + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap +} + +/** + * Convert bitmap to byte array using ByteBuffer. + * + * This method calls [Bitmap.recycle] so this function will make the bitmap unusable after that. + */ +fun Bitmap.convertToByteArray(): ByteArray { + val stream = ByteArrayOutputStream() + + // Very important, see https://stackoverflow.com/questions/51516310/sending-bitmap-to-flutter-from-android-platform + // Without compressing the raw bitmap, Flutter Image widget cannot decode it correctly and will throw a error. + this.compress(Bitmap.CompressFormat.PNG, 100, stream) + + val byteArray = stream.toByteArray() + + this.recycle() + + return byteArray +} + +fun Bitmap.generateSerializableBitmapData( + uri: Uri, + additional: Map = mapOf() +): Map { + val metadata = mapOf( + "uri" to "$uri", + "width" to this.width, + "height" to this.height, + "byteCount" to this.byteCount, + "density" to this.density + ) + + val bytes: ByteArray = this.convertToByteArray() + + return metadata + additional + mapOf( + "bytes" to bytes + ) +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt index 8a6419a..4c5874d 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt @@ -1,259 +1,400 @@ -package io.alexrintt.sharedstorage.deprecated.lib - -import android.content.ContentResolver -import android.content.Context -import android.graphics.Bitmap -import android.net.Uri -import android.os.Build -import android.provider.DocumentsContract -import android.util.Base64 -import androidx.annotation.RequiresApi -import androidx.documentfile.provider.DocumentFile -import io.alexrintt.sharedstorage.plugin.* -import io.alexrintt.sharedstorage.storageaccessframework.* -import java.io.ByteArrayOutputStream -import java.io.Closeable - -/** - * Generate the `DocumentFile` reference from string `uri` - */ -@RequiresApi(API_21) -fun documentFromUri(context: Context, uri: String): DocumentFile? = - documentFromUri(context, Uri.parse(uri)) - -/** - * Generate the `DocumentFile` reference from URI `uri` - */ -@RequiresApi(API_21) -fun documentFromUri( - context: Context, - uri: Uri -): DocumentFile? { - return if (isTreeUri(uri)) { - DocumentFile.fromTreeUri(context, uri) - } else { - DocumentFile.fromSingleUri(context, uri) - } -} - - -/** - * Convert a [DocumentFile] using the default method for map encoding - */ -fun createDocumentFileMap(documentFile: DocumentFile?): Map? { - if (documentFile == null) return null - - return createDocumentFileMap( - DocumentsContract.getDocumentId(documentFile.uri), - parentUri = documentFile.parentFile?.uri, - isDirectory = documentFile.isDirectory, - isFile = documentFile.isFile, - isVirtual = documentFile.isVirtual, - name = documentFile.name, - type = documentFile.type, - uri = documentFile.uri, - exists = documentFile.exists(), - size = documentFile.length(), - lastModified = documentFile.lastModified() - ) -} - -/** - * Standard map encoding of a `DocumentFile` and must be used before returning any `DocumentFile` - * from plugin results, like: - * ```dart - * result.success(createDocumentFileMap(documentFile)) - * ``` - */ -fun createDocumentFileMap( - id: String?, - parentUri: Uri?, - isDirectory: Boolean?, - isFile: Boolean?, - isVirtual: Boolean?, - name: String?, - type: String?, - uri: Uri, - exists: Boolean?, - size: Long?, - lastModified: Long? -): Map { - return mapOf( - "id" to id, - "parentUri" to "$parentUri", - "isDirectory" to isDirectory, - "isFile" to isFile, - "isVirtual" to isVirtual, - "name" to name, - "type" to type, - "uri" to "$uri", - "exists" to exists, - "size" to size, - "lastModified" to lastModified - ) -} - -/** - * Util method to close a closeable - */ -fun closeQuietly(closeable: Closeable?) { - if (closeable != null) { - try { - closeable.close() - } catch (e: RuntimeException) { - throw e - } catch (ignore: Exception) { - } - } -} - -@RequiresApi(API_21) -fun traverseDirectoryEntries( - contentResolver: ContentResolver, - targetUri: Uri, - columns: Array, - rootOnly: Boolean, - block: (data: Map, isLast: Boolean) -> Unit -): Boolean { - val documentId = try { - DocumentsContract.getDocumentId(targetUri) - } catch(e: IllegalArgumentException) { - DocumentsContract.getTreeDocumentId(targetUri) - } - val treeDocumentId = DocumentsContract.getTreeDocumentId(targetUri) - - val rootUri = DocumentsContract.buildTreeDocumentUri( - targetUri.authority, - treeDocumentId - ) - val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( - rootUri, - documentId - ) - - // Keep track of our directory hierarchy - val dirNodes = mutableListOf(Pair(targetUri, childrenUri)) - - while (dirNodes.isNotEmpty()) { - val (parent, children) = dirNodes.removeAt(0) - - val requiredColumns = - if (rootOnly) emptyArray() else arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) - - val intrinsicColumns = - arrayOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_FLAGS - ) - - val projection = arrayOf( - *columns, - *requiredColumns, - *intrinsicColumns - ).toSet().toTypedArray() - - val cursor = contentResolver.query( - children, - projection, - // TODO: Add support for `selection`, `selectionArgs` and `sortOrder` - null, - null, - null - ) ?: return false - - try { - if (cursor.count == 0) { - return false - } - - while (cursor.moveToNext()) { - val data = mutableMapOf() - - for (column in projection) { - val columnValue: Any? = cursorHandlerOf(typeOfColumn(column)!!)( - cursor, - cursor.getColumnIndexOrThrow(column) - ) - - data[column] = columnValue - } - - val mimeType = - data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String? - - val id = - data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String - - val isDirectory = if (mimeType != null) isDirectory(mimeType) else null - - val uri = DocumentsContract.buildDocumentUriUsingTree( - rootUri, - DocumentsContract.getDocumentId( - DocumentsContract.buildDocumentUri(parent.authority, id) - ) - ) - - if (isDirectory == true && !rootOnly) { - val nextChildren = - DocumentsContract.buildChildDocumentsUriUsingTree(targetUri, id) - - val nextNode = Pair(uri, nextChildren) - - dirNodes.add(nextNode) - } - - block( - createDocumentFileMap( - parentUri = parent, - uri = uri, - name = data[DocumentsContract.Document.COLUMN_DISPLAY_NAME] as String?, - exists = true, - id = data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String, - isDirectory = isDirectory == true, - isFile = isDirectory == false, - isVirtual = if (Build.VERSION.SDK_INT >= API_24) { - (data[DocumentsContract.Document.COLUMN_FLAGS] as Int and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0 - } else { - false - }, - type = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String?, - size = data[DocumentsContract.Document.COLUMN_SIZE] as Long?, - lastModified = data[DocumentsContract.Document.COLUMN_LAST_MODIFIED] as Long? - ), - dirNodes.isEmpty() && cursor.isLast - ) - } - } finally { - closeQuietly(cursor) - } - } - - return true -} - -private fun isDirectory(mimeType: String): Boolean { - return DocumentsContract.Document.MIME_TYPE_DIR == mimeType -} - -fun bitmapToBase64(bitmap: Bitmap): String { - val outputStream = ByteArrayOutputStream() - - val fullQuality = 100 - - bitmap.compress(Bitmap.CompressFormat.PNG, fullQuality, outputStream) - - return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) -} - -/** - * Trick to verify if is a tree URI even not in API 26+ - */ -fun isTreeUri(uri: Uri): Boolean { - if (Build.VERSION.SDK_INT >= API_24) { - return DocumentsContract.isTreeUri(uri) - } - - val paths = uri.pathSegments - - return paths.size >= 2 && "tree" == paths[0] -} +package io.alexrintt.sharedstorage.storageaccessframework.lib + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.provider.DocumentsProvider +import android.util.Base64 +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.extension.isMediaDocument +import com.anggrayudi.storage.extension.isMediaFile +import com.anggrayudi.storage.file.DocumentFileCompat +import io.alexrintt.sharedstorage.deprecated.lib.cursorHandlerOf +import io.alexrintt.sharedstorage.deprecated.lib.typeOfColumn +import io.alexrintt.sharedstorage.mediastore.ScopedFileSystemEntity +import io.alexrintt.sharedstorage.plugin.* +import io.alexrintt.sharedstorage.storageaccessframework.* +import java.io.ByteArrayOutputStream +import java.io.Closeable + +/** + * Generate the `DocumentFile` reference from string `uri` + */ +@RequiresApi(API_21) +fun documentFromUri(context: Context, uri: String): DocumentFile? = + documentFromUri(context, Uri.parse(uri)) + +/** + * Generate the `DocumentFile` reference from URI `uri` + */ +@RequiresApi(API_21) +fun documentFromUri(context: Context, uri: Uri): DocumentFile? { + return if (isTreeUri(uri)) { + DocumentFile.fromTreeUri(context, uri) + } else { + DocumentFile.fromSingleUri(context, uri) + } +} + + +/** + * Convert a [DocumentFile] using the default method for map encoding + */ +fun createDocumentFileMap(documentFile: DocumentFile?): Map? { + if (documentFile == null) return null + + return createDocumentFileMap( + DocumentsContract.getDocumentId(documentFile.uri), + parentUri = documentFile.parentFile?.uri, + isDirectory = documentFile.isDirectory, + isFile = documentFile.isFile, + isVirtual = documentFile.isVirtual, + name = documentFile.name, + type = documentFile.type, + uri = documentFile.uri, + exists = documentFile.exists(), + size = documentFile.length(), + lastModified = documentFile.lastModified() + ) +} + +/** + * Standard map encoding of a `DocumentFile` and must be used before returning any `DocumentFile` + * from plugin results, like: + * ```dart + * result.success(createDocumentFileMap(documentFile)) + * ``` + */ +fun createDocumentFileMap( + id: String?, + parentUri: Uri?, + isDirectory: Boolean?, + isFile: Boolean?, + isVirtual: Boolean?, + name: String?, + type: String?, + uri: Uri, + exists: Boolean?, + size: Long?, + lastModified: Long? +): Map { + return mapOf( + "id" to id, + "parentUri" to "$parentUri", + "isDirectory" to isDirectory, + "isFile" to isFile, + "isVirtual" to isVirtual, + "name" to name, + "type" to type, + "uri" to "$uri", + "exists" to exists, + "size" to size, + "lastModified" to lastModified + ) +} + +/** + * Util method to close a closeable + */ +fun closeQuietly(closeable: Closeable?) { + if (closeable != null) { + try { + closeable.close() + } catch (e: RuntimeException) { + throw e + } catch (ignore: Exception) { + } + } +} + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +fun traverseMediaDirectoryEntries( + context: Context, + targetUri: Uri, + columns: Array, + rootOnly: Boolean, + block: (data: Map, isLast: Boolean) -> Unit +): Boolean { + val documentId = try { + DocumentsContract.getDocumentId(targetUri) + } catch (e: IllegalArgumentException) { + DocumentsContract.getTreeDocumentId(targetUri) + } + val treeDocumentId = DocumentsContract.getTreeDocumentId(targetUri) + + val rootUri = DocumentsContract.buildTreeDocumentUri( + targetUri.authority, + treeDocumentId + ) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + rootUri, + documentId + ) + + // Keep track of our directory hierarchy + val dirNodes = mutableListOf(Pair(targetUri, childrenUri)) + + while (dirNodes.isNotEmpty()) { + val (parent, children) = dirNodes.removeAt(0) + + val requiredColumns = + if (rootOnly) emptyArray() else arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) + + val intrinsicColumns = + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_FLAGS + ) + + val projection = arrayOf( + *columns, + *requiredColumns, + *intrinsicColumns + ).toSet().toTypedArray() + + val cursor = context.contentResolver.query( + children, + projection, + // TODO: Add support for `selection`, `selectionArgs` and `sortOrder` + null, + null, + null + ) ?: return false + + try { + if (cursor.count == 0) { + return false + } + + while (cursor.moveToNext()) { + val data = mutableMapOf() + + for (column in projection) { + val columnValue: Any? = cursorHandlerOf(typeOfColumn(column)!!)( + cursor, + cursor.getColumnIndexOrThrow(column) + ) + + data[column] = columnValue + } + + val mimeType = + data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String? + + val id = + data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String + + val isDirectory = if (mimeType != null) isDirectory(mimeType) else null + + val uri = DocumentsContract.buildDocumentUriUsingTree( + rootUri, + DocumentsContract.getDocumentId( + DocumentsContract.buildDocumentUri(parent.authority, id) + ) + ) + + if (isDirectory == true && !rootOnly) { + val nextChildren = + DocumentsContract.buildChildDocumentsUriUsingTree(targetUri, id) + + val nextNode = Pair(uri, nextChildren) + + dirNodes.add(nextNode) + } + + block( + ScopedFileSystemEntity( + parentUri = parent, + uri = uri, + displayName = data[DocumentsContract.Document.COLUMN_DISPLAY_NAME] as String, + id = data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String, + entityType = if (isDirectory == true) "directory" else "file", + mimeType = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String, + length = data[DocumentsContract.Document.COLUMN_SIZE] as Long, + lastModified = data[DocumentsContract.Document.COLUMN_LAST_MODIFIED] as Long + ).toMap(), + dirNodes.isEmpty() && cursor.isLast + ) + } + } finally { + closeQuietly(cursor) + } + } + + return true +} + +fun isDirectory(mimeType: String): Boolean { + return mimeType == DocumentsContract.Document.MIME_TYPE_DIR +} + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +fun traverseSafDirectoryEntries( + context: Context, + targetUri: Uri, + columns: Array, + rootOnly: Boolean, + block: (data: Map, isLast: Boolean) -> Unit +): Boolean { + val documentId = try { + DocumentsContract.getDocumentId(targetUri) + } catch (e: IllegalArgumentException) { + DocumentsContract.getTreeDocumentId(targetUri) + } + val treeDocumentId = DocumentsContract.getTreeDocumentId(targetUri) + + val rootUri = DocumentsContract.buildTreeDocumentUri( + targetUri.authority, + treeDocumentId + ) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + rootUri, + documentId + ) + + // Keep track of our directory hierarchy + val dirNodes = mutableListOf(Pair(targetUri, childrenUri)) + + while (dirNodes.isNotEmpty()) { + val (parent, children) = dirNodes.removeAt(0) + + val requiredColumns = + if (rootOnly) emptyArray() else arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) + + val intrinsicColumns = + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_FLAGS + ) + + val projection = arrayOf( + *columns, + *requiredColumns, + *intrinsicColumns + ).toSet().toTypedArray() + + val cursor = context.contentResolver.query( + children, + projection, + // TODO: Add support for `selection`, `selectionArgs` and `sortOrder` + null, + null, + null + ) ?: return false + + try { + if (cursor.count == 0) { + return false + } + + while (cursor.moveToNext()) { + val data = mutableMapOf() + + for (column in projection) { + val columnValue: Any? = cursorHandlerOf(typeOfColumn(column)!!)( + cursor, + cursor.getColumnIndexOrThrow(column) + ) + + data[column] = columnValue + } + + val mimeType = + data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String? + + val id = + data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String + + val isDirectory = if (mimeType != null) isDirectory(mimeType) else null + + val uri = DocumentsContract.buildDocumentUriUsingTree( + rootUri, + DocumentsContract.getDocumentId( + DocumentsContract.buildDocumentUri(parent.authority, id) + ) + ) + + if (isDirectory == true && !rootOnly) { + val nextChildren = + DocumentsContract.buildChildDocumentsUriUsingTree(targetUri, id) + + val nextNode = Pair(uri, nextChildren) + + dirNodes.add(nextNode) + } + + block( + ScopedFileSystemEntity( + parentUri = parent, + uri = uri, + displayName = data[DocumentsContract.Document.COLUMN_DISPLAY_NAME] as String, + id = data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String, + entityType = if (isDirectory == true) "directory" else "file", + mimeType = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String, + length = data[DocumentsContract.Document.COLUMN_SIZE] as Long, + lastModified = data[DocumentsContract.Document.COLUMN_LAST_MODIFIED] as Long + ).toMap(), + dirNodes.isEmpty() && cursor.isLast + ) + } + } finally { + closeQuietly(cursor) + } + } + + return true +} + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +fun traverseDirectoryEntries( + context: Context, + targetUri: Uri, + columns: Array, + rootOnly: Boolean, + block: (data: Map, isLast: Boolean) -> Unit +): Boolean { + return when { + targetUri.isMediaFile || targetUri.isMediaDocument -> traverseMediaDirectoryEntries( + context = context, + targetUri = targetUri, + columns = columns, + rootOnly = rootOnly, + block = block, + ) + + else -> traverseSafDirectoryEntries( + context = context, + targetUri = targetUri, + columns = columns, + rootOnly = rootOnly, + block = block, + ) + } +} + + +fun bitmapToBase64(bitmap: Bitmap): String { + val outputStream = ByteArrayOutputStream() + + val fullQuality = 100 + + bitmap.compress(Bitmap.CompressFormat.PNG, fullQuality, outputStream) + + return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) +} + +/** + * Trick to verify if is a tree URI even not in API 26+ + */ +fun isTreeUri(uri: Uri): Boolean { + if (Build.VERSION.SDK_INT >= API_24) { + return DocumentsContract.isTreeUri(uri) + } + + val paths = uri.pathSegments + + return paths.size >= 2 && "tree" == paths[0] +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt index c473bbc..4b6450f 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt @@ -1,108 +1,74 @@ -package io.alexrintt.sharedstorage.deprecated.lib - -import android.database.Cursor -import android.provider.DocumentsContract -import java.lang.NullPointerException - -private const val PREFIX = "DocumentFileColumn" - -enum class DocumentFileColumn { - ID, - DISPLAY_NAME, - MIME_TYPE, - SUMMARY, - LAST_MODIFIED, - SIZE -} - -enum class DocumentFileColumnType { - LONG, - STRING, - INT -} - -fun deserializeDocumentFileColumn(column: String): DocumentFileColumn? { - val values = mapOf( - "$PREFIX.COLUMN_DOCUMENT_ID" to DocumentFileColumn.ID, - "$PREFIX.COLUMN_DISPLAY_NAME" to DocumentFileColumn.DISPLAY_NAME, - "$PREFIX.COLUMN_MIME_TYPE" to DocumentFileColumn.MIME_TYPE, - "$PREFIX.COLUMN_SIZE" to DocumentFileColumn.SIZE, - "$PREFIX.COLUMN_SUMMARY" to DocumentFileColumn.SUMMARY, - "$PREFIX.COLUMN_LAST_MODIFIED" to DocumentFileColumn.LAST_MODIFIED - ) - - return values[column] -} - -fun serializeDocumentFileColumn(column: DocumentFileColumn): String? { - val values = mapOf( - DocumentFileColumn.ID to "$PREFIX.COLUMN_DOCUMENT_ID", - DocumentFileColumn.DISPLAY_NAME to "$PREFIX.COLUMN_DISPLAY_NAME", - DocumentFileColumn.MIME_TYPE to "$PREFIX.COLUMN_MIME_TYPE", - DocumentFileColumn.SIZE to "$PREFIX.COLUMN_SIZE", - DocumentFileColumn.SUMMARY to "$PREFIX.COLUMN_SUMMARY", - DocumentFileColumn.LAST_MODIFIED to "$PREFIX.COLUMN_LAST_MODIFIED" - ) - - return values[column] -} - -fun documentFileColumnToActualDocumentsContractEnumString(column: DocumentFileColumn): String { - val values = mapOf( - DocumentFileColumn.ID to DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentFileColumn.DISPLAY_NAME to DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentFileColumn.MIME_TYPE to DocumentsContract.Document.COLUMN_MIME_TYPE, - DocumentFileColumn.SIZE to DocumentsContract.Document.COLUMN_SIZE, - DocumentFileColumn.SUMMARY to DocumentsContract.Document.COLUMN_SUMMARY, - DocumentFileColumn.LAST_MODIFIED to DocumentsContract.Document.COLUMN_LAST_MODIFIED - ) - - return values[column]!! -} - -/// `column` must be a constant String from `DocumentsContract.Document.COLUMN*` -fun typeOfColumn(column: String): DocumentFileColumnType? { - val values = mapOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID to DocumentFileColumnType.STRING, - DocumentsContract.Document.COLUMN_DISPLAY_NAME to DocumentFileColumnType.STRING, - DocumentsContract.Document.COLUMN_MIME_TYPE to DocumentFileColumnType.STRING, - DocumentsContract.Document.COLUMN_SIZE to DocumentFileColumnType.LONG, - DocumentsContract.Document.COLUMN_SUMMARY to DocumentFileColumnType.STRING, - DocumentsContract.Document.COLUMN_LAST_MODIFIED to DocumentFileColumnType.LONG, - DocumentsContract.Document.COLUMN_FLAGS to DocumentFileColumnType.INT - ) - - return values[column] -} - -fun cursorHandlerOf(type: DocumentFileColumnType): (Cursor, Int) -> Any? { - when (type) { - DocumentFileColumnType.LONG -> { - return { cursor, index -> - try { - cursor.getLong(index) - } catch (e: NullPointerException) { - null - } - } - } - DocumentFileColumnType.STRING -> { - return { cursor, index -> - try { - cursor.getString(index) - } catch (e: NullPointerException) { - null - } - } - } - DocumentFileColumnType.INT -> { - return { cursor, index -> - try { - cursor.getInt(index) - } catch (e: NullPointerException) { - null - } - } - } - } -} +package io.alexrintt.sharedstorage.deprecated.lib + +import android.database.Cursor +import android.provider.DocumentsContract +import java.lang.NullPointerException + +private const val PREFIX = "DocumentFileColumn" + +enum class DocumentFileColumnType { + LONG, + STRING, + INT +} + + +fun getDocumentsContractColumns(): List { + return listOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_SUMMARY, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + ) +} + +/// `column` must be a constant String from `DocumentsContract.Document.COLUMN*` +fun typeOfColumn(column: String): DocumentFileColumnType? { + val values = mapOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID to DocumentFileColumnType.STRING, + DocumentsContract.Document.COLUMN_DISPLAY_NAME to DocumentFileColumnType.STRING, + DocumentsContract.Document.COLUMN_MIME_TYPE to DocumentFileColumnType.STRING, + DocumentsContract.Document.COLUMN_SIZE to DocumentFileColumnType.LONG, + DocumentsContract.Document.COLUMN_SUMMARY to DocumentFileColumnType.STRING, + DocumentsContract.Document.COLUMN_LAST_MODIFIED to DocumentFileColumnType.LONG, + DocumentsContract.Document.COLUMN_FLAGS to DocumentFileColumnType.INT + ) + + return values[column] +} + +fun cursorHandlerOf(type: DocumentFileColumnType): (Cursor, Int) -> Any? { + when (type) { + DocumentFileColumnType.LONG -> { + return { cursor, index -> + try { + cursor.getLong(index) + } catch (e: NullPointerException) { + null + } + } + } + + DocumentFileColumnType.STRING -> { + return { cursor, index -> + try { + cursor.getString(index) + } catch (e: NullPointerException) { + null + } + } + } + + DocumentFileColumnType.INT -> { + return { cursor, index -> + try { + cursor.getInt(index) + } catch (e: NullPointerException) { + null + } + } + } + } +} diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 180d76f..d914bab 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,60 +1,60 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file("local.properties") -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader("UTF-8") { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty("flutter.sdk") -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty("flutter.versionCode") -if (flutterVersionCode == null) { - flutterVersionCode = "1" -} - -def flutterVersionName = localProperties.getProperty("flutter.versionName") -if (flutterVersionName == null) { - flutterVersionName = "1.0" -} - -apply plugin: "com.android.application" -apply plugin: "kotlin-android" -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion 33 - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - } - - defaultConfig { - applicationId "io.alexrintt.sharedstorage.example" - minSdkVersion 19 - targetSdkVersion 31 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug - } - } -} - -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -flutter { - source "../.." -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} +def localProperties = new Properties() +def localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader("UTF-8") { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty("flutter.sdk") +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty("flutter.versionCode") +if (flutterVersionCode == null) { + flutterVersionCode = "1" +} + +def flutterVersionName = localProperties.getProperty("flutter.versionName") +if (flutterVersionName == null) { + flutterVersionName = "1.0" +} + +apply plugin: "com.android.application" +apply plugin: "kotlin-android" +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 33 + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + } + + defaultConfig { + applicationId "io.alexrintt.sharedstorage.example" + minSdkVersion 19 + targetSdkVersion 31 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +flutter { + source "../.." +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index d8efeeb..d3db29e 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,33 +1,72 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/example/android/build.gradle b/example/android/build.gradle index 1202707..14c54f0 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,30 +1,30 @@ -buildscript { - ext.kotlin_version = '1.8.21' - ext.gradle_version = '7.4.2' - repositories { - google() - jcenter() - } - - dependencies { - classpath "com.android.tools.build:gradle:$gradle_version" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - jcenter() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" - project.evaluationDependsOn(':app') -} - -task clean(type: Delete) { - delete rootProject.buildDir -} +buildscript { + ext.kotlin_version = '1.8.21' + ext.gradle_version = '7.4.2' + repositories { + google() + jcenter() + } + + dependencies { + classpath "com.android.tools.build:gradle:$gradle_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index 16de3b4..19a0486 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -1,493 +1,528 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:shared_storage/shared_storage.dart'; - -import '../../utils/apply_if_not_null.dart'; -import '../../utils/confirm_decorator.dart'; -import '../../utils/disabled_text_style.dart'; -import '../../utils/document_file_utils.dart'; -import '../../utils/format_bytes.dart'; -import '../../utils/inline_span.dart'; -import '../../utils/mime_types.dart'; -import '../../widgets/buttons.dart'; -import '../../widgets/key_value_text.dart'; -import '../../widgets/simple_card.dart'; -import '../../widgets/text_field_dialog.dart'; -import '../large_file/large_file_screen.dart'; -import 'file_explorer_page.dart'; - -class FileExplorerCard extends StatefulWidget { - const FileExplorerCard({ - super.key, - required this.documentFile, - required this.didUpdateDocument, - }); - - final DocumentFile documentFile; - final void Function(DocumentFile?) didUpdateDocument; - - @override - _FileExplorerCardState createState() => _FileExplorerCardState(); -} - -class _FileExplorerCardState extends State { - DocumentFile get _file => widget.documentFile; - - static const _kExpandedThumbnailSize = Size.square(150); - - Uint8List? _thumbnailImageBytes; - Size? _thumbnailSize; - - int get _sizeInBytes => _file.size ?? 0; - - bool _expanded = false; - String? get _displayName => _file.name; - - Future _loadThumbnailIfAvailable() async { - final uri = _file.uri; - - final bitmap = await getDocumentThumbnail( - uri: uri, - width: _kExpandedThumbnailSize.width, - height: _kExpandedThumbnailSize.height, - ); - - if (bitmap == null) { - _thumbnailImageBytes = Uint8List.fromList([]); - _thumbnailSize = Size.zero; - } else { - _thumbnailImageBytes = bitmap.bytes; - _thumbnailSize = Size(bitmap.width! / 1, bitmap.height! / 1); - } - - if (mounted) setState(() {}); - } - - StreamSubscription? _subscription; - - Future Function() _fileConfirmation( - String action, - VoidCallback callback, - ) { - return confirm( - context, - action, - callback, - message: [ - normal('You are '), - bold('writing'), - normal(' to this file and it is '), - bold('not a reversible action'), - normal('. It can '), - bold(red('corrupt the file')), - normal(' or '), - bold(red('cause data loss')), - normal(', '), - italic('be cautious'), - normal('.'), - ], - ); - } - - VoidCallback _directoryConfirmation(String action, VoidCallback callback) { - return confirm( - context, - action, - callback, - message: [ - normal('You are '), - bold('deleting'), - normal(' this folder, this is '), - bold('not reversible'), - normal(' and '), - bold(red('can cause data loss ')), - normal('or even'), - bold(red(' corrupt some apps')), - normal(' depending on which folder you are deleting, '), - italic('be cautious.'), - ], - ); - } - - Widget _buildMimeTypeIconThumbnail(String mimeType, {double? size}) { - if (_isDirectory) { - return Icon(Icons.folder, size: size, color: Colors.blueGrey); - } - - if (mimeType == kApkMime) { - return Icon(Icons.android, color: const Color(0xff3AD17D), size: size); - } - - if (mimeType == kTextPlainMime) { - return Icon(Icons.description, size: size, color: Colors.blue); - } - - if (mimeType.startsWith(kVideoMime)) { - return Icon(Icons.movie, size: size, color: Colors.deepOrange); - } - - return Icon( - Icons.browser_not_supported_outlined, - size: size, - color: disabledColor(), - ); - } - - @override - void initState() { - super.initState(); - - _loadThumbnailIfAvailable(); - } - - @override - void didUpdateWidget(covariant FileExplorerCard oldWidget) { - super.didUpdateWidget(oldWidget); - - if (oldWidget.documentFile.id != widget.documentFile.id) { - _loadThumbnailIfAvailable(); - if (mounted) setState(() => _expanded = false); - } - } - - @override - void dispose() { - _subscription?.cancel(); - super.dispose(); - } - - void _openFolderFileListPage(Uri uri) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => FileExplorerPage(uri: uri), - ), - ); - } - - Uint8List? content; - - bool get _isDirectory => _file.isDirectory == true; - - int _generateLuckNumber() { - final random = Random(); - - return random.nextInt(1000); - } - - Widget _buildThumbnailImage({double? size}) { - late Widget thumbnail; - - if (_thumbnailImageBytes == null) { - thumbnail = const CircularProgressIndicator(); - } else if (_thumbnailImageBytes!.isEmpty) { - thumbnail = _buildMimeTypeIconThumbnail( - _mimeTypeOrEmpty, - size: size, - ); - } else { - thumbnail = Image.memory( - _thumbnailImageBytes!, - fit: BoxFit.contain, - ); - - if (!_expanded) { - final width = _thumbnailSize?.width; - final height = _thumbnailSize?.height; - - final aspectRatio = - width != null && height != null ? width / height : 1.0; - - thumbnail = AspectRatio( - aspectRatio: aspectRatio, - child: thumbnail, - ); - } - } - - return thumbnail; - } - - Widget _buildThumbnail({double? size}) { - final Widget thumbnail = _buildThumbnailImage(size: size); - - List children; - - if (_expanded) { - children = [ - Flexible( - child: Align( - child: thumbnail, - ), - ), - Flexible(child: _buildExpandButton()), - ]; - } else { - children = [thumbnail]; - } - - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: _expanded ? MainAxisSize.max : MainAxisSize.min, - children: children, - ), - ); - } - - Widget _buildExpandButton() { - return IconButton( - onPressed: () => setState(() => _expanded = !_expanded), - icon: _expanded - ? const Icon(Icons.expand_less, color: Colors.grey) - : const Icon(Icons.expand_more, color: Colors.grey), - ); - } - - Uri get _currentUri => widget.documentFile.uri; - - Widget _buildNotAvailableText() { - return Text('Not available', style: disabledTextStyle()); - } - - Widget _buildOpenWithButton() => - Button('Open with', onTap: _currentUri.openWithExternalApp); - - Widget _buildDocumentSimplifiedTile() { - return ListTile( - dense: true, - leading: _buildThumbnail(size: 25), - title: Text( - '$_displayName', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text(formatBytes(_sizeInBytes, 2)), - trailing: _buildExpandButton(), - ); - } - - String? get _lastModified { - if (_file.lastModified == null) { - return null; - } - - return _file.lastModified!.toIso8601String(); - } - - Widget _buildDocumentMetadata() { - return KeyValueText( - entries: { - 'name': '$_displayName', - 'type': '${_file.type}', - 'isVirtual': '${_file.isVirtual}', - 'isDirectory': '${_file.isDirectory}', - 'isFile': '${_file.isFile}', - 'size': '${formatBytes(_sizeInBytes, 2)} ($_sizeInBytes bytes)', - 'lastModified': _lastModified.toString(), - 'id': '${_file.id}', - 'parentUri': _file.parentUri?.apply((u) => Uri.decodeFull('$u')) ?? - _buildNotAvailableText(), - 'uri': Uri.decodeFull('${_file.uri}'), - }, - ); - } - - Future _shareDocument() async { - await widget.documentFile.share(); - } - - Future _copyTo() async { - final Uri? parentUri = await openDocumentTree(persistablePermission: false); - - if (parentUri != null) { - final DocumentFile? parentDocumentFile = await parentUri.toDocumentFile(); - - if (widget.documentFile.type != null && - widget.documentFile.name != null) { - final DocumentFile? recipient = await parentDocumentFile?.createFile( - mimeType: widget.documentFile.type!, - displayName: widget.documentFile.name!, - ); - - if (recipient != null) { - widget.documentFile.copy(recipient.uri); - } - } - } - } - - void _openLargeFileScreen() { - Navigator.push( - context, - MaterialPageRoute( - builder: (BuildContext context) { - return LargeFileScreen(uri: widget.documentFile.uri); - }, - ), - ); - } - - Widget _buildAvailableActions() { - return Wrap( - children: [ - if (_isDirectory) - ActionButton( - 'Open Directory', - onTap: _openDirectory, - ), - _buildOpenWithButton(), - DangerButton( - 'Rename', - onTap: _renameDocFile, - ), - DangerButton( - 'Delete ${_isDirectory ? 'Directory' : 'File'}', - onTap: _isDirectory - ? _directoryConfirmation('Delete', _deleteDocument) - : _fileConfirmation('Delete', _deleteDocument), - ), - if (!_isDirectory) ...[ - ActionButton( - 'Lazy load its content', - onTap: _openLargeFileScreen, - ), - ActionButton( - 'Copy to', - onTap: _copyTo, - ), - ActionButton( - 'Share Document', - onTap: _shareDocument, - ), - DangerButton( - 'Write to File', - onTap: _fileConfirmation('Overwite', _overwriteFileContents), - ), - DangerButton( - 'Append to file', - onTap: _fileConfirmation('Append', _appendFileContents), - ), - DangerButton( - 'Erase file content', - onTap: _fileConfirmation('Erase', _eraseFileContents), - ), - DangerButton( - 'Edit file contents', - onTap: _editFileContents, - ), - ], - ], - ); - } - - String get _mimeTypeOrEmpty => _file.type ?? ''; - - Future _deleteDocument() async { - final deleted = await delete(_currentUri); - - if (deleted ?? false) { - widget.didUpdateDocument(null); - } - } - - Future _overwriteFileContents() async { - await writeToFile( - _currentUri, - content: 'Hello World! Your luck number is: ${_generateLuckNumber()}', - mode: FileMode.write, - ); - } - - Future _appendFileContents() async { - final contents = await getDocumentContentAsString( - _currentUri, - ); - - final prependWithNewLine = contents?.isNotEmpty ?? true; - - await writeToFile( - _currentUri, - content: - "${prependWithNewLine ? '\n' : ''}You file got bigger! Here's your luck number: ${_generateLuckNumber()}", - mode: FileMode.append, - ); - } - - Future _renameDocFile() async { - final newDisplayName = await showDialog( - context: context, - builder: (context) { - return TextFieldDialog( - labelText: - 'New ${widget.documentFile.isDirectory ?? false ? 'directory' : 'file'} name:', - hintText: widget.documentFile.name ?? '', - actionText: 'Edit', - ); - }, - ); - - if (newDisplayName == null) return; - - final updatedDocumentFile = - await widget.documentFile.renameTo(newDisplayName); - - widget.didUpdateDocument(updatedDocumentFile); - } - - Future _eraseFileContents() async { - await writeToFile( - _currentUri, - content: '', - mode: FileMode.write, - ); - } - - Future _editFileContents() async { - final content = await showDialog( - context: context, - builder: (context) { - return const TextFieldDialog( - labelText: 'New file content:', - hintText: 'Writing to this file', - actionText: 'Edit', - ); - }, - ); - - if (content != null) { - _fileConfirmation( - 'Overwrite', - () => writeToFileAsString( - _currentUri, - content: content, - mode: FileMode.write, - ), - )(); - } - } - - Future _openDirectory() async { - if (_isDirectory) { - _openFolderFileListPage(_file.uri); - } - } - - @override - Widget build(BuildContext context) { - return SimpleCard( - onTap: _isDirectory ? _openDirectory : () => _file.showContents(context), - children: [ - if (_expanded) ...[ - _buildThumbnail(size: 50), - _buildDocumentMetadata(), - _buildAvailableActions() - ] else - _buildDocumentSimplifiedTile(), - ], - ); - } -} +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import '../../utils/apply_if_not_null.dart'; +import '../../utils/confirm_decorator.dart'; +import '../../utils/disabled_text_style.dart'; +import '../../utils/document_file_utils.dart'; +import '../../utils/format_bytes.dart'; +import '../../utils/inline_span.dart'; +import '../../utils/mime_types.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/key_value_text.dart'; +import '../../widgets/simple_card.dart'; +import '../../widgets/text_field_dialog.dart'; +import '../large_file/large_file_screen.dart'; +import 'file_explorer_page.dart'; + +class FileExplorerCard extends StatefulWidget { + const FileExplorerCard({ + super.key, + required this.scopedFileSystemEntity, + required this.didUpdateDocument, + this.allowExpand = true, + }); + + final ScopedFileSystemEntity scopedFileSystemEntity; + final void Function(ScopedFileSystemEntity?) didUpdateDocument; + final bool allowExpand; + + @override + _FileExplorerCardState createState() => _FileExplorerCardState(); +} + +class _FileExplorerCardState extends State { + ScopedFileSystemEntity get _fileSystemEntity => widget.scopedFileSystemEntity; + + ScopedFile? get _file { + if (_fileSystemEntity is ScopedFile) { + return _fileSystemEntity as ScopedFile; + } + return null; + } + + ScopedDirectory? get _directory { + if (_fileSystemEntity is ScopedDirectory) { + return _fileSystemEntity as ScopedDirectory; + } + return null; + } + + static const _kExpandedThumbnailSize = Size.square(150); + + Uint8List? _thumbnailImageBytes; + Size? _thumbnailSize; + + int get _sizeInBytes => _file?.length ?? 0; + + bool _expanded = false; + String? get _displayName => _fileSystemEntity.displayName; + + Future _loadThumbnailIfAvailable() async { + if (_isDirectory) return; + + final uri = _fileSystemEntity.uri; + + final bitmap = await getDocumentThumbnail( + uri: uri, + width: _kExpandedThumbnailSize.width, + height: _kExpandedThumbnailSize.height, + ); + + if (bitmap == null) { + _thumbnailImageBytes = Uint8List.fromList([]); + _thumbnailSize = Size.zero; + } else { + _thumbnailImageBytes = bitmap.bytes; + _thumbnailSize = Size(bitmap.width! / 1, bitmap.height! / 1); + } + + if (mounted) setState(() {}); + } + + StreamSubscription? _subscription; + + Future Function() _fileConfirmation( + String action, + VoidCallback callback, + ) { + return confirm( + context, + action, + callback, + message: [ + normal('You are '), + bold('writing'), + normal(' to this file and it is '), + bold('not a reversible action'), + normal('. It can '), + bold(red('corrupt the file')), + normal(' or '), + bold(red('cause data loss')), + normal(', '), + italic('be cautious'), + normal('.'), + ], + ); + } + + VoidCallback _directoryConfirmation(String action, VoidCallback callback) { + return confirm( + context, + action, + callback, + message: [ + normal('You are '), + bold('deleting'), + normal(' this folder, this is '), + bold('not reversible'), + normal(' and '), + bold(red('can cause data loss ')), + normal('or even'), + bold(red(' corrupt some apps')), + normal(' depending on which folder you are deleting, '), + italic('be cautious.'), + ], + ); + } + + Widget _buildMimeTypeIconThumbnail(String mimeType, {double? size}) { + if (_isDirectory) { + return Icon(Icons.folder, size: size, color: Colors.blueGrey); + } + + if (mimeType == kApkMime) { + return Icon(Icons.android, color: const Color(0xff3AD17D), size: size); + } + + if (mimeType == kTextPlainMime) { + return Icon(Icons.description, size: size, color: Colors.blue); + } + + if (mimeType.startsWith(kVideoMime)) { + return Icon(Icons.movie, size: size, color: Colors.deepOrange); + } + + return Icon( + Icons.browser_not_supported_outlined, + size: size, + color: disabledColor(), + ); + } + + @override + void initState() { + super.initState(); + + _loadThumbnailIfAvailable(); + } + + @override + void didUpdateWidget(covariant FileExplorerCard oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.scopedFileSystemEntity.id != + widget.scopedFileSystemEntity.id) { + _loadThumbnailIfAvailable(); + if (mounted) setState(() => _expanded = false); + } + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + void _openFolderFileListPage(Uri uri) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => FileExplorerPage(uri: uri), + ), + ); + } + + Uint8List? content; + + bool get _isDirectory => _fileSystemEntity is ScopedDirectory; + bool get _isFile => _fileSystemEntity is ScopedFile; + + int _generateLuckNumber() { + final random = Random(); + + return random.nextInt(1000); + } + + Widget _buildThumbnailImage({double? size}) { + if (_isDirectory) { + return const Align( + alignment: Alignment.centerLeft, + child: Icon( + Icons.folder, + color: Colors.grey, + ), + ); + } + + late Widget thumbnail; + + if (_thumbnailImageBytes == null) { + thumbnail = const CircularProgressIndicator(); + } else if (_thumbnailImageBytes!.isEmpty) { + thumbnail = _buildMimeTypeIconThumbnail( + _mimeTypeOrEmpty, + size: size, + ); + } else { + thumbnail = Image.memory( + _thumbnailImageBytes!, + fit: BoxFit.contain, + ); + + if (!_expanded) { + final width = _thumbnailSize?.width; + final height = _thumbnailSize?.height; + + final aspectRatio = + width != null && height != null ? width / height : 1.0; + + thumbnail = AspectRatio( + aspectRatio: aspectRatio, + child: thumbnail, + ); + } + } + + return thumbnail; + } + + Widget _buildThumbnail({double? size}) { + final Widget thumbnail = _buildThumbnailImage(size: size); + + List children; + + if (_expanded) { + children = [ + Flexible( + child: Align( + child: thumbnail, + ), + ), + Flexible(child: _buildExpandButton()), + ]; + } else { + children = [thumbnail]; + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: _expanded ? MainAxisSize.max : MainAxisSize.min, + children: children, + ), + ); + } + + Widget _buildExpandButton() { + return IconButton( + onPressed: () => setState(() => _expanded = !_expanded), + icon: _expanded + ? const Icon(Icons.expand_less, color: Colors.grey) + : const Icon(Icons.expand_more, color: Colors.grey), + ); + } + + Uri get _location => widget.scopedFileSystemEntity.uri; + + Widget _buildNotAvailableText() { + return Text('Not available', style: disabledTextStyle()); + } + + Widget _buildOpenWithButton() => + Button('Open with', onTap: _location.openWithExternalApp); + + Widget _buildDocumentSimplifiedTile() { + return ListTile( + dense: true, + leading: _buildThumbnail(size: 25), + title: Text( + '$_displayName', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(formatBytes(_sizeInBytes, 2)), + trailing: widget.allowExpand ? _buildExpandButton() : null, + ); + } + + String get _lastModified { + return _fileSystemEntity.lastModified.toIso8601String(); + } + + Widget _buildDocumentMetadata() { + return KeyValueText( + entries: { + 'name': '$_displayName', + 'type': '${_isFile ? _file!.mimeType : null}', + 'isDirectory': '$_isDirectory', + 'isFile': '$_isFile', + 'size': '${formatBytes(_sizeInBytes, 2)} ($_sizeInBytes bytes)', + 'lastModified': _lastModified, + 'id': _fileSystemEntity.id, + 'parentUri': + _fileSystemEntity.parentUri?.apply((u) => Uri.decodeFull('$u')) ?? + _buildNotAvailableText(), + 'uri': Uri.decodeFull('${_fileSystemEntity.uri}'), + }, + ); + } + + Uri get _currentUri => _fileSystemEntity.uri; + + Future _shareFile() async { + if (_isFile) { + await SharedStorage.shareScopedFile(_file!); + } + } + + Future _copyTo() async { + assert(_isFile); + + try { + final ScopedDirectory parentDirectory = + await SharedStorage.pickDirectory(persist: false); + + if (_file?.mimeType != null && _file?.displayName != null) { + final ScopedFile recipient = await parentDirectory.createChildFile( + displayName: _file!.displayName, + mimeType: _file!.mimeType, + ); + + // TODO: Add stream based [copy] method to [ScopedFile]. + _file!.copyTo(recipient.uri); + } + } on SharedStorageDirectoryWasNotSelectedException { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('User did not select a directory.'), + ), + ); + } + } + + void _openLargeFileScreen() { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) { + return LargeFileScreen(uri: widget.scopedFileSystemEntity.uri); + }, + ), + ); + } + + Widget _buildAvailableActions() { + return Wrap( + children: [ + if (_isDirectory) + ActionButton( + 'Open Directory', + onTap: _openDirectory, + ), + _buildOpenWithButton(), + DangerButton( + 'Rename', + onTap: _renameDocFile, + ), + DangerButton( + 'Delete ${_isDirectory ? 'Directory' : 'File'}', + onTap: _isDirectory + ? _directoryConfirmation('Delete', _deleteDocument) + : _fileConfirmation('Delete', _deleteDocument), + ), + if (!_isDirectory) ...[ + ActionButton( + 'Lazy load its content', + onTap: _openLargeFileScreen, + ), + ActionButton( + 'Copy to', + onTap: _copyTo, + ), + ActionButton( + 'Share Document', + onTap: _shareFile, + ), + DangerButton( + 'Write to File', + onTap: _fileConfirmation('Overwite', _overwriteFileContents), + ), + DangerButton( + 'Append to file', + onTap: _fileConfirmation('Append', _appendFileContents), + ), + DangerButton( + 'Erase file content', + onTap: _fileConfirmation('Erase', _eraseFileContents), + ), + DangerButton( + 'Edit file contents', + onTap: _editFileContents, + ), + ], + ], + ); + } + + String get _mimeTypeOrEmpty => _file?.mimeType ?? ''; + + Future _deleteDocument() async { + final deleted = await delete(_currentUri); + + if (deleted ?? false) { + widget.didUpdateDocument(null); + } + } + + Future _overwriteFileContents() async { + await writeToFile( + _currentUri, + content: 'Hello World! Your luck number is: ${_generateLuckNumber()}', + mode: FileMode.write, + ); + } + + Future _appendFileContents() async { + final contents = await getDocumentContentAsString( + _currentUri, + ); + + final prependWithNewLine = contents?.isNotEmpty ?? true; + + await writeToFile( + _currentUri, + content: + "${prependWithNewLine ? '\n' : ''}You file got bigger! Here's your luck number: ${_generateLuckNumber()}", + mode: FileMode.append, + ); + } + + Future _renameDocFile() async { + final newDisplayName = await showDialog( + context: context, + builder: (context) { + return TextFieldDialog( + labelText: 'New ${_isDirectory ? 'directory' : 'file'} name:', + hintText: _fileSystemEntity.displayName, + actionText: 'Edit', + ); + }, + ); + + if (newDisplayName == null) return; + + final updatedDocumentFile = + await widget.scopedFileSystemEntity.rename(newDisplayName); + + widget.didUpdateDocument(updatedDocumentFile); + } + + Future _eraseFileContents() async { + await writeToFile( + _currentUri, + content: '', + mode: FileMode.write, + ); + } + + Future _editFileContents() async { + final content = await showDialog( + context: context, + builder: (context) { + return const TextFieldDialog( + labelText: 'New file content:', + hintText: 'Writing to this file', + actionText: 'Edit', + ); + }, + ); + + if (content != null) { + _fileConfirmation( + 'Overwrite', + () => writeToFileAsString( + _currentUri, + content: content, + mode: FileMode.write, + ), + )(); + } + } + + Future _openDirectory() async { + if (_isDirectory) { + _openFolderFileListPage(_directory!.uri); + } + } + + @override + Widget build(BuildContext context) { + return SimpleCard( + onTap: _isDirectory ? _openDirectory : () => _file!.showContents(context), + children: [ + if (_expanded) ...[ + _buildThumbnail(size: 50), + _buildDocumentMetadata(), + _buildAvailableActions() + ] else + _buildDocumentSimplifiedTile(), + ], + ); + } +} diff --git a/example/lib/screens/file_explorer/file_explorer_page.dart b/example/lib/screens/file_explorer/file_explorer_page.dart index dfd9567..790a7ac 100644 --- a/example/lib/screens/file_explorer/file_explorer_page.dart +++ b/example/lib/screens/file_explorer/file_explorer_page.dart @@ -1,250 +1,238 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:shared_storage/shared_storage.dart'; - -import '../../theme/spacing.dart'; -import '../../widgets/buttons.dart'; -import '../../widgets/light_text.dart'; -import '../../widgets/simple_card.dart'; -import '../../widgets/text_field_dialog.dart'; -import 'file_explorer_card.dart'; - -class FileExplorerPage extends StatefulWidget { - const FileExplorerPage({ - super.key, - required this.uri, - }); - - final Uri uri; - - @override - _FileExplorerPageState createState() => _FileExplorerPageState(); -} - -class _FileExplorerPageState extends State { - List? _files; - - late bool _hasPermission; - - StreamSubscription? _listener; - - Future _grantAccess() async { - final uri = await openDocumentTree(initialUri: widget.uri); - - if (uri == null) return; - - _files = null; - - _loadFiles(); - } - - Widget _buildNoPermissionWarning() { - return SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - SimpleCard( - onTap: () => {}, - children: [ - Center( - child: LightText( - 'No permission granted to this folder\n\n${widget.uri}\n', - ), - ), - Center( - child: ActionButton( - 'Grant Access', - onTap: _grantAccess, - ), - ), - ], - ), - ], - ), - ), - ); - } - - Future _createCustomDocument() async { - final filename = await showDialog( - context: context, - builder: (context) => const TextFieldDialog( - hintText: 'File name:', - labelText: 'My Text File', - suffixText: '.txt', - actionText: 'Create', - ), - ); - - if (filename == null) return; - - final createdFile = await createFile( - widget.uri, - mimeType: 'text/plain', - displayName: filename, - ); - - if (createdFile != null) { - _files?.add(createdFile); - - if (mounted) setState(() {}); - } - } - - Widget _buildCreateDocumentButton() { - return SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - Center( - child: ActionButton( - 'Create a custom document', - onTap: _createCustomDocument, - ), - ), - ], - ), - ), - ); - } - - void _didUpdateDocument( - DocumentFile before, - DocumentFile? after, - ) { - if (_files == null) return; - - if (after == null) { - _files!.removeWhere((doc) => doc.id == before.id); - } else { - final indexToUpdate = _files!.indexWhere((doc) => doc.id == before.id); - _files![indexToUpdate] = after; - } - - if (mounted) setState(() {}); - } - - Widget _buildDocumentList() { - return SliverPadding( - padding: k6dp.et, - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final file = _files![index]; - - return FileExplorerCard( - documentFile: file, - didUpdateDocument: (document) => - _didUpdateDocument(file, document), - ); - }, - childCount: _files!.length, - ), - ), - ); - } - - Widget _buildEmptyFolderWarning() { - return SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - SimpleCard( - onTap: () => {}, - children: const [ - Center( - child: LightText( - 'Empty folder', - ), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildFileList() { - return CustomScrollView( - slivers: [ - if (!_hasPermission) - _buildNoPermissionWarning() - else ...[ - _buildCreateDocumentButton(), - if (_files!.isNotEmpty) - _buildDocumentList() - else - _buildEmptyFolderWarning(), - ] - ], - ); - } - - @override - void initState() { - super.initState(); - - _loadFiles(); - } - - @override - void dispose() { - _listener?.cancel(); - - super.dispose(); - } - - Future _loadFiles() async { - _hasPermission = await canRead(widget.uri) ?? false; - - if (!_hasPermission) { - return setState(() => _files = []); - } - - final folderUri = widget.uri; - - const columns = [ - DocumentFileColumn.displayName, - DocumentFileColumn.size, - DocumentFileColumn.lastModified, - DocumentFileColumn.mimeType, - // The column below is a optional column - // you can wether include or not here and - // it will be always available on the results - DocumentFileColumn.id, - ]; - - final fileListStream = listFiles(folderUri, columns: columns); - - _listener = fileListStream.listen( - (file) { - /// Append new files to the current file list - _files = [...?_files, file]; - - /// Update the state only if the widget is currently showing - if (mounted) { - setState(() {}); - } else { - _listener?.cancel(); - } - }, - onDone: () => setState(() => _files = [...?_files]), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('Inside ${widget.uri.pathSegments.last}')), - body: _files == null - ? const Center(child: CircularProgressIndicator()) - : _buildFileList(), - ); - } -} +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:shared_storage/shared_storage.dart'; + +import '../../theme/spacing.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/light_text.dart'; +import '../../widgets/simple_card.dart'; +import '../../widgets/text_field_dialog.dart'; +import 'file_explorer_card.dart'; + +class FileExplorerPage extends StatefulWidget { + const FileExplorerPage({ + super.key, + required this.uri, + }); + + final Uri uri; + + @override + _FileExplorerPageState createState() => _FileExplorerPageState(); +} + +class _FileExplorerPageState extends State { + List? _files; + + late bool _hasPermission; + + ScopedDirectory? _directory; + + StreamSubscription? _listener; + + Future _grantAccess() async { + await SharedStorage.pickDirectory(initialUri: widget.uri); + + _files = null; + + _loadFiles(); + } + + Widget _buildNoPermissionWarning() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + SimpleCard( + onTap: () => {}, + children: [ + Center( + child: LightText( + 'No permission granted to this folder\n\n${widget.uri}\n', + ), + ), + Center( + child: ActionButton( + 'Grant Access', + onTap: _grantAccess, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _createCustomDocument() async { + if (_directory == null) return; + + final filename = await showDialog( + context: context, + builder: (context) => const TextFieldDialog( + hintText: 'File name:', + labelText: 'My Text File', + suffixText: '.txt', + actionText: 'Create', + ), + ); + + if (filename == null) return; + + final createdFile = await _directory!.createChildFile( + mimeType: 'text/plain', + displayName: filename, + ); + + _files?.add(createdFile); + + if (mounted) setState(() {}); + } + + Widget _buildCreateDocumentButton() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + Center( + child: ActionButton( + 'Create a custom document', + onTap: _createCustomDocument, + ), + ), + ], + ), + ), + ); + } + + void _didUpdateDocument( + ScopedFileSystemEntity before, + ScopedFileSystemEntity? after, + ) { + if (_files == null) return; + + if (after == null) { + _files!.removeWhere((doc) => doc.id == before.id); + } else { + final indexToUpdate = _files!.indexWhere((doc) => doc.id == before.id); + _files![indexToUpdate] = after; + } + + if (mounted) setState(() {}); + } + + Widget _buildDocumentList() { + return SliverPadding( + padding: k6dp.et, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final file = _files![index]; + + return FileExplorerCard( + scopedFileSystemEntity: file, + didUpdateDocument: (document) => + _didUpdateDocument(file, document), + ); + }, + childCount: _files!.length, + ), + ), + ); + } + + Widget _buildEmptyFolderWarning() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + SimpleCard( + onTap: () => {}, + children: const [ + Center( + child: LightText( + 'Empty folder', + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildFileList() { + return CustomScrollView( + slivers: [ + if (!_hasPermission) + _buildNoPermissionWarning() + else ...[ + _buildCreateDocumentButton(), + if (_files!.isNotEmpty) + _buildDocumentList() + else + _buildEmptyFolderWarning(), + ] + ], + ); + } + + @override + void initState() { + super.initState(); + + _loadFiles(); + } + + @override + void dispose() { + _listener?.cancel(); + + super.dispose(); + } + + Future _loadFiles() async { + final directory = await ScopedDirectory.fromUri(widget.uri); + + _hasPermission = await directory.canRead(); + + if (!_hasPermission) { + return setState(() => _files = []); + } + + final fileListStream = directory.list(); + + _listener = fileListStream.listen( + (file) { + /// Append new files to the current file list + _files = [...?_files, file]; + + /// Update the state only if the widget is currently showing + if (mounted) { + setState(() {}); + } else { + _listener?.cancel(); + } + }, + onDone: () => setState(() => _files = [...?_files]), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Inside ${widget.uri.pathSegments.last}')), + body: _files == null + ? const Center(child: CircularProgressIndicator()) + : _buildFileList(), + ); + } +} diff --git a/example/lib/screens/granted_uris/granted_uri_card.dart b/example/lib/screens/granted_uris/granted_uri_card.dart index c023f85..476c1f6 100644 --- a/example/lib/screens/granted_uris/granted_uri_card.dart +++ b/example/lib/screens/granted_uris/granted_uri_card.dart @@ -1,200 +1,205 @@ -import 'package:flutter/material.dart'; -import 'package:shared_storage/shared_storage.dart'; - -import '../../theme/spacing.dart'; -import '../../utils/disabled_text_style.dart'; -import '../../utils/document_file_utils.dart'; -import '../../widgets/buttons.dart'; -import '../../widgets/key_value_text.dart'; -import '../../widgets/simple_card.dart'; -import '../file_explorer/file_explorer_card.dart'; -import '../file_explorer/file_explorer_page.dart'; - -class GrantedUriCard extends StatefulWidget { - const GrantedUriCard({ - super.key, - required this.permissionUri, - required this.onChange, - }); - - final UriPermission permissionUri; - final VoidCallback onChange; - - @override - _GrantedUriCardState createState() => _GrantedUriCardState(); -} - -class _GrantedUriCardState extends State { - Future _appendSampleFile(Uri parentUri) async { - /// Create a new file inside the `parentUri` - final documentFile = await parentUri.toDocumentFile(); - - const kFilename = 'Sample File'; - - final child = await documentFile?.child(kFilename); - - if (child == null) { - documentFile?.createFileAsString( - mimeType: 'text/plain', - content: 'Sample File Content', - displayName: kFilename, - ); - } else { - print('This File Already Exists'); - } - } - - Future _revokeUri(Uri uri) async { - await releasePersistableUriPermission(uri); - - widget.onChange(); - } - - void _openListFilesPage() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => FileExplorerPage(uri: widget.permissionUri.uri), - ), - ); - } - - List _getTreeAvailableOptions() { - return [ - ActionButton( - 'Create sample file', - onTap: () => _appendSampleFile( - widget.permissionUri.uri, - ), - ), - ActionButton( - 'Open file picker here', - onTap: () => openDocumentTree(initialUri: widget.permissionUri.uri), - ) - ]; - } - - @override - void didUpdateWidget(covariant GrantedUriCard oldWidget) { - super.didUpdateWidget(oldWidget); - - documentFile = null; - loading = false; - error = null; - } - - DocumentFile? documentFile; - bool loading = false; - String? error; - - Future _loadDocumentFile() async { - loading = true; - setState(() {}); - - documentFile = await widget.permissionUri.uri.toDocumentFile(); - loading = false; - - if (mounted) setState(() {}); - } - - Future _showDocumentFileContents() async { - try { - final documentFile = await widget.permissionUri.uri.toDocumentFile(); - - if (mounted) documentFile?.showContents(context); - } catch (e) { - error = e.toString(); - } - } - - VoidCallback get _onTapHandler => widget.permissionUri.isTreeDocumentFile - ? _openListFilesPage - : _showDocumentFileContents; - - List _getDocumentAvailableOptions() { - return [ - ActionButton( - widget.permissionUri.isTreeDocumentFile - ? 'Open folder' - : 'Open document', - onTap: _onTapHandler, - ), - if (!widget.permissionUri.isTreeDocumentFile) - ActionButton( - 'Load extra document file data linked to this permission', - onTap: _loadDocumentFile, - ), - ]; - } - - Widget _buildAvailableActions() { - return Wrap( - children: [ - if (widget.permissionUri.isTreeDocumentFile) - ..._getTreeAvailableOptions(), - ..._getDocumentAvailableOptions(), - Padding(padding: k2dp.all), - DangerButton( - 'Revoke', - onTap: () => _revokeUri( - widget.permissionUri.uri, - ), - ), - ], - ); - } - - Widget _buildGrantedUriMetadata() { - return KeyValueText( - entries: { - 'isWritePermission': '${widget.permissionUri.isWritePermission}', - 'isReadPermission': '${widget.permissionUri.isReadPermission}', - 'persistedTime': '${widget.permissionUri.persistedTime}', - 'uri': Uri.decodeFull('${widget.permissionUri.uri}'), - 'isTreeDocumentFile': '${widget.permissionUri.isTreeDocumentFile}', - }, - ); - } - - @override - Widget build(BuildContext context) { - return SimpleCard( - onTap: _onTapHandler, - children: [ - Padding( - padding: k2dp.all.copyWith(top: k8dp, bottom: k8dp), - child: Row( - children: [ - Icon( - Icons.security, - color: disabledColor(), - ), - Text( - widget.permissionUri.isTreeDocumentFile - ? ' Permission over a folder' - : ' Permission over a file', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ], - ), - ), - _buildGrantedUriMetadata(), - _buildAvailableActions(), - if (loading) - const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ) - else if (error != null) - Text('Error was thrown: $error') - else if (documentFile != null) - FileExplorerCard( - documentFile: documentFile!, - didUpdateDocument: (updatedDocumentFile) { - documentFile = updatedDocumentFile; - }, - ) - ], - ); - } -} +import 'package:flutter/material.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import '../../theme/spacing.dart'; +import '../../utils/disabled_text_style.dart'; +import '../../utils/document_file_utils.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/key_value_text.dart'; +import '../../widgets/simple_card.dart'; +import '../file_explorer/file_explorer_card.dart'; +import '../file_explorer/file_explorer_page.dart'; + +class GrantedUriCard extends StatefulWidget { + const GrantedUriCard({ + super.key, + required this.permissionUri, + required this.onChange, + }); + + final UriPermission permissionUri; + final VoidCallback onChange; + + @override + _GrantedUriCardState createState() => _GrantedUriCardState(); +} + +class _GrantedUriCardState extends State { + Future _appendSampleFile(Uri parentUri) async { + /// Create a new file inside the `parentUri` + final directory = await SharedStorage.buildDirectoryFromUri(parentUri); + + const kFilename = 'Sample File'; + + final child = await directory.child(kFilename); + + if (child == null) { + final recipient = await directory.createChildFile( + mimeType: 'text/plain', + displayName: kFilename, + ); + + await recipient.writeAsString('Sample File Content'); + } else { + print('This File Already Exists'); + } + } + + Future _revokeUri(Uri uri) async { + await releasePersistableUriPermission(uri); + + widget.onChange(); + } + + void _openListFilesPage() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => FileExplorerPage(uri: widget.permissionUri.uri), + ), + ); + } + + List _getTreeAvailableOptions() { + return [ + ActionButton( + 'Create sample file', + onTap: () => _appendSampleFile( + widget.permissionUri.uri, + ), + ), + ActionButton( + 'Open file picker here', + onTap: () => + SharedStorage.pickDirectory(initialUri: widget.permissionUri.uri), + ) + ]; + } + + @override + void didUpdateWidget(covariant GrantedUriCard oldWidget) { + super.didUpdateWidget(oldWidget); + + fileSystemEntity = null; + loading = false; + error = null; + } + + ScopedFileSystemEntity? fileSystemEntity; + bool loading = false; + String? error; + + Future _loadDocumentFile() async { + loading = true; + + setState(() {}); + + fileSystemEntity = + await SharedStorage.buildScopedFileFromUri(widget.permissionUri.uri); + loading = false; + + if (mounted) setState(() {}); + } + + Future _showDocumentFileContents() async { + try { + final file = + await SharedStorage.buildScopedFileFromUri(widget.permissionUri.uri); + + if (mounted) file.showContents(context); + } catch (e) { + error = e.toString(); + } + } + + VoidCallback get _onTapHandler => widget.permissionUri.isTreeDocumentFile + ? _openListFilesPage + : _showDocumentFileContents; + + List _getDocumentAvailableOptions() { + return [ + ActionButton( + widget.permissionUri.isTreeDocumentFile + ? 'Open folder' + : 'Open document', + onTap: _onTapHandler, + ), + if (!widget.permissionUri.isTreeDocumentFile) + ActionButton( + 'Load extra document file data linked to this permission', + onTap: _loadDocumentFile, + ), + ]; + } + + Widget _buildAvailableActions() { + return Wrap( + children: [ + if (widget.permissionUri.isTreeDocumentFile) + ..._getTreeAvailableOptions(), + ..._getDocumentAvailableOptions(), + Padding(padding: k2dp.all), + DangerButton( + 'Revoke', + onTap: () => _revokeUri( + widget.permissionUri.uri, + ), + ), + ], + ); + } + + Widget _buildGrantedUriMetadata() { + return KeyValueText( + entries: { + 'isWritePermission': '${widget.permissionUri.isWritePermission}', + 'isReadPermission': '${widget.permissionUri.isReadPermission}', + 'persistedTime': '${widget.permissionUri.persistedTime}', + 'uri': Uri.decodeFull('${widget.permissionUri.uri}'), + 'isTreeDocumentFile': '${widget.permissionUri.isTreeDocumentFile}', + }, + ); + } + + @override + Widget build(BuildContext context) { + return SimpleCard( + onTap: _onTapHandler, + children: [ + Padding( + padding: k2dp.all.copyWith(top: k8dp, bottom: k8dp), + child: Row( + children: [ + Icon( + Icons.security, + color: disabledColor(), + ), + Text( + widget.permissionUri.isTreeDocumentFile + ? ' Permission over a folder' + : ' Permission over a file', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + _buildGrantedUriMetadata(), + _buildAvailableActions(), + if (loading) + const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ) + else if (error != null) + Text('Error was thrown: $error') + else if (fileSystemEntity != null) + FileExplorerCard( + scopedFileSystemEntity: fileSystemEntity!, + didUpdateDocument: (updatedDocumentFile) { + fileSystemEntity = updatedDocumentFile; + }, + ) + ], + ); + } +} diff --git a/example/lib/screens/granted_uris/granted_uris_page.dart b/example/lib/screens/granted_uris/granted_uris_page.dart index cdd248c..b1f6ea9 100644 --- a/example/lib/screens/granted_uris/granted_uris_page.dart +++ b/example/lib/screens/granted_uris/granted_uris_page.dart @@ -1,132 +1,248 @@ -import 'package:flutter/material.dart'; -import 'package:shared_storage/shared_storage.dart'; - -import '../../theme/spacing.dart'; -import '../../utils/disabled_text_style.dart'; -import '../../widgets/light_text.dart'; -import 'granted_uri_card.dart'; - -class GrantedUrisPage extends StatefulWidget { - const GrantedUrisPage({super.key}); - - @override - _GrantedUrisPageState createState() => _GrantedUrisPageState(); -} - -class _GrantedUrisPageState extends State { - List? __persistedPermissionUris; - List? get _persistedPermissionUris { - if (__persistedPermissionUris == null) return null; - - return List.from(__persistedPermissionUris!) - ..sort((a, z) => z.persistedTime - a.persistedTime); - } - - @override - void initState() { - super.initState(); - - _loadPersistedUriPermissions(); - } - - Future _loadPersistedUriPermissions() async { - __persistedPermissionUris = await persistedUriPermissions(); - - if (mounted) setState(() => {}); - } - - /// Prompt user with a folder picker (Available for Android 5.0+) - Future _openDocumentTree() async { - /// Sample initial directory (WhatsApp status directory) - const kWppStatusFolder = - 'content://com.android.externalstorage.documents/tree/primary%3AAndroid%2Fmedia/document/primary%3AAndroid%2Fmedia%2Fcom.whatsapp%2FWhatsApp%2FMedia%2F.Statuses'; - - /// If the folder don't exist, the OS will ignore the initial directory - await openDocumentTree(initialUri: Uri.parse(kWppStatusFolder)); - - /// TODO: Add broadcast listener to be aware when a Uri permission changes - await _loadPersistedUriPermissions(); - } - - Future _openDocument() async { - const kDownloadsFolder = - 'content://com.android.externalstorage.documents/tree/primary%3ADownloads/document/primary%3ADownloads'; - - final List? selectedDocumentUris = await openDocument( - initialUri: Uri.parse(kDownloadsFolder), - multiple: true, - ); - - if (selectedDocumentUris == null) return; - - await _loadPersistedUriPermissions(); - } - - Widget _buildNoFolderAllowedYetWarning() { - return Padding( - padding: k8dp.all, - child: const Center( - child: LightText('No folders or files allowed yet'), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Shared Storage Sample'), - ), - body: RefreshIndicator( - onRefresh: _loadPersistedUriPermissions, - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: k6dp.all, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - Center( - child: Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - TextButton( - onPressed: _openDocumentTree, - child: const Text('New allowed folder'), - ), - const Padding(padding: EdgeInsets.all(k2dp)), - TextButton( - onPressed: _openDocument, - child: const Text('New allowed files'), - ), - ], - ), - ), - if (_persistedPermissionUris != null) - if (_persistedPermissionUris!.isEmpty) - _buildNoFolderAllowedYetWarning() - else - for (final permissionUri in _persistedPermissionUris!) - GrantedUriCard( - permissionUri: permissionUri, - onChange: _loadPersistedUriPermissions, - ) - else - Center( - child: Text( - 'Loading...', - style: disabledTextStyle(), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} +import 'package:flutter/material.dart'; +import 'package:receive_intent/receive_intent.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import '../../theme/spacing.dart'; +import '../../utils/disabled_text_style.dart'; +import '../../widgets/light_text.dart'; +import '../file_explorer/file_explorer_card.dart'; +import 'granted_uri_card.dart'; + +class GrantedUrisPage extends StatefulWidget { + const GrantedUrisPage({super.key}); + + @override + _GrantedUrisPageState createState() => _GrantedUrisPageState(); +} + +class _GrantedUrisPageState extends State { + List? __persistedPermissionUris; + + List? get _persistedPermissionUris { + if (__persistedPermissionUris == null) return null; + + return List.from(__persistedPermissionUris!) + ..sort((a, z) => z.persistedTime - a.persistedTime); + } + + @override + void initState() { + super.initState(); + + _loadPersistedUriPermissions(); + _loadInitialIntents(); + } + + Future _loadInitialIntents() async { + final receivedIntent = await ReceiveIntent.getInitialIntent(); + + List getMultiSendUris() { + try { + final List receivedUris = List.from( + receivedIntent?.extra?['android.intent.extra.STREAM'] + as Iterable, + ); + + return receivedUris.map(Uri.parse).whereType().toList(); + } catch (e) { + return []; + } + } + + List getSingleSendUri() { + final dynamic receivedUri = + receivedIntent?.extra?['android.intent.extra.STREAM']; + + if (receivedUri is! String) { + return []; + } + + return [Uri.tryParse(receivedUri)].whereType().toList(); + } + + final List uris = []; + + switch (receivedIntent?.action) { + case 'android.intent.action.SEND_MULTIPLE': + uris.addAll(getMultiSendUris()); + break; + case 'android.intent.action.SEND': + uris.addAll(getSingleSendUri()); + break; + } + + // for (final uri in uris) { + // int bytes = 0; + // getDocumentContentAsStream(uri).listen( + // (event) { + // bytes += event.length; + // }, + // onDone: () => print('Done, loaded $bytes'), + // ); + // } + + // return; + + final files = [ + for (final uri in uris) await SharedStorage.buildScopedFileFromUri(uri) + ].whereType().toList(); + + if (files.isNotEmpty) { + if (context.mounted) { + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Received files'), + content: ReceivedUrisByIntentList(files: files), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Ok'), + ), + ], + ); + }, + ); + } + } + } + + Future _loadPersistedUriPermissions() async { + __persistedPermissionUris = await persistedUriPermissions(); + + if (mounted) setState(() => {}); + } + + /// Prompt user with a folder picker (Available for Android 5.0+) + Future _openDocumentTree() async { + /// Sample initial directory (WhatsApp status directory) + const kWppStatusFolder = + 'content://com.android.externalstorage.documents/tree/primary%3AAndroid%2Fmedia/document/primary%3AAndroid%2Fmedia%2Fcom.whatsapp%2FWhatsApp%2FMedia%2F.Statuses'; + + /// If the folder don't exist, the OS will ignore the initial directory + await SharedStorage.pickDirectory(initialUri: Uri.parse(kWppStatusFolder)); + + /// TODO: Add broadcast listener to be aware when a Uri permission changes + await _loadPersistedUriPermissions(); + } + + Future _openDocument() async { + const kDownloadsFolder = + 'content://com.android.externalstorage.documents/tree/primary%3ADownloads/document/primary%3ADownloads'; + + await SharedStorage.pickFiles(initialUri: Uri.parse(kDownloadsFolder)); + + await _loadPersistedUriPermissions(); + } + + Widget _buildNoFolderAllowedYetWarning() { + return Padding( + padding: k8dp.all, + child: const Center( + child: LightText('No folders or files allowed yet'), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Shared Storage Sample'), + ), + body: RefreshIndicator( + onRefresh: _loadPersistedUriPermissions, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: k6dp.all, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + Center( + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + TextButton( + onPressed: _openDocumentTree, + child: const Text('New allowed folder'), + ), + const Padding(padding: EdgeInsets.all(k2dp)), + TextButton( + onPressed: _openDocument, + child: const Text('New allowed files'), + ), + ], + ), + ), + if (_persistedPermissionUris != null) + if (_persistedPermissionUris!.isEmpty) + _buildNoFolderAllowedYetWarning() + else + for (final permissionUri in _persistedPermissionUris!) + GrantedUriCard( + permissionUri: permissionUri, + onChange: _loadPersistedUriPermissions, + ) + else + Center( + child: Text( + 'Loading...', + style: disabledTextStyle(), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class ReceivedUrisByIntentList extends StatefulWidget { + const ReceivedUrisByIntentList({super.key, required this.files}); + + final List files; + + @override + State createState() => + _ReceivedUrisByIntentListState(); +} + +class _ReceivedUrisByIntentListState extends State { + final Map _updated = {}; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height * 0.5, + child: Scrollbar( + thumbVisibility: true, + child: ListView( + shrinkWrap: true, + physics: const AlwaysScrollableScrollPhysics(), + children: [ + for (final fileSystemEntity in widget.files) + FileExplorerCard( + allowExpand: false, + scopedFileSystemEntity: + _updated[fileSystemEntity.id] ?? fileSystemEntity, + didUpdateDocument: (updated) { + // Ignore, we do not allow the card to expand thus not allow to update. + }, + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/screens/large_file/large_file_screen.dart b/example/lib/screens/large_file/large_file_screen.dart index 461fe77..39f60b7 100644 --- a/example/lib/screens/large_file/large_file_screen.dart +++ b/example/lib/screens/large_file/large_file_screen.dart @@ -17,14 +17,14 @@ class LargeFileScreen extends StatefulWidget { } class _LargeFileScreenState extends State { - DocumentFile? _documentFile; + ScopedFile? _file; StreamSubscription? _subscription; int _bytesLoaded = 0; @override void initState() { super.initState(); - _loadDocFile(); + _loadFile(); } @override @@ -33,8 +33,8 @@ class _LargeFileScreenState extends State { super.dispose(); } - Future _loadDocFile() async { - _documentFile = await widget.uri.toDocumentFile(); + Future _loadFile() async { + _file = await ScopedFile.fromUri(widget.uri); setState(() {}); @@ -42,7 +42,7 @@ class _LargeFileScreenState extends State { } Future _startLoadingFile() async { - final Stream byteStream = getDocumentContentAsStream(widget.uri); + final Stream byteStream = _file!.openRead(); _subscription = byteStream.listen( (bytes) { @@ -70,7 +70,7 @@ class _LargeFileScreenState extends State { return Scaffold( appBar: AppBar( title: Text( - _documentFile?.name ?? 'Loading...', + _file?.displayName ?? 'Loading...', ), ), body: Center( diff --git a/example/lib/utils/document_file_utils.dart b/example/lib/utils/document_file_utils.dart index 93aa5ac..fa22897 100644 --- a/example/lib/utils/document_file_utils.dart +++ b/example/lib/utils/document_file_utils.dart @@ -1,201 +1,195 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:fl_toast/fl_toast.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:shared_storage/shared_storage.dart'; - -import '../screens/large_file/large_file_screen.dart'; -import '../theme/spacing.dart'; -import '../widgets/buttons.dart'; -import 'disabled_text_style.dart'; -import 'mime_types.dart'; - -extension ShowText on BuildContext { - Future showToast(String text, {Duration? duration}) { - return showTextToast( - text: text, - context: this, - duration: const Duration(seconds: 5), - ); - } -} - -extension OpenUriWithExternalApp on Uri { - Future openWithExternalApp() async { - final uri = this; - - try { - final launched = await openDocumentFile(uri); - - if (launched) { - print('Successfully opened $uri'); - } else { - print('Failed to launch $uri'); - } - } on PlatformException { - print( - "There's no activity associated with the file type of this Uri: $uri", - ); - } - } -} - -extension ShowDocumentFileContents on DocumentFile { - Future showContents(BuildContext context) async { - if (context.mounted) { - final mimeTypeOrEmpty = type ?? ''; - - if (!mimeTypeOrEmpty.startsWith(kTextMime) && - !mimeTypeOrEmpty.startsWith(kImageMime)) { - return uri.openWithExternalApp(); - } - - await showModalBottomSheet( - context: context, - builder: (context) => DocumentContentViewer(documentFile: this), - ); - } - } -} - -class DocumentContentViewer extends StatefulWidget { - const DocumentContentViewer({super.key, required this.documentFile}); - - final DocumentFile documentFile; - - @override - State createState() => _DocumentContentViewerState(); -} - -class _DocumentContentViewerState extends State { - late Uint8List _bytes; - StreamSubscription? _subscription; - late int _bytesLoaded; - late bool _loaded = false; - - @override - void initState() { - super.initState(); - _bytes = Uint8List.fromList([]); - _bytesLoaded = 0; - _loaded = false; - _startLoadingFile(); - } - - @override - void dispose() { - _unsubscribe(); - super.dispose(); - } - - void _unsubscribe() { - _subscription?.cancel(); - } - - static const double _kLargerFileSupported = k1MB * 10; - - Future _startLoadingFile() async { - // The implementation of [getDocumentContent] is no longer blocking! - // It now just merges all events of [getDocumentContentAsStream]. - // Basically: lazy loaded -> No performance issues. - final Stream byteStream = - getDocumentContentAsStream(widget.documentFile.uri); - - _subscription = byteStream.listen( - (Uint8List chunk) { - _bytesLoaded += chunk.length; - if (_bytesLoaded < _kLargerFileSupported) { - // Load file - _bytes = Uint8List.fromList(_bytes + chunk); - } else { - // otherwise just bump we are not going to display a large file - _bytes = Uint8List.fromList([]); - } - setState(() {}); - }, - cancelOnError: false, - onError: (e, stackTrace) { - print('Error: $e, st: $stackTrace'); - _loaded = true; - _unsubscribe(); - setState(() {}); - }, - onDone: () { - print('Done'); - _loaded = true; - _unsubscribe(); - setState(() {}); - }, - ); - } - - @override - void setState(VoidCallback fn) { - if (mounted) super.setState(fn); - } - - @override - Widget build(BuildContext context) { - if (!_loaded || _bytesLoaded >= _kLargerFileSupported) { - // The ideal approach is to implement a backpressure using: - // - Pause: _subscription!.pause(); - // - Resume: _subscription!.resume(); - // 'Backpressure' is a short term for 'loading only when the user asks for'. - // This happens because there is no way to load a 5GB file into a variable and expect you app doesn't crash. - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Is done: $_loaded'), - if (_bytesLoaded >= k1MB * 10) - Text('File too long to show: ${widget.documentFile.name}'), - ContentSizeCard(bytes: _bytesLoaded), - Wrap( - children: [ - ActionButton( - 'Pause', - onTap: () { - if (_subscription?.isPaused == false) { - _subscription?.pause(); - } - }, - ), - ActionButton( - 'Resume', - onTap: () { - if (_subscription?.isPaused == true) { - _subscription?.resume(); - } - }, - ), - ], - ) - ], - ), - ); - } - - final type = widget.documentFile.type; - final mimeTypeOrEmpty = type ?? ''; - - final isImage = mimeTypeOrEmpty.startsWith(kImageMime); - - if (isImage) { - return Image.memory(_bytes); - } - - final contentAsString = utf8.decode(_bytes); - - final fileIsEmpty = contentAsString.isEmpty; - - return Container( - padding: k8dp.all, - child: Text( - fileIsEmpty ? 'This file is empty' : contentAsString, - style: fileIsEmpty ? disabledTextStyle() : null, - ), - ); - } -} +import 'dart:async'; +import 'dart:convert'; + +import 'package:fl_toast/fl_toast.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import '../screens/large_file/large_file_screen.dart'; +import '../theme/spacing.dart'; +import '../widgets/buttons.dart'; +import 'disabled_text_style.dart'; +import 'mime_types.dart'; + +extension ShowText on BuildContext { + Future showToast(String text, {Duration? duration}) { + return showTextToast( + text: text, + context: this, + duration: const Duration(seconds: 5), + ); + } +} + +extension OpenUriWithExternalApp on Uri { + Future openWithExternalApp() async { + final uri = this; + + try { + await SharedStorage.launchUriWithExternalApp(uri); + print('Successfully opened $uri'); + } on SharedStorageException catch (e) { + print('Failed to launch $uri. Cause: ${e.message}.'); + } + } +} + +extension ShowDocumentFileContents on ScopedFile { + Future showContents(BuildContext context) async { + if (context.mounted) { + final mimeTypeOrEmpty = mimeType; + + if (!mimeTypeOrEmpty.startsWith(kTextMime) && + !mimeTypeOrEmpty.startsWith(kImageMime)) { + return uri.openWithExternalApp(); + } + + await showModalBottomSheet( + context: context, + builder: (context) => DocumentContentViewer(file: this), + ); + } + } +} + +class DocumentContentViewer extends StatefulWidget { + const DocumentContentViewer({super.key, required this.file}); + + final ScopedFile file; + + @override + State createState() => _DocumentContentViewerState(); +} + +class _DocumentContentViewerState extends State { + late Uint8List _bytes; + StreamSubscription? _subscription; + late int _bytesLoaded; + late bool _loaded = false; + + @override + void initState() { + super.initState(); + _bytes = Uint8List.fromList([]); + _bytesLoaded = 0; + _loaded = false; + _startLoadingFile(); + } + + @override + void dispose() { + _unsubscribe(); + super.dispose(); + } + + void _unsubscribe() { + _subscription?.cancel(); + } + + static const double _kLargerFileSupported = k1MB * 10; + + Future _startLoadingFile() async { + // The implementation of [getDocumentContent] is no longer blocking! + // It now just merges all events of [getDocumentContentAsStream]. + // Basically: lazy loaded -> No performance issues. + final ScopedFile scopedFile = await ScopedFile.fromUri(widget.file.uri); + + final byteStream = scopedFile.openRead().map(Uint8List.fromList); + + _subscription = byteStream.listen( + (Uint8List chunk) { + _bytesLoaded += chunk.length; + if (_bytesLoaded < _kLargerFileSupported) { + // Load file + _bytes = Uint8List.fromList(_bytes + chunk); + } else { + // otherwise just bump we are not going to display a large file + _bytes = Uint8List.fromList([]); + } + setState(() {}); + }, + cancelOnError: false, + onError: (e, stackTrace) { + print('Error: $e, st: $stackTrace'); + _loaded = true; + _unsubscribe(); + setState(() {}); + }, + onDone: () { + print('Done'); + _loaded = true; + _unsubscribe(); + setState(() {}); + }, + ); + } + + @override + void setState(VoidCallback fn) { + if (mounted) super.setState(fn); + } + + @override + Widget build(BuildContext context) { + if (!_loaded || _bytesLoaded >= _kLargerFileSupported) { + // The ideal approach is to implement a backpressure using: + // - Pause: _subscription!.pause(); + // - Resume: _subscription!.resume(); + // 'Backpressure' is a short term for 'loading only when the user asks for'. + // This happens because there is no way to load a 5GB file into a variable and expect you app doesn't crash. + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Is done: $_loaded'), + if (_bytesLoaded >= k1MB * 10) + Text('File too long to show: ${widget.file.displayName}'), + ContentSizeCard(bytes: _bytesLoaded), + Wrap( + children: [ + ActionButton( + 'Pause', + onTap: () { + if (_subscription?.isPaused == false) { + _subscription?.pause(); + } + }, + ), + ActionButton( + 'Resume', + onTap: () { + if (_subscription?.isPaused == true) { + _subscription?.resume(); + } + }, + ), + ], + ) + ], + ), + ); + } + + final type = widget.file.mimeType; + final mimeTypeOrEmpty = type; + + final isImage = mimeTypeOrEmpty.startsWith(kImageMime); + + if (isImage) { + return Image.memory(_bytes); + } + + final contentAsString = utf8.decode(_bytes); + + final fileIsEmpty = contentAsString.isEmpty; + + return Container( + padding: k8dp.all, + child: Text( + fileIsEmpty ? 'This file is empty' : contentAsString, + style: fileIsEmpty ? disabledTextStyle() : null, + ), + ); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9708e1c..86a7f45 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,67 +1,68 @@ -name: shared_storage_example -description: Demonstrates how to use the shared_storage plugin. - -# The following line prevents the package from being accidentally published to -# pub.dev using `pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev - -environment: - sdk: ">=2.17.0 <3.0.0" - -dependencies: - fl_toast: ^3.1.0 - flutter: - sdk: flutter - lint: ^1.8.2 - shared_storage: - # When depending on this package from a real application you should use: - # shared_storage: ^x.y.z - # See https://dart.dev/tools/pub/dependencies#version-constraints - # The example app is bundled with the plugin so we use a path dependency on - # the parent directory to use the current plugin's version. - path: ../ - -dev_dependencies: - flutter_test: - sdk: flutter - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages +name: shared_storage_example +description: Demonstrates how to use the shared_storage plugin. + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +environment: + sdk: ">=2.17.0 <3.0.0" + +dependencies: + fl_toast: ^3.1.0 + flutter: + sdk: flutter + lint: ^1.8.2 + receive_intent: ^0.2.4 + shared_storage: + # When depending on this package from a real application you should use: + # shared_storage: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/lib/src/media_store/barrel.dart b/lib/src/media_store/barrel.dart index fd41157..53f09ab 100644 --- a/lib/src/media_store/barrel.dart +++ b/lib/src/media_store/barrel.dart @@ -1,2 +1,2 @@ -export './media_store.dart'; -export './media_store_collection.dart'; +export 'media_store.dart'; +export 'models/barrel.dart'; diff --git a/lib/src/media_store/media_store.dart b/lib/src/media_store/media_store.dart index 4d5a107..49e71d4 100644 --- a/lib/src/media_store/media_store.dart +++ b/lib/src/media_store/media_store.dart @@ -1,30 +1,268 @@ -import '../channels.dart'; -import 'media_store_collection.dart'; - -/// The contract between the media provider and applications. -/// -/// Get the directory of a given [MediaStoreCollection] -/// -/// [Refer to details](https://developer.android.com/reference/android/provider/MediaStore#summary) -@Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', -) -Future getMediaStoreContentDirectory( - MediaStoreCollection collection, -) async { - const String kGetMediaStoreContentDirectory = 'getMediaStoreContentDirectory'; - const String kCollectionArg = 'collection'; - - final Map args = { - kCollectionArg: '$collection' - }; - - final String? publicDir = await kMediaStoreChannel.invokeMethod( - kGetMediaStoreContentDirectory, - args, - ); - - if (publicDir == null) return null; - - return Uri.parse(publicDir); -} +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../../shared_storage.dart'; +import '../channels.dart'; +import 'shared_storage_platform_interface.dart'; + +export 'package:mime/mime.dart'; + +class SharedStorage { + const SharedStorage._(); + + static SharedStoragePlatformInterface instance = SharedStorageImpl(); + + static Future buildScopedFileFrom(File file) => + instance.buildScopedFileFrom(file); + + static Future buildScopedFileFromUri(Uri uri) => + instance.buildScopedFileFromUri(uri); + + static Future shareScopedFile(ScopedFile scopedFile) => + instance.shareScopedFile(scopedFile); + + static Future buildScopedDirectoryFrom( + Directory directory, + ) => + instance.buildScopedDirectoryFrom(directory); + + static Future buildDirectoryFromUri(Uri uri) => + instance.buildScopedDirectoryFromUri(uri); + + static Future launchFileWithExternalApp(File file) => + instance.launchFileWithExternalApp(file); + + static Future launchScopedFileWithExternalApp(ScopedFile scopedFile) => + instance.launchScopedFileWithExternalApp(scopedFile); + + static Future launchUriWithExternalApp(Uri uri) => + instance.launchUriWithExternalApp(uri); + + static Future pickDirectory({ + bool persist = true, + bool grantWritePermission = true, + Uri? initialUri, + ScopedDirectory? initialDirectory, + }) => + instance.pickDirectory( + persist: persist, + grantWritePermission: grantWritePermission, + initialUri: initialUri, + initialDirectory: initialDirectory, + ); + + static Future> pickFiles({ + bool persist = true, + bool grantWritePermission = true, + Uri? initialUri, + String mimeType = '*/*', + bool multiple = true, + ScopedDirectory? initialDirectory, + }) => + instance.pickFiles( + persist: persist, + grantWritePermission: grantWritePermission, + initialUri: initialUri, + mimeType: mimeType, + multiple: multiple, + initialDirectory: initialDirectory, + ); +} + +class SharedStorageImpl implements SharedStoragePlatformInterface { + factory SharedStorageImpl() => _instance ??= SharedStorageImpl._(); + + SharedStorageImpl._(); + + static SharedStorageImpl? _instance; + + @override + Future buildScopedFileFrom(File file) { + return ScopedFile.fromFile(file); + } + + @override + Future shareScopedFile(ScopedFile scopedFile) { + return shareUri(scopedFile.uri); + } + + @override + Future buildScopedFileFromUri(Uri uri) { + return ScopedFile.fromUri(uri); + } + + @override + Future buildScopedDirectoryFrom(Directory directory) { + return ScopedDirectory.fromDirectory(directory); + } + + @override + Future buildScopedDirectoryFromUri(Uri uri) { + return ScopedDirectory.fromUri(uri); + } + + @override + Future pickDirectory({ + bool grantWritePermission = true, + bool persist = true, + Uri? initialUri, + ScopedDirectory? initialDirectory, + }) async { + final Map args = { + 'grantWritePermission': grantWritePermission, + 'persistablePermission': persist, + if (initialUri != null) + 'initialUri': initialUri.toString() + else if (initialDirectory != null) + 'initialUri': initialDirectory.uri.toString(), + }; + + final String? selectedDirectoryUri = await kDocumentFileChannel + .invokeMethod('openDocumentTree', args); + + if (selectedDirectoryUri == null) { + throw SharedStorageDirectoryWasNotSelectedException( + 'Scoped directory was not selected. To handle this exception, you can use try-catch block. This is an exception to avoid returning null.', + StackTrace.current, + ); + } + + return ScopedDirectory.fromUri(Uri.parse(selectedDirectoryUri)); + } + + /// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT). + @override + Future> pickFiles({ + bool persist = true, + bool grantWritePermission = true, + Uri? initialUri, + String mimeType = '*/*', + bool multiple = true, + ScopedDirectory? initialDirectory, + }) async { + const String kOpenDocument = 'openDocument'; + + final Map args = { + if (initialUri != null || initialDirectory != null) + 'initialUri': '${initialUri ?? initialDirectory?.uri}', + 'grantWritePermission': grantWritePermission, + 'persistablePermission': persist, + 'mimeType': mimeType, + 'multiple': multiple, + }; + + final List? selectedUriList = + await kDocumentFileChannel.invokeListMethod(kOpenDocument, args); + + if (selectedUriList == null) { + return []; + } + + return Stream.fromIterable(selectedUriList) + .map((dynamic e) => Uri.parse(e as String)) + .asyncMap((Uri uri) => ScopedFile.fromUri(uri)) + .toList(); + } + + /// {@template sharedstorage.saf.share} + /// Start share intent for the given [uri]. + /// + /// To share a file, use [Uri.parse] passing the file absolute path as argument. + /// + /// Note that this method can only share files that your app has permission over, + /// either by being in your app domain (e.g file from your app cache) or that is granted by [openDocumentTree]. + /// + /// Usage: + /// + /// ```dart + /// try { + /// await shareUriOrFile( + /// uri: uri, + /// filePath: path, + /// file: file, + /// ); + /// } on PlatformException catch (e) { + /// // The user clicked twice too fast, which created 2 share requests and the second one failed. + /// // Unhandled Exception: PlatformException(Share callback error, prior share-sheet did not call back, did you await it? Maybe use non-result variant, null, null). + /// log('Error when calling [shareFile]: $e'); + /// return; + /// } + /// ``` + /// {@endtemplate} + @override + Future shareUri( + Uri uri, { + String? mimeType, + }) { + final Map args = { + 'uri': '$uri', + 'type': mimeType, + }; + + return kDocumentFileHelperChannel.invokeMethod('shareUri', args); + } + + @override + Future shareFile( + File file, { + String? mimeType, + }) { + return shareUri(Uri.file(file.path), mimeType: mimeType); + } + + @override + Future shareFileFromPath( + String filePath, { + String? mimeType, + }) { + return shareUri(Uri.file(filePath), mimeType: mimeType); + } + + @override + Future launchFileWithExternalApp(File file) async { + return launchUriWithExternalApp(Uri.file(file.path)); + } + + /// {@template sharedstorage.saf.openDocumentFileWithResult} + /// It's a convenience method to launch the default application associated + /// with the given MIME type. + /// + /// Launch `ACTION_VIEW` intent to open the given document `uri`. + /// + /// Returns a [OpenDocumentFileResult] that allows you handle all edge-cases. + /// {@endtemplate} + @override + Future launchUriWithExternalApp(Uri uri) async { + try { + await kDocumentFileHelperChannel.invokeMethod( + 'openDocumentFile', + {'uri': '$uri'}, + ); + } on PlatformException catch (e) { + switch (e.code) { + case 'EXCEPTION_ACTIVITY_NOT_FOUND': + throw SharedStorageExternalAppNotFoundException( + 'Did not find any app to handle the intent. Make sure you have an app that can handle the given uri: $uri', + StackTrace.current, + ); + case 'EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY': + throw SharedStorageSecurityException( + 'The system denied read access to the given uri: $uri', + StackTrace.current, + ); + case 'EXCEPTION_CANT_OPEN_DOCUMENT_FILE': + default: + throw SharedStorageUnknownException( + 'Unknown exception when trying to open the given uri: $uri', + StackTrace.current, + ); + } + } + } + + @override + Future launchScopedFileWithExternalApp(ScopedFile file) { + return launchUriWithExternalApp(file.uri); + } +} diff --git a/lib/src/media_store/media_store_collection.dart b/lib/src/media_store/media_store_collection.dart deleted file mode 100644 index c960009..0000000 --- a/lib/src/media_store/media_store_collection.dart +++ /dev/null @@ -1,64 +0,0 @@ -/// Representation of the [android.provider.MediaStore] Android SDK -/// -/// [Refer to details](https://developer.android.com/reference/android/provider/MediaStore#summary) -class MediaStoreCollection { - const MediaStoreCollection._(this.id); - - final String id; - - static const String _kPrefix = 'MediaStoreCollection'; - - /// Available for Android [10 to 12] - /// - /// Equivalent to: - /// - [MediaStore.Audio] - @Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', - ) - static const MediaStoreCollection audio = - MediaStoreCollection._('$_kPrefix.Audio'); - - /// Available for Android [10 to 12] - /// - /// Equivalent to: - /// - [MediaStore.Downloads] - @Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', - ) - static const MediaStoreCollection downloads = - MediaStoreCollection._('$_kPrefix.Downloads'); - - /// Available for Android [10 to 12] - /// - /// Equivalent to: - /// - [MediaStore.Images] - @Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', - ) - static const MediaStoreCollection images = - MediaStoreCollection._('$_kPrefix.Images'); - - /// Available for Android [10 to 12] - /// - /// Equivalent to: - /// - [MediaStore.Video] - @Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', - ) - static const MediaStoreCollection video = - MediaStoreCollection._('$_kPrefix.Video'); - - @override - bool operator ==(Object other) { - return other is MediaStoreCollection && other.id == id; - } - - @override - int get hashCode => id.hashCode; - - @override - @Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', - ) - String toString() => id; -} diff --git a/lib/src/media_store/models/barrel.dart b/lib/src/media_store/models/barrel.dart new file mode 100644 index 0000000..18e0eeb --- /dev/null +++ b/lib/src/media_store/models/barrel.dart @@ -0,0 +1,3 @@ +export 'scoped_directory.dart'; +export 'scoped_file.dart'; +export 'scoped_file_system_entity.dart'; diff --git a/lib/src/media_store/models/scoped_directory.dart b/lib/src/media_store/models/scoped_directory.dart new file mode 100644 index 0000000..1207cbe --- /dev/null +++ b/lib/src/media_store/models/scoped_directory.dart @@ -0,0 +1,229 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart'; + +import '../../../shared_storage.dart'; +import '../../channels.dart'; + +abstract class ScopedDirectory implements ScopedFileSystemEntity { + static Future fromUri(Uri uri) => + _ScopedDirectory.fromUri(uri); + + static ScopedDirectory fromMap(Map map) { + return _ScopedDirectory.fromMap(map); + } + + static Future fromDirectory(Directory directory) => + _ScopedDirectory.fromDirectory(directory); + + /// Create a child entry with the given `displayName` and `mimeType` using the directory reference. + /// + /// Scoped storage doesn't support "concatenating" paths. + Future createChildDirectory({required String displayName}); + + Future createChildFile({ + required String displayName, + required String mimeType, + }); + + Future child(String displayName); + + @override + Future rename(String newPath); + + Stream list({ + bool recursive = false, + bool followLinks = false, + }); + + ScopedDirectory renameSync(String newPath); +} + +class _ScopedDirectory implements ScopedDirectory { + const _ScopedDirectory({ + required this.displayName, + required this.id, + required this.uri, + required this.parentUri, + required this.lastModified, + }); + + @override + final DateTime lastModified; + + static ScopedDirectory fromMap(Map map) { + return _ScopedDirectory( + displayName: map['displayName'] as String, + id: map['id'] as String, + uri: Uri.parse(map['uri'] as String), + parentUri: map['parentUri'] != null + // If it's not a String, it's better to throw TypeError + ? Uri.parse(map['parentUri'] as String) + : null, + lastModified: DateTime.fromMillisecondsSinceEpoch( + map['lastModified'] as int, + ), + ); + } + + @override + final String displayName; + + @override + final String id; + + @override + final Uri uri; + + @override + final Uri? parentUri; + + static Future fromUri(Uri uri) async { + if (uri.scheme == 'file') { + assert(uri.toString().endsWith('/')); + + final Directory directory = Directory.fromUri(uri); + + if (!directory.existsSync()) { + throw SharedStorageFileNotFoundException( + '${directory.path} does not exist. It either means the file actually does not exist or maybe you do not have permission to read it.', + StackTrace.current, + ); + } + + final FileStat stat = directory.statSync(); + + return _ScopedDirectory( + displayName: basename(directory.path), + id: directory.path, + uri: uri, + lastModified: stat.modified, + parentUri: (() { + try { + return directory.parent.uri; + } on Exception { + return null; + } + })(), + ); + } else { + final Map? response = + await kMediaStoreChannel.invokeMapMethod( + 'getScopedFileSystemEntityFromUri', + { + 'uri': uri.toString(), + }, + ); + + return _ScopedDirectory.fromMap(response!); + } + } + + static Future fromDirectory(Directory directory) async { + return _ScopedDirectory.fromUri(Uri.directory(directory.path)); + } + + @override + Future delete({bool recursive = false}) { + // TODO: implement delete + throw UnimplementedError(); + } + + @override + Future exists() { + // TODO: implement exists + throw UnimplementedError(); + } + + @override + Stream list({ + bool recursive = false, + bool followLinks = true, + }) { + final Map args = { + 'uri': '$uri', + 'event': 'listFiles', + }; + + final Stream onCursorRowResult = + kDocumentFileEventChannel.receiveBroadcastStream(args); + + ScopedFileSystemEntity mapCursorRowToScopedFileSystemEntity( + Map e, + ) { + switch (e['entityType']) { + case 'file': + return ScopedFile.fromMap(e); + case 'directory': + return ScopedDirectory.fromMap(e); + default: + throw ArgumentError('Unknown entity type: ${e['entityType']}.'); + } + } + + Map castDynamicMapType(dynamic event) => + Map.from(event as Map); + + return onCursorRowResult + .map(castDynamicMapType) + .cast>() + .map(mapCursorRowToScopedFileSystemEntity); + } + + @override + Future rename(String newPath) { + // TODO: implement rename + throw UnimplementedError(); + } + + @override + ScopedDirectory renameSync(String newPath) { + // TODO: implement renameSync + throw UnimplementedError(); + } + + @override + Future createChildDirectory({required String displayName}) { + // TODO: implement createChildScopedDirectory + throw UnimplementedError(); + } + + @override + Future createChildFile({ + required String displayName, + required String mimeType, + }) { + // TODO: implement createChildScopedFile + throw UnimplementedError(); + } + + @override + Future child(String displayName) { + // TODO: implement child + throw UnimplementedError(); + } + + @override + FutureOr copyTo( + Uri destination, { + bool recursive = false, + }) { + // TODO: implement copyTo + throw UnimplementedError(); + } + + @override + FutureOr canRead() { + return kDocumentFileChannel.invokeMethod( + 'canRead', + {'uri': '$uri'}, + ).then((bool? value) => value ?? false); + } + + @override + FutureOr canWrite() { + // TODO: implement canWrite + throw UnimplementedError(); + } +} diff --git a/lib/src/media_store/models/scoped_file.dart b/lib/src/media_store/models/scoped_file.dart new file mode 100644 index 0000000..2da91a5 --- /dev/null +++ b/lib/src/media_store/models/scoped_file.dart @@ -0,0 +1,433 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:path/path.dart'; + +import '../../../shared_storage.dart'; +import '../../channels.dart'; +import '../../saf/common/generate_id.dart'; + +extension Also on T { + T also(void Function(T) fn) { + fn(this); + return this; + } +} + +abstract class ScopedFile implements ScopedFileSystemEntity { + const ScopedFile(); + + String get mimeType; + int get length; + + static Future fromUri(Uri uri) { + return _ScopedFile.fromUri(uri); + } + + static ScopedFile fromMap(Map map) { + return _ScopedFile.fromMap(map); + } + + static Future fromFile(File file) { + return _ScopedFile.fromFile(file); + } + + Stream openRead([int start = 0, int? end]); + Future readAsBytes([int start = 0, int? end]); + Future readAsString({Encoding encoding = utf8}); + Future> readAsLines({Encoding encoding = utf8}); + + void openWrite( + Stream byteStream, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + }); + Future writeAsBytes( + Uint8List bytes, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }); + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }); +} + +class _ScopedFile implements ScopedFile { + const _ScopedFile({ + required this.id, + required this.mimeType, + required this.displayName, + required this.length, + required this.uri, + required this.parentUri, + required this.lastModified, + }); + + static const String kOctetStreamMimeType = 'application/octet-stream'; + static const String kDefaultMimeType = kOctetStreamMimeType; + + @override + final DateTime lastModified; + + @override + final int length; + + @override + final Uri uri; + + @override + final Uri? parentUri; + + @override + final String id; + + @override + final String mimeType; + + @override + final String displayName; + + // Will retrive to the header bytes of the file + // See also: + // - https://en.wikipedia.org/wiki/List_of_file_signatures + // - https://pub.dev/packages/mime + static Future _getHeaderBytes( + Stream fileByteStream, { + int headerBytesLength = k1KB, + }) async { + final List headerBytes = []; + + await fileByteStream + .takeWhile((_) => headerBytes.length < headerBytesLength) + .forEach(headerBytes.addAll); + + return Uint8List.fromList(headerBytes); + } + + static Future fromUri(Uri uri) async { + if (uri.scheme == 'file') { + assert(!uri.toString().endsWith('/')); + + // File URI and Directory URI both use 'file' scheme the difference is that directory URIs ends with /. + final bool isDirectory = uri.toFilePath().endsWith('/'); + + final FileSystemEntity entity = + isDirectory ? Directory.fromUri(uri) : File.fromUri(uri); + + if (!entity.existsSync()) { + throw SharedStorageFileNotFoundException( + '${entity.path} does not exist. It either means the file actually does not exist or maybe you do not have permission to read the file', + StackTrace.current, + ); + } + + final FileStat stat = entity.statSync(); + final File file = File.fromUri(uri); + + final Uint8List headerBytes = + await _getHeaderBytes(file.openRead().map(Uint8List.fromList)); + + return _ScopedFile( + mimeType: lookupMimeType(file.path, headerBytes: headerBytes) ?? + kDefaultMimeType, + displayName: basename(file.path), + id: entity.path, + length: stat.size, + uri: uri, + lastModified: stat.modified, + parentUri: entity.parent.uri, + ); + } else { + final Map? response = + await kMediaStoreChannel.invokeMapMethod( + 'getScopedFileSystemEntityFromUri', + { + 'uri': uri.toString(), + }, + ); + + return _ScopedFile.fromMap(response!); + } + } + + static Future fromFile(File file) { + return _ScopedFile.fromUri(Uri.file(file.path)); + } + + @override + Future delete({bool recursive = false}) { + // TODO: implement delete + throw UnimplementedError(); + } + + /// {@template sharedstorage.saf.exists} + /// Equivalent to `DocumentFile.exists`. + /// + /// Verify wheter or not a given [uri] exists. + /// + /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#exists()). + /// {@endtemplate} + @override + Future exists() { + return kDocumentFileChannel.invokeMethod('exists', { + 'uri': '$uri', + }).then((bool? value) => value ?? false); + } + + @override + Future open({FileMode mode = FileMode.read}) { + // TODO: implement open + throw UnimplementedError(); + } + + /// {@template sharedstorage.ScopedFile.openRead} + /// Read the given [uri] contents with lazy-strategy using [Stream]s. + /// + /// Each [Stream] event contains only a small fraction of the [uri] bytes of size [bufferSize]. + /// + /// e.g let target [uri] be a 500MB file and [bufferSize] is 1MB, the returned [Stream] will emit 500 events, each one containing a [Uint8List] of size 1MB (may vary but that's the idea). + /// + /// Since only chunks of the files are actually loaded, there are no performance gaps or the risk of app crash. + /// + /// If that happens, provide the [bufferSize] with a lower limit. + /// + /// Greater [bufferSize] values will speed-up reading but will increase [OutOfMemoryError] chances. + /// {@endtemplate} + + @override + Stream openRead([ + int? start, + int? end, + int bufferSize = k1MB, // max 16 bit integer + ]) { + if (uri.scheme == 'file') { + return _openReadFile(start, end); + } else { + return _openReadScopedFile(start, end, bufferSize); + } + } + + Stream _openReadFile([int? start, int? end]) { + return File.fromUri(uri).openRead(start, end).map(Uint8List.fromList); + } + + static const int kDefaultBufferSize = k1MB; + + Stream _openReadScopedFile([ + int? start, + int? end, + int? bufferSize, + ]) async* { + assert(start == null || start >= 0); + assert(end == null || end >= 0); + + final String callId = generateTimeBasedId(); + + final int initial = start ?? 0; // inclusive + final int? last = end; // inclusive + final int? diff = last != null ? last - initial : null; + final int byteChunkSize = bufferSize ?? kDefaultBufferSize; + + int offset = start ?? 0; + int totalRead = 0; + + await kDocumentFileChannel.invokeMethod( + 'openInputStream', + {'uri': uri.toString(), 'callId': callId}, + ); + + // Offset must be applied only to the first byte chunk + int currentOffset() { + final int current = offset; + offset = 0; + return current; + } + + int calcBufferSize() { + if (diff == null) { + // Read until the EOF + return byteChunkSize; + } + + final int pendingByteChunkSize = diff - totalRead; + + return min(pendingByteChunkSize, byteChunkSize); + } + + Future readByteChunk() async { + final Map? result = + await kDocumentFileChannel.invokeMapMethod( + 'readInputStream', + { + 'callId': callId, + 'offset': currentOffset(), + 'bufferSize': calcBufferSize(), + }, + ); + + if (result == null) { + return null; + } + + final int readBufferSize = result['readBufferSize'] as int; + + if (readBufferSize == -1) { + return null; + } + + return (result['bytes'] as Uint8List) + .also((Uint8List bytes) => totalRead += bytes.length); + } + + while (true) { + final Uint8List? byteChunk = await readByteChunk(); + + if (byteChunk == null) { + break; + } else { + yield byteChunk; + } + } + + await kDocumentFileChannel.invokeMethod( + 'closeInputStream', + { + 'callId': callId, + }, + ); + } + + @override + void openWrite( + Stream byteStream, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + }) { + // TODO: implement openWrite + throw UnimplementedError(); + } + + @override + Future readAsBytes([int start = 0, int? end]) { + return openRead(start, end).reduce( + (Uint8List previous, Uint8List element) => + Uint8List.fromList(previous + element), + ); + } + + @override + Future> readAsLines({Encoding encoding = utf8}) { + return openRead() + .map(encoding.decode) + .transform(const LineSplitter()) + .toList(); + } + + @override + Future readAsString({Encoding encoding = utf8}) { + return openRead() + .map(encoding.decode) + .reduce((String previous, String element) => previous + element); + } + + @override + FutureOr copyTo( + Uri destination, { + bool recursive = false, + }) { + // TODO: implement copyTo + throw UnimplementedError(); + } + + @override + FutureOr rename(String displayName) { + // TODO: implement rename + throw UnimplementedError(); + } + + Map toMap() { + return { + 'lastModified': lastModified.millisecondsSinceEpoch, + 'length': length, + 'uri': uri.toString(), + 'parentUri': parentUri?.toString(), + 'id': id, + 'mimeType': mimeType, + 'displayName': displayName, + }; + } + + factory _ScopedFile.fromMap(Map map) { + return _ScopedFile( + lastModified: + DateTime.fromMillisecondsSinceEpoch(map['lastModified'] as int), + length: map['length'] as int, + uri: Uri.parse(map['uri'] as String), + parentUri: map['parentUri'] != null + ? Uri.parse(map['parentUri'] as String) + : null, + id: map['id'] as String, + mimeType: map['mimeType'] as String, + displayName: map['displayName'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory _ScopedFile.fromJson(String source) => + _ScopedFile.fromMap(json.decode(source) as Map); + + /// {@template sharedstorage.saf.canRead} + /// Equivalent to `DocumentFile.canRead`. + /// + /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#canRead()). + /// {@endtemplate} + @override + FutureOr canRead() async { + return kDocumentFileChannel.invokeMethod('canRead', { + 'uri': '$uri', + }).then((bool? value) => value ?? false); + } + + /// {@template sharedstorage.saf.canWrite} + /// Equivalent to `DocumentFile.canWrite`. + /// + /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#canWrite()). + /// {@endtemplate} + @override + FutureOr canWrite() async { + return kDocumentFileChannel.invokeMethod('canWrite', { + 'uri': '$uri', + }).then((bool? value) => value ?? false); + } + + @override + Future writeAsBytes( + Uint8List bytes, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) { + // TODO: implement writeAsBytes + throw UnimplementedError(); + } + + @override + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) { + // TODO: implement writeAsString + throw UnimplementedError(); + } +} diff --git a/lib/src/media_store/models/scoped_file_system_entity.dart b/lib/src/media_store/models/scoped_file_system_entity.dart new file mode 100644 index 0000000..6f93ab2 --- /dev/null +++ b/lib/src/media_store/models/scoped_file_system_entity.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +abstract class ScopedFileSystemEntity { + /// The URI representing the absolute location of this file system entity. + Uri get uri; + + String get displayName; + + String get id; + + Uri? get parentUri; + + DateTime get lastModified; + + // FutureOr length(); + + // DateTime get lastModified; + + FutureOr copyTo( + Uri destination, { + bool recursive = false, + }); + + FutureOr exists(); + + FutureOr rename(String displayName); + + FutureOr delete({bool recursive = false}); + + FutureOr canRead(); + FutureOr canWrite(); +} + +// final FileSystemEntity a; diff --git a/lib/src/media_store/shared_storage_platform_interface.dart b/lib/src/media_store/shared_storage_platform_interface.dart new file mode 100644 index 0000000..6c034aa --- /dev/null +++ b/lib/src/media_store/shared_storage_platform_interface.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../../shared_storage.dart'; +import '../channels.dart'; + +abstract class SharedStoragePlatformInterface { + Future buildScopedFileFrom(File file); + + Future shareScopedFile(ScopedFile file); + + Future shareUri(Uri uri); + + Future shareFile( + File file, { + String? mimeType, + }); + + Future shareFileFromPath( + String filePath, { + String? mimeType, + }); + + Future buildScopedFileFromUri(Uri uri); + + Future buildScopedDirectoryFrom(Directory directory); + + Future buildScopedDirectoryFromUri(Uri uri); + + Future pickDirectory({ + bool persist, + bool grantWritePermission, + Uri? initialUri, + ScopedDirectory? initialDirectory, + }); + + Future> pickFiles({ + bool persist, + bool grantWritePermission, + Uri? initialUri, + String mimeType, + bool multiple, + ScopedDirectory? initialDirectory, + }); + + Future launchFileWithExternalApp(File file); + Future launchScopedFileWithExternalApp(ScopedFile file); + Future launchUriWithExternalApp(Uri uri); +} diff --git a/lib/src/saf/api/content.dart b/lib/src/saf/api/content.dart index f704e79..983a240 100644 --- a/lib/src/saf/api/content.dart +++ b/lib/src/saf/api/content.dart @@ -1,128 +1,126 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:typed_data'; - -import '../../channels.dart'; -import '../../common/functional_extender.dart'; -import '../common/generate_id.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.getDocumentContentAsString} -/// Helper method to read document using -/// `getDocumentContent` and get the content as String instead as `Uint8List`. -/// {@endtemplate} -Future getDocumentContentAsString( - Uri uri, { - bool throwIfError = false, -}) async { - return utf8.decode(await getDocumentContent(uri)); -} - -/// {@template sharedstorage.saf.getDocumentContent} -/// Get content of a given document [uri]. -/// -/// This method is an alias for [getDocumentContentAsStream] that merges every file chunk into the memory. -/// -/// Be careful: this method crashes the app if the target [uri] is a large file, prefer [getDocumentContentAsStream] instead. -/// {@endtemplate} -Future getDocumentContent(Uri uri) { - return getDocumentContentAsStream(uri).reduce( - (Uint8List previous, Uint8List element) => Uint8List.fromList( - [ - ...previous, - ...element, - ], - ), - ); -} - -const int k1B = 1; -const int k1KB = k1B * 1024; -const int k512KB = k1B * 512; -const int k1MB = k1KB * 1024; -const int k512MB = k1MB * 512; -const int k1GB = k1MB * 1024; -const int k1TB = k1GB * 1024; -const int k1PB = k1TB * 1024; - -/// {@template sharedstorage.getDocumentContentAsStream} -/// Read the given [uri] contents with lazy-strategy using [Stream]s. -/// -/// Each [Stream] event contains only a small fraction of the [uri] bytes of size [bufferSize]. -/// -/// e.g let target [uri] be a 500MB file and [bufferSize] is 1MB, the returned [Stream] will emit 500 events, each one containing a [Uint8List] of size 1MB (may vary but that's the idea). -/// -/// Since only chunks of the files are actually loaded, there are no performance gaps or the risk of app crash. -/// -/// If that happens, provide the [bufferSize] with a lower limit. -/// -/// Greater [bufferSize] values will speed-up reading but will increase [OutOfMemoryError] chances. -/// {@endtemplate} -Stream getDocumentContentAsStream( - Uri uri, { - int bufferSize = k1MB, - int offset = 0, -}) async* { - final String callId = generateTimeBasedId(); - - await kDocumentFileChannel.invokeMethod( - 'openInputStream', - {'uri': uri.toString(), 'callId': callId}, - ); - - int readBufferSize = 0; - - while (true) { - final Map? result = - await kDocumentFileChannel.invokeMapMethod( - 'readInputStream', - { - 'callId': callId, - 'offset': offset, - 'bufferSize': bufferSize, - }, - ); - - if (result != null) { - readBufferSize = result['readBufferSize'] as int; - if (readBufferSize < 0) { - break; - } else { - if (readBufferSize != bufferSize) { - // Slice the buffer to the actual read size. - yield (result['bytes'] as Uint8List).sublist(0, readBufferSize); - } else { - // No need to slice the buffer, just yield it. - yield result['bytes'] as Uint8List; - } - } - } - } - - await kDocumentFileChannel.invokeMethod( - 'closeInputStream', - {'callId': callId}, - ); -} - -/// {@template sharedstorage.saf.getDocumentThumbnail} -/// Equivalent to `DocumentsContract.getDocumentThumbnail`. -/// -/// [Refer to details](https://developer.android.com/reference/android/provider/DocumentsContract#getDocumentThumbnail(android.content.ContentResolver,%20android.net.Uri,%20android.graphics.Point,%20android.os.CancellationSignal)). -/// {@endtemplate} -Future getDocumentThumbnail({ - required Uri uri, - required double width, - required double height, -}) async { - final Map args = { - 'uri': '$uri', - 'width': width, - 'height': height, - }; - - final Map? bitmap = await kDocumentsContractChannel - .invokeMapMethod('getDocumentThumbnail', args); - - return bitmap?.apply((Map b) => DocumentBitmap.fromMap(b)); -} +import 'dart:async'; +import 'dart:typed_data'; + +import '../../../shared_storage.dart'; +import '../../channels.dart'; +import '../../common/functional_extender.dart'; + +/// {@template sharedstorage.saf.getDocumentContentAsString} +/// Helper method to read document using +/// `getDocumentContent` and get the content as String instead as `Uint8List`. +/// {@endtemplate} +Future getDocumentContentAsString(Uri uri) async { + final ScopedFile scopedFile = await ScopedFile.fromUri(uri); + return scopedFile.readAsString(); +} + +/// {@template sharedstorage.saf.getDocumentContent} +/// Get content of a given document [uri]. +/// +/// This method is an alias for [getDocumentContentAsStream] that merges every file chunk into the memory. +/// +/// Be careful: this method crashes the app if the target [uri] is a large file, prefer [getDocumentContentAsStream] instead. +/// {@endtemplate} +Future getDocumentContent(Uri uri) { + return getDocumentContentAsStream(uri).reduce( + (Uint8List previous, Uint8List element) => Uint8List.fromList( + [ + ...previous, + ...element, + ], + ), + ); +} + +const int k1B = 1; +const int k1KB = k1B * 1024; +const int k512KB = k1B * 512; +const int k1MB = k1KB * 1024; +const int k512MB = k1MB * 512; +const int k1GB = k1MB * 1024; +const int k1TB = k1GB * 1024; +const int k1PB = k1TB * 1024; + +/// {@template sharedstorage.getDocumentContentAsStream} +/// Read the given [uri] contents with lazy-strategy using [Stream]s. +/// +/// Each [Stream] event contains only a small fraction of the [uri] bytes of size [bufferSize]. +/// +/// e.g let target [uri] be a 500MB file and [bufferSize] is 1MB, the returned [Stream] will emit 500 events, each one containing a [Uint8List] of size 1MB (may vary but that's the idea). +/// +/// Since only chunks of the files are actually loaded, there are no performance gaps or the risk of app crash. +/// +/// If that happens, provide the [bufferSize] with a lower limit. +/// +/// Greater [bufferSize] values will speed-up reading but will increase [OutOfMemoryError] chances. +/// {@endtemplate} +Stream getDocumentContentAsStream( + Uri uri, { + int start = 0, + int? end, +}) async* { + final ScopedFile scopedFile = await ScopedFile.fromUri(uri); + yield* scopedFile.openRead(start, end); + + // final String callId = generateTimeBasedId(); + + // await kDocumentFileChannel.invokeMethod( + // 'openInputStream', + // {'uri': uri.toString(), 'callId': callId}, + // ); + + // while (true) { + // final Map? result = + // await kDocumentFileChannel.invokeMapMethod( + // 'readInputStream', + // { + // 'callId': callId, + // 'offset': offset, + // 'bufferSize': bufferSize, + // }, + // ); + + // if (result != null) { + // final int readBufferSize = result['readBufferSize'] as int; + + // if (readBufferSize < 0) { + // break; + // } else { + // if (readBufferSize != bufferSize) { + // // Slice the buffer to the actual read size. + // yield (result['bytes'] as Uint8List).sublist(0, readBufferSize); + // } else { + // // No need to slice the buffer, just yield it. + // yield result['bytes'] as Uint8List; + // } + // } + // } + // } + + // await kDocumentFileChannel.invokeMethod( + // 'closeInputStream', + // {'callId': callId}, + // ); +} + +/// {@template sharedstorage.saf.getDocumentThumbnail} +/// Equivalent to `DocumentsContract.getDocumentThumbnail`. +/// +/// [Refer to details](https://developer.android.com/reference/android/provider/DocumentsContract#getDocumentThumbnail(android.content.ContentResolver,%20android.net.Uri,%20android.graphics.Point,%20android.os.CancellationSignal)). +/// {@endtemplate} +Future getDocumentThumbnail({ + required Uri uri, + required double width, + required double height, +}) async { + final Map args = { + 'uri': '$uri', + 'width': width, + 'height': height, + }; + + final Map? bitmap = await kDocumentsContractChannel + .invokeMapMethod('getDocumentThumbnail', args); + + return bitmap?.apply((Map b) => DocumentBitmap.fromMap(b)); +} diff --git a/lib/src/saf/api/copy.dart b/lib/src/saf/api/copy.dart index 527cc18..915dd5c 100644 --- a/lib/src/saf/api/copy.dart +++ b/lib/src/saf/api/copy.dart @@ -1,16 +1,16 @@ -import '../common/barrel.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.copy} -/// Copy a document `uri` to the `destination`. -/// -/// This API uses the `createFile` and `getDocumentContent` API's behind the scenes. -/// {@endtemplate} -Future copy(Uri uri, Uri destination) async { - final Map args = { - 'uri': '$uri', - 'destination': '$destination' - }; - - return invokeMapMethod('copy', args); -} +// import '../common/barrel.dart'; +// import '../models/barrel.dart'; + +// /// {@template sharedstorage.saf.copy} +// /// Copy a document `uri` to the `destination`. +// /// +// /// This API uses the `createFile` and `getDocumentContent` API's behind the scenes. +// /// {@endtemplate} +// Future copy(Uri uri, Uri destination) async { +// final Map args = { +// 'uri': '$uri', +// 'destination': '$destination' +// }; + +// return invokeMapMethod('copy', args); +// } diff --git a/lib/src/saf/api/create.dart b/lib/src/saf/api/create.dart index 64d09a6..4b4d953 100644 --- a/lib/src/saf/api/create.dart +++ b/lib/src/saf/api/create.dart @@ -1,102 +1,102 @@ -import 'dart:typed_data'; - -import '../../channels.dart'; -import '../../common/functional_extender.dart'; -import '../common/barrel.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.createDirectory} -/// Create a direct child document tree named `displayName` given a parent `parentUri`. -/// -/// Equivalent to `DocumentFile.createDirectory`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createDirectory%28java.lang.String%29). -/// {@endtemplate} -Future createDirectory(Uri parentUri, String displayName) async { - final Map args = { - 'uri': '$parentUri', - 'displayName': displayName, - }; - - final Map? createdDocumentFile = await kDocumentFileChannel - .invokeMapMethod('createDirectory', args); - - return createdDocumentFile - ?.apply((Map c) => DocumentFile.fromMap(c)); -} - -/// {@template sharedstorage.saf.createFile} -/// Convenient method to create files using either [String] or raw bytes [Uint8List]. -/// -/// Under the hood this method calls `createFileAsString` or `createFileAsBytes` -/// depending on which argument is passed. -/// -/// If both (bytes and content) are passed, the bytes will be used and the content will be ignored. -/// {@endtemplate} -Future createFile( - Uri parentUri, { - required String mimeType, - required String displayName, - Uint8List? bytes, - String content = '', -}) { - return bytes != null - ? createFileAsBytes( - parentUri, - mimeType: mimeType, - displayName: displayName, - bytes: bytes, - ) - : createFileAsString( - parentUri, - mimeType: mimeType, - displayName: displayName, - content: content, - ); -} - -/// {@template sharedstorage.saf.createFileAsBytes} -/// Create a direct child document of `parentUri`. -/// - `mimeType` is the type of document following [this specs](https://www.iana.org/assignments/media-types/media-types.xhtml). -/// - `displayName` is the name of the document, must be a valid file name. -/// - `bytes` is the content of the document as a list of bytes `Uint8List`. -/// -/// Returns the created file as a `DocumentFile`. -/// -/// Mirror of [`DocumentFile.createFile`](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createFile(java.lang.String,%20java.lang.String)) -/// {@endtemplate} -Future createFileAsBytes( - Uri parentUri, { - required String mimeType, - required String displayName, - required Uint8List bytes, -}) async { - final String directoryUri = '$parentUri'; - - final Map args = { - 'mimeType': mimeType, - 'content': bytes, - 'displayName': displayName, - 'directoryUri': directoryUri, - }; - - return invokeMapMethod('createFile', args); -} - -/// {@template sharedstorage.saf.createFileAsString} -/// Convenient method to create a file. -/// using `content` as String instead Uint8List. -/// {@endtemplate} -Future createFileAsString( - Uri parentUri, { - required String mimeType, - required String displayName, - required String content, -}) { - return createFileAsBytes( - parentUri, - displayName: displayName, - mimeType: mimeType, - bytes: Uint8List.fromList(content.codeUnits), - ); -} +// import 'dart:typed_data'; + +// import '../../channels.dart'; +// import '../../common/functional_extender.dart'; +// import '../common/barrel.dart'; +// import '../models/barrel.dart'; + +// /// {@template sharedstorage.saf.createDirectory} +// /// Create a direct child document tree named `displayName` given a parent `parentUri`. +// /// +// /// Equivalent to `DocumentFile.createDirectory`. +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createDirectory%28java.lang.String%29). +// /// {@endtemplate} +// Future createDirectory(Uri parentUri, String displayName) async { +// final Map args = { +// 'uri': '$parentUri', +// 'displayName': displayName, +// }; + +// final Map? createdDocumentFile = await kDocumentFileChannel +// .invokeMapMethod('createDirectory', args); + +// return createdDocumentFile +// ?.apply((Map c) => DocumentFile.fromMap(c)); +// } + +// /// {@template sharedstorage.saf.createFile} +// /// Convenient method to create files using either [String] or raw bytes [Uint8List]. +// /// +// /// Under the hood this method calls `createFileAsString` or `createFileAsBytes` +// /// depending on which argument is passed. +// /// +// /// If both (bytes and content) are passed, the bytes will be used and the content will be ignored. +// /// {@endtemplate} +// Future createFile( +// Uri parentUri, { +// required String mimeType, +// required String displayName, +// Uint8List? bytes, +// String content = '', +// }) { +// return bytes != null +// ? createFileAsBytes( +// parentUri, +// mimeType: mimeType, +// displayName: displayName, +// bytes: bytes, +// ) +// : createFileAsString( +// parentUri, +// mimeType: mimeType, +// displayName: displayName, +// content: content, +// ); +// } + +// /// {@template sharedstorage.saf.createFileAsBytes} +// /// Create a direct child document of `parentUri`. +// /// - `mimeType` is the type of document following [this specs](https://www.iana.org/assignments/media-types/media-types.xhtml). +// /// - `displayName` is the name of the document, must be a valid file name. +// /// - `bytes` is the content of the document as a list of bytes `Uint8List`. +// /// +// /// Returns the created file as a `DocumentFile`. +// /// +// /// Mirror of [`DocumentFile.createFile`](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createFile(java.lang.String,%20java.lang.String)) +// /// {@endtemplate} +// Future createFileAsBytes( +// Uri parentUri, { +// required String mimeType, +// required String displayName, +// required Uint8List bytes, +// }) async { +// final String directoryUri = '$parentUri'; + +// final Map args = { +// 'mimeType': mimeType, +// 'content': bytes, +// 'displayName': displayName, +// 'directoryUri': directoryUri, +// }; + +// return invokeMapMethod('createFile', args); +// } + +// /// {@template sharedstorage.saf.createFileAsString} +// /// Convenient method to create a file. +// /// using `content` as String instead Uint8List. +// /// {@endtemplate} +// Future createFileAsString( +// Uri parentUri, { +// required String mimeType, +// required String displayName, +// required String content, +// }) { +// return createFileAsBytes( +// parentUri, +// displayName: displayName, +// mimeType: mimeType, +// bytes: Uint8List.fromList(content.codeUnits), +// ); +// } diff --git a/lib/src/saf/api/exception.dart b/lib/src/saf/api/exception.dart index 292343c..9e5dfd7 100644 --- a/lib/src/saf/api/exception.dart +++ b/lib/src/saf/api/exception.dart @@ -1,5 +1,6 @@ /// Exception thrown when the provided URI is invalid, possible reasons: /// +/// - You have no permissions to read or write in the provided URI/File. /// - The file was deleted and you are trying to read. /// - [delete] and [readDocumentContent] ran at the same time. class SharedStorageFileNotFoundException extends SharedStorageException { @@ -13,6 +14,44 @@ class SharedStorageInternalException extends SharedStorageException { const SharedStorageInternalException(super.message, super.stackTrace); } +/// Exception thrown when the user does not select a folder when calling [SharedStorage.pickScopedDirectory]. +class SharedStorageDirectoryWasNotSelectedException + extends SharedStorageException { + const SharedStorageDirectoryWasNotSelectedException( + super.message, + super.stackTrace, + ); +} + +/// Exception thrown when the user does not select a folder when calling [SharedStorage.pickScopedDirectory]. +class SharedStorageFileWasNotSelectedException extends SharedStorageException { + const SharedStorageFileWasNotSelectedException( + super.message, + super.stackTrace, + ); +} + +class SharedStorageExternalAppNotFoundException extends SharedStorageException { + const SharedStorageExternalAppNotFoundException( + super.message, + super.stackTrace, + ); +} + +class SharedStorageSecurityException extends SharedStorageException { + const SharedStorageSecurityException( + super.message, + super.stackTrace, + ); +} + +class SharedStorageUnknownException extends SharedStorageException { + const SharedStorageUnknownException( + super.message, + super.stackTrace, + ); +} + /// Custom type for exceptions of [shared_storage] package. class SharedStorageException implements Exception { const SharedStorageException(this.message, this.stackTrace); diff --git a/lib/src/saf/api/grant.dart b/lib/src/saf/api/grant.dart index 6efb59c..387b748 100644 --- a/lib/src/saf/api/grant.dart +++ b/lib/src/saf/api/grant.dart @@ -1,59 +1,59 @@ -import '../../channels.dart'; -import '../../common/functional_extender.dart'; - -/// {@template sharedstorage.saf.openDocumentTree} -/// Start Activity Action: Allow the user to pick a directory subtree. -/// -/// When invoked, the system will display the various `DocumentsProvider` -/// instances installed on the device, letting the user navigate through them. -/// Apps can fully manage documents within the returned directory. -/// -/// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT_TREE). -/// -/// support the initial directory of the directory picker. -/// {@endtemplate} -Future openDocumentTree({ - bool grantWritePermission = true, - bool persistablePermission = true, - Uri? initialUri, -}) async { - const String kOpenDocumentTree = 'openDocumentTree'; - - final Map args = { - 'grantWritePermission': grantWritePermission, - 'persistablePermission': persistablePermission, - if (initialUri != null) 'initialUri': '$initialUri', - }; - - final String? selectedDirectoryUri = - await kDocumentFileChannel.invokeMethod(kOpenDocumentTree, args); - - return selectedDirectoryUri?.apply((String e) => Uri.parse(e)); -} - -/// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT). -Future?> openDocument({ - Uri? initialUri, - bool grantWritePermission = true, - bool persistablePermission = true, - String mimeType = '*/*', - bool multiple = false, -}) async { - const String kOpenDocument = 'openDocument'; - - final Map args = { - if (initialUri != null) 'initialUri': '$initialUri', - 'grantWritePermission': grantWritePermission, - 'persistablePermission': persistablePermission, - 'mimeType': mimeType, - 'multiple': multiple, - }; - - final List? selectedUriList = - await kDocumentFileChannel.invokeListMethod(kOpenDocument, args); - - return selectedUriList?.apply( - (List list) => - list.map((dynamic e) => Uri.parse(e as String)).toList(), - ); -} +// import '../../channels.dart'; +// import '../../common/functional_extender.dart'; + +// /// {@template sharedstorage.saf.openDocumentTree} +// /// Start Activity Action: Allow the user to pick a directory subtree. +// /// +// /// When invoked, the system will display the various `DocumentsProvider` +// /// instances installed on the device, letting the user navigate through them. +// /// Apps can fully manage documents within the returned directory. +// /// +// /// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT_TREE). +// /// +// /// support the initial directory of the directory picker. +// /// {@endtemplate} +// Future openDocumentTree({ +// bool grantWritePermission = true, +// bool persistablePermission = true, +// Uri? initialUri, +// }) async { +// const String kOpenDocumentTree = 'openDocumentTree'; + +// final Map args = { +// 'grantWritePermission': grantWritePermission, +// 'persistablePermission': persistablePermission, +// if (initialUri != null) 'initialUri': '$initialUri', +// }; + +// final String? selectedDirectoryUri = +// await kDocumentFileChannel.invokeMethod(kOpenDocumentTree, args); + +// return selectedDirectoryUri?.apply((String e) => Uri.parse(e)); +// } + +// /// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT). +// Future?> openDocument({ +// Uri? initialUri, +// bool grantWritePermission = true, +// bool persistablePermission = true, +// String mimeType = '*/*', +// bool multiple = false, +// }) async { +// const String kOpenDocument = 'openDocument'; + +// final Map args = { +// if (initialUri != null) 'initialUri': '$initialUri', +// 'grantWritePermission': grantWritePermission, +// 'persistablePermission': persistablePermission, +// 'mimeType': mimeType, +// 'multiple': multiple, +// }; + +// final List? selectedUriList = +// await kDocumentFileChannel.invokeListMethod(kOpenDocument, args); + +// return selectedUriList?.apply( +// (List list) => +// list.map((dynamic e) => Uri.parse(e as String)).toList(), +// ); +// } diff --git a/lib/src/saf/api/info.dart b/lib/src/saf/api/info.dart index 1da646c..d3f5a12 100644 --- a/lib/src/saf/api/info.dart +++ b/lib/src/saf/api/info.dart @@ -1,54 +1 @@ -import '../../channels.dart'; -import '../../common/functional_extender.dart'; - -/// {@template sharedstorage.saf.length} -/// Equivalent to `DocumentFile.length`. -/// -/// Returns the size of a given document `uri` in bytes. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#length%28%29). -/// {@endtemplate} -Future documentLength(Uri uri) async => kDocumentFileChannel - .invokeMethod('length', {'uri': '$uri'}); - -/// {@template sharedstorage.saf.lastModified} -/// Equivalent to `DocumentFile.lastModified`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#lastModified%28%29). -/// {@endtemplate} -Future lastModified(Uri uri) async { - const String kLastModified = 'lastModified'; - - final int? inMillisecondsSinceEpoch = await kDocumentFileChannel - .invokeMethod(kLastModified, {'uri': '$uri'}); - - return inMillisecondsSinceEpoch - ?.takeIf((int i) => i > 0) - ?.apply((int i) => DateTime.fromMillisecondsSinceEpoch(i)); -} - -/// {@template sharedstorage.saf.canRead} -/// Equivalent to `DocumentFile.canRead`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#canRead()). -/// {@endtemplate} -Future canRead(Uri uri) async => kDocumentFileChannel - .invokeMethod('canRead', {'uri': '$uri'}); - -/// {@template sharedstorage.saf.canWrite} -/// Equivalent to `DocumentFile.canWrite`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#canWrite()). -/// {@endtemplate} -Future canWrite(Uri uri) async => kDocumentFileChannel - .invokeMethod('canWrite', {'uri': '$uri'}); - -/// {@template sharedstorage.saf.exists} -/// Equivalent to `DocumentFile.exists`. -/// -/// Verify wheter or not a given [uri] exists. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#exists()). -/// {@endtemplate} -Future exists(Uri uri) async => kDocumentFileChannel - .invokeMethod('exists', {'uri': '$uri'}); + diff --git a/lib/src/saf/api/open.dart b/lib/src/saf/api/open.dart index 474cae5..0054847 100644 --- a/lib/src/saf/api/open.dart +++ b/lib/src/saf/api/open.dart @@ -1,42 +1,42 @@ -import 'package:flutter/services.dart'; - -import '../../channels.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.openDocumentFile} -/// Alias for [openDocumentFileWithResult] that returns true if the target [uri] -/// was successfully launched, false otherwise. -/// {@endtemplate} -Future openDocumentFile(Uri uri) async { - final OpenDocumentFileResult result = await openDocumentFileWithResult(uri); - - return result.success; -} - -/// {@template sharedstorage.saf.openDocumentFileWithResult} -/// It's a convenience method to launch the default application associated -/// with the given MIME type and can't be considered an official SAF API. -/// -/// Launch `ACTION_VIEW` intent to open the given document `uri`. -/// -/// Returns a [OpenDocumentFileResult] that allows you handle all edge-cases. -/// {@endtemplate} -Future openDocumentFileWithResult(Uri uri) async { - try { - await kDocumentFileHelperChannel.invokeMethod( - 'openDocumentFile', - {'uri': '$uri'}, - ); - return OpenDocumentFileResult.launched; - } on PlatformException catch (e) { - switch (e.code) { - case 'EXCEPTION_ACTIVITY_NOT_FOUND': - return OpenDocumentFileResult.failedDueActivityNotFound; - case 'EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY': - return OpenDocumentFileResult.failedDueSecurityPolicy; - case 'EXCEPTION_CANT_OPEN_DOCUMENT_FILE': - default: - return OpenDocumentFileResult.failedDueUnknownReason; - } - } -} +// import 'package:flutter/services.dart'; + +// import '../../channels.dart'; +// import '../models/barrel.dart'; + +// /// {@template sharedstorage.saf.openDocumentFile} +// /// Alias for [openDocumentFileWithResult] that returns true if the target [uri] +// /// was successfully launched, false otherwise. +// /// {@endtemplate} +// Future openDocumentFile(Uri uri) async { +// final OpenDocumentFileResult result = await openDocumentFileWithResult(uri); + +// return result.success; +// } + +// /// {@template sharedstorage.saf.openDocumentFileWithResult} +// /// It's a convenience method to launch the default application associated +// /// with the given MIME type and can't be considered an official SAF API. +// /// +// /// Launch `ACTION_VIEW` intent to open the given document `uri`. +// /// +// /// Returns a [OpenDocumentFileResult] that allows you handle all edge-cases. +// /// {@endtemplate} +// Future openDocumentFileWithResult(Uri uri) async { +// try { +// await kDocumentFileHelperChannel.invokeMethod( +// 'openDocumentFile', +// {'uri': '$uri'}, +// ); +// return OpenDocumentFileResult.launched; +// } on PlatformException catch (e) { +// switch (e.code) { +// case 'EXCEPTION_ACTIVITY_NOT_FOUND': +// return OpenDocumentFileResult.failedDueActivityNotFound; +// case 'EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY': +// return OpenDocumentFileResult.failedDueSecurityPolicy; +// case 'EXCEPTION_CANT_OPEN_DOCUMENT_FILE': +// default: +// return OpenDocumentFileResult.failedDueUnknownReason; +// } +// } +// } diff --git a/lib/src/saf/api/rename.dart b/lib/src/saf/api/rename.dart index fa37c95..5249ce8 100644 --- a/lib/src/saf/api/rename.dart +++ b/lib/src/saf/api/rename.dart @@ -1,20 +1,20 @@ -import '../barrel.dart'; -import '../common/barrel.dart'; - -/// {@template sharedstorage.saf.renameTo} -/// Rename the current document `uri` to a new `displayName`. -/// -/// **Note: after using this method `uri` is not longer valid, -/// use the returned document instead**. -/// -/// Returns the updated document. -/// -/// Equivalent to `DocumentFile.renameTo`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#renameTo%28java.lang.String%29). -/// {@endtemplate} -Future renameTo(Uri uri, String displayName) async => - invokeMapMethod( - 'renameTo', - {'uri': '$uri', 'displayName': displayName}, - ); +// import '../barrel.dart'; +// import '../common/barrel.dart'; + +// /// {@template sharedstorage.saf.renameTo} +// /// Rename the current document `uri` to a new `displayName`. +// /// +// /// **Note: after using this method `uri` is not longer valid, +// /// use the returned document instead**. +// /// +// /// Returns the updated document. +// /// +// /// Equivalent to `DocumentFile.renameTo`. +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#renameTo%28java.lang.String%29). +// /// {@endtemplate} +// Future renameTo(Uri uri, String displayName) async => +// invokeMapMethod( +// 'renameTo', +// {'uri': '$uri', 'displayName': displayName}, +// ); diff --git a/lib/src/saf/api/search.dart b/lib/src/saf/api/search.dart index 09f963e..854fc6a 100644 --- a/lib/src/saf/api/search.dart +++ b/lib/src/saf/api/search.dart @@ -1,18 +1,18 @@ -import '../barrel.dart'; -import '../common/barrel.dart'; - -/// {@template sharedstorage.saf.findFile} -/// Equivalent to `DocumentFile.findFile`. -/// -/// If you want to check if a given document file exists by their [displayName] prefer using `child` instead. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#findFile%28java.lang.String%29). -/// {@endtemplate} -Future findFile(Uri directoryUri, String displayName) async { - final Map args = { - 'uri': '$directoryUri', - 'displayName': displayName, - }; - - return invokeMapMethod('findFile', args); -} +// import '../barrel.dart'; +// import '../common/barrel.dart'; + +// /// {@template sharedstorage.saf.findFile} +// /// Equivalent to `DocumentFile.findFile`. +// /// +// /// If you want to check if a given document file exists by their [displayName] prefer using `child` instead. +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#findFile%28java.lang.String%29). +// /// {@endtemplate} +// Future findFile(Uri directoryUri, String displayName) async { +// final Map args = { +// 'uri': '$directoryUri', +// 'displayName': displayName, +// }; + +// return invokeMapMethod('findFile', args); +// } diff --git a/lib/src/saf/api/share.dart b/lib/src/saf/api/share.dart index dd04c4f..8b13789 100644 --- a/lib/src/saf/api/share.dart +++ b/lib/src/saf/api/share.dart @@ -1,81 +1 @@ -import 'dart:io'; -import '../../channels.dart'; - -/// {@template sharedstorage.saf.share} -/// Start share intent for the given [uri]. -/// -/// To share a file, use [Uri.parse] passing the file absolute path as argument. -/// -/// Note that this method can only share files that your app has permission over, -/// either by being in your app domain (e.g file from your app cache) or that is granted by [openDocumentTree]. -/// -/// Usage: -/// -/// ```dart -/// try { -/// await shareUriOrFile( -/// uri: uri, -/// filePath: path, -/// file: file, -/// ); -/// } on PlatformException catch (e) { -/// // The user clicked twice too fast, which created 2 share requests and the second one failed. -/// // Unhandled Exception: PlatformException(Share callback error, prior share-sheet did not call back, did you await it? Maybe use non-result variant, null, null). -/// log('Error when calling [shareFile]: $e'); -/// return; -/// } -/// ``` -/// {@endtemplate} -Future shareUri( - Uri uri, { - String? type, -}) { - final Map args = { - 'uri': '$uri', - 'type': type, - }; - - return kDocumentFileHelperChannel.invokeMethod('shareUri', args); -} - -/// Alias for [shareUri]. -Future shareFile({File? file, String? path}) { - return shareUriOrFile(filePath: path, file: file); -} - -/// Alias for [shareUri]. -Future shareUriOrFile({String? filePath, File? file, Uri? uri}) { - return shareUri( - _getShareableUriFrom(file: file, filePath: filePath, uri: uri), - ); -} - -/// Helper function to get the shareable URI from [file], [filePath] or the [uri] itself. -/// -/// Usage: -/// -/// ```dart -/// shareUri(getShareableUri(...)); -/// ``` -Uri _getShareableUriFrom({String? filePath, File? file, Uri? uri}) { - if (filePath == null && file == null && uri == null) { - throw ArgumentError.value( - null, - 'getShareableUriFrom', - 'Tried to call [getShareableUriFrom] or with all arguments ({String? filePath, File? file, Uri? uri}) set to [null].', - ); - } - - late Uri target; - - if (uri != null) { - target = uri; - } else if (filePath != null) { - target = Uri.parse(filePath); - } else if (file != null) { - target = Uri.parse(file.absolute.path); - } - - return target; -} diff --git a/lib/src/saf/api/tree.dart b/lib/src/saf/api/tree.dart index c7c12e8..32ce9a4 100644 --- a/lib/src/saf/api/tree.dart +++ b/lib/src/saf/api/tree.dart @@ -1,84 +1,80 @@ -import '../../channels.dart'; -import '../common/barrel.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.listFiles} -/// **Important**: Ensure you have read permission by calling `canRead` before calling `listFiles`. -/// -/// Emits a new event for each child document file. -/// -/// Works with small and large data file sets. -/// -/// ```dart -/// /// Usage: -/// -/// final myState = []; -/// -/// final onDocumentFile = listFiles(myUri, [DocumentFileColumn.id]); -/// -/// onDocumentFile.listen((document) { -/// myState.add(document); -/// -/// final documentId = document.data?[DocumentFileColumn.id] -/// -/// print('$documentId was added to state'); -/// }); -/// ``` -/// -/// [Refer to details](https://stackoverflow.com/questions/41096332/issues-traversing-through-directory-hierarchy-with-android-storage-access-framew). -/// {@endtemplate} -Stream listFiles( - Uri uri, { - required List columns, -}) { - final Map args = { - 'uri': '$uri', - 'event': 'listFiles', - 'columns': columns.map((DocumentFileColumn e) => '$e').toList(), - }; - - final Stream onCursorRowResult = - kDocumentFileEventChannel.receiveBroadcastStream(args); - - return onCursorRowResult.map( - (dynamic e) => DocumentFile.fromMap( - Map.from( - e as Map, - ), - ), - ); -} - -/// {@template sharedstorage.saf.child} -/// Return the `child` of the given `uri` if it exists otherwise `null`. -/// -/// It's faster than [DocumentFile.findFile] -/// `path` is the single file name or file path. Empty string returns to itself. -/// -/// Equivalent to `DocumentFile.child` extension/overload. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29) -/// {@endtemplate} -Future child( - Uri uri, - String path, { - bool requiresWriteAccess = false, -}) async { - final Map args = { - 'uri': '$uri', - 'path': path, - 'requiresWriteAccess': requiresWriteAccess, - }; - - return invokeMapMethod('child', args); -} - -/// {@template sharedstorage.saf.parentFile} -/// Get the parent file of the given `uri`. -/// -/// Equivalent to `DocumentFile.getParentFile`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#getParentFile%28%29). -/// {@endtemplate} -Future parentFile(Uri uri) async => - invokeMapMethod('parentFile', {'uri': '$uri'}); +// import '../../channels.dart'; +// import '../common/barrel.dart'; +// import '../models/barrel.dart'; + +// /// {@template sharedstorage.saf.listFiles} +// /// **Important**: Ensure you have read permission by calling `canRead` before calling `listFiles`. +// /// +// /// Emits a new event for each child document file. +// /// +// /// Works with small and large data file sets. +// /// +// /// ```dart +// /// /// Usage: +// /// +// /// final myState = []; +// /// +// /// final onDocumentFile = listFiles(myUri, [DocumentFileColumn.id]); +// /// +// /// onDocumentFile.listen((document) { +// /// myState.add(document); +// /// +// /// final documentId = document.data?[DocumentFileColumn.id] +// /// +// /// print('$documentId was added to state'); +// /// }); +// /// ``` +// /// +// /// [Refer to details](https://stackoverflow.com/questions/41096332/issues-traversing-through-directory-hierarchy-with-android-storage-access-framew). +// /// {@endtemplate} +// Stream listFiles(Uri uri) { +// final Map args = { +// 'uri': '$uri', +// 'event': 'listFiles', +// }; + +// final Stream onCursorRowResult = +// kDocumentFileEventChannel.receiveBroadcastStream(args); + +// return onCursorRowResult.map( +// (dynamic e) => DocumentFile.fromMap( +// Map.from( +// e as Map, +// ), +// ), +// ); +// } + +// /// {@template sharedstorage.saf.child} +// /// Return the `child` of the given `uri` if it exists otherwise `null`. +// /// +// /// It's faster than [DocumentFile.findFile] +// /// `path` is the single file name or file path. Empty string returns to itself. +// /// +// /// Equivalent to `DocumentFile.child` extension/overload. +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29) +// /// {@endtemplate} +// Future child( +// Uri uri, +// String path, { +// bool requiresWriteAccess = false, +// }) async { +// final Map args = { +// 'uri': '$uri', +// 'path': path, +// 'requiresWriteAccess': requiresWriteAccess, +// }; + +// return invokeMapMethod('child', args); +// } + +// /// {@template sharedstorage.saf.parentFile} +// /// Get the parent file of the given `uri`. +// /// +// /// Equivalent to `DocumentFile.getParentFile`. +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#getParentFile%28%29). +// /// {@endtemplate} +// Future parentFile(Uri uri) async => +// invokeMapMethod('parentFile', {'uri': '$uri'}); diff --git a/lib/src/saf/api/utility.dart b/lib/src/saf/api/utility.dart index cecc8af..76ed14f 100644 --- a/lib/src/saf/api/utility.dart +++ b/lib/src/saf/api/utility.dart @@ -1,12 +1,12 @@ -import '../common/barrel.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.fromTreeUri} -/// Create a new `DocumentFile` instance given `uri`. -/// -/// Equivalent to `DocumentFile.fromTreeUri`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29). -/// {@endtemplate} -Future fromTreeUri(Uri uri) async => - invokeMapMethod('fromTreeUri', {'uri': '$uri'}); +import '../common/barrel.dart'; +import '../models/barrel.dart'; + +/// {@template sharedstorage.saf.fromTreeUri} +/// Create a new `DocumentFile` instance given `uri`. +/// +/// Equivalent to `DocumentFile.fromTreeUri`. +/// +/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29). +/// {@endtemplate} +// Future fromTreeUri(Uri uri) async => +// invokeMapMethod('fromTreeUri', {'uri': '$uri'}); diff --git a/lib/src/saf/common/method_channel_helper.dart b/lib/src/saf/common/method_channel_helper.dart index f0f68f8..f30e977 100644 --- a/lib/src/saf/common/method_channel_helper.dart +++ b/lib/src/saf/common/method_channel_helper.dart @@ -1,16 +1,16 @@ -import '../../channels.dart'; -import '../models/barrel.dart'; - -/// Helper method to invoke a native SAF method and return a document file -/// if not null, shouldn't be called directly from non-package code -Future invokeMapMethod( - String method, - Map args, -) async { - final Map? documentMap = - await kDocumentFileChannel.invokeMapMethod(method, args); - - if (documentMap == null) return null; - - return DocumentFile.fromMap(documentMap); -} +// import '../../channels.dart'; +// import '../models/barrel.dart'; + +// /// Helper method to invoke a native SAF method and return a document file +// /// if not null, shouldn't be called directly from non-package code +// Future invokeMapMethod( +// String method, +// Map args, +// ) async { +// final Map? documentMap = +// await kDocumentFileChannel.invokeMapMethod(method, args); + +// if (documentMap == null) return null; + +// return DocumentFile.fromMap(documentMap); +// } diff --git a/lib/src/saf/models/document_file.dart b/lib/src/saf/models/document_file.dart index f0b04ce..0a85ab6 100644 --- a/lib/src/saf/models/document_file.dart +++ b/lib/src/saf/models/document_file.dart @@ -1,266 +1,266 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import '../../common/functional_extender.dart'; -import '../api/barrel.dart' as saf; - -extension UriDocumentFileUtils on Uri { - /// {@macro sharedstorage.saf.fromTreeUri} - Future toDocumentFile() => DocumentFile.fromTreeUri(this); - - /// {@macro sharedstorage.saf.openDocumentFile} - Future openDocumentFile() => saf.openDocumentFile(this); -} - -/// Equivalent to Android `DocumentFile` class -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) -class DocumentFile { - const DocumentFile({ - required this.id, - required this.parentUri, - required this.size, - required this.name, - required this.type, - required this.uri, - required this.isDirectory, - required this.isFile, - required this.isVirtual, - required this.lastModified, - }); - - factory DocumentFile.fromMap(Map map) { - return DocumentFile( - parentUri: - (map['parentUri'] as String?)?.apply((String p) => Uri.parse(p)), - id: map['id'] as String?, - isDirectory: map['isDirectory'] as bool?, - isFile: map['isFile'] as bool?, - isVirtual: map['isVirtual'] as bool?, - name: map['name'] as String?, - type: map['type'] as String?, - uri: Uri.parse(map['uri'] as String), - size: map['size'] as int?, - lastModified: (map['lastModified'] as int?) - ?.apply((int l) => DateTime.fromMillisecondsSinceEpoch(l)), - ); - } - - /// Display name of this document file, useful to show as a title in a list of files. - final String? name; - - /// Mimetype of this document file, useful to determine how to display it. - final String? type; - - /// Path, URI, location of this document, it can exists or not, you should check by using `exists()` API. - final Uri uri; - - /// Uri of the parent document of [this] document. - final Uri? parentUri; - - /// Generally represented as `primary:/Some/Resource` and can be used to identify the current document file. - /// - /// See [this diagram](https://raw.githubusercontent.com/anggrayudi/SimpleStorage/master/art/terminology.png) for details, source: [anggrayudi/SimpleStorage](https://github.com/anggrayudi/SimpleStorage). - final String? id; - - /// Size of a document in bytes - final int? size; - - /// Whether this document is a directory or not. - /// - /// Since it's a [DocumentFile], it can represent a folder/directory rather than a file. - final bool? isDirectory; - - /// Indicates if this [DocumentFile] represents a _file_. - /// - /// Be aware there are several differences between documents and traditional files: - /// - Documents express their display name and MIME type as separate fields, instead of relying on file extensions. - /// Some documents providers may still choose to append extensions to their display names, but that's an implementation detail. - /// - A single document may appear as the child of multiple directories, so it doesn't inherently know who its parent is. - /// That is, documents don't have a strong notion of path. - /// You can easily traverse a tree of documents from parent to child, but not from child to parent. - /// - Each document has a unique identifier within that provider. - /// This identifier is an opaque implementation detail of the provider, and as such it must not be parsed. - /// - /// [Android Reference](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#:~:text=androidx.documentfile.provider.DocumentFile,but%20it%20has%20substantial%20overhead.() - final bool? isFile; - - /// Indicates if this file represents a virtual document. - /// - /// What is a virtual document? - /// - [Video answer](https://www.youtube.com/watch?v=4h7yCZt231Y) - /// - [Text docs answer](https://developer.android.com/about/versions/nougat/android-7.0#virtual_files) - final bool? isVirtual; - - /// {@macro sharedstorage.saf.fromTreeUri} - static Future fromTreeUri(Uri uri) => saf.fromTreeUri(uri); - - /// {@macro sharedstorage.saf.child} - Future child( - String path, { - bool requiresWriteAccess = false, - }) => - saf.child(uri, path, requiresWriteAccess: requiresWriteAccess); - - /// {@macro sharedstorage.saf.openDocumentFile} - Future openDocumentFile() => saf.openDocumentFile(uri); - - /// {@macro sharedstorage.saf.openDocumentFile} - /// - /// Alias/shortname for [openDocumentFile] - Future open() => openDocumentFile(); - - /// {@macro sharedstorage.saf.canRead} - Future canRead() async => saf.canRead(uri); - - /// {@macro sharedstorage.saf.share} - Future share() async => saf.shareUri(uri); - - /// {@macro sharedstorage.saf.canWrite} - Future canWrite() async => saf.canWrite(uri); - - /// {@macro sharedstorage.saf.exists} - Future exists() => saf.exists(uri); - - /// {@macro sharedstorage.saf.delete} - Future delete() => saf.delete(uri); - - /// {@macro sharedstorage.saf.copy} - Future copy(Uri destination) => saf.copy(uri, destination); - - /// {@macro sharedstorage.saf.getDocumentContent} - Future getContent() => saf.getDocumentContent(uri); - - /// {@macro sharedstorage.saf.getContentAsString} - Future getContentAsString() => saf.getDocumentContentAsString(uri); - - /// {@macro sharedstorage.getDocumentContentAsStream} - Stream getContentAsStream() => saf.getDocumentContentAsStream(uri); - - /// {@macro sharedstorage.saf.createDirectory} - Future createDirectory(String displayName) => - saf.createDirectory(uri, displayName); - - /// {@macro sharedstorage.saf.createFileAsBytes} - Future createFileAsBytes({ - required String mimeType, - required String displayName, - required Uint8List bytes, - }) => - saf.createFile( - uri, - mimeType: mimeType, - displayName: displayName, - bytes: bytes, - ); - - /// {@macro sharedstorage.saf.createFile} - Future createFile({ - required String mimeType, - required String displayName, - String content = '', - Uint8List? bytes, - }) => - saf.createFile( - uri, - mimeType: mimeType, - displayName: displayName, - content: content, - bytes: bytes, - ); - - /// Alias for [createFile] with [content] param - Future createFileAsString({ - required String mimeType, - required String displayName, - required String content, - }) => - saf.createFile( - uri, - mimeType: mimeType, - displayName: displayName, - content: content, - ); - - /// {@macro sharedstorage.saf.writeToFileAsBytes} - Future writeToFileAsBytes({ - required Uint8List bytes, - FileMode? mode, - }) => - saf.writeToFileAsBytes( - uri, - bytes: bytes, - mode: mode, - ); - - /// {@macro sharedstorage.saf.writeToFile} - Future writeToFile({ - String? content, - Uint8List? bytes, - FileMode? mode, - }) => - saf.writeToFile( - uri, - content: content, - bytes: bytes, - mode: mode, - ); - - /// Alias for [writeToFile] with [content] param - Future writeToFileAsString({ - required String content, - FileMode? mode, - }) => - saf.writeToFile( - uri, - content: content, - mode: mode, - ); - - /// {@macro sharedstorage.saf.lastModified} - final DateTime? lastModified; - - /// {@macro sharedstorage.saf.findFile} - Future findFile(String displayName) => - saf.findFile(uri, displayName); - - /// {@macro sharedstorage.saf.renameTo} - Future renameTo(String displayName) => - saf.renameTo(uri, displayName); - - /// {@macro sharedstorage.saf.parentFile} - Future parentFile() => saf.parentFile(uri); - - Map toMap() { - return { - 'id': id, - 'uri': '$uri', - 'parentUri': '$parentUri', - 'isDirectory': isDirectory, - 'isFile': isFile, - 'isVirtual': isVirtual, - 'name': name, - 'type': type, - 'size': size, - 'lastModified': lastModified?.millisecondsSinceEpoch, - }; - } - - @override - bool operator ==(Object other) { - if (other is! DocumentFile) return false; - - return id == other.id && - parentUri == other.parentUri && - isDirectory == other.isDirectory && - isFile == other.isFile && - isVirtual == other.isVirtual && - name == other.name && - type == other.type && - uri == other.uri; - } - - @override - int get hashCode => - Object.hash(isDirectory, isFile, isVirtual, name, type, uri); -} +// import 'dart:io'; +// import 'dart:typed_data'; + +// import '../../common/functional_extender.dart'; +// import '../api/barrel.dart' as saf; + +// extension UriDocumentFileUtils on Uri { +// /// {@macro sharedstorage.saf.fromTreeUri} +// // Future toDocumentFile() => DocumentFile.fromTreeUri(this); + +// /// {@macro sharedstorage.saf.openDocumentFile} +// Future openDocumentFile() => saf.openDocumentFile(this); +// } + +// /// Equivalent to Android `DocumentFile` class +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) +// class DocumentFile { +// const DocumentFile({ +// required this.id, +// required this.parentUri, +// required this.size, +// required this.name, +// required this.type, +// required this.uri, +// required this.isDirectory, +// required this.isFile, +// required this.isVirtual, +// required this.lastModified, +// }); + +// factory DocumentFile.fromMap(Map map) { +// return DocumentFile( +// parentUri: +// (map['parentUri'] as String?)?.apply((String p) => Uri.parse(p)), +// id: map['id'] as String?, +// isDirectory: map['isDirectory'] as bool?, +// isFile: map['isFile'] as bool?, +// isVirtual: map['isVirtual'] as bool?, +// name: map['name'] as String?, +// type: map['type'] as String?, +// uri: Uri.parse(map['uri'] as String), +// size: map['size'] as int?, +// lastModified: (map['lastModified'] as int?) +// ?.apply((int l) => DateTime.fromMillisecondsSinceEpoch(l)), +// ); +// } + +// /// Display name of this document file, useful to show as a title in a list of files. +// final String? name; + +// /// Mimetype of this document file, useful to determine how to display it. +// final String? type; + +// /// Path, URI, location of this document, it can exists or not, you should check by using `exists()` API. +// final Uri uri; + +// /// Uri of the parent document of [this] document. +// final Uri? parentUri; + +// /// Generally represented as `primary:/Some/Resource` and can be used to identify the current document file. +// /// +// /// See [this diagram](https://raw.githubusercontent.com/anggrayudi/SimpleStorage/master/art/terminology.png) for details, source: [anggrayudi/SimpleStorage](https://github.com/anggrayudi/SimpleStorage). +// final String? id; + +// /// Size of a document in bytes +// final int? size; + +// /// Whether this document is a directory or not. +// /// +// /// Since it's a [DocumentFile], it can represent a folder/directory rather than a file. +// final bool? isDirectory; + +// /// Indicates if this [DocumentFile] represents a _file_. +// /// +// /// Be aware there are several differences between documents and traditional files: +// /// - Documents express their display name and MIME type as separate fields, instead of relying on file extensions. +// /// Some documents providers may still choose to append extensions to their display names, but that's an implementation detail. +// /// - A single document may appear as the child of multiple directories, so it doesn't inherently know who its parent is. +// /// That is, documents don't have a strong notion of path. +// /// You can easily traverse a tree of documents from parent to child, but not from child to parent. +// /// - Each document has a unique identifier within that provider. +// /// This identifier is an opaque implementation detail of the provider, and as such it must not be parsed. +// /// +// /// [Android Reference](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#:~:text=androidx.documentfile.provider.DocumentFile,but%20it%20has%20substantial%20overhead.() +// final bool? isFile; + +// /// Indicates if this file represents a virtual document. +// /// +// /// What is a virtual document? +// /// - [Video answer](https://www.youtube.com/watch?v=4h7yCZt231Y) +// /// - [Text docs answer](https://developer.android.com/about/versions/nougat/android-7.0#virtual_files) +// final bool? isVirtual; + +// /// {@macro sharedstorage.saf.fromTreeUri} +// // static Future fromTreeUri(Uri uri) => saf.fromTreeUri(uri); + +// /// {@macro sharedstorage.saf.child} +// Future child( +// String path, { +// bool requiresWriteAccess = false, +// }) => +// saf.child(uri, path, requiresWriteAccess: requiresWriteAccess); + +// /// {@macro sharedstorage.saf.openDocumentFile} +// Future openDocumentFile() => saf.openDocumentFile(uri); + +// /// {@macro sharedstorage.saf.openDocumentFile} +// /// +// /// Alias/shortname for [openDocumentFile] +// Future open() => openDocumentFile(); + +// /// {@macro sharedstorage.saf.canRead} +// Future canRead() async => saf.canRead(uri); + +// /// {@macro sharedstorage.saf.share} +// Future share() async => saf.shareUri(uri); + +// /// {@macro sharedstorage.saf.canWrite} +// Future canWrite() async => saf.canWrite(uri); + +// /// {@macro sharedstorage.saf.exists} +// Future exists() => saf.exists(uri); + +// /// {@macro sharedstorage.saf.delete} +// Future delete() => saf.delete(uri); + +// /// {@macro sharedstorage.saf.copy} +// Future copy(Uri destination) => saf.copy(uri, destination); + +// /// {@macro sharedstorage.saf.getDocumentContent} +// Future getContent() => saf.getDocumentContent(uri); + +// /// {@macro sharedstorage.saf.getContentAsString} +// Future getContentAsString() => saf.getDocumentContentAsString(uri); + +// /// {@macro sharedstorage.getDocumentContentAsStream} +// Stream getContentAsStream() => saf.getDocumentContentAsStream(uri); + +// /// {@macro sharedstorage.saf.createDirectory} +// Future createDirectory(String displayName) => +// saf.createDirectory(uri, displayName); + +// /// {@macro sharedstorage.saf.createFileAsBytes} +// Future createFileAsBytes({ +// required String mimeType, +// required String displayName, +// required Uint8List bytes, +// }) => +// saf.createFile( +// uri, +// mimeType: mimeType, +// displayName: displayName, +// bytes: bytes, +// ); + +// /// {@macro sharedstorage.saf.createFile} +// Future createFile({ +// required String mimeType, +// required String displayName, +// String content = '', +// Uint8List? bytes, +// }) => +// saf.createFile( +// uri, +// mimeType: mimeType, +// displayName: displayName, +// content: content, +// bytes: bytes, +// ); + +// /// Alias for [createFile] with [content] param +// Future createFileAsString({ +// required String mimeType, +// required String displayName, +// required String content, +// }) => +// saf.createFile( +// uri, +// mimeType: mimeType, +// displayName: displayName, +// content: content, +// ); + +// /// {@macro sharedstorage.saf.writeToFileAsBytes} +// Future writeToFileAsBytes({ +// required Uint8List bytes, +// FileMode? mode, +// }) => +// saf.writeToFileAsBytes( +// uri, +// bytes: bytes, +// mode: mode, +// ); + +// /// {@macro sharedstorage.saf.writeToFile} +// Future writeToFile({ +// String? content, +// Uint8List? bytes, +// FileMode? mode, +// }) => +// saf.writeToFile( +// uri, +// content: content, +// bytes: bytes, +// mode: mode, +// ); + +// /// Alias for [writeToFile] with [content] param +// Future writeToFileAsString({ +// required String content, +// FileMode? mode, +// }) => +// saf.writeToFile( +// uri, +// content: content, +// mode: mode, +// ); + +// /// {@macro sharedstorage.saf.lastModified} +// final DateTime? lastModified; + +// /// {@macro sharedstorage.saf.findFile} +// Future findFile(String displayName) => +// saf.findFile(uri, displayName); + +// /// {@macro sharedstorage.saf.renameTo} +// Future renameTo(String displayName) => +// saf.renameTo(uri, displayName); + +// /// {@macro sharedstorage.saf.parentFile} +// Future parentFile() => saf.parentFile(uri); + +// Map toMap() { +// return { +// 'id': id, +// 'uri': '$uri', +// 'parentUri': '$parentUri', +// 'isDirectory': isDirectory, +// 'isFile': isFile, +// 'isVirtual': isVirtual, +// 'name': name, +// 'type': type, +// 'size': size, +// 'lastModified': lastModified?.millisecondsSinceEpoch, +// }; +// } + +// @override +// bool operator ==(Object other) { +// if (other is! DocumentFile) return false; + +// return id == other.id && +// parentUri == other.parentUri && +// isDirectory == other.isDirectory && +// isFile == other.isFile && +// isVirtual == other.isVirtual && +// name == other.name && +// type == other.type && +// uri == other.uri; +// } + +// @override +// int get hashCode => +// Object.hash(isDirectory, isFile, isVirtual, name, type, uri); +// } diff --git a/lib/src/saf/models/document_file_column.dart b/lib/src/saf/models/document_file_column.dart index 2acbe20..2f94740 100644 --- a/lib/src/saf/models/document_file_column.dart +++ b/lib/src/saf/models/document_file_column.dart @@ -1,33 +1,33 @@ -/// Representation of the available columns of `DocumentsContract.Document.` -/// -/// [Refer to details](https://developer.android.com/reference/android/provider/DocumentsContract.Document) -enum DocumentFileColumn { - /// Equivalent to [`COLUMN_DOCUMENT_ID`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_DOCUMENT_ID) - id('COLUMN_DOCUMENT_ID'), - - /// Equivalent to [`COLUMN_DISPLAY_NAME`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_DISPLAY_NAME) - displayName('COLUMN_DISPLAY_NAME'), - - /// Equivalent to [`COLUMN_MIME_TYPE`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_MIME_TYPE) - mimeType('COLUMN_MIME_TYPE'), - - /// Equivalent to [`COLUMN_LAST_MODIFIED`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_LAST_MODIFIED) - lastModified('COLUMN_LAST_MODIFIED'), - - /// Equivalent to [`COLUMN_SIZE`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_SIZE) - size('COLUMN_SIZE'), - - /// Equivalent to [`COLUMN_SUMMARY`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_SUMMARY) - summary('COLUMN_SUMMARY'); - - const DocumentFileColumn(this.androidEnumItemName); - - static const String _kAndroidEnumTypeName = 'DocumentFileColumn'; - - final String androidEnumItemName; - - String get androidEnumItemId => '$_kAndroidEnumTypeName.$androidEnumItemName'; - - @override - String toString() => androidEnumItemId; -} +// /// Representation of the available columns of `DocumentsContract.Document.` +// /// +// /// [Refer to details](https://developer.android.com/reference/android/provider/DocumentsContract.Document) +// enum DocumentFileColumn { +// /// Equivalent to [`COLUMN_DOCUMENT_ID`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_DOCUMENT_ID) +// id('COLUMN_DOCUMENT_ID'), + +// /// Equivalent to [`COLUMN_DISPLAY_NAME`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_DISPLAY_NAME) +// displayName('COLUMN_DISPLAY_NAME'), + +// /// Equivalent to [`COLUMN_MIME_TYPE`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_MIME_TYPE) +// mimeType('COLUMN_MIME_TYPE'), + +// /// Equivalent to [`COLUMN_LAST_MODIFIED`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_LAST_MODIFIED) +// lastModified('COLUMN_LAST_MODIFIED'), + +// /// Equivalent to [`COLUMN_SIZE`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_SIZE) +// size('COLUMN_SIZE'), + +// /// Equivalent to [`COLUMN_SUMMARY`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_SUMMARY) +// summary('COLUMN_SUMMARY'); + +// const DocumentFileColumn(this.androidEnumItemName); + +// static const String _kAndroidEnumTypeName = 'DocumentFileColumn'; + +// final String androidEnumItemName; + +// String get androidEnumItemId => '$_kAndroidEnumTypeName.$androidEnumItemName'; + +// @override +// String toString() => androidEnumItemId; +// } diff --git a/pubspec.yaml b/pubspec.yaml index 075afe0..0708219 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,30 +1,32 @@ -name: shared_storage -description: "Flutter plugin to work with external storage and privacy-friendly APIs." -version: 0.9.0 -homepage: https://github.com/alexrintt/shared-storage -repository: https://github.com/alexrintt/shared-storage -issue_tracker: https://github.com/alexrintt/shared-storage/issues -documentation: https://github.com/alexrintt/shared-storage -funding: - - https://donate.alexrintt.io - - https://github.com/sponsors/alexrintt - -environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=2.5.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - lint: ^1.8.2 - -flutter: - plugin: - platforms: - android: - package: io.alexrintt.sharedstorage - pluginClass: SharedStoragePlugin +name: shared_storage +description: "Flutter plugin to work with external storage and privacy-friendly APIs." +version: 0.9.0 +homepage: https://github.com/alexrintt/shared-storage +repository: https://github.com/alexrintt/shared-storage +issue_tracker: https://github.com/alexrintt/shared-storage/issues +documentation: https://github.com/alexrintt/shared-storage +funding: + - https://donate.alexrintt.io + - https://github.com/sponsors/alexrintt + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + mime: ^1.0.0 + path: ^1.8.0 + +dev_dependencies: + flutter_test: + sdk: flutter + lint: ^1.8.2 + +flutter: + plugin: + platforms: + android: + package: io.alexrintt.sharedstorage + pluginClass: SharedStoragePlugin diff --git a/test/scoped_file_test.dart b/test/scoped_file_test.dart new file mode 100644 index 0000000..1b8e0fb --- /dev/null +++ b/test/scoped_file_test.dart @@ -0,0 +1,5 @@ +// import 'package:shared_storage/src/media_store/models/barrel.dart'; + +// void main() { +// final scopedFile = ScopedFile.fromUri(); +// } From 7574594d24a02b0ab4544d75c41dde4a54930c96 Mon Sep 17 00:00:00 2001 From: Alex Rintt <51419598+alexrintt@users.noreply.github.com> Date: Tue, 18 Jul 2023 01:53:12 -0300 Subject: [PATCH 18/18] Remove unused imports and APIs --- .../shared_storage_platform_interface.dart | 3 --- lib/src/saf/api/utility.dart | 11 ----------- 2 files changed, 14 deletions(-) diff --git a/lib/src/media_store/shared_storage_platform_interface.dart b/lib/src/media_store/shared_storage_platform_interface.dart index 6c034aa..1e0f7a2 100644 --- a/lib/src/media_store/shared_storage_platform_interface.dart +++ b/lib/src/media_store/shared_storage_platform_interface.dart @@ -1,9 +1,6 @@ import 'dart:io'; -import 'package:flutter/services.dart'; - import '../../shared_storage.dart'; -import '../channels.dart'; abstract class SharedStoragePlatformInterface { Future buildScopedFileFrom(File file); diff --git a/lib/src/saf/api/utility.dart b/lib/src/saf/api/utility.dart index 76ed14f..d3f5a12 100644 --- a/lib/src/saf/api/utility.dart +++ b/lib/src/saf/api/utility.dart @@ -1,12 +1 @@ -import '../common/barrel.dart'; -import '../models/barrel.dart'; -/// {@template sharedstorage.saf.fromTreeUri} -/// Create a new `DocumentFile` instance given `uri`. -/// -/// Equivalent to `DocumentFile.fromTreeUri`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29). -/// {@endtemplate} -// Future fromTreeUri(Uri uri) async => -// invokeMapMethod('fromTreeUri', {'uri': '$uri'});