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/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 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). 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..3620e01 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,54 +1,54 @@ -group 'io.alexrintt.sharedstorage' -version '1.0-SNAPSHOT' - -buildscript { - ext.kotlin_version = '1.8.21' - ext.gradle_version = '7.4.2' - repositories { - google() - jcenter() - maven { url "https://oss.sonatype.org/content/repositories/snapshots" } - } - - dependencies { - classpath "com.android.tools.build:gradle:$gradle_version" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -rootProject.allprojects { - repositories { - google() - jcenter() - } -} - -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' - -android { - compileSdkVersion 30 - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - defaultConfig { - minSdkVersion 19 - } -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "androidx.documentfile:documentfile:1.0.1" - - /** - * Allow usage of `CoroutineScope` to run heavy - * computation and queries outside the Main (UI) Thread - */ - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1" - - /** - * `SimpleStorage` library - */ - implementation "com.anggrayudi:storage:1.3.0" -} +group 'io.alexrintt.sharedstorage' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.8.21' + ext.gradle_version = '7.4.2' + repositories { + google() + jcenter() + maven { url "https://oss.sonatype.org/content/repositories/snapshots" } + } + + dependencies { + classpath "com.android.tools.build:gradle:$gradle_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + jcenter() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 33 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + defaultConfig { + minSdkVersion 19 + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "androidx.documentfile:documentfile:1.0.1" + + /** + * Allow usage of `CoroutineScope` to run heavy + * computation and queries outside the Main (UI) Thread + */ + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1" + + /** + * `SimpleStorage` library + */ + implementation "com.anggrayudi:storage:1.3.0" +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/mediastore/MediaStoreApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/mediastore/MediaStoreApi.kt index ad2c2c7..3ed3db1 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/mediastore/MediaStoreApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/mediastore/MediaStoreApi.kt @@ -1,70 +1,248 @@ -package io.alexrintt.sharedstorage.mediastore - -import android.os.Build -import android.provider.MediaStore -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.alexrintt.sharedstorage.ROOT_CHANNEL -import io.alexrintt.sharedstorage.SharedStoragePlugin -import io.alexrintt.sharedstorage.plugin.API_29 -import io.alexrintt.sharedstorage.plugin.Listenable - -class MediaStoreApi(val plugin: SharedStoragePlugin) : MethodChannel.MethodCallHandler, Listenable { - private var channel: MethodChannel? = null - - companion object { - private const val GET_MEDIA_STORE_CONTENT_DIRECTORY = "getMediaStoreContentDirectory" - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - GET_MEDIA_STORE_CONTENT_DIRECTORY -> { - getMediaStoreContentDirectory( - result, - call.argument("collection") as String - ) - } - else -> result.notImplemented() - } - } - - private fun getMediaStoreContentDirectory( - result: MethodChannel.Result, - collection: String - ) = result.success(mediaStoreOf(collection)) - - /** - * Returns the [EXTERNAL_CONTENT_URI] of [MediaStore.] equivalent to [collection] - */ - private fun mediaStoreOf(collection: String): String? { - val mapper = mutableMapOf( - "MediaStoreCollection.Audio" to MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.path, - "MediaStoreCollection.Video" to MediaStore.Video.Media.EXTERNAL_CONTENT_URI.path, - "MediaStoreCollection.Images" to MediaStore.Images.Media.EXTERNAL_CONTENT_URI.path - ) - - if (Build.VERSION.SDK_INT >= API_29) { - mapper["MediaStoreCollection.Downloads"] = - MediaStore.Downloads.EXTERNAL_CONTENT_URI.path - } - - return mapper[collection] - } - - override fun startListening(binaryMessenger: BinaryMessenger) { - if (channel != null) { - stopListening() - } - - channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/mediastore") - channel?.setMethodCallHandler(this) - } - - override fun stopListening() { - if (channel == null) return - - channel?.setMethodCallHandler(null) - channel = null - } -} +package io.alexrintt.sharedstorage.mediastore + +import android.content.Context +import android.database.Cursor +import android.database.SQLException +import android.database.sqlite.SQLiteException +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns +import androidx.core.database.getStringOrNull +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.extension.isMediaDocument +import com.anggrayudi.storage.extension.isMediaFile +import com.anggrayudi.storage.extension.isTreeDocumentFile +import com.anggrayudi.storage.file.DocumentFileCompat +import com.anggrayudi.storage.file.id +import com.anggrayudi.storage.file.mimeType +import com.anggrayudi.storage.file.mimeTypeByFileName +import com.anggrayudi.storage.media.MediaStoreCompat +import io.alexrintt.sharedstorage.ROOT_CHANNEL +import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.deprecated.lib.getDocumentsContractColumns +import io.alexrintt.sharedstorage.plugin.Listenable +import io.flutter.Log +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + + +data class ScopedFileSystemEntity( + val id: String, + val mimeType: String?, + val length: Long, + val displayName: String, + val uri: Uri, + val parentUri: Uri?, + val lastModified: Long, + val entityType: String +) { + fun toMap(): Map { + return mapOf( + "id" to id, + "mimeType" to mimeType, + "length" to length, + "displayName" to displayName, + "uri" to uri.toString(), + "lastModified" to lastModified, + "parentUri" to parentUri?.toString(), + "entityType" to entityType, + ) + } +} + +fun getScopedFileSystemEntityFromMediaStoreUri( + context: Context, uri: Uri +): ScopedFileSystemEntity? { + val projection: MutableList = mutableListOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DOCUMENT_ID, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.SIZE, + MediaStore.MediaColumns.MIME_TYPE, + MediaStore.MediaColumns.DATE_MODIFIED, + // TODO: Add support for mime type specific files (e.g [ALBUM_ARTIST] when the file is a mp3) but all inside a [extra] map field to not pollute/modify the [ScopedFileSystemEntity] interface. + ) + + var cursor: Cursor? = try { + context.contentResolver.query( + uri, projection.toTypedArray(), null, null, null + ) + } catch (e: SQLiteException) { + // Some android 8.0 devices throw "DOCUMENT_ID is not a column" + projection.remove(MediaStore.MediaColumns.DOCUMENT_ID) + context.contentResolver.query( + uri, projection.toTypedArray(), null, null, null + ) + } + + if (cursor != null && cursor.moveToFirst()) { + val id: String? + val idColumn = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + id = cursor.getStringOrNull(idColumn) + + val documentId: String? + val documentIdColumn = + cursor.getColumnIndex(MediaStore.MediaColumns.DOCUMENT_ID) + documentId = cursor.getStringOrNull(documentIdColumn) + + val dateModified: Long + val dateModifiedColumn = + cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED) + dateModified = cursor.getLong(dateModifiedColumn) + + val displayName: String + val displayNameColumn = + cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) + displayName = cursor.getString(displayNameColumn) + + val mimeType: String + val mimeTypeColumn = + cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) + mimeType = cursor.getString(mimeTypeColumn) + + val size: Long + val sizeColumn = cursor.getColumnIndex(MediaStore.MediaColumns.SIZE) + size = cursor.getLong(sizeColumn) + + cursor.close() + + return ScopedFileSystemEntity( + id = documentId ?: id ?: uri.toString(), + mimeType = mimeType, + length = size, + displayName = displayName, + uri = uri, + parentUri = null, + lastModified = dateModified, + entityType = "file" + ) + } + + return null +} + +fun getOpenableUriNameAndLength( + context: Context, uri: Uri +): Pair { + context.contentResolver.query(uri, null, null, null, null)?.use { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + it.moveToFirst() + return Pair(it.getString(nameIndex), it.getLong(sizeIndex)) + } + + return Pair(null, null) +} + +fun getScopedFileSystemEntityFromSafUri( + context: Context, uri: Uri +): ScopedFileSystemEntity? { + val documentTree = DocumentFileCompat.fromUri(context, uri) ?: return null + + return ScopedFileSystemEntity( + id = documentTree.id, + displayName = documentTree.name ?: Uri.decode(uri.lastPathSegment), + uri = uri, + length = documentTree.length(), + parentUri = documentTree.parentFile?.uri, + mimeType = documentTree.mimeType ?: documentTree.mimeTypeByFileName, + lastModified = documentTree.lastModified(), + entityType = if (documentTree.isDirectory) "directory" else "file" + ) +} + +fun getScopedFileSystemEntityFromUri( + context: Context, uri: Uri +): ScopedFileSystemEntity? { + Log.d("getScopedFileSystemEntityFromUri1", uri.isMediaFile.toString()) + Log.d("getScopedFileSystemEntityFromUri1", uri.authority.toString()) + + // Some devices do return "0@media" as URI authority when "sharing with" + // so we need try to parse this URI using everything we can and know because scoped storage (SAF cof cof) is just about it: + // parse unknown URIs using unknown columns by unknown providers with unknown or behavior, accept it. + return getScopedFileSystemEntityFromUriUsingPredeterminedConstantConditionStrategy( + context, + uri + ) ?: getScopedFileSystemEntityFromUriUsingTryCatchStrategy(context, uri) +} + +fun getScopedFileSystemEntityFromUriUsingPredeterminedConstantConditionStrategy( + context: Context, + uri: Uri +): ScopedFileSystemEntity? { + try { + return when { + uri.isMediaFile || uri.isMediaDocument -> getScopedFileSystemEntityFromMediaStoreUri( + context, + uri + ) + + else -> getScopedFileSystemEntityFromSafUri(context, uri) + } + } catch (e: Throwable) { + Log.d( + "URI PARSE FAILED", + "[getScopedFileSystemEntityFromUriUsingPredeterminedConstantConditionStrategy] failed to parse URI $uri. Error: $e" + ) + return null + } +} + +fun getScopedFileSystemEntityFromUriUsingTryCatchStrategy( + context: Context, + uri: Uri +): ScopedFileSystemEntity? { + return try { + getScopedFileSystemEntityFromMediaStoreUri(context, uri) + } catch (e: Throwable) { + getScopedFileSystemEntityFromSafUri(context, uri) + } +} + + +class MediaStoreApi(val plugin: SharedStoragePlugin) : + MethodChannel.MethodCallHandler, Listenable { + private var channel: MethodChannel? = null + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getScopedFileSystemEntityFromUri" -> { + val uri = Uri.parse(call.argument("uri")!!) + + val scopedFile = getScopedFileSystemEntityFromUri( + plugin.context.applicationContext, uri + ) + + if (scopedFile == null) { + result.error( + "NOT_FOUND", + "The URI $uri was not found: did not return any results", + mapOf("uri" to uri.toString()) + ) + } else { + result.success(scopedFile.toMap()) + } + } + + else -> result.notImplemented() + } + } + + override fun startListening(binaryMessenger: BinaryMessenger) { + if (channel != null) { + stopListening() + } + + channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/mediastore") + channel?.setMethodCallHandler(this) + } + + override fun stopListening() { + if (channel == null) return + + channel?.setMethodCallHandler(null) + channel = null + } +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt index eedfab9..a3e6dfd 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt @@ -1,661 +1,779 @@ -package io.alexrintt.sharedstorage.storageaccessframework - -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.provider.DocumentsContract -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.extension.isTreeDocumentFile -import com.anggrayudi.storage.file.child -import io.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.plugin.* -import io.alexrintt.sharedstorage.storageaccessframework.lib.* -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 - -/** - * Aimed to implement strictly only the APIs already available from the native and original - * `DocumentFile` API - * - * Basically, this is just an adapter of the native `DocumentFile` class to a Flutter Plugin class, - * without any modifications or abstractions - */ -internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : - MethodChannel.MethodCallHandler, PluginRegistry.ActivityResultListener, - Listenable, ActivityListener, StreamHandler { - private val pendingResults: MutableMap> = - mutableMapOf() - private var channel: MethodChannel? = null - private var eventChannel: EventChannel? = null - private var eventSink: EventChannel.EventSink? = null - - companion object { - private const val CHANNEL = "documentfile" - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - GET_DOCUMENT_CONTENT -> { - val uri = Uri.parse(call.argument("uri")!!) - - if (Build.VERSION.SDK_INT >= API_21) { - CoroutineScope(Dispatchers.IO).launch { - val content = readDocumentContent(uri) - - launch(Dispatchers.Main) { result.success(content) } - } - } else { - 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, - call.argument("mimeType")!!, - call.argument("displayName")!!, - call.argument("directoryUri")!!, - call.argument("content")!! - ) - } - WRITE_TO_FILE -> writeToFile( - result, - call.argument("uri")!!, - call.argument("content")!!, - call.argument("mode")!! - ) - PERSISTED_URI_PERMISSIONS -> persistedUriPermissions(result) - RELEASE_PERSISTABLE_URI_PERMISSION -> releasePersistableUriPermission( - result, call.argument("uri") as String - ) - FROM_TREE_URI -> if (Build.VERSION.SDK_INT >= API_21) { - result.success( - createDocumentFileMap( - documentFromUri( - plugin.context, call.argument("uri") as String - ) - ) - ) - } - CAN_WRITE -> if (Build.VERSION.SDK_INT >= API_21) { - result.success( - documentFromUri( - plugin.context, call.argument("uri") as String - )?.canWrite() - ) - } - CAN_READ -> if (Build.VERSION.SDK_INT >= API_21) { - val uri = call.argument("uri") as String - - result.success(documentFromUri(plugin.context, uri)?.canRead()) - } - LENGTH -> if (Build.VERSION.SDK_INT >= API_21) { - result.success( - documentFromUri( - plugin.context, call.argument("uri") as String - )?.length() - ) - } - EXISTS -> if (Build.VERSION.SDK_INT >= API_21) { - result.success( - documentFromUri( - plugin.context, call.argument("uri") as String - )?.exists() - ) - } - DELETE -> if (Build.VERSION.SDK_INT >= API_21) { - result.success( - documentFromUri( - plugin.context, call.argument("uri") as String - )?.delete() - ) - } - LAST_MODIFIED -> if (Build.VERSION.SDK_INT >= API_21) { - val document = documentFromUri( - plugin.context, call.argument("uri") as String - ) - - result.success(document?.lastModified()) - } - CREATE_DIRECTORY -> { - if (Build.VERSION.SDK_INT >= API_21) { - val uri = call.argument("uri") as String - val displayName = call.argument("displayName") as String - - val createdDirectory = - documentFromUri(plugin.context, uri)?.createDirectory(displayName) - ?: return - - result.success(createDocumentFileMap(createdDirectory)) - } else { - result.notSupported(call.method, API_21) - } - } - FIND_FILE -> { - if (Build.VERSION.SDK_INT >= API_21) { - val uri = call.argument("uri") as String - val displayName = call.argument("displayName") as String - - result.success( - createDocumentFileMap( - documentFromUri( - plugin.context, uri - )?.findFile(displayName) - ) - ) - } - } - COPY -> { - val uri = Uri.parse(call.argument("uri")!!) - val destination = Uri.parse(call.argument("destination")!!) - - if (Build.VERSION.SDK_INT >= API_21) { - if (Build.VERSION.SDK_INT >= API_24) { - DocumentsContract.copyDocument( - plugin.context.contentResolver, uri, destination - ) - } else { - val inputStream = openInputStream(uri) - val outputStream = openOutputStream(destination) - - outputStream?.let { inputStream?.copyTo(it) } - } - } else { - result.notSupported( - RENAME_TO, - API_21, - mapOf("uri" to "$uri", "destination" to "$destination") - ) - } - } - RENAME_TO -> { - val uri = call.argument("uri") as String - val displayName = call.argument("displayName") as String - - if (Build.VERSION.SDK_INT >= API_21) { - documentFromUri(plugin.context, uri)?.apply { - val success = renameTo(displayName) - - result.success( - if (success) createDocumentFileMap( - documentFromUri( - plugin.context, this.uri - )!! - ) - else null - ) - } - } else { - result.notSupported( - RENAME_TO, API_21, mapOf("uri" to uri, "displayName" to displayName) - ) - } - } - PARENT_FILE -> { - val uri = call.argument("uri")!! - - if (Build.VERSION.SDK_INT >= API_21) { - val parent = documentFromUri(plugin.context, uri)?.parentFile - - result.success(if (parent != null) createDocumentFileMap(parent) else null) - } else { - result.notSupported(PARENT_FILE, API_21, mapOf("uri" to uri)) - } - } - CHILD -> { - val uri = call.argument("uri")!! - val path = call.argument("path")!! - val requiresWriteAccess = - call.argument("requiresWriteAccess") ?: false - - if (Build.VERSION.SDK_INT >= API_21) { - val document = documentFromUri(plugin.context, uri) - val childDocument = - document?.child(plugin.context, path, requiresWriteAccess) - - result.success(createDocumentFileMap(childDocument)) - } else { - result.notSupported(CHILD, API_21, mapOf("uri" to uri)) - } - } - else -> result.notImplemented() - } - } - - @RequiresApi(API_21) - private fun openDocument(call: MethodCall, result: MethodChannel.Result) { - val initialUri = call.argument("initialUri") - val grantWritePermission = call.argument("grantWritePermission")!! - val persistablePermission = - call.argument("persistablePermission")!! - - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - if (persistablePermission) { - addFlags( - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - ) - } - - if (grantWritePermission) { - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - } - - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - - if (initialUri != null) { - val tree = - DocumentFile.fromTreeUri(plugin.context, Uri.parse(initialUri)) - if (Build.VERSION.SDK_INT >= API_26) { - putExtra(DocumentsContract.EXTRA_INITIAL_URI, tree?.uri) - } - } - - type = call.argument("mimeType") ?: "*/*" - putExtra( - Intent.EXTRA_ALLOW_MULTIPLE, call.argument("multiple") ?: false - ) - } - - if (pendingResults[OPEN_DOCUMENT_CODE] != null) return - - pendingResults[OPEN_DOCUMENT_CODE] = Pair(call, result) - - plugin.binding?.activity?.startActivityForResult(intent, OPEN_DOCUMENT_CODE) - } - - @RequiresApi(API_21) - private fun openDocumentTree(call: MethodCall, result: MethodChannel.Result) { - val initialUri = call.argument("initialUri") - val grantWritePermission = call.argument("grantWritePermission")!! - val persistablePermission = - call.argument("persistablePermission")!! - - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { - if (persistablePermission) { - addFlags( - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - ) - } - - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - - if (grantWritePermission) { - addFlags( - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } - - if (initialUri != null) { - val tree = - DocumentFile.fromTreeUri(plugin.context, Uri.parse(initialUri)) - - if (Build.VERSION.SDK_INT >= API_26) { - putExtra( - if (Build.VERSION.SDK_INT >= API_26) DocumentsContract.EXTRA_INITIAL_URI - else DOCUMENTS_CONTRACT_EXTRA_INITIAL_URI, tree?.uri - ) - } - } - } - - if (pendingResults[OPEN_DOCUMENT_TREE_CODE] != null) return - - pendingResults[OPEN_DOCUMENT_TREE_CODE] = Pair(call, result) - - plugin.binding?.activity?.startActivityForResult( - intent, OPEN_DOCUMENT_TREE_CODE - ) - } - - @RequiresApi(API_21) - private fun createFile( - result: MethodChannel.Result, - mimeType: String, - displayName: String, - directory: String, - content: ByteArray - ) { - createFile(Uri.parse(directory), mimeType, displayName, content) { - result.success(createDocumentFileMap(this)) - } - } - - @RequiresApi(Build.VERSION_CODES.LOLLIPOP) - private fun createFile( - treeUri: Uri, - mimeType: String, - displayName: String, - content: ByteArray, - block: DocumentFile?.() -> Unit - ) { - val createdFile = documentFromUri(plugin.context, treeUri)!!.createFile( - mimeType, displayName - ) - - createdFile?.uri?.apply { - plugin.context.contentResolver.openOutputStream(this)?.apply { - write(content) - flush() - close() - - val createdFileDocument = - documentFromUri(plugin.context, createdFile.uri) - - block(createdFileDocument) - } - } - } - - private fun writeToFile( - result: MethodChannel.Result, uri: String, content: ByteArray, mode: String - ) { - try { - plugin.context.contentResolver.openOutputStream(Uri.parse(uri), mode) - ?.apply { - write(content) - flush() - close() - - result.success(true) - } - } catch (e: Exception) { - result.success(false) - } - } - - @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()) - } - - @RequiresApi(API_19) - private fun releasePersistableUriPermission( - result: MethodChannel.Result, directoryUri: String - ) { - val targetUri = Uri.parse(directoryUri) - - val persistedUriPermissions = - plugin.context.contentResolver.persistedUriPermissions - - for (persistedUriPermission in persistedUriPermissions) { - if (persistedUriPermission.uri == targetUri) { - var flags = 0 - - if (persistedUriPermission.isReadPermission) { - flags = flags or Intent.FLAG_GRANT_READ_URI_PERMISSION - } - - if (persistedUriPermission.isWritePermission) { - flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - } - - plugin.context.contentResolver.releasePersistableUriPermission( - targetUri, - flags - ) - } - } - - result.success(null) - } - - @RequiresApi(API_19) - override fun onActivityResult( - requestCode: Int, resultCode: Int, resultIntent: Intent? - ): Boolean { - when (requestCode) { - OPEN_DOCUMENT_TREE_CODE -> { - val pendingResult = - pendingResults[OPEN_DOCUMENT_TREE_CODE] ?: return false - - val grantWritePermission = - pendingResult.first.argument("grantWritePermission")!! - val persistablePermission = - pendingResult.first.argument("persistablePermission")!! - - try { - val uri = resultIntent?.data - - if (uri != null) { - if (persistablePermission) { - if (grantWritePermission) { - plugin.context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } else { - plugin.context.contentResolver.takePersistableUriPermission( - uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } - } - - pendingResult.second.success("$uri") - - return true - } - - pendingResult.second.success(null) - } finally { - pendingResults.remove(OPEN_DOCUMENT_TREE_CODE) - } - } - OPEN_DOCUMENT_CODE -> { - val pendingResult = pendingResults[OPEN_DOCUMENT_CODE] ?: return false - - val grantWritePermission = - pendingResult.first.argument("grantWritePermission")!! - val persistablePermission = - pendingResult.first.argument("persistablePermission")!! - - try { - // if data.clipData not null, uriList from data.clipData, else uriList is data.data - val uriList = resultIntent?.clipData?.let { - (0 until it.itemCount).map { i -> it.getItemAt(i).uri } - } ?: resultIntent?.data?.let { listOf(it) } - - // After some experiments, I noticed that you need grant both (read and write permission) - // otherwise, when rebooting the read permission will fail). - fun persistUriListWithWritePermissionAndReadPermission() { - for (uri in uriList!!) { - plugin.context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - } - - fun persistUriListWithReadPermissionOnly() { - for (uri in uriList!!) { - plugin.context.contentResolver.takePersistableUriPermission( - uri, Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - } - - fun persistUriList() { - if (grantWritePermission) { - persistUriListWithWritePermissionAndReadPermission() - } else { - persistUriListWithReadPermissionOnly() - } - } - - if (uriList != null) { - if (persistablePermission) { - persistUriList() - } - - pendingResult.second.success(uriList.map { "$it" }) - - return true - } - - pendingResult.second.success(null) - } finally { - pendingResults.remove(OPEN_DOCUMENT_CODE) - } - } - } - - return false - } - - override fun startListening(binaryMessenger: BinaryMessenger) { - if (channel != null) stopListening() - - channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") - channel?.setMethodCallHandler(this) - - eventChannel = EventChannel(binaryMessenger, "$ROOT_CHANNEL/event/$CHANNEL") - eventChannel?.setStreamHandler(this) - } - - override fun stopListening() { - if (channel == null) return - - channel?.setMethodCallHandler(null) - channel = null - - eventChannel?.setStreamHandler(null) - eventChannel = null - } - - override fun startListeningToActivity() { - plugin.binding?.addActivityResultListener(this) - } - - override fun stopListeningToActivity() { - plugin.binding?.removeActivityResultListener(this) - } - - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - val args = arguments as Map<*, *> - - eventSink = events - - when (args["event"]) { - LIST_FILES -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - listFilesEvent(eventSink, args) - } - } - } - - /** - * Read files of a given `uri` and dispatches all files under it through the `eventSink` and - * closes the stream after the last record - * - * Useful to read files under a `uri` with a large set of children - */ - private fun listFilesEvent( - eventSink: EventChannel.EventSink?, args: Map<*, *> - ) { - if (eventSink == null) return - - val columns = args["columns"] as List<*> - val uri = Uri.parse(args["uri"] as String) - val document = DocumentFile.fromTreeUri(plugin.context, uri) - - if (document == null) { - eventSink.error( - EXCEPTION_NOT_SUPPORTED, - "Android SDK must be greater or equal than [Build.VERSION_CODES.N]", - "Got (Build.VERSION.SDK_INT): ${Build.VERSION.SDK_INT}" - ) - } else { - if (!document.canRead()) { - val error = "You cannot read a URI that you don't have read permissions" - - Log.d("NO PERMISSION!!!", error) - - eventSink.error( - EXCEPTION_MISSING_PERMISSIONS, error, mapOf("uri" to args["uri"]) - ) - - eventSink.endOfStream() - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - CoroutineScope(Dispatchers.IO).launch { - try { - traverseDirectoryEntries( - plugin.context.contentResolver, - rootOnly = true, - targetUri = document.uri, - columns = columns.map { - parseDocumentFileColumn(parseDocumentFileColumn(it as String)!!) - }.toTypedArray() - ) { data, _ -> - launch(Dispatchers.Main) { - eventSink.success( - data - ) - } - } - } finally { - launch(Dispatchers.Main) { eventSink.endOfStream() } - } - } - } - } - } - } - - /** Alias for `plugin.context.contentResolver.openOutputStream(uri)` */ - private fun openOutputStream(uri: Uri): OutputStream? { - return plugin.context.contentResolver.openOutputStream(uri) - } - - /** Alias for `plugin.context.contentResolver.openInputStream(uri)` */ - private fun openInputStream(uri: Uri): InputStream? { - return plugin.context.contentResolver.openInputStream(uri) - } - - /** Get a document content as `ByteArray` equivalent to `Uint8List` in Dart */ - @RequiresApi(API_21) - private fun readDocumentContent(uri: Uri): ByteArray? { - return try { - val inputStream = openInputStream(uri) - - val bytes = inputStream?.readBytes() - - inputStream?.close() - - bytes - } catch (e: FileNotFoundException) { - null - } catch (e: IOException) { - null - } - } - - override fun onCancel(arguments: Any?) { - eventSink = null - } -} +package io.alexrintt.sharedstorage.storageaccessframework + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.extension.isTreeDocumentFile +import com.anggrayudi.storage.file.child +import io.alexrintt.sharedstorage.ROOT_CHANNEL +import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.deprecated.lib.* +import io.alexrintt.sharedstorage.plugin.* +import io.alexrintt.sharedstorage.storageaccessframework.* +import io.alexrintt.sharedstorage.storageaccessframework.lib.* +import io.flutter.plugin.common.* +import io.flutter.plugin.common.EventChannel.StreamHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.* + + +/** + * Aimed to implement strictly only the APIs already available from the native and original + * `DocumentFile` API + * + * Basically, this is just an adapter of the native `DocumentFile` class to a Flutter Plugin class, + * without any modifications or abstractions + */ +internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : + MethodChannel.MethodCallHandler, PluginRegistry.ActivityResultListener, + Listenable, ActivityListener, StreamHandler { + private val pendingResults: MutableMap> = + mutableMapOf() + private var channel: MethodChannel? = null + private var eventChannel: EventChannel? = null + private var eventSink: EventChannel.EventSink? = null + + private val openInputStreams = mutableMapOf() + + companion object { + private const val CHANNEL = "documentfile" + } + + private fun readBytes( + inputStream: InputStream, + offset: Int, + bufferSize: Int = 1024, + ): Pair { + var buffer = ByteArray(bufferSize) + val readBufferSize = inputStream.read(buffer, offset, bufferSize) + + if (readBufferSize == -1) { + buffer = ByteArray(0) + } else if (readBufferSize < bufferSize) { + buffer = buffer.copyOfRange(0, readBufferSize) + } + + return Pair(buffer, readBufferSize) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + OPEN_INPUT_STREAM -> { + val uri = Uri.parse(call.argument("uri")!!) + val callId = call.argument("callId")!! + + if (openInputStreams[callId] != null) { + // There is already a open input stream for this URI + result.success(null) + } else { + val (inputStream, exception) = openInputStream(uri) + if (exception != null) { + result.error( + exception.code, exception.details, mapOf("uri" to uri.toString()) + ) + } else if (inputStream != null) { + openInputStreams[callId] = inputStream + result.success(null) + } else { + result.error( + "INTERNAL_ERROR", + "[openInputStream] doesn't returned errors neither success", + mapOf("uri" to uri.toString(), "callId" to callId) + ) + } + } + } + + CLOSE_INPUT_STREAM -> { + val callId = call.argument("callId")!! + openInputStreams[callId]?.close() + openInputStreams.remove(callId) + result.success(null) + } + + READ_INPUT_STREAM -> { + val callId = call.argument("callId")!! + val offset = call.argument("offset")!! + val bufferSize = call.argument("bufferSize")!! + val inputStream = openInputStreams[callId] + + if (inputStream == null) { + result.error( + "NOT_FOUND", + "Input stream was not found opened, please, call [openInputStream] method before", + mapOf("callId" to callId) + ) + } else { + CoroutineScope(Dispatchers.IO).launch { + try { + val (bytes, readBufferSize) = readBytes( + inputStream, offset, bufferSize + ) + launch(Dispatchers.Main) { + result.success( + mapOf("bytes" to bytes, "readBufferSize" to readBufferSize) + ) + } + } catch (e: IOException) { + // Concurrent reads or too fast reads (opening, closing, opening, closing, opening, ...) + } + } + } + } + + OPEN_DOCUMENT -> if (Build.VERSION.SDK_INT >= API_21) { + openDocument(call, result) + } + + OPEN_DOCUMENT_TREE -> if (Build.VERSION.SDK_INT >= API_21) { + openDocumentTree(call, result) + } + + CREATE_FILE -> if (Build.VERSION.SDK_INT >= API_21) { + createFile( + result, + call.argument("mimeType")!!, + call.argument("displayName")!!, + call.argument("directoryUri")!!, + call.argument("content")!! + ) + } + + WRITE_TO_FILE -> writeToFile( + result, + call.argument("uri")!!, + call.argument("content")!!, + call.argument("mode")!! + ) + + PERSISTED_URI_PERMISSIONS -> persistedUriPermissions(result) + + RELEASE_PERSISTABLE_URI_PERMISSION -> releasePersistableUriPermission( + result, call.argument("uri") as String + ) + + FROM_TREE_URI -> if (Build.VERSION.SDK_INT >= API_21) { + result.success( + createDocumentFileMap( + documentFromUri( + plugin.context, call.argument("uri") as String + ) + ) + ) + } + + CAN_WRITE -> if (Build.VERSION.SDK_INT >= API_21) { + result.success( + documentFromUri( + plugin.context, call.argument("uri") as String + )?.canWrite() + ) + } + + CAN_READ -> if (Build.VERSION.SDK_INT >= API_21) { + val uri = call.argument("uri") as String + + result.success(documentFromUri(plugin.context, uri)?.canRead()) + } + + LENGTH -> if (Build.VERSION.SDK_INT >= API_21) { + result.success( + documentFromUri( + plugin.context, call.argument("uri") as String + )?.length() + ) + } + + EXISTS -> if (Build.VERSION.SDK_INT >= API_21) { + result.success( + documentFromUri( + plugin.context, call.argument("uri") as String + )?.exists() + ) + } + + DELETE -> if (Build.VERSION.SDK_INT >= API_21) { + try { + result.success( + documentFromUri( + plugin.context, call.argument("uri") as String + )?.delete() + ) + } catch (e: FileNotFoundException) { + // File is already deleted. + result.success(null) + } catch (e: IllegalStateException) { + // File is already deleted. + result.success(null) + } catch (e: IllegalArgumentException) { + // File is already deleted. + result.success(null) + } catch (e: IOException) { + // Unknown, can be anything. + result.success(null) + } catch (e: Throwable) { + Log.d( + "sharedstorage", + "Unknown error when calling [delete] method with [uri]." + ) + // Unknown, can be anything. + result.success(null) + } + } + + LAST_MODIFIED -> if (Build.VERSION.SDK_INT >= API_21) { + val document = documentFromUri( + plugin.context, call.argument("uri") as String + ) + + result.success(document?.lastModified()) + } + + CREATE_DIRECTORY -> { + if (Build.VERSION.SDK_INT >= API_21) { + val uri = call.argument("uri") as String + val displayName = call.argument("displayName") as String + + val createdDirectory = + documentFromUri(plugin.context, uri)?.createDirectory(displayName) + ?: return + + result.success(createDocumentFileMap(createdDirectory)) + } else { + result.notSupported(call.method, API_21) + } + } + + FIND_FILE -> { + if (Build.VERSION.SDK_INT >= API_21) { + val uri = call.argument("uri") as String + val displayName = call.argument("displayName") as String + + result.success( + createDocumentFileMap( + documentFromUri( + plugin.context, uri + )?.findFile(displayName) + ) + ) + } + } + + COPY -> { + val uri = Uri.parse(call.argument("uri")!!) + val destination = Uri.parse(call.argument("destination")!!) + + if (Build.VERSION.SDK_INT >= API_21) { + CoroutineScope(Dispatchers.IO).launch { + withContext(Dispatchers.IO) { + val (inputStream) = openInputStream(uri) + val outputStream = openOutputStream(destination) + + // TODO: Implement progress indicator by re-writing the [copyTo] impl with an optional callback fn. + outputStream?.let { inputStream?.copyTo(it) } + + inputStream?.close() + outputStream?.close() + } + + launch(Dispatchers.Main) { + result.success(null) + } + } + } else { + result.notSupported( + RENAME_TO, + API_21, + mapOf("uri" to "$uri", "destination" to "$destination") + ) + } + } + + RENAME_TO -> { + val uri = call.argument("uri") as String + val displayName = call.argument("displayName") as String + + if (Build.VERSION.SDK_INT >= API_21) { + documentFromUri(plugin.context, uri)?.apply { + val success = renameTo(displayName) + + result.success( + if (success) createDocumentFileMap( + documentFromUri( + plugin.context, this.uri + )!! + ) + else null + ) + } + } else { + result.notSupported( + RENAME_TO, API_21, mapOf("uri" to uri, "displayName" to displayName) + ) + } + } + + PARENT_FILE -> { + val uri = call.argument("uri")!! + + if (Build.VERSION.SDK_INT >= API_21) { + val parent = documentFromUri(plugin.context, uri)?.parentFile + + result.success(if (parent != null) createDocumentFileMap(parent) else null) + } else { + result.notSupported(PARENT_FILE, API_21, mapOf("uri" to uri)) + } + } + + CHILD -> { + val uri = call.argument("uri")!! + val path = call.argument("path")!! + val requiresWriteAccess = + call.argument("requiresWriteAccess") ?: false + + if (Build.VERSION.SDK_INT >= API_21) { + val document = documentFromUri(plugin.context, uri) + val childDocument = + document?.child(plugin.context, path, requiresWriteAccess) + + result.success(createDocumentFileMap(childDocument)) + } else { + result.notSupported(CHILD, API_21, mapOf("uri" to uri)) + } + } + + else -> result.notImplemented() + } + } + + @RequiresApi(API_21) + private fun openDocument(call: MethodCall, result: MethodChannel.Result) { + val initialUri = call.argument("initialUri") + val grantWritePermission = call.argument("grantWritePermission")!! + val persistablePermission = + call.argument("persistablePermission")!! + + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + if (persistablePermission) { + addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + } + + if (grantWritePermission) { + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + if (initialUri != null) { + val tree = + DocumentFile.fromTreeUri(plugin.context, Uri.parse(initialUri)) + if (Build.VERSION.SDK_INT >= API_26) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, tree?.uri) + } + } + + type = call.argument("mimeType") ?: "*/*" + putExtra( + Intent.EXTRA_ALLOW_MULTIPLE, call.argument("multiple") ?: false + ) + } + + if (pendingResults[OPEN_DOCUMENT_CODE] != null) return + + pendingResults[OPEN_DOCUMENT_CODE] = Pair(call, result) + + plugin.binding?.activity?.startActivityForResult(intent, OPEN_DOCUMENT_CODE) + } + + @RequiresApi(API_21) + private fun openDocumentTree(call: MethodCall, result: MethodChannel.Result) { + val initialUri = call.argument("initialUri") + val grantWritePermission = call.argument("grantWritePermission")!! + val persistablePermission = + call.argument("persistablePermission")!! + + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + if (persistablePermission) { + addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + } + + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + if (grantWritePermission) { + addFlags( + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + + if (initialUri != null) { + val tree = + DocumentFile.fromTreeUri(plugin.context, Uri.parse(initialUri)) + + if (Build.VERSION.SDK_INT >= API_26) { + putExtra( + if (Build.VERSION.SDK_INT >= API_26) DocumentsContract.EXTRA_INITIAL_URI + else DOCUMENTS_CONTRACT_EXTRA_INITIAL_URI, tree?.uri + ) + } + } + } + + if (pendingResults[OPEN_DOCUMENT_TREE_CODE] != null) return + + pendingResults[OPEN_DOCUMENT_TREE_CODE] = Pair(call, result) + + plugin.binding?.activity?.startActivityForResult( + intent, OPEN_DOCUMENT_TREE_CODE + ) + } + + @RequiresApi(API_21) + private fun createFile( + result: MethodChannel.Result, + mimeType: String, + displayName: String, + directory: String, + content: ByteArray + ) { + createFile(Uri.parse(directory), mimeType, displayName, content) { + result.success(createDocumentFileMap(this)) + } + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private fun createFile( + treeUri: Uri, + mimeType: String, + displayName: String, + content: ByteArray, + block: DocumentFile?.() -> Unit + ) { + CoroutineScope(Dispatchers.IO).launch { + val createdFile = documentFromUri(plugin.context, treeUri)!!.createFile( + mimeType, displayName + ) + + createdFile?.uri?.apply { + kotlin.runCatching { + plugin.context.contentResolver.openOutputStream(this)?.use { + it.write(content) + it.flush() + + val createdFileDocument = + documentFromUri(plugin.context, createdFile.uri) + + launch(Dispatchers.Main) { + block(createdFileDocument) + } + } + } + } + } + } + + private fun writeToFile( + result: MethodChannel.Result, uri: String, content: ByteArray, mode: String + ) { + try { + plugin.context.contentResolver.openOutputStream(Uri.parse(uri), mode) + ?.apply { + write(content) + flush() + close() + + result.success(true) + } + } catch (e: Exception) { + result.success(false) + } + } + + private fun persistedUriPermissions(result: MethodChannel.Result) { + val persistedUriPermissions = + plugin.context.contentResolver.persistedUriPermissions + + result.success(persistedUriPermissions.map { + mapOf( + "isReadPermission" to it.isReadPermission, + "isWritePermission" to it.isWritePermission, + "persistedTime" to it.persistedTime, + "uri" to "${it.uri}", + "isTreeDocumentFile" to it.uri.isTreeDocumentFile + ) + }.toList()) + } + + private fun releasePersistableUriPermission( + result: MethodChannel.Result, directoryUri: String + ) { + val targetUri = Uri.parse(directoryUri) + + val persistedUriPermissions = + plugin.context.contentResolver.persistedUriPermissions + + for (persistedUriPermission in persistedUriPermissions) { + if (persistedUriPermission.uri == targetUri) { + var flags = 0 + + if (persistedUriPermission.isReadPermission) { + flags = flags or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + + if (persistedUriPermission.isWritePermission) { + flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + } + + plugin.context.contentResolver.releasePersistableUriPermission( + targetUri, flags + ) + } + } + + result.success(null) + } + + override fun onActivityResult( + requestCode: Int, resultCode: Int, resultIntent: Intent? + ): Boolean { + when (requestCode) { + OPEN_DOCUMENT_TREE_CODE -> { + val pendingResult = + pendingResults[OPEN_DOCUMENT_TREE_CODE] ?: return false + + val grantWritePermission = + pendingResult.first.argument("grantWritePermission")!! + val persistablePermission = + pendingResult.first.argument("persistablePermission")!! + + try { + val uri = resultIntent?.data + + if (uri != null) { + if (persistablePermission) { + if (grantWritePermission) { + plugin.context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } else { + plugin.context.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + } + + pendingResult.second.success("$uri") + + return true + } + + pendingResult.second.success(null) + } finally { + pendingResults.remove(OPEN_DOCUMENT_TREE_CODE) + } + } + + OPEN_DOCUMENT_CODE -> { + val pendingResult = pendingResults[OPEN_DOCUMENT_CODE] ?: return false + + val grantWritePermission = + pendingResult.first.argument("grantWritePermission")!! + val persistablePermission = + pendingResult.first.argument("persistablePermission")!! + + try { + // if data.clipData not null, uriList from data.clipData, else uriList is data.data + val uriList = resultIntent?.clipData?.let { + (0 until it.itemCount).map { i -> it.getItemAt(i).uri } + } ?: resultIntent?.data?.let { listOf(it) } + + // After some experiments, I noticed that you need grant both (read and write permission) + // otherwise, when rebooting the read permission will fail). + fun persistUriListWithWritePermissionAndReadPermission() { + for (uri in uriList!!) { + plugin.context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + + fun persistUriListWithReadPermissionOnly() { + for (uri in uriList!!) { + plugin.context.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + + fun persistUriList() { + if (grantWritePermission) { + persistUriListWithWritePermissionAndReadPermission() + } else { + persistUriListWithReadPermissionOnly() + } + } + + if (uriList != null) { + if (persistablePermission) { + persistUriList() + } + + pendingResult.second.success(uriList.map { "$it" }) + + return true + } + + pendingResult.second.success(null) + } finally { + pendingResults.remove(OPEN_DOCUMENT_CODE) + } + } + } + + return false + } + + override fun startListening(binaryMessenger: BinaryMessenger) { + if (channel != null) stopListening() + + channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") + channel?.setMethodCallHandler(this) + + eventChannel = EventChannel(binaryMessenger, "$ROOT_CHANNEL/event/$CHANNEL") + eventChannel?.setStreamHandler(this) + } + + override fun stopListening() { + if (channel == null) return + + channel?.setMethodCallHandler(null) + channel = null + + eventChannel?.setStreamHandler(null) + eventChannel = null + } + + override fun startListeningToActivity() { + plugin.binding?.addActivityResultListener(this) + } + + override fun stopListeningToActivity() { + plugin.binding?.removeActivityResultListener(this) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + val args = arguments as Map<*, *> + + eventSink = events + + when (args["event"]) { + LIST_FILES -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + listFilesEvent(eventSink, args) + } + } + } + + /** + * Read files of a given `uri` and dispatches all files under it through the `eventSink` and + * closes the stream after the last record + * + * Useful to read files under a `uri` with a large set of children + */ + private fun listFilesEvent( + eventSink: EventChannel.EventSink?, args: Map<*, *> + ) { + if (eventSink == null) return + + val uri = Uri.parse(args["uri"] as String) + val document = DocumentFile.fromTreeUri(plugin.context, uri) + + if (document == null) { + eventSink.error( + EXCEPTION_NOT_SUPPORTED, + "Android SDK must be greater or equal than [Build.VERSION_CODES.N]", + "Got (Build.VERSION.SDK_INT): ${Build.VERSION.SDK_INT}" + ) + eventSink.endOfStream() + } else { + if (!document.canRead()) { + val error = "You cannot read a URI that you don't have read permissions" + + Log.d("NO PERMISSION!!!", error) + + eventSink.error( + EXCEPTION_MISSING_PERMISSIONS, error, mapOf("uri" to args["uri"]) + ) + + eventSink.endOfStream() + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + CoroutineScope(Dispatchers.IO).launch { + try { + traverseDirectoryEntries( + plugin.context, + rootOnly = true, + targetUri = document.uri, + columns = getDocumentsContractColumns().toTypedArray() + ) { data, _ -> + launch(Dispatchers.Main) { + eventSink.success( + data + ) + } + } + } finally { + launch(Dispatchers.Main) { eventSink.endOfStream() } + } + } + } else { + eventSink.endOfStream() + } + } + } + } + + + /** Alias for `plugin.context.contentResolver.openOutputStream(uri)` */ + private fun openOutputStream(uri: Uri): OutputStream? { + return plugin.context.contentResolver.openOutputStream(uri) + } + + /** Alias for `plugin.context.contentResolver.openInputStream(uri)` */ + private fun openInputStream(uri: Uri): Pair { + return try { + Pair(plugin.context.contentResolver.openInputStream(uri), null) + } catch (e: FileNotFoundException) { + // Probably the file was already deleted and now you are trying to read. + Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) + } catch (e: IOException) { + // Unknown, can be anything. + Pair(null, PlatformException("INTERNAL_ERROR", e.toString())) + } catch (e: IllegalArgumentException) { + // Probably the file was already deleted and now you are trying to read. + Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) + } catch (e: IllegalStateException) { + // Probably you ran [delete] and [readDocumentContent] at the same time. + Pair(null, PlatformException("FILE_NOT_FOUND_EXCEPTION", e.toString())) + } + } + + override fun onCancel(arguments: Any?) { + eventSink?.endOfStream() + eventSink = null + } +} + +data class PlatformException(val code: String, val details: String) diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt index f7b23f7..83ca4cd 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileHelperApi.kt @@ -1,135 +1,243 @@ -package io.alexrintt.sharedstorage.storageaccessframework - -import android.content.ActivityNotFoundException -import android.content.Intent -import android.net.Uri -import android.util.Log -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.plugin.ActivityListener -import io.alexrintt.sharedstorage.plugin.Listenable -import io.alexrintt.sharedstorage.storageaccessframework.lib.* - -/** - * Aimed to be a class which takes the `DocumentFile` API and implement some APIs not supported - * natively by Android. - * - * This is why it is separated from the original and raw `DocumentFileApi` which is the class that - * only exposes the APIs without modifying them (Mirror API). - * - * Then here is where we can implement the main abstractions/use-cases which would be available - * globally without modifying the strict APIs. - */ -internal class DocumentFileHelperApi(private val plugin: SharedStoragePlugin) : - MethodChannel.MethodCallHandler, - PluginRegistry.ActivityResultListener, - Listenable, - ActivityListener, - StreamHandler { - private val pendingResults: MutableMap> = - mutableMapOf() - private var channel: MethodChannel? = null - private var eventChannel: EventChannel? = null - private var eventSink: EventChannel.EventSink? = null - - companion object { - private const val CHANNEL = "documentfilehelper" - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - OPEN_DOCUMENT_FILE -> openDocumentFile(call, result) - else -> result.notImplemented() - } - } - - 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) - - val intent = - Intent(Intent.ACTION_VIEW).apply { - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - data = uri - } - - try { - plugin.binding?.activity?.startActivity(intent, null) - - Log.d("sharedstorage", "Successfully launched uri $uri ") - - result.success(true) - } 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) - ) - } 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") - ) - } 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") - ) - } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { - when (requestCode) { - /** TODO(@alexrintt): Implement if required */ - else -> return true - } - - return false - } - - override fun startListening(binaryMessenger: BinaryMessenger) { - if (channel != null) stopListening() - - channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") - channel?.setMethodCallHandler(this) - - eventChannel = EventChannel(binaryMessenger, "$ROOT_CHANNEL/event/$CHANNEL") - eventChannel?.setStreamHandler(this) - } - - override fun stopListening() { - if (channel == null) return - - channel?.setMethodCallHandler(null) - channel = null - - eventChannel?.setStreamHandler(null) - eventChannel = null - } - - override fun startListeningToActivity() { - plugin.binding?.addActivityResultListener(this) - } - - override fun stopListeningToActivity() { - plugin.binding?.removeActivityResultListener(this) - } - - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - val args = arguments as Map<*, *> - - eventSink = events - - when (args["event"]) { - /** TODO(@alexrintt): Implement if required */ - } - } - - override fun onCancel(arguments: Any?) { - eventSink = null - } -} +package io.alexrintt.sharedstorage.storageaccessframework + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.ShareCompat +import com.anggrayudi.storage.file.isTreeDocumentFile +import com.anggrayudi.storage.file.mimeType +import io.alexrintt.sharedstorage.ROOT_CHANNEL +import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.storageaccessframework.lib.documentFromUri +import io.alexrintt.sharedstorage.plugin.ActivityListener +import io.alexrintt.sharedstorage.plugin.Listenable +import io.alexrintt.sharedstorage.storageaccessframework.lib.* +import io.flutter.plugin.common.* +import io.flutter.plugin.common.EventChannel.StreamHandler +import java.net.URLConnection + + +/** + * Aimed to be a class which takes the `DocumentFile` API and implement some APIs not supported + * natively by Android. + * + * This is why it is separated from the original and raw `DocumentFileApi` which is the class that + * only exposes the APIs without modifying them (Mirror API). + * + * Then here is where we can implement the main abstractions/use-cases which would be available + * globally without modifying the strict APIs. + */ +internal class DocumentFileHelperApi(private val plugin: SharedStoragePlugin) : + MethodChannel.MethodCallHandler, + PluginRegistry.ActivityResultListener, + Listenable, + ActivityListener, + StreamHandler { + private val pendingResults: MutableMap> = + mutableMapOf() + private var channel: MethodChannel? = null + private var eventChannel: EventChannel? = null + private var eventSink: EventChannel.EventSink? = null + + companion object { + private const val CHANNEL = "documentfilehelper" + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + OPEN_DOCUMENT_FILE -> openDocumentFile(call, result) + SHARE_URI -> shareUri(call, result) + else -> result.notImplemented() + } + } + + private fun shareUri(call: MethodCall, result: MethodChannel.Result) { + val uri = Uri.parse(call.argument("uri")!!) + val type = + call.argument("type") + ?: plugin.binding!!.activity.contentResolver.getType(uri) + ?: URLConnection.guessContentTypeFromName(uri.lastPathSegment) + ?: "application/octet-stream" + + try { + Log.d("sharedstorage", "Trying to share uri $uri with type $type") + + ShareCompat + .IntentBuilder(plugin.binding!!.activity) + .setChooserTitle("Share") + .setType(type) + .setStream(uri) + .startChooser() + + Log.d("sharedstorage", "Successfully shared uri $uri of type $type.") + + result.success(null) + } catch (e: ActivityNotFoundException) { + result.error( + EXCEPTION_ACTIVITY_NOT_FOUND, + "There's no activity handler that can process the uri $uri of type $type, error: $e.", + mapOf("uri" to "$uri", "type" to type) + ) + } catch (e: SecurityException) { + result.error( + EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, + "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity, error: $e.", + mapOf("uri" to "$uri", "type" to type) + ) + } catch (e: Throwable) { + result.error( + EXCEPTION_CANT_OPEN_DOCUMENT_FILE, + "Couldn't start activity to open document file for uri: $uri, error: $e.", + mapOf("uri" to "$uri") + ) + } + } + + private fun openDocumentAsSimpleFile(uri: Uri, type: String?) { + Log.d("sharedstorage", "Trying to open uri $uri with type $type") + + val intent = + Intent(Intent.ACTION_VIEW).apply { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + + setDataAndType(uri, type) + setFlags(flags) + } + + plugin.binding?.activity?.startActivity(intent, null) + + Log.d( + "sharedstorage", + "Successfully launched uri $uri as single|file uri." + ) + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private fun openDocumentAsTree(uri: Uri) { + val file = documentFromUri(plugin.context, uri) + if (file?.isTreeDocumentFile == true) { + val intent = Intent(Intent.ACTION_VIEW) + + intent.setDataAndType(uri, "vnd.android.document/root") + + plugin.binding?.activity?.startActivity(intent, null) + + Log.d( + "sharedstorage", + "Successfully launched uri $uri as tree uri." + ) + } else { + throw Exception("Not a document tree URI") + } + } + + private fun openDocumentFile(call: MethodCall, result: MethodChannel.Result) { + val uri = Uri.parse(call.argument("uri")!!) + val type = + call.argument("type") ?: plugin.context.contentResolver.getType( + uri + ) + + fun successfullyOpenedUri() { + result.success(true) + } + + try { + openDocumentAsSimpleFile(uri, type) + return successfullyOpenedUri() + } catch (e: ActivityNotFoundException) { + Log.d( + "sharedstorage", + "No activity is defined to handle $uri, trying to recover from error and interpret as tree." + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + try { + openDocumentAsTree(uri) + return successfullyOpenedUri() + } catch (e: Throwable) { + Log.d( + "sharedstorage", + "Tried to recover from missing activity exception but did not work, exception: $e" + ) + } + } + + return result.error( + EXCEPTION_ACTIVITY_NOT_FOUND, + "There's no activity handler that can process the uri $uri of type $type", + mapOf("uri" to "$uri", "type" to type) + ) + } catch (e: SecurityException) { + return result.error( + EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, + "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity", + mapOf("uri" to "$uri", "type" to "$type") + ) + } catch (e: Throwable) { + return result.error( + EXCEPTION_CANT_OPEN_DOCUMENT_FILE, + "Couldn't start activity to open document file for uri: $uri", + mapOf("uri" to "$uri") + ) + } + } + + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + data: Intent? + ): Boolean { + when (requestCode) { + /** TODO(@alexrintt): Implement if required */ + else -> return true + } + + return false + } + + override fun startListening(binaryMessenger: BinaryMessenger) { + if (channel != null) stopListening() + + channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") + channel?.setMethodCallHandler(this) + + eventChannel = EventChannel(binaryMessenger, "$ROOT_CHANNEL/event/$CHANNEL") + eventChannel?.setStreamHandler(this) + } + + override fun stopListening() { + if (channel == null) return + + channel?.setMethodCallHandler(null) + channel = null + + eventChannel?.setStreamHandler(null) + eventChannel = null + } + + override fun startListeningToActivity() { + plugin.binding?.addActivityResultListener(this) + } + + override fun stopListeningToActivity() { + plugin.binding?.removeActivityResultListener(this) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + val args = arguments as Map<*, *> + + eventSink = events + + when (args["event"]) { + /** TODO(@alexrintt): Implement if required */ + } + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt index 9b8848b..dd13786 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentsContractApi.kt @@ -1,97 +1,267 @@ -package io.alexrintt.sharedstorage.storageaccessframework - -import android.graphics.Point -import android.net.Uri -import android.os.Build -import android.provider.DocumentsContract -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 - -internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : - MethodChannel.MethodCallHandler, Listenable, ActivityListener { - private var channel: MethodChannel? = null - - companion object { - private const val CHANNEL = "documentscontract" - } - - 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 - ) - - 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 - ) - - launch(Dispatchers.Main) { result.success(data) } - } - } else { - result.success(null) - } - } catch(e: IllegalArgumentException) { - // Tried to load thumbnail of a folder. - result.success(null) - } - } else { - result.notSupported(call.method, API_21) - } - } - } - } - - override fun startListening(binaryMessenger: BinaryMessenger) { - if (channel != null) stopListening() - - channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") - channel?.setMethodCallHandler(this) - } - - override fun stopListening() { - if (channel == null) return - - channel?.setMethodCallHandler(null) - channel = null - } - - override fun startListeningToActivity() { - /** Implement if needed */ - } - - override fun stopListeningToActivity() { - /** Implement if needed */ - } -} +package io.alexrintt.sharedstorage.storageaccessframework + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Point +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.util.Log +import io.alexrintt.sharedstorage.ROOT_CHANNEL +import io.alexrintt.sharedstorage.SharedStoragePlugin +import io.alexrintt.sharedstorage.plugin.* +import io.alexrintt.sharedstorage.storageaccessframework.* +import io.alexrintt.sharedstorage.storageaccessframework.lib.* +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.InputStream +import java.io.Serializable +import java.util.* + + +const val APK_MIME_TYPE = "application/vnd.android.package-archive" + +internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : + MethodChannel.MethodCallHandler, Listenable, ActivityListener { + private var channel: MethodChannel? = null + + companion object { + private const val CHANNEL = "documentscontract" + } + + private fun createTempUriFile(sourceUri: Uri, callback: (File?) -> Unit) { + try { + val destinationFilename: String = UUID.randomUUID().toString() + + val tempDestinationFile = + File(plugin.context.cacheDir.path, destinationFilename) + + plugin.context.contentResolver.openInputStream(sourceUri)?.use { + createFileFromStream(it, tempDestinationFile) + } + callback(tempDestinationFile) + } catch (_: FileNotFoundException) { + callback(null) + } + } + + private fun createFileFromStream(ins: InputStream, destination: File?) { + FileOutputStream(destination).use { fileOutputStream -> + val buffer = ByteArray(4096) + var length: Int + while (ins.read(buffer).also { length = it } > 0) { + fileOutputStream.write(buffer, 0, length) + } + fileOutputStream.flush() + } + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + GET_DOCUMENT_THUMBNAIL -> { + val uri = Uri.parse(call.argument("uri")) + val mimeType: String? = plugin.context.contentResolver.getType(uri) + + if (mimeType == APK_MIME_TYPE) { + return result.success(null) +// getThumbnailForApkFile(call, result, uri) + } else { + if (Build.VERSION.SDK_INT >= API_21) { + getThumbnailForApi24(call, result) + } else { + result.notSupported(call.method, API_21) + } + } + } + } + } + + private fun getThumbnailForApkFile( + call: MethodCall, + result: MethodChannel.Result, + uri: Uri + ) { + CoroutineScope(Dispatchers.IO).launch { + createTempUriFile(uri) { + if (it == null) { + launch(Dispatchers.Main) { result.success(null) } + return@createTempUriFile + } + + kotlin.runCatching { + val packageManager: PackageManager = + plugin.context.packageManager + val packageInfo: PackageInfo? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageArchiveInfo( + it.path, + PackageManager.PackageInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageArchiveInfo( + it.path, + 0 + ) + } + + if (packageInfo == null) { + if (it.exists()) it.delete() + return@createTempUriFile result.success(null) + } + + // Parse the apk and to get the icon later on + packageInfo.applicationInfo.sourceDir = it.path + packageInfo.applicationInfo.publicSourceDir = it.path + + val apkIcon: Drawable = + packageInfo.applicationInfo.loadIcon(packageManager) + + val bitmap: Bitmap = drawableToBitmap(apkIcon) + + val data = bitmap.generateSerializableBitmapData(uri) + + if (it.exists()) it.delete() + + launch(Dispatchers.Main) { result.success(data) } + } + + try { + } catch (e: FileNotFoundException) { + // The target file apk is invalid + launch(Dispatchers.Main) { result.success(null) } + } + } + } + } + + private fun getThumbnailForApi24( + call: MethodCall, + result: MethodChannel.Result + ) { + CoroutineScope(Dispatchers.IO).launch { + val uri = Uri.parse(call.argument("uri")) + val width = call.argument("width")!! + val height = call.argument("height")!! + + // run catching because [DocumentsContract.getDocumentThumbnail] + // can throw a [FileNotFoundException]. + kotlin.runCatching { + val bitmap = DocumentsContract.getDocumentThumbnail( + plugin.context.contentResolver, + uri, + Point(width, height), + null + ) + + if (bitmap != null) { + val data = bitmap.generateSerializableBitmapData(uri) + + launch(Dispatchers.Main) { result.success(data) } + } else { + Log.d("GET_DOCUMENT_THUMBNAIL", "bitmap is null") + launch(Dispatchers.Main) { result.success(null) } + } + } + } + } + + override fun startListening(binaryMessenger: BinaryMessenger) { + if (channel != null) stopListening() + + channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") + channel?.setMethodCallHandler(this) + } + + override fun stopListening() { + if (channel == null) return + + channel?.setMethodCallHandler(null) + channel = null + } + + override fun startListeningToActivity() { + /** Implement if needed */ + } + + override fun stopListeningToActivity() { + /** Implement if needed */ + } +} + +fun drawableToBitmap(drawable: Drawable): Bitmap { + if (drawable is BitmapDrawable) { + val bitmapDrawable: BitmapDrawable = drawable + if (bitmapDrawable.bitmap != null) { + return bitmapDrawable.bitmap + } + } + val bitmap: Bitmap = + if (drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) { + Bitmap.createBitmap( + 1, + 1, + Bitmap.Config.ARGB_8888 + ) // Single color bitmap will be created of 1x1 pixel + } else { + Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + } + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap +} + +/** + * Convert bitmap to byte array using ByteBuffer. + * + * This method calls [Bitmap.recycle] so this function will make the bitmap unusable after that. + */ +fun Bitmap.convertToByteArray(): ByteArray { + val stream = ByteArrayOutputStream() + + // Very important, see https://stackoverflow.com/questions/51516310/sending-bitmap-to-flutter-from-android-platform + // Without compressing the raw bitmap, Flutter Image widget cannot decode it correctly and will throw a error. + this.compress(Bitmap.CompressFormat.PNG, 100, stream) + + val byteArray = stream.toByteArray() + + this.recycle() + + return byteArray +} + +fun Bitmap.generateSerializableBitmapData( + uri: Uri, + additional: Map = mapOf() +): Map { + val metadata = mapOf( + "uri" to "$uri", + "width" to this.width, + "height" to this.height, + "byteCount" to this.byteCount, + "density" to this.density + ) + + val bytes: ByteArray = this.convertToByteArray() + + return metadata + additional + mapOf( + "bytes" to bytes + ) +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt index 00ddd6a..4c5874d 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentCommon.kt @@ -1,259 +1,400 @@ -package io.alexrintt.sharedstorage.storageaccessframework.lib - -import android.content.ContentResolver -import android.content.Context -import android.graphics.Bitmap -import android.net.Uri -import android.os.Build -import android.provider.DocumentsContract -import android.util.Base64 -import androidx.annotation.RequiresApi -import androidx.documentfile.provider.DocumentFile -import io.alexrintt.sharedstorage.plugin.API_19 -import io.alexrintt.sharedstorage.plugin.API_21 -import io.alexrintt.sharedstorage.plugin.API_24 -import java.io.ByteArrayOutputStream -import java.io.Closeable - -/** - * Generate the `DocumentFile` reference from string `uri` - */ -@RequiresApi(API_21) -fun documentFromUri(context: Context, uri: String): DocumentFile? = - documentFromUri(context, Uri.parse(uri)) - -/** - * Generate the `DocumentFile` reference from URI `uri` - */ -@RequiresApi(API_21) -fun documentFromUri( - context: Context, - uri: Uri -): DocumentFile? { - return if (isTreeUri(uri)) { - DocumentFile.fromTreeUri(context, uri) - } else { - DocumentFile.fromSingleUri(context, uri) - } -} - - -/** - * Convert a [DocumentFile] using the default method for map encoding - */ -fun createDocumentFileMap(documentFile: DocumentFile?): Map? { - if (documentFile == null) return null - - return createDocumentFileMap( - DocumentsContract.getDocumentId(documentFile.uri), - parentUri = documentFile.parentFile?.uri, - isDirectory = documentFile.isDirectory, - isFile = documentFile.isFile, - isVirtual = documentFile.isVirtual, - name = documentFile.name, - type = documentFile.type, - uri = documentFile.uri, - exists = documentFile.exists(), - size = documentFile.length(), - lastModified = documentFile.lastModified() - ) -} - -/** - * Standard map encoding of a `DocumentFile` and must be used before returning any `DocumentFile` - * from plugin results, like: - * ```dart - * result.success(createDocumentFileMap(documentFile)) - * ``` - */ -fun createDocumentFileMap( - id: String?, - parentUri: Uri?, - isDirectory: Boolean?, - isFile: Boolean?, - isVirtual: Boolean?, - name: String?, - type: String?, - uri: Uri, - exists: Boolean?, - size: Long?, - lastModified: Long? -): Map { - return mapOf( - "id" to id, - "parentUri" to "$parentUri", - "isDirectory" to isDirectory, - "isFile" to isFile, - "isVirtual" to isVirtual, - "name" to name, - "type" to type, - "uri" to "$uri", - "exists" to exists, - "size" to size, - "lastModified" to lastModified - ) -} - -/** - * Util method to close a closeable - */ -fun closeQuietly(closeable: Closeable?) { - if (closeable != null) { - try { - closeable.close() - } catch (e: RuntimeException) { - throw e - } catch (ignore: Exception) { - } - } -} - -@RequiresApi(API_21) -fun traverseDirectoryEntries( - contentResolver: ContentResolver, - targetUri: Uri, - columns: Array, - rootOnly: Boolean, - block: (data: Map, isLast: Boolean) -> Unit -): Boolean { - val documentId = try { - DocumentsContract.getDocumentId(targetUri) - } catch(e: IllegalArgumentException) { - DocumentsContract.getTreeDocumentId(targetUri) - } - val treeDocumentId = DocumentsContract.getTreeDocumentId(targetUri) - - val rootUri = DocumentsContract.buildTreeDocumentUri( - targetUri.authority, - treeDocumentId - ) - val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( - rootUri, - documentId - ) - - // Keep track of our directory hierarchy - val dirNodes = mutableListOf(Pair(targetUri, childrenUri)) - - while (dirNodes.isNotEmpty()) { - val (parent, children) = dirNodes.removeAt(0) - - val requiredColumns = - if (rootOnly) emptyArray() else arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) - - val intrinsicColumns = - arrayOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_FLAGS - ) - - val projection = arrayOf( - *columns, - *requiredColumns, - *intrinsicColumns - ).toSet().toTypedArray() - - val cursor = contentResolver.query( - children, - projection, - // TODO: Add support for `selection`, `selectionArgs` and `sortOrder` - null, - null, - null - ) ?: return false - - try { - if (cursor.count == 0) { - return false - } - - while (cursor.moveToNext()) { - val data = mutableMapOf() - - for (column in projection) { - data[column] = cursorHandlerOf(typeOfColumn(column)!!)( - cursor, - cursor.getColumnIndexOrThrow(column) - ) - } - - val mimeType = - data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String? - - val id = - data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String - - val isDirectory = if (mimeType != null) isDirectory(mimeType) else null - - val uri = DocumentsContract.buildDocumentUriUsingTree( - rootUri, - DocumentsContract.getDocumentId( - DocumentsContract.buildDocumentUri(parent.authority, id) - ) - ) - - if (isDirectory == true && !rootOnly) { - val nextChildren = - DocumentsContract.buildChildDocumentsUriUsingTree(targetUri, id) - - val nextNode = Pair(uri, nextChildren) - - dirNodes.add(nextNode) - } - - block( - createDocumentFileMap( - parentUri = parent, - uri = uri, - name = data[DocumentsContract.Document.COLUMN_DISPLAY_NAME] as String?, - exists = true, - id = data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String, - isDirectory = isDirectory == true, - isFile = isDirectory == false, - isVirtual = if (Build.VERSION.SDK_INT >= API_24) { - (data[DocumentsContract.Document.COLUMN_FLAGS] as Int and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0 - } else { - false - }, - type = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String?, - size = data[DocumentsContract.Document.COLUMN_SIZE] as Long?, - lastModified = data[DocumentsContract.Document.COLUMN_LAST_MODIFIED] as Long? - ), - dirNodes.isEmpty() && cursor.isLast - ) - } - } finally { - closeQuietly(cursor) - } - } - - return true -} - -@RequiresApi(API_19) -private fun isDirectory(mimeType: String): Boolean { - return DocumentsContract.Document.MIME_TYPE_DIR == mimeType -} - -fun bitmapToBase64(bitmap: Bitmap): String { - val outputStream = ByteArrayOutputStream() - - val fullQuality = 100 - - bitmap.compress(Bitmap.CompressFormat.PNG, fullQuality, outputStream) - - return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) -} - -/** - * Trick to verify if is a tree URI even not in API 26+ - */ -fun isTreeUri(uri: Uri): Boolean { - if (Build.VERSION.SDK_INT >= API_24) { - return DocumentsContract.isTreeUri(uri) - } - - val paths = uri.pathSegments - - return paths.size >= 2 && "tree" == paths[0] -} +package io.alexrintt.sharedstorage.storageaccessframework.lib + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.provider.DocumentsProvider +import android.util.Base64 +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.extension.isMediaDocument +import com.anggrayudi.storage.extension.isMediaFile +import com.anggrayudi.storage.file.DocumentFileCompat +import io.alexrintt.sharedstorage.deprecated.lib.cursorHandlerOf +import io.alexrintt.sharedstorage.deprecated.lib.typeOfColumn +import io.alexrintt.sharedstorage.mediastore.ScopedFileSystemEntity +import io.alexrintt.sharedstorage.plugin.* +import io.alexrintt.sharedstorage.storageaccessframework.* +import java.io.ByteArrayOutputStream +import java.io.Closeable + +/** + * Generate the `DocumentFile` reference from string `uri` + */ +@RequiresApi(API_21) +fun documentFromUri(context: Context, uri: String): DocumentFile? = + documentFromUri(context, Uri.parse(uri)) + +/** + * Generate the `DocumentFile` reference from URI `uri` + */ +@RequiresApi(API_21) +fun documentFromUri(context: Context, uri: Uri): DocumentFile? { + return if (isTreeUri(uri)) { + DocumentFile.fromTreeUri(context, uri) + } else { + DocumentFile.fromSingleUri(context, uri) + } +} + + +/** + * Convert a [DocumentFile] using the default method for map encoding + */ +fun createDocumentFileMap(documentFile: DocumentFile?): Map? { + if (documentFile == null) return null + + return createDocumentFileMap( + DocumentsContract.getDocumentId(documentFile.uri), + parentUri = documentFile.parentFile?.uri, + isDirectory = documentFile.isDirectory, + isFile = documentFile.isFile, + isVirtual = documentFile.isVirtual, + name = documentFile.name, + type = documentFile.type, + uri = documentFile.uri, + exists = documentFile.exists(), + size = documentFile.length(), + lastModified = documentFile.lastModified() + ) +} + +/** + * Standard map encoding of a `DocumentFile` and must be used before returning any `DocumentFile` + * from plugin results, like: + * ```dart + * result.success(createDocumentFileMap(documentFile)) + * ``` + */ +fun createDocumentFileMap( + id: String?, + parentUri: Uri?, + isDirectory: Boolean?, + isFile: Boolean?, + isVirtual: Boolean?, + name: String?, + type: String?, + uri: Uri, + exists: Boolean?, + size: Long?, + lastModified: Long? +): Map { + return mapOf( + "id" to id, + "parentUri" to "$parentUri", + "isDirectory" to isDirectory, + "isFile" to isFile, + "isVirtual" to isVirtual, + "name" to name, + "type" to type, + "uri" to "$uri", + "exists" to exists, + "size" to size, + "lastModified" to lastModified + ) +} + +/** + * Util method to close a closeable + */ +fun closeQuietly(closeable: Closeable?) { + if (closeable != null) { + try { + closeable.close() + } catch (e: RuntimeException) { + throw e + } catch (ignore: Exception) { + } + } +} + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +fun traverseMediaDirectoryEntries( + context: Context, + targetUri: Uri, + columns: Array, + rootOnly: Boolean, + block: (data: Map, isLast: Boolean) -> Unit +): Boolean { + val documentId = try { + DocumentsContract.getDocumentId(targetUri) + } catch (e: IllegalArgumentException) { + DocumentsContract.getTreeDocumentId(targetUri) + } + val treeDocumentId = DocumentsContract.getTreeDocumentId(targetUri) + + val rootUri = DocumentsContract.buildTreeDocumentUri( + targetUri.authority, + treeDocumentId + ) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + rootUri, + documentId + ) + + // Keep track of our directory hierarchy + val dirNodes = mutableListOf(Pair(targetUri, childrenUri)) + + while (dirNodes.isNotEmpty()) { + val (parent, children) = dirNodes.removeAt(0) + + val requiredColumns = + if (rootOnly) emptyArray() else arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) + + val intrinsicColumns = + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_FLAGS + ) + + val projection = arrayOf( + *columns, + *requiredColumns, + *intrinsicColumns + ).toSet().toTypedArray() + + val cursor = context.contentResolver.query( + children, + projection, + // TODO: Add support for `selection`, `selectionArgs` and `sortOrder` + null, + null, + null + ) ?: return false + + try { + if (cursor.count == 0) { + return false + } + + while (cursor.moveToNext()) { + val data = mutableMapOf() + + for (column in projection) { + val columnValue: Any? = cursorHandlerOf(typeOfColumn(column)!!)( + cursor, + cursor.getColumnIndexOrThrow(column) + ) + + data[column] = columnValue + } + + val mimeType = + data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String? + + val id = + data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String + + val isDirectory = if (mimeType != null) isDirectory(mimeType) else null + + val uri = DocumentsContract.buildDocumentUriUsingTree( + rootUri, + DocumentsContract.getDocumentId( + DocumentsContract.buildDocumentUri(parent.authority, id) + ) + ) + + if (isDirectory == true && !rootOnly) { + val nextChildren = + DocumentsContract.buildChildDocumentsUriUsingTree(targetUri, id) + + val nextNode = Pair(uri, nextChildren) + + dirNodes.add(nextNode) + } + + block( + ScopedFileSystemEntity( + parentUri = parent, + uri = uri, + displayName = data[DocumentsContract.Document.COLUMN_DISPLAY_NAME] as String, + id = data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String, + entityType = if (isDirectory == true) "directory" else "file", + mimeType = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String, + length = data[DocumentsContract.Document.COLUMN_SIZE] as Long, + lastModified = data[DocumentsContract.Document.COLUMN_LAST_MODIFIED] as Long + ).toMap(), + dirNodes.isEmpty() && cursor.isLast + ) + } + } finally { + closeQuietly(cursor) + } + } + + return true +} + +fun isDirectory(mimeType: String): Boolean { + return mimeType == DocumentsContract.Document.MIME_TYPE_DIR +} + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +fun traverseSafDirectoryEntries( + context: Context, + targetUri: Uri, + columns: Array, + rootOnly: Boolean, + block: (data: Map, isLast: Boolean) -> Unit +): Boolean { + val documentId = try { + DocumentsContract.getDocumentId(targetUri) + } catch (e: IllegalArgumentException) { + DocumentsContract.getTreeDocumentId(targetUri) + } + val treeDocumentId = DocumentsContract.getTreeDocumentId(targetUri) + + val rootUri = DocumentsContract.buildTreeDocumentUri( + targetUri.authority, + treeDocumentId + ) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + rootUri, + documentId + ) + + // Keep track of our directory hierarchy + val dirNodes = mutableListOf(Pair(targetUri, childrenUri)) + + while (dirNodes.isNotEmpty()) { + val (parent, children) = dirNodes.removeAt(0) + + val requiredColumns = + if (rootOnly) emptyArray() else arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) + + val intrinsicColumns = + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_FLAGS + ) + + val projection = arrayOf( + *columns, + *requiredColumns, + *intrinsicColumns + ).toSet().toTypedArray() + + val cursor = context.contentResolver.query( + children, + projection, + // TODO: Add support for `selection`, `selectionArgs` and `sortOrder` + null, + null, + null + ) ?: return false + + try { + if (cursor.count == 0) { + return false + } + + while (cursor.moveToNext()) { + val data = mutableMapOf() + + for (column in projection) { + val columnValue: Any? = cursorHandlerOf(typeOfColumn(column)!!)( + cursor, + cursor.getColumnIndexOrThrow(column) + ) + + data[column] = columnValue + } + + val mimeType = + data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String? + + val id = + data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String + + val isDirectory = if (mimeType != null) isDirectory(mimeType) else null + + val uri = DocumentsContract.buildDocumentUriUsingTree( + rootUri, + DocumentsContract.getDocumentId( + DocumentsContract.buildDocumentUri(parent.authority, id) + ) + ) + + if (isDirectory == true && !rootOnly) { + val nextChildren = + DocumentsContract.buildChildDocumentsUriUsingTree(targetUri, id) + + val nextNode = Pair(uri, nextChildren) + + dirNodes.add(nextNode) + } + + block( + ScopedFileSystemEntity( + parentUri = parent, + uri = uri, + displayName = data[DocumentsContract.Document.COLUMN_DISPLAY_NAME] as String, + id = data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String, + entityType = if (isDirectory == true) "directory" else "file", + mimeType = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String, + length = data[DocumentsContract.Document.COLUMN_SIZE] as Long, + lastModified = data[DocumentsContract.Document.COLUMN_LAST_MODIFIED] as Long + ).toMap(), + dirNodes.isEmpty() && cursor.isLast + ) + } + } finally { + closeQuietly(cursor) + } + } + + return true +} + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +fun traverseDirectoryEntries( + context: Context, + targetUri: Uri, + columns: Array, + rootOnly: Boolean, + block: (data: Map, isLast: Boolean) -> Unit +): Boolean { + return when { + targetUri.isMediaFile || targetUri.isMediaDocument -> traverseMediaDirectoryEntries( + context = context, + targetUri = targetUri, + columns = columns, + rootOnly = rootOnly, + block = block, + ) + + else -> traverseSafDirectoryEntries( + context = context, + targetUri = targetUri, + columns = columns, + rootOnly = rootOnly, + block = block, + ) + } +} + + +fun bitmapToBase64(bitmap: Bitmap): String { + val outputStream = ByteArrayOutputStream() + + val fullQuality = 100 + + bitmap.compress(Bitmap.CompressFormat.PNG, fullQuality, outputStream) + + return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) +} + +/** + * Trick to verify if is a tree URI even not in API 26+ + */ +fun isTreeUri(uri: Uri): Boolean { + if (Build.VERSION.SDK_INT >= API_24) { + return DocumentsContract.isTreeUri(uri) + } + + val paths = uri.pathSegments + + return paths.size >= 2 && "tree" == paths[0] +} diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt index a1b7523..4b6450f 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt @@ -1,83 +1,74 @@ -package io.alexrintt.sharedstorage.storageaccessframework.lib - -import android.database.Cursor -import android.provider.DocumentsContract - -private const val PREFIX = "DocumentFileColumn" - -enum class DocumentFileColumn { - ID, - DISPLAY_NAME, - MIME_TYPE, - SUMMARY, - LAST_MODIFIED, - SIZE -} - -enum class DocumentFileColumnType { - LONG, - STRING, - INT -} - -fun parseDocumentFileColumn(column: String): DocumentFileColumn? { - val values = mapOf( - "$PREFIX.COLUMN_DOCUMENT_ID" to DocumentFileColumn.ID, - "$PREFIX.COLUMN_DISPLAY_NAME" to DocumentFileColumn.DISPLAY_NAME, - "$PREFIX.COLUMN_MIME_TYPE" to DocumentFileColumn.MIME_TYPE, - "$PREFIX.COLUMN_SIZE" to DocumentFileColumn.SIZE, - "$PREFIX.COLUMN_SUMMARY" to DocumentFileColumn.SUMMARY, - "$PREFIX.COLUMN_LAST_MODIFIED" to DocumentFileColumn.LAST_MODIFIED - ) - - return values[column] -} - -fun documentFileColumnToRawString(column: DocumentFileColumn): String? { - val values = mapOf( - DocumentFileColumn.ID to "$PREFIX.COLUMN_DOCUMENT_ID", - DocumentFileColumn.DISPLAY_NAME to "$PREFIX.COLUMN_DISPLAY_NAME", - DocumentFileColumn.MIME_TYPE to "$PREFIX.COLUMN_MIME_TYPE", - DocumentFileColumn.SIZE to "$PREFIX.COLUMN_SIZE", - DocumentFileColumn.SUMMARY to "$PREFIX.COLUMN_SUMMARY", - DocumentFileColumn.LAST_MODIFIED to "$PREFIX.COLUMN_LAST_MODIFIED" - ) - - return values[column] -} - -fun parseDocumentFileColumn(column: DocumentFileColumn): String { - val values = mapOf( - DocumentFileColumn.ID to DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentFileColumn.DISPLAY_NAME to DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentFileColumn.MIME_TYPE to DocumentsContract.Document.COLUMN_MIME_TYPE, - DocumentFileColumn.SIZE to DocumentsContract.Document.COLUMN_SIZE, - DocumentFileColumn.SUMMARY to DocumentsContract.Document.COLUMN_SUMMARY, - DocumentFileColumn.LAST_MODIFIED to DocumentsContract.Document.COLUMN_LAST_MODIFIED - ) - - return values[column]!! -} - -/// `column` must be a constant String from `DocumentsContract.Document.COLUMN*` -fun typeOfColumn(column: String): DocumentFileColumnType? { - val values = mapOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID to DocumentFileColumnType.STRING, - DocumentsContract.Document.COLUMN_DISPLAY_NAME to DocumentFileColumnType.STRING, - DocumentsContract.Document.COLUMN_MIME_TYPE to DocumentFileColumnType.STRING, - DocumentsContract.Document.COLUMN_SIZE to DocumentFileColumnType.LONG, - DocumentsContract.Document.COLUMN_SUMMARY to DocumentFileColumnType.STRING, - DocumentsContract.Document.COLUMN_LAST_MODIFIED to DocumentFileColumnType.LONG, - DocumentsContract.Document.COLUMN_FLAGS to DocumentFileColumnType.INT - ) - - return values[column] -} - -fun cursorHandlerOf(type: DocumentFileColumnType): (Cursor, Int) -> Any { - when(type) { - DocumentFileColumnType.LONG -> { return { cursor, index -> cursor.getLong(index) } } - DocumentFileColumnType.STRING -> { return { cursor, index -> cursor.getString(index) } } - DocumentFileColumnType.INT -> { return { cursor, index -> cursor.getInt(index) } } - } -} +package io.alexrintt.sharedstorage.deprecated.lib + +import android.database.Cursor +import android.provider.DocumentsContract +import java.lang.NullPointerException + +private const val PREFIX = "DocumentFileColumn" + +enum class DocumentFileColumnType { + LONG, + STRING, + INT +} + + +fun getDocumentsContractColumns(): List { + return listOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_SUMMARY, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + ) +} + +/// `column` must be a constant String from `DocumentsContract.Document.COLUMN*` +fun typeOfColumn(column: String): DocumentFileColumnType? { + val values = mapOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID to DocumentFileColumnType.STRING, + DocumentsContract.Document.COLUMN_DISPLAY_NAME to DocumentFileColumnType.STRING, + DocumentsContract.Document.COLUMN_MIME_TYPE to DocumentFileColumnType.STRING, + DocumentsContract.Document.COLUMN_SIZE to DocumentFileColumnType.LONG, + DocumentsContract.Document.COLUMN_SUMMARY to DocumentFileColumnType.STRING, + DocumentsContract.Document.COLUMN_LAST_MODIFIED to DocumentFileColumnType.LONG, + DocumentsContract.Document.COLUMN_FLAGS to DocumentFileColumnType.INT + ) + + return values[column] +} + +fun cursorHandlerOf(type: DocumentFileColumnType): (Cursor, Int) -> Any? { + when (type) { + DocumentFileColumnType.LONG -> { + return { cursor, index -> + try { + cursor.getLong(index) + } catch (e: NullPointerException) { + null + } + } + } + + DocumentFileColumnType.STRING -> { + return { cursor, index -> + try { + cursor.getString(index) + } catch (e: NullPointerException) { + null + } + } + } + + DocumentFileColumnType.INT -> { + return { cursor, index -> + try { + cursor.getInt(index) + } catch (e: NullPointerException) { + null + } + } + } + } +} diff --git a/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..62c4145 100644 --- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt +++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt @@ -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 @@ -56,3 +57,7 @@ const val GET_DOCUMENT_CONTENT = "getDocumentContent" */ const val OPEN_DOCUMENT_TREE_CODE = 10 const val OPEN_DOCUMENT_CODE = 11 + +const val OPEN_INPUT_STREAM = "openInputStream" +const val CLOSE_INPUT_STREAM = "closeInputStream" +const val READ_INPUT_STREAM = "readInputStream" diff --git a/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'); 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 diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index e9d9fce..d914bab 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,60 +1,60 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file("local.properties") -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader("UTF-8") { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty("flutter.sdk") -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty("flutter.versionCode") -if (flutterVersionCode == null) { - flutterVersionCode = "1" -} - -def flutterVersionName = localProperties.getProperty("flutter.versionName") -if (flutterVersionName == null) { - flutterVersionName = "1.0" -} - -apply plugin: "com.android.application" -apply plugin: "kotlin-android" -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion 31 - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - } - - defaultConfig { - applicationId "io.alexrintt.sharedstorage.example" - minSdkVersion 19 - targetSdkVersion 31 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug - } - } -} - -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -flutter { - source "../.." -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} +def localProperties = new Properties() +def localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader("UTF-8") { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty("flutter.sdk") +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty("flutter.versionCode") +if (flutterVersionCode == null) { + flutterVersionCode = "1" +} + +def flutterVersionName = localProperties.getProperty("flutter.versionName") +if (flutterVersionName == null) { + flutterVersionName = "1.0" +} + +apply plugin: "com.android.application" +apply plugin: "kotlin-android" +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 33 + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + } + + defaultConfig { + applicationId "io.alexrintt.sharedstorage.example" + minSdkVersion 19 + targetSdkVersion 31 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +flutter { + source "../.." +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 9b5789e..d3db29e 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,26 +1,72 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/example/android/build.gradle b/example/android/build.gradle index 1202707..14c54f0 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,30 +1,30 @@ -buildscript { - ext.kotlin_version = '1.8.21' - ext.gradle_version = '7.4.2' - repositories { - google() - jcenter() - } - - dependencies { - classpath "com.android.tools.build:gradle:$gradle_version" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - jcenter() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" - project.evaluationDependsOn(':app') -} - -task clean(type: Delete) { - delete rootProject.buildDir -} +buildscript { + ext.kotlin_version = '1.8.21' + ext.gradle_version = '7.4.2' + repositories { + google() + jcenter() + } + + dependencies { + classpath "com.android.tools.build:gradle:$gradle_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 03f0aec..25cd0d3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -5,7 +5,7 @@ import 'screens/granted_uris/granted_uris_page.dart'; void main() => runApp(const Root()); class Root extends StatefulWidget { - const Root({Key? key}) : super(key: key); + const Root({super.key}); @override _RootState createState() => _RootState(); diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index baf2f03..19a0486 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -1,439 +1,528 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:shared_storage/shared_storage.dart'; - -import '../../utils/apply_if_not_null.dart'; -import '../../utils/confirm_decorator.dart'; -import '../../utils/disabled_text_style.dart'; -import '../../utils/document_file_utils.dart'; -import '../../utils/format_bytes.dart'; -import '../../utils/inline_span.dart'; -import '../../utils/mime_types.dart'; -import '../../widgets/buttons.dart'; -import '../../widgets/key_value_text.dart'; -import '../../widgets/simple_card.dart'; -import '../../widgets/text_field_dialog.dart'; -import 'file_explorer_page.dart'; - -class FileExplorerCard extends StatefulWidget { - const FileExplorerCard({ - Key? key, - required this.documentFile, - required this.didUpdateDocument, - }) : super(key: key); - - final DocumentFile documentFile; - final void Function(DocumentFile?) didUpdateDocument; - - @override - _FileExplorerCardState createState() => _FileExplorerCardState(); -} - -class _FileExplorerCardState extends State { - DocumentFile get _file => widget.documentFile; - - static const _expandedThumbnailSize = Size.square(150); - - Uint8List? _thumbnailImageBytes; - Size? _thumbnailSize; - - int get _sizeInBytes => _file.size ?? 0; - - bool _expanded = false; - String? get _displayName => _file.name; - - Future _loadThumbnailIfAvailable() async { - final uri = _file.uri; - - final bitmap = await getDocumentThumbnail( - uri: uri, - width: _expandedThumbnailSize.width, - height: _expandedThumbnailSize.height, - ); - - if (bitmap == null) { - _thumbnailImageBytes = Uint8List.fromList([]); - _thumbnailSize = Size.zero; - } else { - _thumbnailImageBytes = bitmap.bytes; - _thumbnailSize = Size(bitmap.width! / 1, bitmap.height! / 1); - } - - if (mounted) setState(() {}); - } - - StreamSubscription? _subscription; - - Future Function() _fileConfirmation( - String action, - VoidCallback callback, - ) { - return confirm( - context, - action, - callback, - message: [ - normal('You are '), - bold('writing'), - normal(' to this file and it is '), - bold('not a reversible action'), - normal('. It can '), - bold(red('corrupt the file')), - normal(' or '), - bold(red('cause data loss')), - normal(', '), - italic('be cautious'), - normal('.'), - ], - ); - } - - VoidCallback _directoryConfirmation(String action, VoidCallback callback) { - return confirm( - context, - action, - callback, - message: [ - normal('You are '), - bold('deleting'), - normal(' this folder, this is '), - bold('not reversible'), - normal(' and '), - bold(red('can cause data loss ')), - normal('or even'), - bold(red(' corrupt some apps')), - normal(' depending on which folder you are deleting, '), - italic('be cautious.'), - ], - ); - } - - Widget _buildMimeTypeIconThumbnail(String mimeType, {double? size}) { - if (_isDirectory) { - return Icon(Icons.folder, size: size, color: Colors.blueGrey); - } - - if (mimeType == kApkMime) { - return Icon(Icons.android, color: const Color(0xff3AD17D), size: size); - } - - if (mimeType == kTextPlainMime) { - return Icon(Icons.description, size: size, color: Colors.blue); - } - - if (mimeType.startsWith(kVideoMime)) { - return Icon(Icons.movie, size: size, color: Colors.deepOrange); - } - - return Icon( - Icons.browser_not_supported_outlined, - size: size, - color: disabledColor(), - ); - } - - @override - void initState() { - super.initState(); - - _loadThumbnailIfAvailable(); - } - - @override - void didUpdateWidget(covariant FileExplorerCard oldWidget) { - super.didUpdateWidget(oldWidget); - - if (oldWidget.documentFile.id != widget.documentFile.id) { - _loadThumbnailIfAvailable(); - if (mounted) setState(() => _expanded = false); - } - } - - @override - void dispose() { - _subscription?.cancel(); - super.dispose(); - } - - void _openFolderFileListPage(Uri uri) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => FileExplorerPage(uri: uri), - ), - ); - } - - Uint8List? content; - - bool get _isDirectory => _file.isDirectory == true; - - int _generateLuckNumber() { - final random = Random(); - - return random.nextInt(1000); - } - - Widget _buildThumbnail({double? size}) { - late Widget thumbnail; - - if (_thumbnailImageBytes == null) { - thumbnail = const CircularProgressIndicator(); - } else if (_thumbnailImageBytes!.isEmpty) { - thumbnail = _buildMimeTypeIconThumbnail( - _mimeTypeOrEmpty, - size: size, - ); - } else { - thumbnail = Image.memory( - _thumbnailImageBytes!, - fit: BoxFit.contain, - ); - - if (!_expanded) { - final width = _thumbnailSize?.width; - final height = _thumbnailSize?.height; - - final aspectRatio = - width != null && height != null ? width / height : 1.0; - - thumbnail = AspectRatio( - aspectRatio: aspectRatio, - child: thumbnail, - ); - } - } - - List children; - - if (_expanded) { - children = [ - Flexible( - child: Align( - child: thumbnail, - ), - ), - Flexible(child: _buildExpandButton()), - ]; - } else { - children = [thumbnail]; - } - - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: _expanded ? MainAxisSize.max : MainAxisSize.min, - children: children, - ), - ); - } - - Widget _buildExpandButton() { - return IconButton( - onPressed: () => setState(() => _expanded = !_expanded), - icon: _expanded - ? const Icon(Icons.expand_less, color: Colors.grey) - : const Icon(Icons.expand_more, color: Colors.grey), - ); - } - - Uri get _currentUri => widget.documentFile.uri; - - Widget _buildNotAvailableText() { - return Text('Not available', style: disabledTextStyle()); - } - - Widget _buildOpenWithButton() => - Button('Open with', onTap: _currentUri.openWithExternalApp); - - Widget _buildDocumentSimplifiedTile() { - return ListTile( - dense: true, - leading: _buildThumbnail(size: 25), - title: Text( - '$_displayName', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text(formatBytes(_sizeInBytes, 2)), - trailing: _buildExpandButton(), - ); - } - - String? get _lastModified { - if (_file.lastModified == null) { - return null; - } - - return _file.lastModified!.toIso8601String(); - } - - Widget _buildDocumentMetadata() { - return KeyValueText( - entries: { - 'name': '$_displayName', - 'type': '${_file.type}', - 'isVirtual': '${_file.isVirtual}', - 'isDirectory': '${_file.isDirectory}', - 'isFile': '${_file.isFile}', - 'size': '${formatBytes(_sizeInBytes, 2)} ($_sizeInBytes bytes)', - 'lastModified': _lastModified.toString(), - 'id': '${_file.id}', - 'parentUri': _file.parentUri?.apply((u) => Uri.decodeFull('$u')) ?? - _buildNotAvailableText(), - 'uri': Uri.decodeFull('${_file.uri}'), - }, - ); - } - - Widget _buildAvailableActions() { - return Wrap( - children: [ - if (_isDirectory) - ActionButton( - 'Open Directory', - onTap: _openDirectory, - ), - _buildOpenWithButton(), - DangerButton( - 'Rename', - onTap: _renameDocFile, - ), - DangerButton( - 'Delete ${_isDirectory ? 'Directory' : 'File'}', - onTap: _isDirectory - ? _directoryConfirmation('Delete', _deleteDocument) - : _fileConfirmation('Delete', _deleteDocument), - ), - if (!_isDirectory) ...[ - DangerButton( - 'Write to File', - onTap: _fileConfirmation('Overwite', _overwriteFileContents), - ), - DangerButton( - 'Append to file', - onTap: _fileConfirmation('Append', _appendFileContents), - ), - DangerButton( - 'Erase file content', - onTap: _fileConfirmation('Erase', _eraseFileContents), - ), - DangerButton( - 'Edit file contents', - onTap: _editFileContents, - ), - ], - ], - ); - } - - String get _mimeTypeOrEmpty => _file.type ?? ''; - - Future _deleteDocument() async { - final deleted = await delete(_currentUri); - - if (deleted ?? false) { - widget.didUpdateDocument(null); - } - } - - Future _overwriteFileContents() async { - await writeToFile( - _currentUri, - content: 'Hello World! Your luck number is: ${_generateLuckNumber()}', - mode: FileMode.write, - ); - } - - Future _appendFileContents() async { - final contents = await getDocumentContentAsString( - _currentUri, - ); - - final prependWithNewLine = contents?.isNotEmpty ?? true; - - await writeToFile( - _currentUri, - content: - "${prependWithNewLine ? '\n' : ''}You file got bigger! Here's your luck number: ${_generateLuckNumber()}", - mode: FileMode.append, - ); - } - - Future _renameDocFile() async { - final newDisplayName = await showDialog( - context: context, - builder: (context) { - return TextFieldDialog( - labelText: - 'New ${widget.documentFile.isDirectory ?? false ? 'directory' : 'file'} name:', - hintText: widget.documentFile.name ?? '', - actionText: 'Edit', - ); - }, - ); - - if (newDisplayName == null) return; - - final updatedDocumentFile = - await widget.documentFile.renameTo(newDisplayName); - - widget.didUpdateDocument(updatedDocumentFile); - } - - Future _eraseFileContents() async { - await writeToFile( - _currentUri, - content: '', - mode: FileMode.write, - ); - } - - Future _editFileContents() async { - final content = await showDialog( - context: context, - builder: (context) { - return const TextFieldDialog( - labelText: 'New file content:', - hintText: 'Writing to this file', - actionText: 'Edit', - ); - }, - ); - - if (content != null) { - _fileConfirmation( - 'Overwrite', - () => writeToFileAsString( - _currentUri, - content: content, - mode: FileMode.write, - ), - )(); - } - } - - Future _openDirectory() async { - if (_isDirectory) { - _openFolderFileListPage(_file.uri); - } - } - - @override - Widget build(BuildContext context) { - return SimpleCard( - onTap: _isDirectory ? _openDirectory : () => _file.showContents(context), - children: [ - if (_expanded) ...[ - _buildThumbnail(size: 50), - _buildDocumentMetadata(), - _buildAvailableActions() - ] else - _buildDocumentSimplifiedTile(), - ], - ); - } -} +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import '../../utils/apply_if_not_null.dart'; +import '../../utils/confirm_decorator.dart'; +import '../../utils/disabled_text_style.dart'; +import '../../utils/document_file_utils.dart'; +import '../../utils/format_bytes.dart'; +import '../../utils/inline_span.dart'; +import '../../utils/mime_types.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/key_value_text.dart'; +import '../../widgets/simple_card.dart'; +import '../../widgets/text_field_dialog.dart'; +import '../large_file/large_file_screen.dart'; +import 'file_explorer_page.dart'; + +class FileExplorerCard extends StatefulWidget { + const FileExplorerCard({ + super.key, + required this.scopedFileSystemEntity, + required this.didUpdateDocument, + this.allowExpand = true, + }); + + final ScopedFileSystemEntity scopedFileSystemEntity; + final void Function(ScopedFileSystemEntity?) didUpdateDocument; + final bool allowExpand; + + @override + _FileExplorerCardState createState() => _FileExplorerCardState(); +} + +class _FileExplorerCardState extends State { + ScopedFileSystemEntity get _fileSystemEntity => widget.scopedFileSystemEntity; + + ScopedFile? get _file { + if (_fileSystemEntity is ScopedFile) { + return _fileSystemEntity as ScopedFile; + } + return null; + } + + ScopedDirectory? get _directory { + if (_fileSystemEntity is ScopedDirectory) { + return _fileSystemEntity as ScopedDirectory; + } + return null; + } + + static const _kExpandedThumbnailSize = Size.square(150); + + Uint8List? _thumbnailImageBytes; + Size? _thumbnailSize; + + int get _sizeInBytes => _file?.length ?? 0; + + bool _expanded = false; + String? get _displayName => _fileSystemEntity.displayName; + + Future _loadThumbnailIfAvailable() async { + if (_isDirectory) return; + + final uri = _fileSystemEntity.uri; + + final bitmap = await getDocumentThumbnail( + uri: uri, + width: _kExpandedThumbnailSize.width, + height: _kExpandedThumbnailSize.height, + ); + + if (bitmap == null) { + _thumbnailImageBytes = Uint8List.fromList([]); + _thumbnailSize = Size.zero; + } else { + _thumbnailImageBytes = bitmap.bytes; + _thumbnailSize = Size(bitmap.width! / 1, bitmap.height! / 1); + } + + if (mounted) setState(() {}); + } + + StreamSubscription? _subscription; + + Future Function() _fileConfirmation( + String action, + VoidCallback callback, + ) { + return confirm( + context, + action, + callback, + message: [ + normal('You are '), + bold('writing'), + normal(' to this file and it is '), + bold('not a reversible action'), + normal('. It can '), + bold(red('corrupt the file')), + normal(' or '), + bold(red('cause data loss')), + normal(', '), + italic('be cautious'), + normal('.'), + ], + ); + } + + VoidCallback _directoryConfirmation(String action, VoidCallback callback) { + return confirm( + context, + action, + callback, + message: [ + normal('You are '), + bold('deleting'), + normal(' this folder, this is '), + bold('not reversible'), + normal(' and '), + bold(red('can cause data loss ')), + normal('or even'), + bold(red(' corrupt some apps')), + normal(' depending on which folder you are deleting, '), + italic('be cautious.'), + ], + ); + } + + Widget _buildMimeTypeIconThumbnail(String mimeType, {double? size}) { + if (_isDirectory) { + return Icon(Icons.folder, size: size, color: Colors.blueGrey); + } + + if (mimeType == kApkMime) { + return Icon(Icons.android, color: const Color(0xff3AD17D), size: size); + } + + if (mimeType == kTextPlainMime) { + return Icon(Icons.description, size: size, color: Colors.blue); + } + + if (mimeType.startsWith(kVideoMime)) { + return Icon(Icons.movie, size: size, color: Colors.deepOrange); + } + + return Icon( + Icons.browser_not_supported_outlined, + size: size, + color: disabledColor(), + ); + } + + @override + void initState() { + super.initState(); + + _loadThumbnailIfAvailable(); + } + + @override + void didUpdateWidget(covariant FileExplorerCard oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.scopedFileSystemEntity.id != + widget.scopedFileSystemEntity.id) { + _loadThumbnailIfAvailable(); + if (mounted) setState(() => _expanded = false); + } + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + void _openFolderFileListPage(Uri uri) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => FileExplorerPage(uri: uri), + ), + ); + } + + Uint8List? content; + + bool get _isDirectory => _fileSystemEntity is ScopedDirectory; + bool get _isFile => _fileSystemEntity is ScopedFile; + + int _generateLuckNumber() { + final random = Random(); + + return random.nextInt(1000); + } + + Widget _buildThumbnailImage({double? size}) { + if (_isDirectory) { + return const Align( + alignment: Alignment.centerLeft, + child: Icon( + Icons.folder, + color: Colors.grey, + ), + ); + } + + late Widget thumbnail; + + if (_thumbnailImageBytes == null) { + thumbnail = const CircularProgressIndicator(); + } else if (_thumbnailImageBytes!.isEmpty) { + thumbnail = _buildMimeTypeIconThumbnail( + _mimeTypeOrEmpty, + size: size, + ); + } else { + thumbnail = Image.memory( + _thumbnailImageBytes!, + fit: BoxFit.contain, + ); + + if (!_expanded) { + final width = _thumbnailSize?.width; + final height = _thumbnailSize?.height; + + final aspectRatio = + width != null && height != null ? width / height : 1.0; + + thumbnail = AspectRatio( + aspectRatio: aspectRatio, + child: thumbnail, + ); + } + } + + return thumbnail; + } + + Widget _buildThumbnail({double? size}) { + final Widget thumbnail = _buildThumbnailImage(size: size); + + List children; + + if (_expanded) { + children = [ + Flexible( + child: Align( + child: thumbnail, + ), + ), + Flexible(child: _buildExpandButton()), + ]; + } else { + children = [thumbnail]; + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: _expanded ? MainAxisSize.max : MainAxisSize.min, + children: children, + ), + ); + } + + Widget _buildExpandButton() { + return IconButton( + onPressed: () => setState(() => _expanded = !_expanded), + icon: _expanded + ? const Icon(Icons.expand_less, color: Colors.grey) + : const Icon(Icons.expand_more, color: Colors.grey), + ); + } + + Uri get _location => widget.scopedFileSystemEntity.uri; + + Widget _buildNotAvailableText() { + return Text('Not available', style: disabledTextStyle()); + } + + Widget _buildOpenWithButton() => + Button('Open with', onTap: _location.openWithExternalApp); + + Widget _buildDocumentSimplifiedTile() { + return ListTile( + dense: true, + leading: _buildThumbnail(size: 25), + title: Text( + '$_displayName', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(formatBytes(_sizeInBytes, 2)), + trailing: widget.allowExpand ? _buildExpandButton() : null, + ); + } + + String get _lastModified { + return _fileSystemEntity.lastModified.toIso8601String(); + } + + Widget _buildDocumentMetadata() { + return KeyValueText( + entries: { + 'name': '$_displayName', + 'type': '${_isFile ? _file!.mimeType : null}', + 'isDirectory': '$_isDirectory', + 'isFile': '$_isFile', + 'size': '${formatBytes(_sizeInBytes, 2)} ($_sizeInBytes bytes)', + 'lastModified': _lastModified, + 'id': _fileSystemEntity.id, + 'parentUri': + _fileSystemEntity.parentUri?.apply((u) => Uri.decodeFull('$u')) ?? + _buildNotAvailableText(), + 'uri': Uri.decodeFull('${_fileSystemEntity.uri}'), + }, + ); + } + + Uri get _currentUri => _fileSystemEntity.uri; + + Future _shareFile() async { + if (_isFile) { + await SharedStorage.shareScopedFile(_file!); + } + } + + Future _copyTo() async { + assert(_isFile); + + try { + final ScopedDirectory parentDirectory = + await SharedStorage.pickDirectory(persist: false); + + if (_file?.mimeType != null && _file?.displayName != null) { + final ScopedFile recipient = await parentDirectory.createChildFile( + displayName: _file!.displayName, + mimeType: _file!.mimeType, + ); + + // TODO: Add stream based [copy] method to [ScopedFile]. + _file!.copyTo(recipient.uri); + } + } on SharedStorageDirectoryWasNotSelectedException { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('User did not select a directory.'), + ), + ); + } + } + + void _openLargeFileScreen() { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) { + return LargeFileScreen(uri: widget.scopedFileSystemEntity.uri); + }, + ), + ); + } + + Widget _buildAvailableActions() { + return Wrap( + children: [ + if (_isDirectory) + ActionButton( + 'Open Directory', + onTap: _openDirectory, + ), + _buildOpenWithButton(), + DangerButton( + 'Rename', + onTap: _renameDocFile, + ), + DangerButton( + 'Delete ${_isDirectory ? 'Directory' : 'File'}', + onTap: _isDirectory + ? _directoryConfirmation('Delete', _deleteDocument) + : _fileConfirmation('Delete', _deleteDocument), + ), + if (!_isDirectory) ...[ + ActionButton( + 'Lazy load its content', + onTap: _openLargeFileScreen, + ), + ActionButton( + 'Copy to', + onTap: _copyTo, + ), + ActionButton( + 'Share Document', + onTap: _shareFile, + ), + DangerButton( + 'Write to File', + onTap: _fileConfirmation('Overwite', _overwriteFileContents), + ), + DangerButton( + 'Append to file', + onTap: _fileConfirmation('Append', _appendFileContents), + ), + DangerButton( + 'Erase file content', + onTap: _fileConfirmation('Erase', _eraseFileContents), + ), + DangerButton( + 'Edit file contents', + onTap: _editFileContents, + ), + ], + ], + ); + } + + String get _mimeTypeOrEmpty => _file?.mimeType ?? ''; + + Future _deleteDocument() async { + final deleted = await delete(_currentUri); + + if (deleted ?? false) { + widget.didUpdateDocument(null); + } + } + + Future _overwriteFileContents() async { + await writeToFile( + _currentUri, + content: 'Hello World! Your luck number is: ${_generateLuckNumber()}', + mode: FileMode.write, + ); + } + + Future _appendFileContents() async { + final contents = await getDocumentContentAsString( + _currentUri, + ); + + final prependWithNewLine = contents?.isNotEmpty ?? true; + + await writeToFile( + _currentUri, + content: + "${prependWithNewLine ? '\n' : ''}You file got bigger! Here's your luck number: ${_generateLuckNumber()}", + mode: FileMode.append, + ); + } + + Future _renameDocFile() async { + final newDisplayName = await showDialog( + context: context, + builder: (context) { + return TextFieldDialog( + labelText: 'New ${_isDirectory ? 'directory' : 'file'} name:', + hintText: _fileSystemEntity.displayName, + actionText: 'Edit', + ); + }, + ); + + if (newDisplayName == null) return; + + final updatedDocumentFile = + await widget.scopedFileSystemEntity.rename(newDisplayName); + + widget.didUpdateDocument(updatedDocumentFile); + } + + Future _eraseFileContents() async { + await writeToFile( + _currentUri, + content: '', + mode: FileMode.write, + ); + } + + Future _editFileContents() async { + final content = await showDialog( + context: context, + builder: (context) { + return const TextFieldDialog( + labelText: 'New file content:', + hintText: 'Writing to this file', + actionText: 'Edit', + ); + }, + ); + + if (content != null) { + _fileConfirmation( + 'Overwrite', + () => writeToFileAsString( + _currentUri, + content: content, + mode: FileMode.write, + ), + )(); + } + } + + Future _openDirectory() async { + if (_isDirectory) { + _openFolderFileListPage(_directory!.uri); + } + } + + @override + Widget build(BuildContext context) { + return SimpleCard( + onTap: _isDirectory ? _openDirectory : () => _file!.showContents(context), + children: [ + if (_expanded) ...[ + _buildThumbnail(size: 50), + _buildDocumentMetadata(), + _buildAvailableActions() + ] else + _buildDocumentSimplifiedTile(), + ], + ); + } +} diff --git a/example/lib/screens/file_explorer/file_explorer_page.dart b/example/lib/screens/file_explorer/file_explorer_page.dart index 85ee49b..790a7ac 100644 --- a/example/lib/screens/file_explorer/file_explorer_page.dart +++ b/example/lib/screens/file_explorer/file_explorer_page.dart @@ -1,250 +1,238 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:shared_storage/shared_storage.dart'; - -import '../../theme/spacing.dart'; -import '../../widgets/buttons.dart'; -import '../../widgets/light_text.dart'; -import '../../widgets/simple_card.dart'; -import '../../widgets/text_field_dialog.dart'; -import 'file_explorer_card.dart'; - -class FileExplorerPage extends StatefulWidget { - const FileExplorerPage({ - Key? key, - required this.uri, - }) : super(key: key); - - final Uri uri; - - @override - _FileExplorerPageState createState() => _FileExplorerPageState(); -} - -class _FileExplorerPageState extends State { - List? _files; - - late bool _hasPermission; - - StreamSubscription? _listener; - - Future _grantAccess() async { - final uri = await openDocumentTree(initialUri: widget.uri); - - if (uri == null) return; - - _files = null; - - _loadFiles(); - } - - Widget _buildNoPermissionWarning() { - return SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - SimpleCard( - onTap: () => {}, - children: [ - Center( - child: LightText( - 'No permission granted to this folder\n\n${widget.uri}\n', - ), - ), - Center( - child: ActionButton( - 'Grant Access', - onTap: _grantAccess, - ), - ), - ], - ), - ], - ), - ), - ); - } - - Future _createCustomDocument() async { - final filename = await showDialog( - context: context, - builder: (context) => const TextFieldDialog( - hintText: 'File name:', - labelText: 'My Text File', - suffixText: '.txt', - actionText: 'Create', - ), - ); - - if (filename == null) return; - - final createdFile = await createFile( - widget.uri, - mimeType: 'text/plain', - displayName: filename, - ); - - if (createdFile != null) { - _files?.add(createdFile); - - if (mounted) setState(() {}); - } - } - - Widget _buildCreateDocumentButton() { - return SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - Center( - child: ActionButton( - 'Create a custom document', - onTap: _createCustomDocument, - ), - ), - ], - ), - ), - ); - } - - void _didUpdateDocument( - DocumentFile before, - DocumentFile? after, - ) { - if (_files == null) return; - - if (after == null) { - _files!.removeWhere((doc) => doc.id == before.id); - } else { - final indexToUpdate = _files!.indexWhere((doc) => doc.id == before.id); - _files![indexToUpdate] = after; - } - - if (mounted) setState(() {}); - } - - Widget _buildDocumentList() { - return SliverPadding( - padding: k6dp.et, - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final file = _files![index]; - - return FileExplorerCard( - documentFile: file, - didUpdateDocument: (document) => - _didUpdateDocument(file, document), - ); - }, - childCount: _files!.length, - ), - ), - ); - } - - Widget _buildEmptyFolderWarning() { - return SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - SimpleCard( - onTap: () => {}, - children: const [ - Center( - child: LightText( - 'Empty folder', - ), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildFileList() { - return CustomScrollView( - slivers: [ - if (!_hasPermission) - _buildNoPermissionWarning() - else ...[ - _buildCreateDocumentButton(), - if (_files!.isNotEmpty) - _buildDocumentList() - else - _buildEmptyFolderWarning(), - ] - ], - ); - } - - @override - void initState() { - super.initState(); - - _loadFiles(); - } - - @override - void dispose() { - _listener?.cancel(); - - super.dispose(); - } - - Future _loadFiles() async { - _hasPermission = await canRead(widget.uri) ?? false; - - if (!_hasPermission) { - return setState(() => _files = []); - } - - final folderUri = widget.uri; - - const columns = [ - DocumentFileColumn.displayName, - DocumentFileColumn.size, - DocumentFileColumn.lastModified, - DocumentFileColumn.mimeType, - // The column below is a optional column - // you can wether include or not here and - // it will be always available on the results - DocumentFileColumn.id, - ]; - - final fileListStream = listFiles(folderUri, columns: columns); - - _listener = fileListStream.listen( - (file) { - /// Append new files to the current file list - _files = [...?_files, file]; - - /// Update the state only if the widget is currently showing - if (mounted) { - setState(() {}); - } else { - _listener?.cancel(); - } - }, - onDone: () => setState(() => _files = [...?_files]), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('Inside ${widget.uri.pathSegments.last}')), - body: _files == null - ? const Center(child: CircularProgressIndicator()) - : _buildFileList(), - ); - } -} +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:shared_storage/shared_storage.dart'; + +import '../../theme/spacing.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/light_text.dart'; +import '../../widgets/simple_card.dart'; +import '../../widgets/text_field_dialog.dart'; +import 'file_explorer_card.dart'; + +class FileExplorerPage extends StatefulWidget { + const FileExplorerPage({ + super.key, + required this.uri, + }); + + final Uri uri; + + @override + _FileExplorerPageState createState() => _FileExplorerPageState(); +} + +class _FileExplorerPageState extends State { + List? _files; + + late bool _hasPermission; + + ScopedDirectory? _directory; + + StreamSubscription? _listener; + + Future _grantAccess() async { + await SharedStorage.pickDirectory(initialUri: widget.uri); + + _files = null; + + _loadFiles(); + } + + Widget _buildNoPermissionWarning() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + SimpleCard( + onTap: () => {}, + children: [ + Center( + child: LightText( + 'No permission granted to this folder\n\n${widget.uri}\n', + ), + ), + Center( + child: ActionButton( + 'Grant Access', + onTap: _grantAccess, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _createCustomDocument() async { + if (_directory == null) return; + + final filename = await showDialog( + context: context, + builder: (context) => const TextFieldDialog( + hintText: 'File name:', + labelText: 'My Text File', + suffixText: '.txt', + actionText: 'Create', + ), + ); + + if (filename == null) return; + + final createdFile = await _directory!.createChildFile( + mimeType: 'text/plain', + displayName: filename, + ); + + _files?.add(createdFile); + + if (mounted) setState(() {}); + } + + Widget _buildCreateDocumentButton() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + Center( + child: ActionButton( + 'Create a custom document', + onTap: _createCustomDocument, + ), + ), + ], + ), + ), + ); + } + + void _didUpdateDocument( + ScopedFileSystemEntity before, + ScopedFileSystemEntity? after, + ) { + if (_files == null) return; + + if (after == null) { + _files!.removeWhere((doc) => doc.id == before.id); + } else { + final indexToUpdate = _files!.indexWhere((doc) => doc.id == before.id); + _files![indexToUpdate] = after; + } + + if (mounted) setState(() {}); + } + + Widget _buildDocumentList() { + return SliverPadding( + padding: k6dp.et, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final file = _files![index]; + + return FileExplorerCard( + scopedFileSystemEntity: file, + didUpdateDocument: (document) => + _didUpdateDocument(file, document), + ); + }, + childCount: _files!.length, + ), + ), + ); + } + + Widget _buildEmptyFolderWarning() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + SimpleCard( + onTap: () => {}, + children: const [ + Center( + child: LightText( + 'Empty folder', + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildFileList() { + return CustomScrollView( + slivers: [ + if (!_hasPermission) + _buildNoPermissionWarning() + else ...[ + _buildCreateDocumentButton(), + if (_files!.isNotEmpty) + _buildDocumentList() + else + _buildEmptyFolderWarning(), + ] + ], + ); + } + + @override + void initState() { + super.initState(); + + _loadFiles(); + } + + @override + void dispose() { + _listener?.cancel(); + + super.dispose(); + } + + Future _loadFiles() async { + final directory = await ScopedDirectory.fromUri(widget.uri); + + _hasPermission = await directory.canRead(); + + if (!_hasPermission) { + return setState(() => _files = []); + } + + final fileListStream = directory.list(); + + _listener = fileListStream.listen( + (file) { + /// Append new files to the current file list + _files = [...?_files, file]; + + /// Update the state only if the widget is currently showing + if (mounted) { + setState(() {}); + } else { + _listener?.cancel(); + } + }, + onDone: () => setState(() => _files = [...?_files]), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Inside ${widget.uri.pathSegments.last}')), + body: _files == null + ? const Center(child: CircularProgressIndicator()) + : _buildFileList(), + ); + } +} diff --git a/example/lib/screens/granted_uris/granted_uri_card.dart b/example/lib/screens/granted_uris/granted_uri_card.dart index 38e87e8..476c1f6 100644 --- a/example/lib/screens/granted_uris/granted_uri_card.dart +++ b/example/lib/screens/granted_uris/granted_uri_card.dart @@ -1,200 +1,205 @@ -import 'package:flutter/material.dart'; -import 'package:shared_storage/shared_storage.dart'; - -import '../../theme/spacing.dart'; -import '../../utils/disabled_text_style.dart'; -import '../../utils/document_file_utils.dart'; -import '../../widgets/buttons.dart'; -import '../../widgets/key_value_text.dart'; -import '../../widgets/simple_card.dart'; -import '../file_explorer/file_explorer_card.dart'; -import '../file_explorer/file_explorer_page.dart'; - -class GrantedUriCard extends StatefulWidget { - const GrantedUriCard({ - Key? key, - required this.permissionUri, - required this.onChange, - }) : super(key: key); - - final UriPermission permissionUri; - final VoidCallback onChange; - - @override - _GrantedUriCardState createState() => _GrantedUriCardState(); -} - -class _GrantedUriCardState extends State { - Future _appendSampleFile(Uri parentUri) async { - /// Create a new file inside the `parentUri` - final documentFile = await parentUri.toDocumentFile(); - - const kFilename = 'Sample File'; - - final child = await documentFile?.child(kFilename); - - if (child == null) { - documentFile?.createFileAsString( - mimeType: 'text/plain', - content: 'Sample File Content', - displayName: kFilename, - ); - } else { - print('This File Already Exists'); - } - } - - Future _revokeUri(Uri uri) async { - await releasePersistableUriPermission(uri); - - widget.onChange(); - } - - void _openListFilesPage() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => FileExplorerPage(uri: widget.permissionUri.uri), - ), - ); - } - - List _getTreeAvailableOptions() { - return [ - ActionButton( - 'Create sample file', - onTap: () => _appendSampleFile( - widget.permissionUri.uri, - ), - ), - ActionButton( - 'Open file picker here', - onTap: () => openDocumentTree(initialUri: widget.permissionUri.uri), - ) - ]; - } - - @override - void didUpdateWidget(covariant GrantedUriCard oldWidget) { - super.didUpdateWidget(oldWidget); - - documentFile = null; - loading = false; - error = null; - } - - DocumentFile? documentFile; - bool loading = false; - String? error; - - Future _loadDocumentFile() async { - loading = true; - setState(() {}); - - documentFile = await widget.permissionUri.uri.toDocumentFile(); - loading = false; - - if (mounted) setState(() {}); - } - - Future _showDocumentFileContents() async { - try { - final documentFile = await widget.permissionUri.uri.toDocumentFile(); - - if (mounted) documentFile?.showContents(context); - } catch (e) { - error = e.toString(); - } - } - - VoidCallback get _onTapHandler => widget.permissionUri.isTreeDocumentFile - ? _openListFilesPage - : _showDocumentFileContents; - - List _getDocumentAvailableOptions() { - return [ - ActionButton( - widget.permissionUri.isTreeDocumentFile - ? 'Open folder' - : 'Open document', - onTap: _onTapHandler, - ), - if (!widget.permissionUri.isTreeDocumentFile) - ActionButton( - 'Load extra document file data linked to this permission', - onTap: _loadDocumentFile, - ), - ]; - } - - Widget _buildAvailableActions() { - return Wrap( - children: [ - if (widget.permissionUri.isTreeDocumentFile) - ..._getTreeAvailableOptions(), - ..._getDocumentAvailableOptions(), - Padding(padding: k2dp.all), - DangerButton( - 'Revoke', - onTap: () => _revokeUri( - widget.permissionUri.uri, - ), - ), - ], - ); - } - - Widget _buildGrantedUriMetadata() { - return KeyValueText( - entries: { - 'isWritePermission': '${widget.permissionUri.isWritePermission}', - 'isReadPermission': '${widget.permissionUri.isReadPermission}', - 'persistedTime': '${widget.permissionUri.persistedTime}', - 'uri': Uri.decodeFull('${widget.permissionUri.uri}'), - 'isTreeDocumentFile': '${widget.permissionUri.isTreeDocumentFile}', - }, - ); - } - - @override - Widget build(BuildContext context) { - return SimpleCard( - onTap: _onTapHandler, - children: [ - Padding( - padding: k2dp.all.copyWith(top: k8dp, bottom: k8dp), - child: Row( - children: [ - Icon( - Icons.security, - color: disabledColor(), - ), - Text( - widget.permissionUri.isTreeDocumentFile - ? ' Permission over a folder' - : ' Permission over a file', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ], - ), - ), - _buildGrantedUriMetadata(), - _buildAvailableActions(), - if (loading) - const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ) - else if (error != null) - Text('Error was thrown: $error') - else if (documentFile != null) - FileExplorerCard( - documentFile: documentFile!, - didUpdateDocument: (updatedDocumentFile) { - documentFile = updatedDocumentFile; - }, - ) - ], - ); - } -} +import 'package:flutter/material.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import '../../theme/spacing.dart'; +import '../../utils/disabled_text_style.dart'; +import '../../utils/document_file_utils.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/key_value_text.dart'; +import '../../widgets/simple_card.dart'; +import '../file_explorer/file_explorer_card.dart'; +import '../file_explorer/file_explorer_page.dart'; + +class GrantedUriCard extends StatefulWidget { + const GrantedUriCard({ + super.key, + required this.permissionUri, + required this.onChange, + }); + + final UriPermission permissionUri; + final VoidCallback onChange; + + @override + _GrantedUriCardState createState() => _GrantedUriCardState(); +} + +class _GrantedUriCardState extends State { + Future _appendSampleFile(Uri parentUri) async { + /// Create a new file inside the `parentUri` + final directory = await SharedStorage.buildDirectoryFromUri(parentUri); + + const kFilename = 'Sample File'; + + final child = await directory.child(kFilename); + + if (child == null) { + final recipient = await directory.createChildFile( + mimeType: 'text/plain', + displayName: kFilename, + ); + + await recipient.writeAsString('Sample File Content'); + } else { + print('This File Already Exists'); + } + } + + Future _revokeUri(Uri uri) async { + await releasePersistableUriPermission(uri); + + widget.onChange(); + } + + void _openListFilesPage() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => FileExplorerPage(uri: widget.permissionUri.uri), + ), + ); + } + + List _getTreeAvailableOptions() { + return [ + ActionButton( + 'Create sample file', + onTap: () => _appendSampleFile( + widget.permissionUri.uri, + ), + ), + ActionButton( + 'Open file picker here', + onTap: () => + SharedStorage.pickDirectory(initialUri: widget.permissionUri.uri), + ) + ]; + } + + @override + void didUpdateWidget(covariant GrantedUriCard oldWidget) { + super.didUpdateWidget(oldWidget); + + fileSystemEntity = null; + loading = false; + error = null; + } + + ScopedFileSystemEntity? fileSystemEntity; + bool loading = false; + String? error; + + Future _loadDocumentFile() async { + loading = true; + + setState(() {}); + + fileSystemEntity = + await SharedStorage.buildScopedFileFromUri(widget.permissionUri.uri); + loading = false; + + if (mounted) setState(() {}); + } + + Future _showDocumentFileContents() async { + try { + final file = + await SharedStorage.buildScopedFileFromUri(widget.permissionUri.uri); + + if (mounted) file.showContents(context); + } catch (e) { + error = e.toString(); + } + } + + VoidCallback get _onTapHandler => widget.permissionUri.isTreeDocumentFile + ? _openListFilesPage + : _showDocumentFileContents; + + List _getDocumentAvailableOptions() { + return [ + ActionButton( + widget.permissionUri.isTreeDocumentFile + ? 'Open folder' + : 'Open document', + onTap: _onTapHandler, + ), + if (!widget.permissionUri.isTreeDocumentFile) + ActionButton( + 'Load extra document file data linked to this permission', + onTap: _loadDocumentFile, + ), + ]; + } + + Widget _buildAvailableActions() { + return Wrap( + children: [ + if (widget.permissionUri.isTreeDocumentFile) + ..._getTreeAvailableOptions(), + ..._getDocumentAvailableOptions(), + Padding(padding: k2dp.all), + DangerButton( + 'Revoke', + onTap: () => _revokeUri( + widget.permissionUri.uri, + ), + ), + ], + ); + } + + Widget _buildGrantedUriMetadata() { + return KeyValueText( + entries: { + 'isWritePermission': '${widget.permissionUri.isWritePermission}', + 'isReadPermission': '${widget.permissionUri.isReadPermission}', + 'persistedTime': '${widget.permissionUri.persistedTime}', + 'uri': Uri.decodeFull('${widget.permissionUri.uri}'), + 'isTreeDocumentFile': '${widget.permissionUri.isTreeDocumentFile}', + }, + ); + } + + @override + Widget build(BuildContext context) { + return SimpleCard( + onTap: _onTapHandler, + children: [ + Padding( + padding: k2dp.all.copyWith(top: k8dp, bottom: k8dp), + child: Row( + children: [ + Icon( + Icons.security, + color: disabledColor(), + ), + Text( + widget.permissionUri.isTreeDocumentFile + ? ' Permission over a folder' + : ' Permission over a file', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + _buildGrantedUriMetadata(), + _buildAvailableActions(), + if (loading) + const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ) + else if (error != null) + Text('Error was thrown: $error') + else if (fileSystemEntity != null) + FileExplorerCard( + scopedFileSystemEntity: fileSystemEntity!, + didUpdateDocument: (updatedDocumentFile) { + fileSystemEntity = updatedDocumentFile; + }, + ) + ], + ); + } +} diff --git a/example/lib/screens/granted_uris/granted_uris_page.dart b/example/lib/screens/granted_uris/granted_uris_page.dart index 133759c..b1f6ea9 100644 --- a/example/lib/screens/granted_uris/granted_uris_page.dart +++ b/example/lib/screens/granted_uris/granted_uris_page.dart @@ -1,132 +1,248 @@ -import 'package:flutter/material.dart'; -import 'package:shared_storage/shared_storage.dart'; - -import '../../theme/spacing.dart'; -import '../../utils/disabled_text_style.dart'; -import '../../widgets/light_text.dart'; -import 'granted_uri_card.dart'; - -class GrantedUrisPage extends StatefulWidget { - const GrantedUrisPage({Key? key}) : super(key: key); - - @override - _GrantedUrisPageState createState() => _GrantedUrisPageState(); -} - -class _GrantedUrisPageState extends State { - List? __persistedPermissionUris; - List? get _persistedPermissionUris { - if (__persistedPermissionUris == null) return null; - - return List.from(__persistedPermissionUris!) - ..sort((a, z) => z.persistedTime - a.persistedTime); - } - - @override - void initState() { - super.initState(); - - _loadPersistedUriPermissions(); - } - - Future _loadPersistedUriPermissions() async { - __persistedPermissionUris = await persistedUriPermissions(); - - if (mounted) setState(() => {}); - } - - /// Prompt user with a folder picker (Available for Android 5.0+) - Future _openDocumentTree() async { - /// Sample initial directory (WhatsApp status directory) - const kWppStatusFolder = - 'content://com.android.externalstorage.documents/tree/primary%3AAndroid%2Fmedia/document/primary%3AAndroid%2Fmedia%2Fcom.whatsapp%2FWhatsApp%2FMedia%2F.Statuses'; - - /// If the folder don't exist, the OS will ignore the initial directory - await openDocumentTree(initialUri: Uri.parse(kWppStatusFolder)); - - /// TODO: Add broadcast listener to be aware when a Uri permission changes - await _loadPersistedUriPermissions(); - } - - Future _openDocument() async { - const kDownloadsFolder = - 'content://com.android.externalstorage.documents/tree/primary%3ADownloads/document/primary%3ADownloads'; - - final List? selectedDocumentUris = await openDocument( - initialUri: Uri.parse(kDownloadsFolder), - multiple: true, - ); - - if (selectedDocumentUris == null) return; - - await _loadPersistedUriPermissions(); - } - - Widget _buildNoFolderAllowedYetWarning() { - return Padding( - padding: k8dp.all, - child: const Center( - child: LightText('No folders or files allowed yet'), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Shared Storage Sample'), - ), - body: RefreshIndicator( - onRefresh: _loadPersistedUriPermissions, - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: k6dp.all, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - Center( - child: Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - TextButton( - onPressed: _openDocumentTree, - child: const Text('New allowed folder'), - ), - const Padding(padding: EdgeInsets.all(k2dp)), - TextButton( - onPressed: _openDocument, - child: const Text('New allowed files'), - ), - ], - ), - ), - if (_persistedPermissionUris != null) - if (_persistedPermissionUris!.isEmpty) - _buildNoFolderAllowedYetWarning() - else - for (final permissionUri in _persistedPermissionUris!) - GrantedUriCard( - permissionUri: permissionUri, - onChange: _loadPersistedUriPermissions, - ) - else - Center( - child: Text( - 'Loading...', - style: disabledTextStyle(), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} +import 'package:flutter/material.dart'; +import 'package:receive_intent/receive_intent.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import '../../theme/spacing.dart'; +import '../../utils/disabled_text_style.dart'; +import '../../widgets/light_text.dart'; +import '../file_explorer/file_explorer_card.dart'; +import 'granted_uri_card.dart'; + +class GrantedUrisPage extends StatefulWidget { + const GrantedUrisPage({super.key}); + + @override + _GrantedUrisPageState createState() => _GrantedUrisPageState(); +} + +class _GrantedUrisPageState extends State { + List? __persistedPermissionUris; + + List? get _persistedPermissionUris { + if (__persistedPermissionUris == null) return null; + + return List.from(__persistedPermissionUris!) + ..sort((a, z) => z.persistedTime - a.persistedTime); + } + + @override + void initState() { + super.initState(); + + _loadPersistedUriPermissions(); + _loadInitialIntents(); + } + + Future _loadInitialIntents() async { + final receivedIntent = await ReceiveIntent.getInitialIntent(); + + List getMultiSendUris() { + try { + final List receivedUris = List.from( + receivedIntent?.extra?['android.intent.extra.STREAM'] + as Iterable, + ); + + return receivedUris.map(Uri.parse).whereType().toList(); + } catch (e) { + return []; + } + } + + List getSingleSendUri() { + final dynamic receivedUri = + receivedIntent?.extra?['android.intent.extra.STREAM']; + + if (receivedUri is! String) { + return []; + } + + return [Uri.tryParse(receivedUri)].whereType().toList(); + } + + final List uris = []; + + switch (receivedIntent?.action) { + case 'android.intent.action.SEND_MULTIPLE': + uris.addAll(getMultiSendUris()); + break; + case 'android.intent.action.SEND': + uris.addAll(getSingleSendUri()); + break; + } + + // for (final uri in uris) { + // int bytes = 0; + // getDocumentContentAsStream(uri).listen( + // (event) { + // bytes += event.length; + // }, + // onDone: () => print('Done, loaded $bytes'), + // ); + // } + + // return; + + final files = [ + for (final uri in uris) await SharedStorage.buildScopedFileFromUri(uri) + ].whereType().toList(); + + if (files.isNotEmpty) { + if (context.mounted) { + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Received files'), + content: ReceivedUrisByIntentList(files: files), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Ok'), + ), + ], + ); + }, + ); + } + } + } + + Future _loadPersistedUriPermissions() async { + __persistedPermissionUris = await persistedUriPermissions(); + + if (mounted) setState(() => {}); + } + + /// Prompt user with a folder picker (Available for Android 5.0+) + Future _openDocumentTree() async { + /// Sample initial directory (WhatsApp status directory) + const kWppStatusFolder = + 'content://com.android.externalstorage.documents/tree/primary%3AAndroid%2Fmedia/document/primary%3AAndroid%2Fmedia%2Fcom.whatsapp%2FWhatsApp%2FMedia%2F.Statuses'; + + /// If the folder don't exist, the OS will ignore the initial directory + await SharedStorage.pickDirectory(initialUri: Uri.parse(kWppStatusFolder)); + + /// TODO: Add broadcast listener to be aware when a Uri permission changes + await _loadPersistedUriPermissions(); + } + + Future _openDocument() async { + const kDownloadsFolder = + 'content://com.android.externalstorage.documents/tree/primary%3ADownloads/document/primary%3ADownloads'; + + await SharedStorage.pickFiles(initialUri: Uri.parse(kDownloadsFolder)); + + await _loadPersistedUriPermissions(); + } + + Widget _buildNoFolderAllowedYetWarning() { + return Padding( + padding: k8dp.all, + child: const Center( + child: LightText('No folders or files allowed yet'), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Shared Storage Sample'), + ), + body: RefreshIndicator( + onRefresh: _loadPersistedUriPermissions, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: k6dp.all, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + Center( + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + TextButton( + onPressed: _openDocumentTree, + child: const Text('New allowed folder'), + ), + const Padding(padding: EdgeInsets.all(k2dp)), + TextButton( + onPressed: _openDocument, + child: const Text('New allowed files'), + ), + ], + ), + ), + if (_persistedPermissionUris != null) + if (_persistedPermissionUris!.isEmpty) + _buildNoFolderAllowedYetWarning() + else + for (final permissionUri in _persistedPermissionUris!) + GrantedUriCard( + permissionUri: permissionUri, + onChange: _loadPersistedUriPermissions, + ) + else + Center( + child: Text( + 'Loading...', + style: disabledTextStyle(), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class ReceivedUrisByIntentList extends StatefulWidget { + const ReceivedUrisByIntentList({super.key, required this.files}); + + final List files; + + @override + State createState() => + _ReceivedUrisByIntentListState(); +} + +class _ReceivedUrisByIntentListState extends State { + final Map _updated = {}; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height * 0.5, + child: Scrollbar( + thumbVisibility: true, + child: ListView( + shrinkWrap: true, + physics: const AlwaysScrollableScrollPhysics(), + children: [ + for (final fileSystemEntity in widget.files) + FileExplorerCard( + allowExpand: false, + scopedFileSystemEntity: + _updated[fileSystemEntity.id] ?? fileSystemEntity, + didUpdateDocument: (updated) { + // Ignore, we do not allow the card to expand thus not allow to update. + }, + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/screens/large_file/large_file_screen.dart b/example/lib/screens/large_file/large_file_screen.dart new file mode 100644 index 0000000..39f60b7 --- /dev/null +++ b/example/lib/screens/large_file/large_file_screen.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import '../../theme/spacing.dart'; +import '../../widgets/key_value_text.dart'; + +class LargeFileScreen extends StatefulWidget { + const LargeFileScreen({super.key, required this.uri}); + + final Uri uri; + + @override + State createState() => _LargeFileScreenState(); +} + +class _LargeFileScreenState extends State { + ScopedFile? _file; + StreamSubscription? _subscription; + int _bytesLoaded = 0; + + @override + void initState() { + super.initState(); + _loadFile(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + Future _loadFile() async { + _file = await ScopedFile.fromUri(widget.uri); + + setState(() {}); + + _startLoadingFile(); + } + + Future _startLoadingFile() async { + final Stream byteStream = _file!.openRead(); + + _subscription = byteStream.listen( + (bytes) { + _bytesLoaded += bytes.length; + // debounce2s(() => setState(() {})); + setState(() {}); + }, + cancelOnError: true, + onError: (_) => _unsubscribe(), + onDone: _unsubscribe, + ); + } + + void _unsubscribe() { + _subscription?.cancel(); + } + + @override + void setState(VoidCallback fn) { + if (mounted) super.setState(fn); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + _file?.displayName ?? 'Loading...', + ), + ), + body: Center( + child: ContentSizeCard(bytes: _bytesLoaded), + ), + ); + } +} + +class ContentSizeCard extends StatelessWidget { + const ContentSizeCard({super.key, required this.bytes}); + + final int bytes; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: k4dp.all, + child: KeyValueText( + entries: { + 'In bytes': '$bytes B', + 'In kilobytes': '${bytes ~/ 1024} KB', + 'In megabytes': '${bytes / 1024 ~/ 1024} MB', + 'In gigabytes': '${bytes / 1024 / 1024 ~/ 1024} GB', + 'In terabytes': '${bytes / 1024 / 1024 / 1024 ~/ 1014} TB', + }, + ), + ), + ); + } +} diff --git a/example/lib/utils/document_file_utils.dart b/example/lib/utils/document_file_utils.dart index ee27d72..fa22897 100644 --- a/example/lib/utils/document_file_utils.dart +++ b/example/lib/utils/document_file_utils.dart @@ -1,92 +1,195 @@ -import 'package:fl_toast/fl_toast.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:shared_storage/shared_storage.dart'; - -import '../theme/spacing.dart'; -import 'disabled_text_style.dart'; -import 'mime_types.dart'; - -extension ShowText on BuildContext { - Future showToast(String text, {Duration? duration}) { - return showTextToast( - text: text, - context: this, - duration: const Duration(seconds: 5), - ); - } -} - -extension OpenUriWithExternalApp on Uri { - Future openWithExternalApp() async { - final uri = this; - - try { - final launched = await openDocumentFile(uri); - - if (launched) { - print('Successfully opened $uri'); - } else { - print('Failed to launch $uri'); - } - } on PlatformException { - print( - "There's no activity associated with the file type of this Uri: $uri", - ); - } - } -} - -extension ShowDocumentFileContents on DocumentFile { - Future showContents(BuildContext context) async { - final mimeTypeOrEmpty = type ?? ''; - final sizeInBytes = size ?? 0; - - const k10mb = 1024 * 1024 * 10; - - 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(); - } - - // Too long, will take too much time to read - if (sizeInBytes > k10mb) { - return context.showToast('File too long to open'); - } - - final content = await getDocumentContent(uri); - - if (content != null) { - final isImage = mimeTypeOrEmpty.startsWith(kImageMime); - - if (context.mounted) { - await showModalBottomSheet( - context: context, - builder: (context) { - if (isImage) { - return Image.memory(content); - } - - final contentAsString = String.fromCharCodes(content); - - final fileIsEmpty = contentAsString.isEmpty; - - return Container( - padding: k8dp.all, - child: Text( - fileIsEmpty ? 'This file is empty' : contentAsString, - style: fileIsEmpty ? disabledTextStyle() : null, - ), - ); - }, - ); - } - } - } -} +import 'dart:async'; +import 'dart:convert'; + +import 'package:fl_toast/fl_toast.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import '../screens/large_file/large_file_screen.dart'; +import '../theme/spacing.dart'; +import '../widgets/buttons.dart'; +import 'disabled_text_style.dart'; +import 'mime_types.dart'; + +extension ShowText on BuildContext { + Future showToast(String text, {Duration? duration}) { + return showTextToast( + text: text, + context: this, + duration: const Duration(seconds: 5), + ); + } +} + +extension OpenUriWithExternalApp on Uri { + Future openWithExternalApp() async { + final uri = this; + + try { + await SharedStorage.launchUriWithExternalApp(uri); + print('Successfully opened $uri'); + } on SharedStorageException catch (e) { + print('Failed to launch $uri. Cause: ${e.message}.'); + } + } +} + +extension ShowDocumentFileContents on ScopedFile { + Future showContents(BuildContext context) async { + if (context.mounted) { + final mimeTypeOrEmpty = mimeType; + + if (!mimeTypeOrEmpty.startsWith(kTextMime) && + !mimeTypeOrEmpty.startsWith(kImageMime)) { + return uri.openWithExternalApp(); + } + + await showModalBottomSheet( + context: context, + builder: (context) => DocumentContentViewer(file: this), + ); + } + } +} + +class DocumentContentViewer extends StatefulWidget { + const DocumentContentViewer({super.key, required this.file}); + + final ScopedFile file; + + @override + State createState() => _DocumentContentViewerState(); +} + +class _DocumentContentViewerState extends State { + late Uint8List _bytes; + StreamSubscription? _subscription; + late int _bytesLoaded; + late bool _loaded = false; + + @override + void initState() { + super.initState(); + _bytes = Uint8List.fromList([]); + _bytesLoaded = 0; + _loaded = false; + _startLoadingFile(); + } + + @override + void dispose() { + _unsubscribe(); + super.dispose(); + } + + void _unsubscribe() { + _subscription?.cancel(); + } + + static const double _kLargerFileSupported = k1MB * 10; + + Future _startLoadingFile() async { + // The implementation of [getDocumentContent] is no longer blocking! + // It now just merges all events of [getDocumentContentAsStream]. + // Basically: lazy loaded -> No performance issues. + final ScopedFile scopedFile = await ScopedFile.fromUri(widget.file.uri); + + final byteStream = scopedFile.openRead().map(Uint8List.fromList); + + _subscription = byteStream.listen( + (Uint8List chunk) { + _bytesLoaded += chunk.length; + if (_bytesLoaded < _kLargerFileSupported) { + // Load file + _bytes = Uint8List.fromList(_bytes + chunk); + } else { + // otherwise just bump we are not going to display a large file + _bytes = Uint8List.fromList([]); + } + setState(() {}); + }, + cancelOnError: false, + onError: (e, stackTrace) { + print('Error: $e, st: $stackTrace'); + _loaded = true; + _unsubscribe(); + setState(() {}); + }, + onDone: () { + print('Done'); + _loaded = true; + _unsubscribe(); + setState(() {}); + }, + ); + } + + @override + void setState(VoidCallback fn) { + if (mounted) super.setState(fn); + } + + @override + Widget build(BuildContext context) { + if (!_loaded || _bytesLoaded >= _kLargerFileSupported) { + // The ideal approach is to implement a backpressure using: + // - Pause: _subscription!.pause(); + // - Resume: _subscription!.resume(); + // 'Backpressure' is a short term for 'loading only when the user asks for'. + // This happens because there is no way to load a 5GB file into a variable and expect you app doesn't crash. + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Is done: $_loaded'), + if (_bytesLoaded >= k1MB * 10) + Text('File too long to show: ${widget.file.displayName}'), + ContentSizeCard(bytes: _bytesLoaded), + Wrap( + children: [ + ActionButton( + 'Pause', + onTap: () { + if (_subscription?.isPaused == false) { + _subscription?.pause(); + } + }, + ), + ActionButton( + 'Resume', + onTap: () { + if (_subscription?.isPaused == true) { + _subscription?.resume(); + } + }, + ), + ], + ) + ], + ), + ); + } + + final type = widget.file.mimeType; + final mimeTypeOrEmpty = type; + + final isImage = mimeTypeOrEmpty.startsWith(kImageMime); + + if (isImage) { + return Image.memory(_bytes); + } + + final contentAsString = utf8.decode(_bytes); + + final fileIsEmpty = contentAsString.isEmpty; + + return Container( + padding: k8dp.all, + child: Text( + fileIsEmpty ? 'This file is empty' : contentAsString, + style: fileIsEmpty ? disabledTextStyle() : null, + ), + ); + } +} diff --git a/example/lib/widgets/buttons.dart b/example/lib/widgets/buttons.dart index 7f0b4c3..6a8da09 100644 --- a/example/lib/widgets/buttons.dart +++ b/example/lib/widgets/buttons.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; class Button extends StatelessWidget { const Button( this.text, { - Key? key, + super.key, this.color, required this.onTap, - }) : super(key: key); + }); final Color? color; final String text; @@ -25,9 +25,9 @@ class Button extends StatelessWidget { class DangerButton extends StatelessWidget { const DangerButton( this.text, { - Key? key, + super.key, required this.onTap, - }) : super(key: key); + }); final String text; final VoidCallback onTap; @@ -41,9 +41,9 @@ class DangerButton extends StatelessWidget { class ActionButton extends StatelessWidget { const ActionButton( this.text, { - Key? key, + super.key, required this.onTap, - }) : super(key: key); + }); final String text; final VoidCallback onTap; diff --git a/example/lib/widgets/confirmation_dialog.dart b/example/lib/widgets/confirmation_dialog.dart index 8fe9c1e..8c5690a 100644 --- a/example/lib/widgets/confirmation_dialog.dart +++ b/example/lib/widgets/confirmation_dialog.dart @@ -4,16 +4,15 @@ import 'buttons.dart'; class ConfirmationDialog extends StatefulWidget { const ConfirmationDialog({ - Key? key, + super.key, required this.color, this.message, this.body, required this.actionName, - }) : assert( + }) : assert( message != null || body != null, '''You should at least provde [message] or body to explain to the user the context of this confirmation''', - ), - super(key: key); + ); final Color color; final String? message; diff --git a/example/lib/widgets/key_value_text.dart b/example/lib/widgets/key_value_text.dart index db600fc..cdd921e 100644 --- a/example/lib/widgets/key_value_text.dart +++ b/example/lib/widgets/key_value_text.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; /// Use the entry value as [Widget] to use a [WidgetSpan] and [Text] to use a [TextSpan] class KeyValueText extends StatefulWidget { - const KeyValueText({Key? key, required this.entries}) : super(key: key); + const KeyValueText({super.key, required this.entries}); final Map entries; diff --git a/example/lib/widgets/light_text.dart b/example/lib/widgets/light_text.dart index fff581c..6d5def1 100644 --- a/example/lib/widgets/light_text.dart +++ b/example/lib/widgets/light_text.dart @@ -1,7 +1,7 @@ import 'package:flutter/cupertino.dart'; class LightText extends StatelessWidget { - const LightText(this.text, {Key? key}) : super(key: key); + const LightText(this.text, {super.key}); final String text; diff --git a/example/lib/widgets/simple_card.dart b/example/lib/widgets/simple_card.dart index 588e4a0..9bcd49a 100644 --- a/example/lib/widgets/simple_card.dart +++ b/example/lib/widgets/simple_card.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; class SimpleCard extends StatefulWidget { - const SimpleCard({Key? key, required this.onTap, required this.children}) - : super(key: key); + const SimpleCard({super.key, required this.onTap, required this.children}); final VoidCallback onTap; final List children; diff --git a/example/lib/widgets/text_field_dialog.dart b/example/lib/widgets/text_field_dialog.dart index d38a4dc..a30b72d 100644 --- a/example/lib/widgets/text_field_dialog.dart +++ b/example/lib/widgets/text_field_dialog.dart @@ -5,12 +5,12 @@ import 'buttons.dart'; class TextFieldDialog extends StatefulWidget { const TextFieldDialog({ - Key? key, + super.key, required this.labelText, required this.hintText, this.suffixText, required this.actionText, - }) : super(key: key); + }); final String labelText; final String hintText; diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 63e59e8..86a7f45 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,67 +1,68 @@ -name: shared_storage_example -description: Demonstrates how to use the shared_storage plugin. - -# The following line prevents the package from being accidentally published to -# pub.dev using `pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev - -environment: - sdk: ">=2.12.0 <3.0.0" - -dependencies: - fl_toast: ^3.1.0 - flutter: - sdk: flutter - lint: ^1.8.2 - shared_storage: - # When depending on this package from a real application you should use: - # shared_storage: ^x.y.z - # See https://dart.dev/tools/pub/dependencies#version-constraints - # The example app is bundled with the plugin so we use a path dependency on - # the parent directory to use the current plugin's version. - path: ../ - -dev_dependencies: - flutter_test: - sdk: flutter - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages +name: shared_storage_example +description: Demonstrates how to use the shared_storage plugin. + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +environment: + sdk: ">=2.17.0 <3.0.0" + +dependencies: + fl_toast: ^3.1.0 + flutter: + sdk: flutter + lint: ^1.8.2 + receive_intent: ^0.2.4 + shared_storage: + # When depending on this package from a real application you should use: + # shared_storage: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/lib/src/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..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 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/barrel.dart b/lib/src/media_store/barrel.dart index fd41157..53f09ab 100644 --- a/lib/src/media_store/barrel.dart +++ b/lib/src/media_store/barrel.dart @@ -1,2 +1,2 @@ -export './media_store.dart'; -export './media_store_collection.dart'; +export 'media_store.dart'; +export 'models/barrel.dart'; diff --git a/lib/src/media_store/media_store.dart b/lib/src/media_store/media_store.dart index 08a12df..49e71d4 100644 --- a/lib/src/media_store/media_store.dart +++ b/lib/src/media_store/media_store.dart @@ -1,28 +1,268 @@ -import '../channels.dart'; -import 'media_store_collection.dart'; - -/// The contract between the media provider and applications. -/// -/// Get the directory of a given [MediaStoreCollection] -/// -/// [Refer to details](https://developer.android.com/reference/android/provider/MediaStore#summary) -@Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', -) -Future getMediaStoreContentDirectory( - MediaStoreCollection collection, -) async { - const kGetMediaStoreContentDirectory = 'getMediaStoreContentDirectory'; - const kCollectionArg = 'collection'; - - final args = {kCollectionArg: '$collection'}; - - final publicDir = await kMediaStoreChannel.invokeMethod( - kGetMediaStoreContentDirectory, - args, - ); - - if (publicDir == null) return null; - - return Uri.parse(publicDir); -} +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../../shared_storage.dart'; +import '../channels.dart'; +import 'shared_storage_platform_interface.dart'; + +export 'package:mime/mime.dart'; + +class SharedStorage { + const SharedStorage._(); + + static SharedStoragePlatformInterface instance = SharedStorageImpl(); + + static Future buildScopedFileFrom(File file) => + instance.buildScopedFileFrom(file); + + static Future buildScopedFileFromUri(Uri uri) => + instance.buildScopedFileFromUri(uri); + + static Future shareScopedFile(ScopedFile scopedFile) => + instance.shareScopedFile(scopedFile); + + static Future buildScopedDirectoryFrom( + Directory directory, + ) => + instance.buildScopedDirectoryFrom(directory); + + static Future buildDirectoryFromUri(Uri uri) => + instance.buildScopedDirectoryFromUri(uri); + + static Future launchFileWithExternalApp(File file) => + instance.launchFileWithExternalApp(file); + + static Future launchScopedFileWithExternalApp(ScopedFile scopedFile) => + instance.launchScopedFileWithExternalApp(scopedFile); + + static Future launchUriWithExternalApp(Uri uri) => + instance.launchUriWithExternalApp(uri); + + static Future pickDirectory({ + bool persist = true, + bool grantWritePermission = true, + Uri? initialUri, + ScopedDirectory? initialDirectory, + }) => + instance.pickDirectory( + persist: persist, + grantWritePermission: grantWritePermission, + initialUri: initialUri, + initialDirectory: initialDirectory, + ); + + static Future> pickFiles({ + bool persist = true, + bool grantWritePermission = true, + Uri? initialUri, + String mimeType = '*/*', + bool multiple = true, + ScopedDirectory? initialDirectory, + }) => + instance.pickFiles( + persist: persist, + grantWritePermission: grantWritePermission, + initialUri: initialUri, + mimeType: mimeType, + multiple: multiple, + initialDirectory: initialDirectory, + ); +} + +class SharedStorageImpl implements SharedStoragePlatformInterface { + factory SharedStorageImpl() => _instance ??= SharedStorageImpl._(); + + SharedStorageImpl._(); + + static SharedStorageImpl? _instance; + + @override + Future buildScopedFileFrom(File file) { + return ScopedFile.fromFile(file); + } + + @override + Future shareScopedFile(ScopedFile scopedFile) { + return shareUri(scopedFile.uri); + } + + @override + Future buildScopedFileFromUri(Uri uri) { + return ScopedFile.fromUri(uri); + } + + @override + Future buildScopedDirectoryFrom(Directory directory) { + return ScopedDirectory.fromDirectory(directory); + } + + @override + Future buildScopedDirectoryFromUri(Uri uri) { + return ScopedDirectory.fromUri(uri); + } + + @override + Future pickDirectory({ + bool grantWritePermission = true, + bool persist = true, + Uri? initialUri, + ScopedDirectory? initialDirectory, + }) async { + final Map args = { + 'grantWritePermission': grantWritePermission, + 'persistablePermission': persist, + if (initialUri != null) + 'initialUri': initialUri.toString() + else if (initialDirectory != null) + 'initialUri': initialDirectory.uri.toString(), + }; + + final String? selectedDirectoryUri = await kDocumentFileChannel + .invokeMethod('openDocumentTree', args); + + if (selectedDirectoryUri == null) { + throw SharedStorageDirectoryWasNotSelectedException( + 'Scoped directory was not selected. To handle this exception, you can use try-catch block. This is an exception to avoid returning null.', + StackTrace.current, + ); + } + + return ScopedDirectory.fromUri(Uri.parse(selectedDirectoryUri)); + } + + /// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT). + @override + Future> pickFiles({ + bool persist = true, + bool grantWritePermission = true, + Uri? initialUri, + String mimeType = '*/*', + bool multiple = true, + ScopedDirectory? initialDirectory, + }) async { + const String kOpenDocument = 'openDocument'; + + final Map args = { + if (initialUri != null || initialDirectory != null) + 'initialUri': '${initialUri ?? initialDirectory?.uri}', + 'grantWritePermission': grantWritePermission, + 'persistablePermission': persist, + 'mimeType': mimeType, + 'multiple': multiple, + }; + + final List? selectedUriList = + await kDocumentFileChannel.invokeListMethod(kOpenDocument, args); + + if (selectedUriList == null) { + return []; + } + + return Stream.fromIterable(selectedUriList) + .map((dynamic e) => Uri.parse(e as String)) + .asyncMap((Uri uri) => ScopedFile.fromUri(uri)) + .toList(); + } + + /// {@template sharedstorage.saf.share} + /// Start share intent for the given [uri]. + /// + /// To share a file, use [Uri.parse] passing the file absolute path as argument. + /// + /// Note that this method can only share files that your app has permission over, + /// either by being in your app domain (e.g file from your app cache) or that is granted by [openDocumentTree]. + /// + /// Usage: + /// + /// ```dart + /// try { + /// await shareUriOrFile( + /// uri: uri, + /// filePath: path, + /// file: file, + /// ); + /// } on PlatformException catch (e) { + /// // The user clicked twice too fast, which created 2 share requests and the second one failed. + /// // Unhandled Exception: PlatformException(Share callback error, prior share-sheet did not call back, did you await it? Maybe use non-result variant, null, null). + /// log('Error when calling [shareFile]: $e'); + /// return; + /// } + /// ``` + /// {@endtemplate} + @override + Future shareUri( + Uri uri, { + String? mimeType, + }) { + final Map args = { + 'uri': '$uri', + 'type': mimeType, + }; + + return kDocumentFileHelperChannel.invokeMethod('shareUri', args); + } + + @override + Future shareFile( + File file, { + String? mimeType, + }) { + return shareUri(Uri.file(file.path), mimeType: mimeType); + } + + @override + Future shareFileFromPath( + String filePath, { + String? mimeType, + }) { + return shareUri(Uri.file(filePath), mimeType: mimeType); + } + + @override + Future launchFileWithExternalApp(File file) async { + return launchUriWithExternalApp(Uri.file(file.path)); + } + + /// {@template sharedstorage.saf.openDocumentFileWithResult} + /// It's a convenience method to launch the default application associated + /// with the given MIME type. + /// + /// Launch `ACTION_VIEW` intent to open the given document `uri`. + /// + /// Returns a [OpenDocumentFileResult] that allows you handle all edge-cases. + /// {@endtemplate} + @override + Future launchUriWithExternalApp(Uri uri) async { + try { + await kDocumentFileHelperChannel.invokeMethod( + 'openDocumentFile', + {'uri': '$uri'}, + ); + } on PlatformException catch (e) { + switch (e.code) { + case 'EXCEPTION_ACTIVITY_NOT_FOUND': + throw SharedStorageExternalAppNotFoundException( + 'Did not find any app to handle the intent. Make sure you have an app that can handle the given uri: $uri', + StackTrace.current, + ); + case 'EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY': + throw SharedStorageSecurityException( + 'The system denied read access to the given uri: $uri', + StackTrace.current, + ); + case 'EXCEPTION_CANT_OPEN_DOCUMENT_FILE': + default: + throw SharedStorageUnknownException( + 'Unknown exception when trying to open the given uri: $uri', + StackTrace.current, + ); + } + } + } + + @override + Future launchScopedFileWithExternalApp(ScopedFile file) { + return launchUriWithExternalApp(file.uri); + } +} diff --git a/lib/src/media_store/media_store_collection.dart b/lib/src/media_store/media_store_collection.dart deleted file mode 100644 index ed694cc..0000000 --- a/lib/src/media_store/media_store_collection.dart +++ /dev/null @@ -1,60 +0,0 @@ -/// Representation of the [android.provider.MediaStore] Android SDK -/// -/// [Refer to details](https://developer.android.com/reference/android/provider/MediaStore#summary) -class MediaStoreCollection { - const MediaStoreCollection._(this.id); - - final String id; - - static const _kPrefix = 'MediaStoreCollection'; - - /// Available for Android [10 to 12] - /// - /// Equivalent to: - /// - [MediaStore.Audio] - @Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', - ) - static const audio = MediaStoreCollection._('$_kPrefix.Audio'); - - /// Available for Android [10 to 12] - /// - /// Equivalent to: - /// - [MediaStore.Downloads] - @Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', - ) - static const downloads = MediaStoreCollection._('$_kPrefix.Downloads'); - - /// Available for Android [10 to 12] - /// - /// Equivalent to: - /// - [MediaStore.Images] - @Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', - ) - static const images = MediaStoreCollection._('$_kPrefix.Images'); - - /// Available for Android [10 to 12] - /// - /// Equivalent to: - /// - [MediaStore.Video] - @Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', - ) - static const video = MediaStoreCollection._('$_kPrefix.Video'); - - @override - bool operator ==(Object other) { - return other is MediaStoreCollection && other.id == id; - } - - @override - int get hashCode => id.hashCode; - - @override - @Deprecated( - 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.', - ) - String toString() => id; -} diff --git a/lib/src/media_store/models/barrel.dart b/lib/src/media_store/models/barrel.dart new file mode 100644 index 0000000..18e0eeb --- /dev/null +++ b/lib/src/media_store/models/barrel.dart @@ -0,0 +1,3 @@ +export 'scoped_directory.dart'; +export 'scoped_file.dart'; +export 'scoped_file_system_entity.dart'; diff --git a/lib/src/media_store/models/scoped_directory.dart b/lib/src/media_store/models/scoped_directory.dart new file mode 100644 index 0000000..1207cbe --- /dev/null +++ b/lib/src/media_store/models/scoped_directory.dart @@ -0,0 +1,229 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart'; + +import '../../../shared_storage.dart'; +import '../../channels.dart'; + +abstract class ScopedDirectory implements ScopedFileSystemEntity { + static Future fromUri(Uri uri) => + _ScopedDirectory.fromUri(uri); + + static ScopedDirectory fromMap(Map map) { + return _ScopedDirectory.fromMap(map); + } + + static Future fromDirectory(Directory directory) => + _ScopedDirectory.fromDirectory(directory); + + /// Create a child entry with the given `displayName` and `mimeType` using the directory reference. + /// + /// Scoped storage doesn't support "concatenating" paths. + Future createChildDirectory({required String displayName}); + + Future createChildFile({ + required String displayName, + required String mimeType, + }); + + Future child(String displayName); + + @override + Future rename(String newPath); + + Stream list({ + bool recursive = false, + bool followLinks = false, + }); + + ScopedDirectory renameSync(String newPath); +} + +class _ScopedDirectory implements ScopedDirectory { + const _ScopedDirectory({ + required this.displayName, + required this.id, + required this.uri, + required this.parentUri, + required this.lastModified, + }); + + @override + final DateTime lastModified; + + static ScopedDirectory fromMap(Map map) { + return _ScopedDirectory( + displayName: map['displayName'] as String, + id: map['id'] as String, + uri: Uri.parse(map['uri'] as String), + parentUri: map['parentUri'] != null + // If it's not a String, it's better to throw TypeError + ? Uri.parse(map['parentUri'] as String) + : null, + lastModified: DateTime.fromMillisecondsSinceEpoch( + map['lastModified'] as int, + ), + ); + } + + @override + final String displayName; + + @override + final String id; + + @override + final Uri uri; + + @override + final Uri? parentUri; + + static Future fromUri(Uri uri) async { + if (uri.scheme == 'file') { + assert(uri.toString().endsWith('/')); + + final Directory directory = Directory.fromUri(uri); + + if (!directory.existsSync()) { + throw SharedStorageFileNotFoundException( + '${directory.path} does not exist. It either means the file actually does not exist or maybe you do not have permission to read it.', + StackTrace.current, + ); + } + + final FileStat stat = directory.statSync(); + + return _ScopedDirectory( + displayName: basename(directory.path), + id: directory.path, + uri: uri, + lastModified: stat.modified, + parentUri: (() { + try { + return directory.parent.uri; + } on Exception { + return null; + } + })(), + ); + } else { + final Map? response = + await kMediaStoreChannel.invokeMapMethod( + 'getScopedFileSystemEntityFromUri', + { + 'uri': uri.toString(), + }, + ); + + return _ScopedDirectory.fromMap(response!); + } + } + + static Future fromDirectory(Directory directory) async { + return _ScopedDirectory.fromUri(Uri.directory(directory.path)); + } + + @override + Future delete({bool recursive = false}) { + // TODO: implement delete + throw UnimplementedError(); + } + + @override + Future exists() { + // TODO: implement exists + throw UnimplementedError(); + } + + @override + Stream list({ + bool recursive = false, + bool followLinks = true, + }) { + final Map args = { + 'uri': '$uri', + 'event': 'listFiles', + }; + + final Stream onCursorRowResult = + kDocumentFileEventChannel.receiveBroadcastStream(args); + + ScopedFileSystemEntity mapCursorRowToScopedFileSystemEntity( + Map e, + ) { + switch (e['entityType']) { + case 'file': + return ScopedFile.fromMap(e); + case 'directory': + return ScopedDirectory.fromMap(e); + default: + throw ArgumentError('Unknown entity type: ${e['entityType']}.'); + } + } + + Map castDynamicMapType(dynamic event) => + Map.from(event as Map); + + return onCursorRowResult + .map(castDynamicMapType) + .cast>() + .map(mapCursorRowToScopedFileSystemEntity); + } + + @override + Future rename(String newPath) { + // TODO: implement rename + throw UnimplementedError(); + } + + @override + ScopedDirectory renameSync(String newPath) { + // TODO: implement renameSync + throw UnimplementedError(); + } + + @override + Future createChildDirectory({required String displayName}) { + // TODO: implement createChildScopedDirectory + throw UnimplementedError(); + } + + @override + Future createChildFile({ + required String displayName, + required String mimeType, + }) { + // TODO: implement createChildScopedFile + throw UnimplementedError(); + } + + @override + Future child(String displayName) { + // TODO: implement child + throw UnimplementedError(); + } + + @override + FutureOr copyTo( + Uri destination, { + bool recursive = false, + }) { + // TODO: implement copyTo + throw UnimplementedError(); + } + + @override + FutureOr canRead() { + return kDocumentFileChannel.invokeMethod( + 'canRead', + {'uri': '$uri'}, + ).then((bool? value) => value ?? false); + } + + @override + FutureOr canWrite() { + // TODO: implement canWrite + throw UnimplementedError(); + } +} diff --git a/lib/src/media_store/models/scoped_file.dart b/lib/src/media_store/models/scoped_file.dart new file mode 100644 index 0000000..2da91a5 --- /dev/null +++ b/lib/src/media_store/models/scoped_file.dart @@ -0,0 +1,433 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:path/path.dart'; + +import '../../../shared_storage.dart'; +import '../../channels.dart'; +import '../../saf/common/generate_id.dart'; + +extension Also on T { + T also(void Function(T) fn) { + fn(this); + return this; + } +} + +abstract class ScopedFile implements ScopedFileSystemEntity { + const ScopedFile(); + + String get mimeType; + int get length; + + static Future fromUri(Uri uri) { + return _ScopedFile.fromUri(uri); + } + + static ScopedFile fromMap(Map map) { + return _ScopedFile.fromMap(map); + } + + static Future fromFile(File file) { + return _ScopedFile.fromFile(file); + } + + Stream openRead([int start = 0, int? end]); + Future readAsBytes([int start = 0, int? end]); + Future readAsString({Encoding encoding = utf8}); + Future> readAsLines({Encoding encoding = utf8}); + + void openWrite( + Stream byteStream, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + }); + Future writeAsBytes( + Uint8List bytes, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }); + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }); +} + +class _ScopedFile implements ScopedFile { + const _ScopedFile({ + required this.id, + required this.mimeType, + required this.displayName, + required this.length, + required this.uri, + required this.parentUri, + required this.lastModified, + }); + + static const String kOctetStreamMimeType = 'application/octet-stream'; + static const String kDefaultMimeType = kOctetStreamMimeType; + + @override + final DateTime lastModified; + + @override + final int length; + + @override + final Uri uri; + + @override + final Uri? parentUri; + + @override + final String id; + + @override + final String mimeType; + + @override + final String displayName; + + // Will retrive to the header bytes of the file + // See also: + // - https://en.wikipedia.org/wiki/List_of_file_signatures + // - https://pub.dev/packages/mime + static Future _getHeaderBytes( + Stream fileByteStream, { + int headerBytesLength = k1KB, + }) async { + final List headerBytes = []; + + await fileByteStream + .takeWhile((_) => headerBytes.length < headerBytesLength) + .forEach(headerBytes.addAll); + + return Uint8List.fromList(headerBytes); + } + + static Future fromUri(Uri uri) async { + if (uri.scheme == 'file') { + assert(!uri.toString().endsWith('/')); + + // File URI and Directory URI both use 'file' scheme the difference is that directory URIs ends with /. + final bool isDirectory = uri.toFilePath().endsWith('/'); + + final FileSystemEntity entity = + isDirectory ? Directory.fromUri(uri) : File.fromUri(uri); + + if (!entity.existsSync()) { + throw SharedStorageFileNotFoundException( + '${entity.path} does not exist. It either means the file actually does not exist or maybe you do not have permission to read the file', + StackTrace.current, + ); + } + + final FileStat stat = entity.statSync(); + final File file = File.fromUri(uri); + + final Uint8List headerBytes = + await _getHeaderBytes(file.openRead().map(Uint8List.fromList)); + + return _ScopedFile( + mimeType: lookupMimeType(file.path, headerBytes: headerBytes) ?? + kDefaultMimeType, + displayName: basename(file.path), + id: entity.path, + length: stat.size, + uri: uri, + lastModified: stat.modified, + parentUri: entity.parent.uri, + ); + } else { + final Map? response = + await kMediaStoreChannel.invokeMapMethod( + 'getScopedFileSystemEntityFromUri', + { + 'uri': uri.toString(), + }, + ); + + return _ScopedFile.fromMap(response!); + } + } + + static Future fromFile(File file) { + return _ScopedFile.fromUri(Uri.file(file.path)); + } + + @override + Future delete({bool recursive = false}) { + // TODO: implement delete + throw UnimplementedError(); + } + + /// {@template sharedstorage.saf.exists} + /// Equivalent to `DocumentFile.exists`. + /// + /// Verify wheter or not a given [uri] exists. + /// + /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#exists()). + /// {@endtemplate} + @override + Future exists() { + return kDocumentFileChannel.invokeMethod('exists', { + 'uri': '$uri', + }).then((bool? value) => value ?? false); + } + + @override + Future open({FileMode mode = FileMode.read}) { + // TODO: implement open + throw UnimplementedError(); + } + + /// {@template sharedstorage.ScopedFile.openRead} + /// Read the given [uri] contents with lazy-strategy using [Stream]s. + /// + /// Each [Stream] event contains only a small fraction of the [uri] bytes of size [bufferSize]. + /// + /// e.g let target [uri] be a 500MB file and [bufferSize] is 1MB, the returned [Stream] will emit 500 events, each one containing a [Uint8List] of size 1MB (may vary but that's the idea). + /// + /// Since only chunks of the files are actually loaded, there are no performance gaps or the risk of app crash. + /// + /// If that happens, provide the [bufferSize] with a lower limit. + /// + /// Greater [bufferSize] values will speed-up reading but will increase [OutOfMemoryError] chances. + /// {@endtemplate} + + @override + Stream openRead([ + int? start, + int? end, + int bufferSize = k1MB, // max 16 bit integer + ]) { + if (uri.scheme == 'file') { + return _openReadFile(start, end); + } else { + return _openReadScopedFile(start, end, bufferSize); + } + } + + Stream _openReadFile([int? start, int? end]) { + return File.fromUri(uri).openRead(start, end).map(Uint8List.fromList); + } + + static const int kDefaultBufferSize = k1MB; + + Stream _openReadScopedFile([ + int? start, + int? end, + int? bufferSize, + ]) async* { + assert(start == null || start >= 0); + assert(end == null || end >= 0); + + final String callId = generateTimeBasedId(); + + final int initial = start ?? 0; // inclusive + final int? last = end; // inclusive + final int? diff = last != null ? last - initial : null; + final int byteChunkSize = bufferSize ?? kDefaultBufferSize; + + int offset = start ?? 0; + int totalRead = 0; + + await kDocumentFileChannel.invokeMethod( + 'openInputStream', + {'uri': uri.toString(), 'callId': callId}, + ); + + // Offset must be applied only to the first byte chunk + int currentOffset() { + final int current = offset; + offset = 0; + return current; + } + + int calcBufferSize() { + if (diff == null) { + // Read until the EOF + return byteChunkSize; + } + + final int pendingByteChunkSize = diff - totalRead; + + return min(pendingByteChunkSize, byteChunkSize); + } + + Future readByteChunk() async { + final Map? result = + await kDocumentFileChannel.invokeMapMethod( + 'readInputStream', + { + 'callId': callId, + 'offset': currentOffset(), + 'bufferSize': calcBufferSize(), + }, + ); + + if (result == null) { + return null; + } + + final int readBufferSize = result['readBufferSize'] as int; + + if (readBufferSize == -1) { + return null; + } + + return (result['bytes'] as Uint8List) + .also((Uint8List bytes) => totalRead += bytes.length); + } + + while (true) { + final Uint8List? byteChunk = await readByteChunk(); + + if (byteChunk == null) { + break; + } else { + yield byteChunk; + } + } + + await kDocumentFileChannel.invokeMethod( + 'closeInputStream', + { + 'callId': callId, + }, + ); + } + + @override + void openWrite( + Stream byteStream, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + }) { + // TODO: implement openWrite + throw UnimplementedError(); + } + + @override + Future readAsBytes([int start = 0, int? end]) { + return openRead(start, end).reduce( + (Uint8List previous, Uint8List element) => + Uint8List.fromList(previous + element), + ); + } + + @override + Future> readAsLines({Encoding encoding = utf8}) { + return openRead() + .map(encoding.decode) + .transform(const LineSplitter()) + .toList(); + } + + @override + Future readAsString({Encoding encoding = utf8}) { + return openRead() + .map(encoding.decode) + .reduce((String previous, String element) => previous + element); + } + + @override + FutureOr copyTo( + Uri destination, { + bool recursive = false, + }) { + // TODO: implement copyTo + throw UnimplementedError(); + } + + @override + FutureOr rename(String displayName) { + // TODO: implement rename + throw UnimplementedError(); + } + + Map toMap() { + return { + 'lastModified': lastModified.millisecondsSinceEpoch, + 'length': length, + 'uri': uri.toString(), + 'parentUri': parentUri?.toString(), + 'id': id, + 'mimeType': mimeType, + 'displayName': displayName, + }; + } + + factory _ScopedFile.fromMap(Map map) { + return _ScopedFile( + lastModified: + DateTime.fromMillisecondsSinceEpoch(map['lastModified'] as int), + length: map['length'] as int, + uri: Uri.parse(map['uri'] as String), + parentUri: map['parentUri'] != null + ? Uri.parse(map['parentUri'] as String) + : null, + id: map['id'] as String, + mimeType: map['mimeType'] as String, + displayName: map['displayName'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory _ScopedFile.fromJson(String source) => + _ScopedFile.fromMap(json.decode(source) as Map); + + /// {@template sharedstorage.saf.canRead} + /// Equivalent to `DocumentFile.canRead`. + /// + /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#canRead()). + /// {@endtemplate} + @override + FutureOr canRead() async { + return kDocumentFileChannel.invokeMethod('canRead', { + 'uri': '$uri', + }).then((bool? value) => value ?? false); + } + + /// {@template sharedstorage.saf.canWrite} + /// Equivalent to `DocumentFile.canWrite`. + /// + /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#canWrite()). + /// {@endtemplate} + @override + FutureOr canWrite() async { + return kDocumentFileChannel.invokeMethod('canWrite', { + 'uri': '$uri', + }).then((bool? value) => value ?? false); + } + + @override + Future writeAsBytes( + Uint8List bytes, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) { + // TODO: implement writeAsBytes + throw UnimplementedError(); + } + + @override + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) { + // TODO: implement writeAsString + throw UnimplementedError(); + } +} diff --git a/lib/src/media_store/models/scoped_file_system_entity.dart b/lib/src/media_store/models/scoped_file_system_entity.dart new file mode 100644 index 0000000..6f93ab2 --- /dev/null +++ b/lib/src/media_store/models/scoped_file_system_entity.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +abstract class ScopedFileSystemEntity { + /// The URI representing the absolute location of this file system entity. + Uri get uri; + + String get displayName; + + String get id; + + Uri? get parentUri; + + DateTime get lastModified; + + // FutureOr length(); + + // DateTime get lastModified; + + FutureOr copyTo( + Uri destination, { + bool recursive = false, + }); + + FutureOr exists(); + + FutureOr rename(String displayName); + + FutureOr delete({bool recursive = false}); + + FutureOr canRead(); + FutureOr canWrite(); +} + +// final FileSystemEntity a; diff --git a/lib/src/media_store/shared_storage_platform_interface.dart b/lib/src/media_store/shared_storage_platform_interface.dart new file mode 100644 index 0000000..1e0f7a2 --- /dev/null +++ b/lib/src/media_store/shared_storage_platform_interface.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import '../../shared_storage.dart'; + +abstract class SharedStoragePlatformInterface { + Future buildScopedFileFrom(File file); + + Future shareScopedFile(ScopedFile file); + + Future shareUri(Uri uri); + + Future shareFile( + File file, { + String? mimeType, + }); + + Future shareFileFromPath( + String filePath, { + String? mimeType, + }); + + Future buildScopedFileFromUri(Uri uri); + + Future buildScopedDirectoryFrom(Directory directory); + + Future buildScopedDirectoryFromUri(Uri uri); + + Future pickDirectory({ + bool persist, + bool grantWritePermission, + Uri? initialUri, + ScopedDirectory? initialDirectory, + }); + + Future> pickFiles({ + bool persist, + bool grantWritePermission, + Uri? initialUri, + String mimeType, + bool multiple, + ScopedDirectory? initialDirectory, + }); + + Future launchFileWithExternalApp(File file); + Future launchScopedFileWithExternalApp(ScopedFile file); + Future launchUriWithExternalApp(Uri uri); +} diff --git a/lib/src/saf/api/barrel.dart b/lib/src/saf/api/barrel.dart index 1bf0c16..dd73717 100644 --- a/lib/src/saf/api/barrel.dart +++ b/lib/src/saf/api/barrel.dart @@ -1,13 +1,15 @@ -export './content.dart'; -export './copy.dart'; -export './create.dart'; -export './delete.dart'; -export './grant.dart'; -export './info.dart'; -export './open.dart'; -export './persisted.dart'; -export './rename.dart'; -export './search.dart'; -export './tree.dart'; -export './utility.dart'; -export './write.dart'; +export 'content.dart'; +export 'copy.dart'; +export 'create.dart'; +export 'delete.dart'; +export 'exception.dart'; +export 'grant.dart'; +export 'info.dart'; +export 'open.dart'; +export 'persisted.dart'; +export 'rename.dart'; +export 'search.dart'; +export 'share.dart'; +export 'tree.dart'; +export 'utility.dart'; +export 'write.dart'; diff --git a/lib/src/saf/api/content.dart b/lib/src/saf/api/content.dart index 15e9f10..983a240 100644 --- a/lib/src/saf/api/content.dart +++ b/lib/src/saf/api/content.dart @@ -1,55 +1,126 @@ -import 'dart:typed_data'; - -import '../../channels.dart'; -import '../../common/functional_extender.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.getDocumentContentAsString} -/// Helper method to read document using -/// `getDocumentContent` and get the content as String instead as `Uint8List`. -/// {@endtemplate} -Future getDocumentContentAsString( - Uri uri, { - bool throwIfError = false, -}) async { - final bytes = await getDocumentContent(uri); - - if (bytes == null) return null; - - return String.fromCharCodes(bytes); -} - -/// {@template sharedstorage.saf.getDocumentContent} -/// Get content of a given document `uri`. -/// -/// Equivalent to `contentDescriptor` usage. -/// -/// [Refer to details](https://developer.android.com/training/data-storage/shared/documents-files#input_stream). -/// {@endtemplate} -Future getDocumentContent(Uri uri) async => - kDocumentFileChannel.invokeMethod( - 'getDocumentContent', - {'uri': '$uri'}, - ); - -/// {@template sharedstorage.saf.getDocumentThumbnail} -/// Equivalent to `DocumentsContract.getDocumentThumbnail`. -/// -/// [Refer to details](https://developer.android.com/reference/android/provider/DocumentsContract#getDocumentThumbnail(android.content.ContentResolver,%20android.net.Uri,%20android.graphics.Point,%20android.os.CancellationSignal)). -/// {@endtemplate} -Future getDocumentThumbnail({ - required Uri uri, - required double width, - required double height, -}) async { - final args = { - 'uri': '$uri', - 'width': width, - 'height': height, - }; - - final bitmap = await kDocumentsContractChannel - .invokeMapMethod('getDocumentThumbnail', args); - - return bitmap?.apply((b) => DocumentBitmap.fromMap(b)); -} +import 'dart:async'; +import 'dart:typed_data'; + +import '../../../shared_storage.dart'; +import '../../channels.dart'; +import '../../common/functional_extender.dart'; + +/// {@template sharedstorage.saf.getDocumentContentAsString} +/// Helper method to read document using +/// `getDocumentContent` and get the content as String instead as `Uint8List`. +/// {@endtemplate} +Future getDocumentContentAsString(Uri uri) async { + final ScopedFile scopedFile = await ScopedFile.fromUri(uri); + return scopedFile.readAsString(); +} + +/// {@template sharedstorage.saf.getDocumentContent} +/// Get content of a given document [uri]. +/// +/// This method is an alias for [getDocumentContentAsStream] that merges every file chunk into the memory. +/// +/// Be careful: this method crashes the app if the target [uri] is a large file, prefer [getDocumentContentAsStream] instead. +/// {@endtemplate} +Future getDocumentContent(Uri uri) { + return getDocumentContentAsStream(uri).reduce( + (Uint8List previous, Uint8List element) => Uint8List.fromList( + [ + ...previous, + ...element, + ], + ), + ); +} + +const int k1B = 1; +const int k1KB = k1B * 1024; +const int k512KB = k1B * 512; +const int k1MB = k1KB * 1024; +const int k512MB = k1MB * 512; +const int k1GB = k1MB * 1024; +const int k1TB = k1GB * 1024; +const int k1PB = k1TB * 1024; + +/// {@template sharedstorage.getDocumentContentAsStream} +/// Read the given [uri] contents with lazy-strategy using [Stream]s. +/// +/// Each [Stream] event contains only a small fraction of the [uri] bytes of size [bufferSize]. +/// +/// e.g let target [uri] be a 500MB file and [bufferSize] is 1MB, the returned [Stream] will emit 500 events, each one containing a [Uint8List] of size 1MB (may vary but that's the idea). +/// +/// Since only chunks of the files are actually loaded, there are no performance gaps or the risk of app crash. +/// +/// If that happens, provide the [bufferSize] with a lower limit. +/// +/// Greater [bufferSize] values will speed-up reading but will increase [OutOfMemoryError] chances. +/// {@endtemplate} +Stream getDocumentContentAsStream( + Uri uri, { + int start = 0, + int? end, +}) async* { + final ScopedFile scopedFile = await ScopedFile.fromUri(uri); + yield* scopedFile.openRead(start, end); + + // final String callId = generateTimeBasedId(); + + // await kDocumentFileChannel.invokeMethod( + // 'openInputStream', + // {'uri': uri.toString(), 'callId': callId}, + // ); + + // while (true) { + // final Map? result = + // await kDocumentFileChannel.invokeMapMethod( + // 'readInputStream', + // { + // 'callId': callId, + // 'offset': offset, + // 'bufferSize': bufferSize, + // }, + // ); + + // if (result != null) { + // final int readBufferSize = result['readBufferSize'] as int; + + // if (readBufferSize < 0) { + // break; + // } else { + // if (readBufferSize != bufferSize) { + // // Slice the buffer to the actual read size. + // yield (result['bytes'] as Uint8List).sublist(0, readBufferSize); + // } else { + // // No need to slice the buffer, just yield it. + // yield result['bytes'] as Uint8List; + // } + // } + // } + // } + + // await kDocumentFileChannel.invokeMethod( + // 'closeInputStream', + // {'callId': callId}, + // ); +} + +/// {@template sharedstorage.saf.getDocumentThumbnail} +/// Equivalent to `DocumentsContract.getDocumentThumbnail`. +/// +/// [Refer to details](https://developer.android.com/reference/android/provider/DocumentsContract#getDocumentThumbnail(android.content.ContentResolver,%20android.net.Uri,%20android.graphics.Point,%20android.os.CancellationSignal)). +/// {@endtemplate} +Future getDocumentThumbnail({ + required Uri uri, + required double width, + required double height, +}) async { + final Map args = { + 'uri': '$uri', + 'width': width, + 'height': height, + }; + + final Map? bitmap = await kDocumentsContractChannel + .invokeMapMethod('getDocumentThumbnail', args); + + return bitmap?.apply((Map b) => DocumentBitmap.fromMap(b)); +} diff --git a/lib/src/saf/api/copy.dart b/lib/src/saf/api/copy.dart index 830abc6..915dd5c 100644 --- a/lib/src/saf/api/copy.dart +++ b/lib/src/saf/api/copy.dart @@ -1,13 +1,16 @@ -import '../common/barrel.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.copy} -/// Copy a document `uri` to the `destination`. -/// -/// This API uses the `createFile` and `getDocumentContent` API's behind the scenes. -/// {@endtemplate} -Future copy(Uri uri, Uri destination) async { - final args = {'uri': '$uri', 'destination': '$destination'}; - - return invokeMapMethod('copy', args); -} +// import '../common/barrel.dart'; +// import '../models/barrel.dart'; + +// /// {@template sharedstorage.saf.copy} +// /// Copy a document `uri` to the `destination`. +// /// +// /// This API uses the `createFile` and `getDocumentContent` API's behind the scenes. +// /// {@endtemplate} +// Future copy(Uri uri, Uri destination) async { +// final Map args = { +// 'uri': '$uri', +// 'destination': '$destination' +// }; + +// return invokeMapMethod('copy', args); +// } diff --git a/lib/src/saf/api/create.dart b/lib/src/saf/api/create.dart index 4ec1638..4b4d953 100644 --- a/lib/src/saf/api/create.dart +++ b/lib/src/saf/api/create.dart @@ -1,101 +1,102 @@ -import 'dart:typed_data'; - -import '../../channels.dart'; -import '../../common/functional_extender.dart'; -import '../common/barrel.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.createDirectory} -/// Create a direct child document tree named `displayName` given a parent `parentUri`. -/// -/// Equivalent to `DocumentFile.createDirectory`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createDirectory%28java.lang.String%29). -/// {@endtemplate} -Future createDirectory(Uri parentUri, String displayName) async { - final args = { - 'uri': '$parentUri', - 'displayName': displayName, - }; - - final createdDocumentFile = await kDocumentFileChannel - .invokeMapMethod('createDirectory', args); - - return createdDocumentFile?.apply((c) => DocumentFile.fromMap(c)); -} - -/// {@template sharedstorage.saf.createFile} -/// Convenient method to create files using either [String] or raw bytes [Uint8List]. -/// -/// Under the hood this method calls `createFileAsString` or `createFileAsBytes` -/// depending on which argument is passed. -/// -/// If both (bytes and content) are passed, the bytes will be used and the content will be ignored. -/// {@endtemplate} -Future createFile( - Uri parentUri, { - required String mimeType, - required String displayName, - Uint8List? bytes, - String content = '', -}) { - return bytes != null - ? createFileAsBytes( - parentUri, - mimeType: mimeType, - displayName: displayName, - bytes: bytes, - ) - : createFileAsString( - parentUri, - mimeType: mimeType, - displayName: displayName, - content: content, - ); -} - -/// {@template sharedstorage.saf.createFileAsBytes} -/// Create a direct child document of `parentUri`. -/// - `mimeType` is the type of document following [this specs](https://www.iana.org/assignments/media-types/media-types.xhtml). -/// - `displayName` is the name of the document, must be a valid file name. -/// - `bytes` is the content of the document as a list of bytes `Uint8List`. -/// -/// Returns the created file as a `DocumentFile`. -/// -/// Mirror of [`DocumentFile.createFile`](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createFile(java.lang.String,%20java.lang.String)) -/// {@endtemplate} -Future createFileAsBytes( - Uri parentUri, { - required String mimeType, - required String displayName, - required Uint8List bytes, -}) async { - final directoryUri = '$parentUri'; - - final args = { - 'mimeType': mimeType, - 'content': bytes, - 'displayName': displayName, - 'directoryUri': directoryUri, - }; - - return invokeMapMethod('createFile', args); -} - -/// {@template sharedstorage.saf.createFileAsString} -/// Convenient method to create a file. -/// using `content` as String instead Uint8List. -/// {@endtemplate} -Future createFileAsString( - Uri parentUri, { - required String mimeType, - required String displayName, - required String content, -}) { - return createFileAsBytes( - parentUri, - displayName: displayName, - mimeType: mimeType, - bytes: Uint8List.fromList(content.codeUnits), - ); -} +// import 'dart:typed_data'; + +// import '../../channels.dart'; +// import '../../common/functional_extender.dart'; +// import '../common/barrel.dart'; +// import '../models/barrel.dart'; + +// /// {@template sharedstorage.saf.createDirectory} +// /// Create a direct child document tree named `displayName` given a parent `parentUri`. +// /// +// /// Equivalent to `DocumentFile.createDirectory`. +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createDirectory%28java.lang.String%29). +// /// {@endtemplate} +// Future createDirectory(Uri parentUri, String displayName) async { +// final Map args = { +// 'uri': '$parentUri', +// 'displayName': displayName, +// }; + +// final Map? createdDocumentFile = await kDocumentFileChannel +// .invokeMapMethod('createDirectory', args); + +// return createdDocumentFile +// ?.apply((Map c) => DocumentFile.fromMap(c)); +// } + +// /// {@template sharedstorage.saf.createFile} +// /// Convenient method to create files using either [String] or raw bytes [Uint8List]. +// /// +// /// Under the hood this method calls `createFileAsString` or `createFileAsBytes` +// /// depending on which argument is passed. +// /// +// /// If both (bytes and content) are passed, the bytes will be used and the content will be ignored. +// /// {@endtemplate} +// Future createFile( +// Uri parentUri, { +// required String mimeType, +// required String displayName, +// Uint8List? bytes, +// String content = '', +// }) { +// return bytes != null +// ? createFileAsBytes( +// parentUri, +// mimeType: mimeType, +// displayName: displayName, +// bytes: bytes, +// ) +// : createFileAsString( +// parentUri, +// mimeType: mimeType, +// displayName: displayName, +// content: content, +// ); +// } + +// /// {@template sharedstorage.saf.createFileAsBytes} +// /// Create a direct child document of `parentUri`. +// /// - `mimeType` is the type of document following [this specs](https://www.iana.org/assignments/media-types/media-types.xhtml). +// /// - `displayName` is the name of the document, must be a valid file name. +// /// - `bytes` is the content of the document as a list of bytes `Uint8List`. +// /// +// /// Returns the created file as a `DocumentFile`. +// /// +// /// Mirror of [`DocumentFile.createFile`](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createFile(java.lang.String,%20java.lang.String)) +// /// {@endtemplate} +// Future createFileAsBytes( +// Uri parentUri, { +// required String mimeType, +// required String displayName, +// required Uint8List bytes, +// }) async { +// final String directoryUri = '$parentUri'; + +// final Map args = { +// 'mimeType': mimeType, +// 'content': bytes, +// 'displayName': displayName, +// 'directoryUri': directoryUri, +// }; + +// return invokeMapMethod('createFile', args); +// } + +// /// {@template sharedstorage.saf.createFileAsString} +// /// Convenient method to create a file. +// /// using `content` as String instead Uint8List. +// /// {@endtemplate} +// Future createFileAsString( +// Uri parentUri, { +// required String mimeType, +// required String displayName, +// required String content, +// }) { +// return createFileAsBytes( +// parentUri, +// displayName: displayName, +// mimeType: mimeType, +// bytes: Uint8List.fromList(content.codeUnits), +// ); +// } diff --git a/lib/src/saf/api/exception.dart b/lib/src/saf/api/exception.dart new file mode 100644 index 0000000..9e5dfd7 --- /dev/null +++ b/lib/src/saf/api/exception.dart @@ -0,0 +1,64 @@ +/// Exception thrown when the provided URI is invalid, possible reasons: +/// +/// - You have no permissions to read or write in the provided URI/File. +/// - The file was deleted and you are trying to read. +/// - [delete] and [readDocumentContent] ran at the same time. +class SharedStorageFileNotFoundException extends SharedStorageException { + const SharedStorageFileNotFoundException(super.message, super.stackTrace); +} + +/// Exception thrown in the platform-side and that cannot be addressed by the client. +/// +/// You can continue the program flow ignoring this exception or open a issue if it's fatal. +class SharedStorageInternalException extends SharedStorageException { + const SharedStorageInternalException(super.message, super.stackTrace); +} + +/// Exception thrown when the user does not select a folder when calling [SharedStorage.pickScopedDirectory]. +class SharedStorageDirectoryWasNotSelectedException + extends SharedStorageException { + const SharedStorageDirectoryWasNotSelectedException( + super.message, + super.stackTrace, + ); +} + +/// Exception thrown when the user does not select a folder when calling [SharedStorage.pickScopedDirectory]. +class SharedStorageFileWasNotSelectedException extends SharedStorageException { + const SharedStorageFileWasNotSelectedException( + super.message, + super.stackTrace, + ); +} + +class SharedStorageExternalAppNotFoundException extends SharedStorageException { + const SharedStorageExternalAppNotFoundException( + super.message, + super.stackTrace, + ); +} + +class SharedStorageSecurityException extends SharedStorageException { + const SharedStorageSecurityException( + super.message, + super.stackTrace, + ); +} + +class SharedStorageUnknownException extends SharedStorageException { + const SharedStorageUnknownException( + super.message, + super.stackTrace, + ); +} + +/// Custom type for exceptions of [shared_storage] package. +class SharedStorageException implements Exception { + const SharedStorageException(this.message, this.stackTrace); + + final String message; + final StackTrace stackTrace; + + @override + String toString() => '$message $stackTrace'; +} diff --git a/lib/src/saf/api/grant.dart b/lib/src/saf/api/grant.dart index db7b559..387b748 100644 --- a/lib/src/saf/api/grant.dart +++ b/lib/src/saf/api/grant.dart @@ -1,57 +1,59 @@ -import '../../channels.dart'; -import '../../common/functional_extender.dart'; - -/// {@template sharedstorage.saf.openDocumentTree} -/// Start Activity Action: Allow the user to pick a directory subtree. -/// -/// When invoked, the system will display the various `DocumentsProvider` -/// instances installed on the device, letting the user navigate through them. -/// Apps can fully manage documents within the returned directory. -/// -/// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT_TREE). -/// -/// support the initial directory of the directory picker. -/// {@endtemplate} -Future openDocumentTree({ - bool grantWritePermission = true, - bool persistablePermission = true, - Uri? initialUri, -}) async { - const kOpenDocumentTree = 'openDocumentTree'; - - final args = { - 'grantWritePermission': grantWritePermission, - 'persistablePermission': persistablePermission, - if (initialUri != null) 'initialUri': '$initialUri', - }; - - final selectedDirectoryUri = - await kDocumentFileChannel.invokeMethod(kOpenDocumentTree, args); - - return selectedDirectoryUri?.apply((e) => Uri.parse(e)); -} - -/// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT). -Future?> openDocument({ - Uri? initialUri, - bool grantWritePermission = true, - bool persistablePermission = true, - String mimeType = '*/*', - bool multiple = false, -}) async { - const kOpenDocument = 'openDocument'; - - final args = { - if (initialUri != null) 'initialUri': '$initialUri', - 'grantWritePermission': grantWritePermission, - 'persistablePermission': persistablePermission, - 'mimeType': mimeType, - 'multiple': multiple, - }; - - final selectedUriList = - await kDocumentFileChannel.invokeListMethod(kOpenDocument, args); - - return selectedUriList - ?.apply((e) => e.map((e) => Uri.parse(e as String)).toList()); -} +// import '../../channels.dart'; +// import '../../common/functional_extender.dart'; + +// /// {@template sharedstorage.saf.openDocumentTree} +// /// Start Activity Action: Allow the user to pick a directory subtree. +// /// +// /// When invoked, the system will display the various `DocumentsProvider` +// /// instances installed on the device, letting the user navigate through them. +// /// Apps can fully manage documents within the returned directory. +// /// +// /// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT_TREE). +// /// +// /// support the initial directory of the directory picker. +// /// {@endtemplate} +// Future openDocumentTree({ +// bool grantWritePermission = true, +// bool persistablePermission = true, +// Uri? initialUri, +// }) async { +// const String kOpenDocumentTree = 'openDocumentTree'; + +// final Map args = { +// 'grantWritePermission': grantWritePermission, +// 'persistablePermission': persistablePermission, +// if (initialUri != null) 'initialUri': '$initialUri', +// }; + +// final String? selectedDirectoryUri = +// await kDocumentFileChannel.invokeMethod(kOpenDocumentTree, args); + +// return selectedDirectoryUri?.apply((String e) => Uri.parse(e)); +// } + +// /// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT). +// Future?> openDocument({ +// Uri? initialUri, +// bool grantWritePermission = true, +// bool persistablePermission = true, +// String mimeType = '*/*', +// bool multiple = false, +// }) async { +// const String kOpenDocument = 'openDocument'; + +// final Map args = { +// if (initialUri != null) 'initialUri': '$initialUri', +// 'grantWritePermission': grantWritePermission, +// 'persistablePermission': persistablePermission, +// 'mimeType': mimeType, +// 'multiple': multiple, +// }; + +// final List? selectedUriList = +// await kDocumentFileChannel.invokeListMethod(kOpenDocument, args); + +// return selectedUriList?.apply( +// (List list) => +// list.map((dynamic e) => Uri.parse(e as String)).toList(), +// ); +// } diff --git a/lib/src/saf/api/info.dart b/lib/src/saf/api/info.dart index 00535c9..d3f5a12 100644 --- a/lib/src/saf/api/info.dart +++ b/lib/src/saf/api/info.dart @@ -1,54 +1 @@ -import '../../channels.dart'; -import '../../common/functional_extender.dart'; - -/// {@template sharedstorage.saf.length} -/// Equivalent to `DocumentFile.length`. -/// -/// Returns the size of a given document `uri` in bytes. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#length%28%29). -/// {@endtemplate} -Future documentLength(Uri uri) async => kDocumentFileChannel - .invokeMethod('length', {'uri': '$uri'}); - -/// {@template sharedstorage.saf.lastModified} -/// Equivalent to `DocumentFile.lastModified`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#lastModified%28%29). -/// {@endtemplate} -Future lastModified(Uri uri) async { - const kLastModified = 'lastModified'; - - final inMillisecondsSinceEpoch = await kDocumentFileChannel - .invokeMethod(kLastModified, {'uri': '$uri'}); - - return inMillisecondsSinceEpoch - ?.takeIf((i) => i > 0) - ?.apply((i) => DateTime.fromMillisecondsSinceEpoch(i)); -} - -/// {@template sharedstorage.saf.canRead} -/// Equivalent to `DocumentFile.canRead`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#canRead()). -/// {@endtemplate} -Future canRead(Uri uri) async => kDocumentFileChannel - .invokeMethod('canRead', {'uri': '$uri'}); - -/// {@template sharedstorage.saf.canWrite} -/// Equivalent to `DocumentFile.canWrite`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#canWrite()). -/// {@endtemplate} -Future canWrite(Uri uri) async => kDocumentFileChannel - .invokeMethod('canWrite', {'uri': '$uri'}); - -/// {@template sharedstorage.saf.exists} -/// Equivalent to `DocumentFile.exists`. -/// -/// Verify wheter or not a given [uri] exists. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#exists()). -/// {@endtemplate} -Future exists(Uri uri) async => kDocumentFileChannel - .invokeMethod('exists', {'uri': '$uri'}); + diff --git a/lib/src/saf/api/open.dart b/lib/src/saf/api/open.dart index 474cae5..0054847 100644 --- a/lib/src/saf/api/open.dart +++ b/lib/src/saf/api/open.dart @@ -1,42 +1,42 @@ -import 'package:flutter/services.dart'; - -import '../../channels.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.openDocumentFile} -/// Alias for [openDocumentFileWithResult] that returns true if the target [uri] -/// was successfully launched, false otherwise. -/// {@endtemplate} -Future openDocumentFile(Uri uri) async { - final OpenDocumentFileResult result = await openDocumentFileWithResult(uri); - - return result.success; -} - -/// {@template sharedstorage.saf.openDocumentFileWithResult} -/// It's a convenience method to launch the default application associated -/// with the given MIME type and can't be considered an official SAF API. -/// -/// Launch `ACTION_VIEW` intent to open the given document `uri`. -/// -/// Returns a [OpenDocumentFileResult] that allows you handle all edge-cases. -/// {@endtemplate} -Future openDocumentFileWithResult(Uri uri) async { - try { - await kDocumentFileHelperChannel.invokeMethod( - 'openDocumentFile', - {'uri': '$uri'}, - ); - return OpenDocumentFileResult.launched; - } on PlatformException catch (e) { - switch (e.code) { - case 'EXCEPTION_ACTIVITY_NOT_FOUND': - return OpenDocumentFileResult.failedDueActivityNotFound; - case 'EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY': - return OpenDocumentFileResult.failedDueSecurityPolicy; - case 'EXCEPTION_CANT_OPEN_DOCUMENT_FILE': - default: - return OpenDocumentFileResult.failedDueUnknownReason; - } - } -} +// import 'package:flutter/services.dart'; + +// import '../../channels.dart'; +// import '../models/barrel.dart'; + +// /// {@template sharedstorage.saf.openDocumentFile} +// /// Alias for [openDocumentFileWithResult] that returns true if the target [uri] +// /// was successfully launched, false otherwise. +// /// {@endtemplate} +// Future openDocumentFile(Uri uri) async { +// final OpenDocumentFileResult result = await openDocumentFileWithResult(uri); + +// return result.success; +// } + +// /// {@template sharedstorage.saf.openDocumentFileWithResult} +// /// It's a convenience method to launch the default application associated +// /// with the given MIME type and can't be considered an official SAF API. +// /// +// /// Launch `ACTION_VIEW` intent to open the given document `uri`. +// /// +// /// Returns a [OpenDocumentFileResult] that allows you handle all edge-cases. +// /// {@endtemplate} +// Future openDocumentFileWithResult(Uri uri) async { +// try { +// await kDocumentFileHelperChannel.invokeMethod( +// 'openDocumentFile', +// {'uri': '$uri'}, +// ); +// return OpenDocumentFileResult.launched; +// } on PlatformException catch (e) { +// switch (e.code) { +// case 'EXCEPTION_ACTIVITY_NOT_FOUND': +// return OpenDocumentFileResult.failedDueActivityNotFound; +// case 'EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY': +// return OpenDocumentFileResult.failedDueSecurityPolicy; +// case 'EXCEPTION_CANT_OPEN_DOCUMENT_FILE': +// default: +// return OpenDocumentFileResult.failedDueUnknownReason; +// } +// } +// } diff --git a/lib/src/saf/api/persisted.dart b/lib/src/saf/api/persisted.dart index e3a62d1..f055203 100644 --- a/lib/src/saf/api/persisted.dart +++ b/lib/src/saf/api/persisted.dart @@ -10,11 +10,17 @@ import '../models/barrel.dart'; /// To remove an persisted [Uri] call `releasePersistableUriPermission`. /// {@endtemplate} Future?> persistedUriPermissions() async { - final persistedUriPermissions = - await kDocumentFileChannel.invokeListMethod('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 +32,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/rename.dart b/lib/src/saf/api/rename.dart index fa37c95..5249ce8 100644 --- a/lib/src/saf/api/rename.dart +++ b/lib/src/saf/api/rename.dart @@ -1,20 +1,20 @@ -import '../barrel.dart'; -import '../common/barrel.dart'; - -/// {@template sharedstorage.saf.renameTo} -/// Rename the current document `uri` to a new `displayName`. -/// -/// **Note: after using this method `uri` is not longer valid, -/// use the returned document instead**. -/// -/// Returns the updated document. -/// -/// Equivalent to `DocumentFile.renameTo`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#renameTo%28java.lang.String%29). -/// {@endtemplate} -Future renameTo(Uri uri, String displayName) async => - invokeMapMethod( - 'renameTo', - {'uri': '$uri', 'displayName': displayName}, - ); +// import '../barrel.dart'; +// import '../common/barrel.dart'; + +// /// {@template sharedstorage.saf.renameTo} +// /// Rename the current document `uri` to a new `displayName`. +// /// +// /// **Note: after using this method `uri` is not longer valid, +// /// use the returned document instead**. +// /// +// /// Returns the updated document. +// /// +// /// Equivalent to `DocumentFile.renameTo`. +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#renameTo%28java.lang.String%29). +// /// {@endtemplate} +// Future renameTo(Uri uri, String displayName) async => +// invokeMapMethod( +// 'renameTo', +// {'uri': '$uri', 'displayName': displayName}, +// ); diff --git a/lib/src/saf/api/search.dart b/lib/src/saf/api/search.dart index 397408f..854fc6a 100644 --- a/lib/src/saf/api/search.dart +++ b/lib/src/saf/api/search.dart @@ -1,18 +1,18 @@ -import '../barrel.dart'; -import '../common/barrel.dart'; - -/// {@template sharedstorage.saf.findFile} -/// Equivalent to `DocumentFile.findFile`. -/// -/// If you want to check if a given document file exists by their [displayName] prefer using `child` instead. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#findFile%28java.lang.String%29). -/// {@endtemplate} -Future findFile(Uri directoryUri, String displayName) async { - final args = { - 'uri': '$directoryUri', - 'displayName': displayName, - }; - - return invokeMapMethod('findFile', args); -} +// import '../barrel.dart'; +// import '../common/barrel.dart'; + +// /// {@template sharedstorage.saf.findFile} +// /// Equivalent to `DocumentFile.findFile`. +// /// +// /// If you want to check if a given document file exists by their [displayName] prefer using `child` instead. +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#findFile%28java.lang.String%29). +// /// {@endtemplate} +// Future findFile(Uri directoryUri, String displayName) async { +// final Map args = { +// 'uri': '$directoryUri', +// 'displayName': displayName, +// }; + +// return invokeMapMethod('findFile', args); +// } diff --git a/lib/src/saf/api/share.dart b/lib/src/saf/api/share.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/src/saf/api/share.dart @@ -0,0 +1 @@ + diff --git a/lib/src/saf/api/tree.dart b/lib/src/saf/api/tree.dart index 4624f78..32ce9a4 100644 --- a/lib/src/saf/api/tree.dart +++ b/lib/src/saf/api/tree.dart @@ -1,80 +1,80 @@ -import '../../channels.dart'; -import '../../common/functional_extender.dart'; -import '../common/barrel.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.listFiles} -/// **Important**: Ensure you have read permission by calling `canRead` before calling `listFiles`. -/// -/// Emits a new event for each child document file. -/// -/// Works with small and large data file sets. -/// -/// ```dart -/// /// Usage: -/// -/// final myState = []; -/// -/// final onDocumentFile = listFiles(myUri, [DocumentFileColumn.id]); -/// -/// onDocumentFile.listen((document) { -/// myState.add(document); -/// -/// final documentId = document.data?[DocumentFileColumn.id] -/// -/// print('$documentId was added to state'); -/// }); -/// ``` -/// -/// [Refer to details](https://stackoverflow.com/questions/41096332/issues-traversing-through-directory-hierarchy-with-android-storage-access-framew). -/// {@endtemplate} -Stream listFiles( - Uri uri, { - required List columns, -}) { - final args = { - 'uri': '$uri', - 'event': 'listFiles', - 'columns': columns.map((e) => '$e').toList(), - }; - - final onCursorRowResult = - kDocumentFileEventChannel.receiveBroadcastStream(args); - - return onCursorRowResult.map((e) => DocumentFile.fromMap(Map.from(e as Map))); -} - -/// {@template sharedstorage.saf.child} -/// Return the `child` of the given `uri` if it exists otherwise `null`. -/// -/// It's faster than [DocumentFile.findFile] -/// `path` is the single file name or file path. Empty string returns to itself. -/// -/// Equivalent to `DocumentFile.child` extension/overload. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29) -/// {@endtemplate} -@willbemovedsoon -Future child( - Uri uri, - String path, { - bool requiresWriteAccess = false, -}) async { - final args = { - 'uri': '$uri', - 'path': path, - 'requiresWriteAccess': requiresWriteAccess, - }; - - return invokeMapMethod('child', args); -} - -/// {@template sharedstorage.saf.parentFile} -/// Get the parent file of the given `uri`. -/// -/// Equivalent to `DocumentFile.getParentFile`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#getParentFile%28%29). -/// {@endtemplate} -Future parentFile(Uri uri) async => - invokeMapMethod('parentFile', {'uri': '$uri'}); +// import '../../channels.dart'; +// import '../common/barrel.dart'; +// import '../models/barrel.dart'; + +// /// {@template sharedstorage.saf.listFiles} +// /// **Important**: Ensure you have read permission by calling `canRead` before calling `listFiles`. +// /// +// /// Emits a new event for each child document file. +// /// +// /// Works with small and large data file sets. +// /// +// /// ```dart +// /// /// Usage: +// /// +// /// final myState = []; +// /// +// /// final onDocumentFile = listFiles(myUri, [DocumentFileColumn.id]); +// /// +// /// onDocumentFile.listen((document) { +// /// myState.add(document); +// /// +// /// final documentId = document.data?[DocumentFileColumn.id] +// /// +// /// print('$documentId was added to state'); +// /// }); +// /// ``` +// /// +// /// [Refer to details](https://stackoverflow.com/questions/41096332/issues-traversing-through-directory-hierarchy-with-android-storage-access-framew). +// /// {@endtemplate} +// Stream listFiles(Uri uri) { +// final Map args = { +// 'uri': '$uri', +// 'event': 'listFiles', +// }; + +// final Stream onCursorRowResult = +// kDocumentFileEventChannel.receiveBroadcastStream(args); + +// return onCursorRowResult.map( +// (dynamic e) => DocumentFile.fromMap( +// Map.from( +// e as Map, +// ), +// ), +// ); +// } + +// /// {@template sharedstorage.saf.child} +// /// Return the `child` of the given `uri` if it exists otherwise `null`. +// /// +// /// It's faster than [DocumentFile.findFile] +// /// `path` is the single file name or file path. Empty string returns to itself. +// /// +// /// Equivalent to `DocumentFile.child` extension/overload. +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29) +// /// {@endtemplate} +// Future child( +// Uri uri, +// String path, { +// bool requiresWriteAccess = false, +// }) async { +// final Map args = { +// 'uri': '$uri', +// 'path': path, +// 'requiresWriteAccess': requiresWriteAccess, +// }; + +// return invokeMapMethod('child', args); +// } + +// /// {@template sharedstorage.saf.parentFile} +// /// Get the parent file of the given `uri`. +// /// +// /// Equivalent to `DocumentFile.getParentFile`. +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#getParentFile%28%29). +// /// {@endtemplate} +// Future parentFile(Uri uri) async => +// invokeMapMethod('parentFile', {'uri': '$uri'}); diff --git a/lib/src/saf/api/utility.dart b/lib/src/saf/api/utility.dart index cecc8af..d3f5a12 100644 --- a/lib/src/saf/api/utility.dart +++ b/lib/src/saf/api/utility.dart @@ -1,12 +1 @@ -import '../common/barrel.dart'; -import '../models/barrel.dart'; - -/// {@template sharedstorage.saf.fromTreeUri} -/// Create a new `DocumentFile` instance given `uri`. -/// -/// Equivalent to `DocumentFile.fromTreeUri`. -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29). -/// {@endtemplate} -Future fromTreeUri(Uri uri) async => - invokeMapMethod('fromTreeUri', {'uri': '$uri'}); + 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/barrel.dart b/lib/src/saf/common/barrel.dart index ea0e2ff..61b5227 100644 --- a/lib/src/saf/common/barrel.dart +++ b/lib/src/saf/common/barrel.dart @@ -1 +1,2 @@ -export './method_channel_helper.dart'; +export 'method_call_handler.dart'; +export 'method_channel_helper.dart'; diff --git a/lib/src/saf/common/generate_id.dart b/lib/src/saf/common/generate_id.dart new file mode 100644 index 0000000..243bfdd --- /dev/null +++ b/lib/src/saf/common/generate_id.dart @@ -0,0 +1,6 @@ +int currentMicrosecondsSinceEpoch() { + final DateTime now = DateTime.now(); + return now.microsecondsSinceEpoch; +} + +String generateTimeBasedId() => currentMicrosecondsSinceEpoch().toString(); diff --git a/lib/src/saf/common/method_call_handler.dart b/lib/src/saf/common/method_call_handler.dart new file mode 100644 index 0000000..f14b94d --- /dev/null +++ b/lib/src/saf/common/method_call_handler.dart @@ -0,0 +1,74 @@ +import 'dart:developer'; + +import 'package:flutter/services.dart'; + +import '../../channels.dart'; + +typedef MethodCallHandler = void Function( + T arguments, + UnsubscribeFn unsubscribe, +); + +final Map _listeners = {}; + +Future _methodCallHandler(MethodCall call) async { + final MethodCallHandler? handler = _listeners[call.method]; + + handler?.call(call.arguments, () => removeMethodCallListener(call.method)); + + if (handler == null) { + log('Tried to invoke undefined handler: ${call.method} with args ${call.arguments}'); + } + + return null; +} + +void _setupMethodCallHandler() { + kDocumentFileChannel.setMethodCallHandler(_methodCallHandler); +} + +void _removeMethodCallHandler() { + kDocumentFileChannel.setMethodCallHandler(null); +} + +void _removeMethodCallHandlerIfThereAreNoMoreListeners() { + if (_listeners.isEmpty) { + _removeMethodCallHandler(); + } +} + +typedef UnsubscribeFn = void Function(); + +UnsubscribeFn addMethodCallListener(String method, MethodCallHandler fn) { + void unsubscribe() => removeMethodCallListener(method); + + _listeners[method] = (dynamic arguments, UnsubscribeFn unsubscribe) { + if (arguments is T) { + fn(arguments, unsubscribe); + } + }; + + _setupMethodCallHandler(); + + return unsubscribe; +} + +void removeMethodCallListener(String method) { + _listeners.remove(method); + _removeMethodCallHandlerIfThereAreNoMoreListeners(); +} + +void addMapMethodCallListener( + String method, + MethodCallHandler> fn, +) { + addMethodCallListener(method, (dynamic arguments, UnsubscribeFn unsubscribe) { + if (arguments is Map) { + try { + fn(Map.from(arguments), unsubscribe); + } finally {} + } + }); + + _setupMethodCallHandler(); +} diff --git a/lib/src/saf/common/method_channel_helper.dart b/lib/src/saf/common/method_channel_helper.dart index 7fd2511..f30e977 100644 --- a/lib/src/saf/common/method_channel_helper.dart +++ b/lib/src/saf/common/method_channel_helper.dart @@ -1,16 +1,16 @@ -import '../../channels.dart'; -import '../models/barrel.dart'; - -/// Helper method to invoke a native SAF method and return a document file -/// if not null, shouldn't be called directly from non-package code -Future invokeMapMethod( - String method, - Map args, -) async { - final documentMap = - await kDocumentFileChannel.invokeMapMethod(method, args); - - if (documentMap == null) return null; - - return DocumentFile.fromMap(documentMap); -} +// import '../../channels.dart'; +// import '../models/barrel.dart'; + +// /// Helper method to invoke a native SAF method and return a document file +// /// if not null, shouldn't be called directly from non-package code +// Future invokeMapMethod( +// String method, +// Map args, +// ) async { +// final Map? documentMap = +// await kDocumentFileChannel.invokeMapMethod(method, args); + +// if (documentMap == null) return null; + +// return DocumentFile.fromMap(documentMap); +// } diff --git a/lib/src/saf/models/barrel.dart b/lib/src/saf/models/barrel.dart index 4d8e5e8..8853e79 100644 --- a/lib/src/saf/models/barrel.dart +++ b/lib/src/saf/models/barrel.dart @@ -1,5 +1,5 @@ -export './document_bitmap.dart'; -export './document_file.dart'; -export './document_file_column.dart'; -export './open_document_file_result.dart'; -export './uri_permission.dart'; +export 'document_bitmap.dart'; +export 'document_file.dart'; +export 'document_file_column.dart'; +export 'open_document_file_result.dart'; +export 'uri_permission.dart'; diff --git a/lib/src/saf/models/document_bitmap.dart b/lib/src/saf/models/document_bitmap.dart index 40aec2c..b798551 100644 --- a/lib/src/saf/models/document_bitmap.dart +++ b/lib/src/saf/models/document_bitmap.dart @@ -1,29 +1,28 @@ -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) { return DocumentBitmap( uri: (() { - final uri = map['uri'] as String?; + final String? uri = map['uri'] as String?; if (uri == null) return null; @@ -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 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); } diff --git a/lib/src/saf/models/document_file.dart b/lib/src/saf/models/document_file.dart index 3304c26..0a85ab6 100644 --- a/lib/src/saf/models/document_file.dart +++ b/lib/src/saf/models/document_file.dart @@ -1,260 +1,266 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import '../../common/functional_extender.dart'; -import '../api/barrel.dart' as saf; - -extension UriDocumentFileUtils on Uri { - /// {@macro sharedstorage.saf.fromTreeUri} - Future toDocumentFile() => DocumentFile.fromTreeUri(this); - - /// {@macro sharedstorage.saf.openDocumentFile} - Future openDocumentFile() => saf.openDocumentFile(this); -} - -/// Equivalent to Android `DocumentFile` class -/// -/// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) -class DocumentFile { - const DocumentFile({ - required this.id, - required this.parentUri, - required this.size, - required this.name, - required this.type, - required this.uri, - required this.isDirectory, - required this.isFile, - required this.isVirtual, - required this.lastModified, - }); - - factory DocumentFile.fromMap(Map map) { - return DocumentFile( - parentUri: (map['parentUri'] as String?)?.apply((p) => Uri.parse(p)), - id: map['id'] as String?, - isDirectory: map['isDirectory'] as bool?, - isFile: map['isFile'] as bool?, - isVirtual: map['isVirtual'] as bool?, - name: map['name'] as String?, - type: map['type'] as String?, - uri: Uri.parse(map['uri'] as String), - size: map['size'] as int?, - lastModified: (map['lastModified'] as int?) - ?.apply((l) => DateTime.fromMillisecondsSinceEpoch(l)), - ); - } - - /// Display name of this document file, useful to show as a title in a list of files. - final String? name; - - /// Mimetype of this document file, useful to determine how to display it. - final String? type; - - /// Path, URI, location of this document, it can exists or not, you should check by using `exists()` API. - final Uri uri; - - /// Uri of the parent document of [this] document. - final Uri? parentUri; - - /// Generally represented as `primary:/Some/Resource` and can be used to identify the current document file. - /// - /// See [this diagram](https://raw.githubusercontent.com/anggrayudi/SimpleStorage/master/art/terminology.png) for details, source: [anggrayudi/SimpleStorage](https://github.com/anggrayudi/SimpleStorage). - final String? id; - - /// Size of a document in bytes - final int? size; - - /// Whether this document is a directory or not. - /// - /// Since it's a [DocumentFile], it can represent a folder/directory rather than a file. - final bool? isDirectory; - - /// Indicates if this [DocumentFile] represents a _file_. - /// - /// Be aware there are several differences between documents and traditional files: - /// - Documents express their display name and MIME type as separate fields, instead of relying on file extensions. - /// Some documents providers may still choose to append extensions to their display names, but that's an implementation detail. - /// - A single document may appear as the child of multiple directories, so it doesn't inherently know who its parent is. - /// That is, documents don't have a strong notion of path. - /// You can easily traverse a tree of documents from parent to child, but not from child to parent. - /// - Each document has a unique identifier within that provider. - /// This identifier is an opaque implementation detail of the provider, and as such it must not be parsed. - /// - /// [Android Reference](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#:~:text=androidx.documentfile.provider.DocumentFile,but%20it%20has%20substantial%20overhead.() - final bool? isFile; - - /// Indicates if this file represents a virtual document. - /// - /// What is a virtual document? - /// - [Video answer](https://www.youtube.com/watch?v=4h7yCZt231Y) - /// - [Text docs answer](https://developer.android.com/about/versions/nougat/android-7.0#virtual_files) - final bool? isVirtual; - - /// {@macro sharedstorage.saf.fromTreeUri} - static Future fromTreeUri(Uri uri) => saf.fromTreeUri(uri); - - /// {@macro sharedstorage.saf.child} - @willbemovedsoon - Future child( - String path, { - bool requiresWriteAccess = false, - }) => - saf.child(uri, path, requiresWriteAccess: requiresWriteAccess); - - /// {@macro sharedstorage.saf.openDocumentFile} - Future openDocumentFile() => saf.openDocumentFile(uri); - - /// {@macro sharedstorage.saf.openDocumentFile} - /// - /// Alias/shortname for [openDocumentFile] - Future open() => openDocumentFile(); - - /// {@macro sharedstorage.saf.canRead} - Future canRead() async => saf.canRead(uri); - - /// {@macro sharedstorage.saf.canWrite} - Future canWrite() async => saf.canWrite(uri); - - /// {@macro sharedstorage.saf.exists} - Future exists() => saf.exists(uri); - - /// {@macro sharedstorage.saf.delete} - Future delete() => saf.delete(uri); - - /// {@macro sharedstorage.saf.copy} - Future copy(Uri destination) => saf.copy(uri, destination); - - /// {@macro sharedstorage.saf.getDocumentContent} - Future getContent() => saf.getDocumentContent(uri); - - /// {@macro sharedstorage.saf.getContentAsString} - Future getContentAsString() => saf.getDocumentContentAsString(uri); - - /// {@macro sharedstorage.saf.createDirectory} - Future createDirectory(String displayName) => - saf.createDirectory(uri, displayName); - - /// {@macro sharedstorage.saf.createFileAsBytes} - Future createFileAsBytes({ - required String mimeType, - required String displayName, - required Uint8List bytes, - }) => - saf.createFile( - uri, - mimeType: mimeType, - displayName: displayName, - bytes: bytes, - ); - - /// {@macro sharedstorage.saf.createFile} - Future createFile({ - required String mimeType, - required String displayName, - String content = '', - Uint8List? bytes, - }) => - saf.createFile( - uri, - mimeType: mimeType, - displayName: displayName, - content: content, - bytes: bytes, - ); - - /// Alias for [createFile] with [content] param - Future createFileAsString({ - required String mimeType, - required String displayName, - required String content, - }) => - saf.createFile( - uri, - mimeType: mimeType, - displayName: displayName, - content: content, - ); - - /// {@macro sharedstorage.saf.writeToFileAsBytes} - Future writeToFileAsBytes({ - required Uint8List bytes, - FileMode? mode, - }) => - saf.writeToFileAsBytes( - uri, - bytes: bytes, - mode: mode, - ); - - /// {@macro sharedstorage.saf.writeToFile} - Future writeToFile({ - String? content, - Uint8List? bytes, - FileMode? mode, - }) => - saf.writeToFile( - uri, - content: content, - bytes: bytes, - mode: mode, - ); - - /// Alias for [writeToFile] with [content] param - Future writeToFileAsString({ - required String content, - FileMode? mode, - }) => - saf.writeToFile( - uri, - content: content, - mode: mode, - ); - - /// {@macro sharedstorage.saf.lastModified} - final DateTime? lastModified; - - /// {@macro sharedstorage.saf.findFile} - Future findFile(String displayName) => - saf.findFile(uri, displayName); - - /// {@macro sharedstorage.saf.renameTo} - Future renameTo(String displayName) => - saf.renameTo(uri, displayName); - - /// {@macro sharedstorage.saf.parentFile} - Future parentFile() => saf.parentFile(uri); - - Map toMap() { - return { - 'id': id, - 'uri': '$uri', - 'parentUri': '$parentUri', - 'isDirectory': isDirectory, - 'isFile': isFile, - 'isVirtual': isVirtual, - 'name': name, - 'type': type, - 'size': size, - 'lastModified': lastModified?.millisecondsSinceEpoch, - }; - } - - @override - bool operator ==(Object other) { - if (other is! DocumentFile) return false; - - return id == other.id && - parentUri == other.parentUri && - isDirectory == other.isDirectory && - isFile == other.isFile && - isVirtual == other.isVirtual && - name == other.name && - type == other.type && - uri == other.uri; - } - - @override - int get hashCode => - Object.hash(isDirectory, isFile, isVirtual, name, type, uri); -} +// import 'dart:io'; +// import 'dart:typed_data'; + +// import '../../common/functional_extender.dart'; +// import '../api/barrel.dart' as saf; + +// extension UriDocumentFileUtils on Uri { +// /// {@macro sharedstorage.saf.fromTreeUri} +// // Future toDocumentFile() => DocumentFile.fromTreeUri(this); + +// /// {@macro sharedstorage.saf.openDocumentFile} +// Future openDocumentFile() => saf.openDocumentFile(this); +// } + +// /// Equivalent to Android `DocumentFile` class +// /// +// /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) +// class DocumentFile { +// const DocumentFile({ +// required this.id, +// required this.parentUri, +// required this.size, +// required this.name, +// required this.type, +// required this.uri, +// required this.isDirectory, +// required this.isFile, +// required this.isVirtual, +// required this.lastModified, +// }); + +// factory DocumentFile.fromMap(Map map) { +// return DocumentFile( +// parentUri: +// (map['parentUri'] as String?)?.apply((String p) => Uri.parse(p)), +// id: map['id'] as String?, +// isDirectory: map['isDirectory'] as bool?, +// isFile: map['isFile'] as bool?, +// isVirtual: map['isVirtual'] as bool?, +// name: map['name'] as String?, +// type: map['type'] as String?, +// uri: Uri.parse(map['uri'] as String), +// size: map['size'] as int?, +// lastModified: (map['lastModified'] as int?) +// ?.apply((int l) => DateTime.fromMillisecondsSinceEpoch(l)), +// ); +// } + +// /// Display name of this document file, useful to show as a title in a list of files. +// final String? name; + +// /// Mimetype of this document file, useful to determine how to display it. +// final String? type; + +// /// Path, URI, location of this document, it can exists or not, you should check by using `exists()` API. +// final Uri uri; + +// /// Uri of the parent document of [this] document. +// final Uri? parentUri; + +// /// Generally represented as `primary:/Some/Resource` and can be used to identify the current document file. +// /// +// /// See [this diagram](https://raw.githubusercontent.com/anggrayudi/SimpleStorage/master/art/terminology.png) for details, source: [anggrayudi/SimpleStorage](https://github.com/anggrayudi/SimpleStorage). +// final String? id; + +// /// Size of a document in bytes +// final int? size; + +// /// Whether this document is a directory or not. +// /// +// /// Since it's a [DocumentFile], it can represent a folder/directory rather than a file. +// final bool? isDirectory; + +// /// Indicates if this [DocumentFile] represents a _file_. +// /// +// /// Be aware there are several differences between documents and traditional files: +// /// - Documents express their display name and MIME type as separate fields, instead of relying on file extensions. +// /// Some documents providers may still choose to append extensions to their display names, but that's an implementation detail. +// /// - A single document may appear as the child of multiple directories, so it doesn't inherently know who its parent is. +// /// That is, documents don't have a strong notion of path. +// /// You can easily traverse a tree of documents from parent to child, but not from child to parent. +// /// - Each document has a unique identifier within that provider. +// /// This identifier is an opaque implementation detail of the provider, and as such it must not be parsed. +// /// +// /// [Android Reference](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#:~:text=androidx.documentfile.provider.DocumentFile,but%20it%20has%20substantial%20overhead.() +// final bool? isFile; + +// /// Indicates if this file represents a virtual document. +// /// +// /// What is a virtual document? +// /// - [Video answer](https://www.youtube.com/watch?v=4h7yCZt231Y) +// /// - [Text docs answer](https://developer.android.com/about/versions/nougat/android-7.0#virtual_files) +// final bool? isVirtual; + +// /// {@macro sharedstorage.saf.fromTreeUri} +// // static Future fromTreeUri(Uri uri) => saf.fromTreeUri(uri); + +// /// {@macro sharedstorage.saf.child} +// Future child( +// String path, { +// bool requiresWriteAccess = false, +// }) => +// saf.child(uri, path, requiresWriteAccess: requiresWriteAccess); + +// /// {@macro sharedstorage.saf.openDocumentFile} +// Future openDocumentFile() => saf.openDocumentFile(uri); + +// /// {@macro sharedstorage.saf.openDocumentFile} +// /// +// /// Alias/shortname for [openDocumentFile] +// Future open() => openDocumentFile(); + +// /// {@macro sharedstorage.saf.canRead} +// Future canRead() async => saf.canRead(uri); + +// /// {@macro sharedstorage.saf.share} +// Future share() async => saf.shareUri(uri); + +// /// {@macro sharedstorage.saf.canWrite} +// Future canWrite() async => saf.canWrite(uri); + +// /// {@macro sharedstorage.saf.exists} +// Future exists() => saf.exists(uri); + +// /// {@macro sharedstorage.saf.delete} +// Future delete() => saf.delete(uri); + +// /// {@macro sharedstorage.saf.copy} +// Future copy(Uri destination) => saf.copy(uri, destination); + +// /// {@macro sharedstorage.saf.getDocumentContent} +// Future getContent() => saf.getDocumentContent(uri); + +// /// {@macro sharedstorage.saf.getContentAsString} +// Future getContentAsString() => saf.getDocumentContentAsString(uri); + +// /// {@macro sharedstorage.getDocumentContentAsStream} +// Stream getContentAsStream() => saf.getDocumentContentAsStream(uri); + +// /// {@macro sharedstorage.saf.createDirectory} +// Future createDirectory(String displayName) => +// saf.createDirectory(uri, displayName); + +// /// {@macro sharedstorage.saf.createFileAsBytes} +// Future createFileAsBytes({ +// required String mimeType, +// required String displayName, +// required Uint8List bytes, +// }) => +// saf.createFile( +// uri, +// mimeType: mimeType, +// displayName: displayName, +// bytes: bytes, +// ); + +// /// {@macro sharedstorage.saf.createFile} +// Future createFile({ +// required String mimeType, +// required String displayName, +// String content = '', +// Uint8List? bytes, +// }) => +// saf.createFile( +// uri, +// mimeType: mimeType, +// displayName: displayName, +// content: content, +// bytes: bytes, +// ); + +// /// Alias for [createFile] with [content] param +// Future createFileAsString({ +// required String mimeType, +// required String displayName, +// required String content, +// }) => +// saf.createFile( +// uri, +// mimeType: mimeType, +// displayName: displayName, +// content: content, +// ); + +// /// {@macro sharedstorage.saf.writeToFileAsBytes} +// Future writeToFileAsBytes({ +// required Uint8List bytes, +// FileMode? mode, +// }) => +// saf.writeToFileAsBytes( +// uri, +// bytes: bytes, +// mode: mode, +// ); + +// /// {@macro sharedstorage.saf.writeToFile} +// Future writeToFile({ +// String? content, +// Uint8List? bytes, +// FileMode? mode, +// }) => +// saf.writeToFile( +// uri, +// content: content, +// bytes: bytes, +// mode: mode, +// ); + +// /// Alias for [writeToFile] with [content] param +// Future writeToFileAsString({ +// required String content, +// FileMode? mode, +// }) => +// saf.writeToFile( +// uri, +// content: content, +// mode: mode, +// ); + +// /// {@macro sharedstorage.saf.lastModified} +// final DateTime? lastModified; + +// /// {@macro sharedstorage.saf.findFile} +// Future findFile(String displayName) => +// saf.findFile(uri, displayName); + +// /// {@macro sharedstorage.saf.renameTo} +// Future renameTo(String displayName) => +// saf.renameTo(uri, displayName); + +// /// {@macro sharedstorage.saf.parentFile} +// Future parentFile() => saf.parentFile(uri); + +// Map toMap() { +// return { +// 'id': id, +// 'uri': '$uri', +// 'parentUri': '$parentUri', +// 'isDirectory': isDirectory, +// 'isFile': isFile, +// 'isVirtual': isVirtual, +// 'name': name, +// 'type': type, +// 'size': size, +// 'lastModified': lastModified?.millisecondsSinceEpoch, +// }; +// } + +// @override +// bool operator ==(Object other) { +// if (other is! DocumentFile) return false; + +// return id == other.id && +// parentUri == other.parentUri && +// isDirectory == other.isDirectory && +// isFile == other.isFile && +// isVirtual == other.isVirtual && +// name == other.name && +// type == other.type && +// uri == other.uri; +// } + +// @override +// int get hashCode => +// Object.hash(isDirectory, isFile, isVirtual, name, type, uri); +// } diff --git a/lib/src/saf/models/document_file_column.dart b/lib/src/saf/models/document_file_column.dart index e939c86..2f94740 100644 --- a/lib/src/saf/models/document_file_column.dart +++ b/lib/src/saf/models/document_file_column.dart @@ -1,33 +1,33 @@ -/// Representation of the available columns of `DocumentsContract.Document.` -/// -/// [Refer to details](https://developer.android.com/reference/android/provider/DocumentsContract.Document) -enum DocumentFileColumn { - /// Equivalent to [`COLUMN_DOCUMENT_ID`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_DOCUMENT_ID) - id('COLUMN_DOCUMENT_ID'), - - /// Equivalent to [`COLUMN_DISPLAY_NAME`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_DISPLAY_NAME) - displayName('COLUMN_DISPLAY_NAME'), - - /// Equivalent to [`COLUMN_MIME_TYPE`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_MIME_TYPE) - mimeType('COLUMN_MIME_TYPE'), - - /// Equivalent to [`COLUMN_LAST_MODIFIED`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_LAST_MODIFIED) - lastModified('COLUMN_LAST_MODIFIED'), - - /// Equivalent to [`COLUMN_SIZE`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_SIZE) - size('COLUMN_SIZE'), - - /// Equivalent to [`COLUMN_SUMMARY`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_SUMMARY) - summary('COLUMN_SUMMARY'); - - const DocumentFileColumn(this.androidEnumItemName); - - static const _kAndroidEnumTypeName = 'DocumentFileColumn'; - - final String androidEnumItemName; - - String get androidEnumItemId => '$_kAndroidEnumTypeName.$androidEnumItemName'; - - @override - String toString() => androidEnumItemId; -} +// /// Representation of the available columns of `DocumentsContract.Document.` +// /// +// /// [Refer to details](https://developer.android.com/reference/android/provider/DocumentsContract.Document) +// enum DocumentFileColumn { +// /// Equivalent to [`COLUMN_DOCUMENT_ID`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_DOCUMENT_ID) +// id('COLUMN_DOCUMENT_ID'), + +// /// Equivalent to [`COLUMN_DISPLAY_NAME`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_DISPLAY_NAME) +// displayName('COLUMN_DISPLAY_NAME'), + +// /// Equivalent to [`COLUMN_MIME_TYPE`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_MIME_TYPE) +// mimeType('COLUMN_MIME_TYPE'), + +// /// Equivalent to [`COLUMN_LAST_MODIFIED`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_LAST_MODIFIED) +// lastModified('COLUMN_LAST_MODIFIED'), + +// /// Equivalent to [`COLUMN_SIZE`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_SIZE) +// size('COLUMN_SIZE'), + +// /// Equivalent to [`COLUMN_SUMMARY`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_SUMMARY) +// summary('COLUMN_SUMMARY'); + +// const DocumentFileColumn(this.androidEnumItemName); + +// static const String _kAndroidEnumTypeName = 'DocumentFileColumn'; + +// final String androidEnumItemName; + +// String get androidEnumItemId => '$_kAndroidEnumTypeName.$androidEnumItemName'; + +// @override +// String toString() => androidEnumItemId; +// } diff --git a/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, diff --git a/pubspec.yaml b/pubspec.yaml index d332239..0708219 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,30 +1,32 @@ -name: shared_storage -description: "Flutter plugin to work with external storage and privacy-friendly APIs." -version: 0.8.0 -homepage: https://github.com/alexrintt/shared-storage -repository: https://github.com/alexrintt/shared-storage -issue_tracker: https://github.com/alexrintt/shared-storage/issues -documentation: https://github.com/alexrintt/shared-storage -funding: - - https://donate.alexrintt.io - - https://github.com/sponsors/alexrintt - -environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=2.5.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - lint: ^1.8.2 - -flutter: - plugin: - platforms: - android: - package: io.alexrintt.sharedstorage - pluginClass: SharedStoragePlugin +name: shared_storage +description: "Flutter plugin to work with external storage and privacy-friendly APIs." +version: 0.9.0 +homepage: https://github.com/alexrintt/shared-storage +repository: https://github.com/alexrintt/shared-storage +issue_tracker: https://github.com/alexrintt/shared-storage/issues +documentation: https://github.com/alexrintt/shared-storage +funding: + - https://donate.alexrintt.io + - https://github.com/sponsors/alexrintt + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + mime: ^1.0.0 + path: ^1.8.0 + +dev_dependencies: + flutter_test: + sdk: flutter + lint: ^1.8.2 + +flutter: + plugin: + platforms: + android: + package: io.alexrintt.sharedstorage + pluginClass: SharedStoragePlugin diff --git a/test/scoped_file_test.dart b/test/scoped_file_test.dart new file mode 100644 index 0000000..1b8e0fb --- /dev/null +++ b/test/scoped_file_test.dart @@ -0,0 +1,5 @@ +// import 'package:shared_storage/src/media_store/models/barrel.dart'; + +// void main() { +// final scopedFile = ScopedFile.fromUri(); +// }