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/13] 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/13] 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/13] 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/13] 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/13] [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/13] 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/13] 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/13] 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/13] [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/13] 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/13] 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/13] 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/13] 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