fix(android): vendor saf_util with a sound pendingResult lifecycle

saf_util 2.0.0 leaves an already-answered MethodChannel.Result behind
in pendingResult: unrelated activity request codes consume (and answer)
the pending picker, and a failed picker launch replies without clearing
the pending state. The next delivery for request 1001 then replies a
second time — IllegalStateException "Reply already submitted" in
MainActivity.onActivityResult, crashing the process.

Vendored at packages/saf_util (BSD-3) with take-and-clear reply
ownership, request codes scoped to the plugin's own pickers, and a
guarded reply on the teardown race.
This commit is contained in:
edde746
2026-07-05 14:16:05 +02:00
parent d9130cd932
commit 173117602a
17 changed files with 2103 additions and 5 deletions
+77
View File
@@ -0,0 +1,77 @@
## 2.0.0 (plezy vendored patch)
Vendored from pub.dev saf_util 2.0.0 with a Result-lifecycle fix:
pending picker replies are take-and-clear (no reply to an already-answered
Result → no "Reply already submitted" crash), unrelated activity request
codes no longer consume the pending picker, and failed picker launches
clear the stale pending state.
## 2.0.0
- Update to Kotlin 2.2.0
## 1.0.0
- No changes. This is just a version bump to mark the stable release.
## 0.12.0
- Add `releasePersistedPermission` to release the persisted permission of a URI.
## 0.11.0
- Allow empty names in `.child`, which returns a [DocumentFile] from the first path param (like `stat`).
## 0.10.0
- Add `stat`. Unlike [documentFileFromUri], `stat` returns null if uri does not exist.
## 0.9.0
- Auto detect path type when `documentFileFromUri.isDir` is null.
## 0.8.0
- Add `hasPersistedPermission`.
## 0.7.0
- Add file descriptor support.
## 0.6.2
- Fix length overflow.
## 0.6.1
- Update docs.
## 0.6.0
- Add `pickFiles` and `pickDirectory` to replace `openFile` and `openDirectory`. The new methods return [DocumentFile] instead of Uri string.
## 0.5.0
- Add `openFile` and `openFiles` to open file picker dialog.
- Make sure read permission is always set when write permission is on.
## 0.4.0
- Relax minimum Dart and Flutter versions (Fixes #2).
## 0.3.0
- `saveThumbnailToFile` width and height params should be int.
## 0.2.0
- Add `saveThumbnailToFile`, which supports extracting image and video thumbnails from an SAF [DocumentFile].
## 0.1.0
- Add `writePermission` and `persistablePermission` to openDirectory
- Support renaming on file `DocumentFile`
## 0.0.1
- Initial release.
+29
View File
@@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2023, Mgenware (Liu YuanYuan)
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+9
View File
@@ -0,0 +1,9 @@
# saf_util
[![pub package](https://img.shields.io/pub/v/saf_util.svg)](https://pub.dev/packages/saf_util)
Util functions for SAF (Storage Access Framework). Note that this package doesn't provide any read / write functions (go to [saf_stream](https://github.com/flutter-cavalry/saf_stream) for that).
## Usage
[Documentation](https://pub.dev/documentation/saf_util/latest/saf_util/)
+4
View File
@@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
@@ -0,0 +1,77 @@
group = "com.fluttercavalry.saf_util"
version = "1.0-SNAPSHOT"
buildscript {
val kotlinVersion = "2.2.20"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
namespace = "com.fluttercavalry.saf_util"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
sourceSets {
getByName("main") {
java.srcDirs("src/main/kotlin")
}
getByName("test") {
java.srcDirs("src/test/kotlin")
}
}
defaultConfig {
minSdk = 24
}
testOptions {
unitTests {
isIncludeAndroidResources = true
all {
it.useJUnitPlatform()
it.outputs.upToDateWhen { false }
it.testLogging {
events("passed", "skipped", "failed", "standardOut", "standardError")
showStandardStreams = true
}
}
}
}
}
dependencies {
implementation("androidx.documentfile:documentfile:1.1.0")
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
}
@@ -0,0 +1 @@
rootProject.name = 'saf_util'
@@ -0,0 +1 @@
rootProject.name = 'saf_util'
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.fluttercavalry.saf_util">
</manifest>
@@ -0,0 +1,862 @@
package com.fluttercavalry.saf_util
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.Point
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import androidx.core.net.toUri
/** SafUtilPlugin */
class SafUtilPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
/// when the Flutter Engine is detached from the Activity
private lateinit var channel : MethodChannel
private lateinit var context: Context
private var activity: Activity? = null
private var pendingResult: Result? = null
private var pendingArguments: PendingArguments? = null
private val requestCodeOpenDocumentTree = 1001
private val requestCodeOpenFiles = 1002
/// Atomically takes ownership of the pending picker state. Every reply to a
/// pending Result must go through this so no already-answered Result is ever
/// left behind to be answered again ("Reply already submitted" crashes).
private fun takePendingResult(): Result? {
val result = pendingResult
pendingResult = null
pendingArguments = null
return result
}
private val fdMap = mutableMapOf<Int, ParcelFileDescriptor>()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "saf_util")
channel.setMethodCallHandler(this)
context = flutterPluginBinding.applicationContext
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener { requestCode, resultCode, data ->
onActivityResult(requestCode, resultCode, data)
}
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"list" -> {
CoroutineScope(Dispatchers.IO).launch {
var cursor: Cursor? = null
try {
val uri = call.argument<String>("uri") as String
val dir = documentFileFromUri(uri, true) ?: throw Exception("Failed to get DocumentFile from $uri")
val resolver = context.contentResolver
val mUri = dir.uri
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
mUri,
DocumentsContract.getDocumentId(mUri)
)
val results = mutableListOf<Map<String, Any?>>()
cursor = resolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED
),
null,
null,
null
)
while (cursor?.moveToNext() == true) {
val documentId = cursor.getString(0)
val fileName = cursor.getString(1)
val fileSize = cursor.getLong(2)
val mimeType = cursor.getString(3)
val lastModified = cursor.getLong(4)
val documentUri = DocumentsContract.buildDocumentUriUsingTree(mUri, documentId)
// Determine if the file is a directory based on the MIME type
val isDirectory = DocumentsContract.Document.MIME_TYPE_DIR == mimeType
// Create a dictionary (map) for each file with its details
val fileInfo = fileObjMap(
documentUri,
isDirectory,
fileName,
fileSize,
lastModified,
)
results.add(fileInfo)
}
launch(Dispatchers.Main) { result.success(results) }
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
} finally {
try {
cursor?.close()
} catch (_: Exception) {
}
}
}
}
"documentFileFromUri" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val isDir = call.argument<Boolean>("isDir")
val df = documentFileFromUri(uri, isDir)
if (df == null) {
launch(Dispatchers.Main) {
result.success(null)
}
return@launch
}
launch(Dispatchers.Main) {
result.success(documentFileToMap(df))
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"stat" -> {
// Unlike `documentFileFromUri` this returns null if `uri` doesn't exist.
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val isDir = call.argument<Boolean>("isDir")
val df = documentFileFromUri(uri, isDir)
if (df == null || !df.exists()) {
launch(Dispatchers.Main) {
result.success(null)
}
return@launch
}
launch(Dispatchers.Main) {
result.success(documentFileToMap(df))
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"exists" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val isDir = call.argument<Boolean>("isDir") as Boolean
val df = documentFileFromUri(uri, isDir)
if (df == null) {
launch(Dispatchers.Main) {
result.success(false)
}
return@launch
}
launch(Dispatchers.Main) {
result.success(df.exists())
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"delete" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val isDir = call.argument<Boolean>("isDir") as Boolean
val df = documentFileFromUri(uri, isDir) ?: throw Exception("Failed to get DocumentFile from $uri")
launch(Dispatchers.Main) {
result.success(df.delete())
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"getFileDescriptor" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val df = documentFileFromUri(uri, false) ?: throw Exception("Failed to get DocumentFile from $uri")
val fd = context.contentResolver.openFileDescriptor(df.uri, "r") ?: throw Exception("Failed to open file descriptor")
val fdInt = fd.fd
fdMap[fdInt] = fd
launch(Dispatchers.Main) {
result.success(fdInt)
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"closeFileDescriptor" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val fdInt = call.argument<Int>("fd") as Int
val fd = fdMap.remove(fdInt)
fd?.close()
launch(Dispatchers.Main) {
result.success(null)
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"mkdirp" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String?>("uri") as String
val names =
call.argument<ArrayList<String>>("names") as ArrayList<String>
var curDocument = documentFileFromUri(uri, true) ?: throw Exception("Failed to get DocumentFile from $uri")
for (curName in names) {
val findRes = findDirectChild(curDocument.uri, curName)
val nextDocument: DocumentFile?
if (findRes == null) {
val createRes = curDocument.createDirectory(curName)
if (createRes != null && createRes.name != curName) {
// There are cases where the created directory has a different name due to concurrent operations.
// In this case, we need to find the directory with the correct name again.
val findRes2 = findDirectChild(curDocument.uri, curName)
nextDocument = if (findRes2 != null) {
documentFileFromUriObj(findRes2.uri, findRes2.isDir)
} else {
null
}
} else {
nextDocument = createRes
}
} else {
nextDocument = documentFileFromUriObj(findRes.uri, findRes.isDir)
}
if (nextDocument == null) {
throw Exception("Failed to create directory at $curName")
}
if (nextDocument.isFile) {
throw Exception("File found at $curName while creating directory")
}
curDocument = nextDocument
}
launch(Dispatchers.Main) {
result.success(documentFileToMap(curDocument))
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"child" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val names = call.argument<ArrayList<String>>("names") as ArrayList<String>
var curDocument = documentFileFromUri(uri, true) ?: throw Exception("Failed to get DocumentFile from $uri")
for (curName in names) {
val findRes = findDirectChild(curDocument.uri, curName)
if (findRes == null) {
launch(Dispatchers.Main) {
result.success(null)
}
return@launch
}
curDocument = documentFileFromUriObj(findRes.uri, findRes.isDir) ?: throw Exception("Failed to get DocumentFile at $curName")
}
launch(Dispatchers.Main) {
result.success(documentFileToMap(curDocument))
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"rename" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val isDir = call.argument<Boolean>("isDir") as Boolean
val newName = call.argument<String>("newName") as String
val df = documentFileFromUri(uri, isDir) ?: throw Exception("Failed to get DocumentFile from $uri")
if (isDir) {
val success = df.renameTo(newName)
if (!success) {
throw Exception("Failed to rename to $newName")
}
launch(Dispatchers.Main) {
result.success(documentFileToMap(df))
}
} else {
val newUri = renameFileDocumentFile(df, newName)
?: throw Exception("Failed to rename to $newName")
val newDF = documentFileFromUriObj(newUri, false)
?: throw Exception("Failed to get DocumentFile from $newUri")
launch(Dispatchers.Main) {
result.success(documentFileToMap(newDF))
}
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"moveTo" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val isDir = call.argument<Boolean>("isDir") as Boolean
val parentUri = call.argument<String>("parentUri") as String
val newParentUri = call.argument<String>("newParentUri") as String
val uriObj = uri.toUri()
val parentUriObj = parentUri.toUri()
val newParentUriObj = newParentUri.toUri()
val resUri = DocumentsContract.moveDocument(
context.contentResolver,
uriObj,
parentUriObj,
newParentUriObj
) ?: throw Exception("Failed to move document")
val resultDF = documentFileFromUriObj(resUri, isDir) ?: throw Exception("Failed to get DocumentFile from $resUri")
launch(Dispatchers.Main) {
result.success(documentFileToMap(resultDF))
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"copyTo" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val isDir = call.argument<Boolean>("isDir") as Boolean
val newParentUri = call.argument<String>("newParentUri") as String
val uriObj = uri.toUri()
val newParentUriObj = newParentUri.toUri()
val resUri = DocumentsContract.copyDocument(
context.contentResolver,
uriObj,
newParentUriObj
) ?: throw Exception("Failed to move document")
val resultDF = documentFileFromUriObj(resUri, isDir) ?: throw Exception("Failed to get DocumentFile from $resUri")
launch(Dispatchers.Main) {
result.success(documentFileToMap(resultDF))
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"pickDirectory" -> {
try {
val initialUri = call.argument<String>("initialUri")
val writePermission = call.argument<Boolean>("writePermission") ?: false
val persistablePermission = call.argument<Boolean>("persistablePermission") ?: false
if (activity == null) {
result.error("NO_ACTIVITY", "Activity is null", null)
return
}
if (pendingResult != null) {
result.error("ALREADY_PICKING", "Another picker process is already in progress", null)
return
}
// Store the result to return the URI later
pendingResult = result
pendingArguments = PendingDirArguments(writePermission, persistablePermission)
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (initialUri != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri.toUri())
}
}
if (persistablePermission) {
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
}
intent.addFlags(
if (writePermission) Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
else Intent.FLAG_GRANT_READ_URI_PERMISSION
)
activity?.startActivityForResult(intent, requestCodeOpenDocumentTree)
} catch (err: Exception) {
// Launch failed after pendingResult was set: drop the pending state
// so a later activity result can't reply to this Result again.
takePendingResult()
result.error("PluginError", err.message, null)
}
}
"pickFiles" -> {
try {
val initialUri = call.argument<String>("initialUri")
val multiple = call.argument<Boolean>("multiple") ?: false
val mimeTypes = call.argument<ArrayList<String>>("mimeTypes") ?: arrayListOf()
if (activity == null) {
result.error("NO_ACTIVITY", "Activity is null", null)
return
}
if (pendingResult != null) {
result.error("ALREADY_PICKING", "Another picker process is already in progress", null)
return
}
// Store the result to return the URI later
pendingResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
if (initialUri != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri.toUri())
}
}
if (multiple) {
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
if (mimeTypes.isEmpty()) {
intent.type = "*/*"
} else if (mimeTypes.size == 1) {
intent.type = mimeTypes[0]
} else {
intent.type = "*/*"
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray())
}
activity?.startActivityForResult(intent, requestCodeOpenFiles)
} catch (err: Exception) {
// Launch failed after pendingResult was set: drop the pending state
// so a later activity result can't reply to this Result again.
takePendingResult()
result.error("PluginError", err.message, null)
}
}
"hasPersistedPermission" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val checkRead = call.argument<Boolean>("checkRead") ?: true
val checkWrite = call.argument<Boolean>("checkWrite") ?: false
val persisted = hasPersistedUriPermission(
context,
uri.toUri(),
checkRead,
checkWrite
)
launch(Dispatchers.Main) {
result.success(persisted)
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"releasePersistedPermission" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val read = call.argument<Boolean>("read") ?: true
val write = call.argument<Boolean>("write") ?: false
context.contentResolver.releasePersistableUriPermission(
uri.toUri(),
if (read && write) Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
else if (read) Intent.FLAG_GRANT_READ_URI_PERMISSION
else if (write) Intent.FLAG_GRANT_WRITE_URI_PERMISSION
else 0
)
launch(Dispatchers.Main) {
result.success(null)
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"saveThumbnailToFile" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = (call.argument<String>("uri") as String).toUri()
val dest = call.argument<String>("destPath")!!
val format = call.argument<String>("format") ?: "jpeg"
val isPng = format == "png"
val quality = call.argument("quality") ?: if (isPng) 100 else 80
val width = call.argument<Int>("width")!!
val height = call.argument<Int>("height")!!
val df = DocumentFile.fromSingleUri(context, uri)
val mime = df?.type
if (mime == null) {
launch(Dispatchers.Main) {
result.success(false)
}
return@launch
}
val bitmap: Bitmap?
// Use MediaMetadataRetriever for video files.
if (mime.startsWith("video/")) {
val mmr = MediaMetadataRetriever()
mmr.setDataSource(context, uri)
bitmap = if (Build.VERSION.SDK_INT >= 27) {
mmr.getScaledFrameAtTime(-1, OPTION_CLOSEST_SYNC, width, height)
} else {
mmr.frameAtTime
}
} else {
// Use DocumentsContract for other files.
bitmap = DocumentsContract.getDocumentThumbnail(
context.contentResolver,
uri,
Point(width, height),
null
)
}
if (bitmap != null) {
File(dest).writeBitmap(
bitmap,
if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG,
quality,
)
launch(Dispatchers.Main) {
result.success(true)
}
} else {
launch(Dispatchers.Main) {
result.success(false)
}
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
else -> result.notImplemented()
}
}
// Handle the result of the folder/file pickers. Returns whether the
// request code was ours — unrelated request codes must not touch (let
// alone answer) the pending picker state.
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode != requestCodeOpenDocumentTree && requestCode != requestCodeOpenFiles) {
return false
}
// Take ownership before replying: a duplicate delivery finds no pending
// state instead of a second reply on an already-answered Result.
val args = pendingArguments
val result = takePendingResult() ?: return true
try {
if (requestCode == requestCodeOpenDocumentTree) {
// Handle the result of the folder picker.
if (resultCode == Activity.RESULT_OK && data != null) {
val uri: Uri? = data.data
if (uri != null && args is PendingDirArguments) {
if (args.persistablePermission) {
context.contentResolver.takePersistableUriPermission(
uri,
if (args.writePermission) Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
else Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
}
val df = documentFileFromUri(uri.toString(), true)
result.success(
if (df != null) documentFileToMap(df)
else null
)
} else {
result.success(null)
}
} else {
// Handle the result of file picker.
if (resultCode == Activity.RESULT_OK && data != null) {
val uris: List<Uri> = if (data.clipData != null) {
val clipData = data.clipData
val uris = mutableListOf<Uri>()
for (i in 0 until clipData!!.itemCount) {
uris.add(clipData.getItemAt(i).uri)
}
uris
} else {
listOf(data.data!!)
}
val documentFileMaps: MutableList<Map<String, Any?>> = mutableListOf()
for (uri in uris) {
val df = documentFileFromUri(uri.toString(), false)
if (df != null) {
documentFileMaps.add(documentFileToMap(df))
}
}
result.success(documentFileMaps) // Return the URIs to Flutter
} else {
result.success(null)
}
}
} catch (err: Exception) {
try {
result.error("PluginError", err.message, null)
} catch (_: IllegalStateException) {
// Reply already submitted — never crash the host activity over a
// picker teardown race.
}
}
return true
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
private fun documentFileFromUri(uri: String, isDir: Boolean?): DocumentFile? {
val uriObj = uri.toUri()
val isDirRes =
isDir ?: DocumentsContract.isTreeUri(uriObj)
return documentFileFromUriObj(uriObj, isDirRes)
}
private fun documentFileFromUriObj(uriObj: Uri, isDir: Boolean): DocumentFile? {
val res = if (isDir) {
DocumentFile.fromTreeUri(context, uriObj)
} else {
DocumentFile.fromSingleUri(context, uriObj)
}
return res
}
private fun hasPersistedUriPermission(
context: Context,
uri: Uri,
checkRead: Boolean,
checkWrite: Boolean
): Boolean {
val permissions = context.contentResolver.persistedUriPermissions
for (permission in permissions) {
if (areSameDocumentLocation(permission.uri, uri)) {
val hasRead = !checkRead || permission.isReadPermission
val hasWrite = !checkWrite || permission.isWritePermission
return hasRead && hasWrite
}
}
return false
}
private fun areSameDocumentLocation(treeUri: Uri, docUri: Uri): Boolean {
val treeDocId = DocumentsContract.getTreeDocumentId(treeUri)
val docId = DocumentsContract.getDocumentId(docUri)
return treeDocId == docId
}
private fun findDirectChild(parentUri: Uri, name: String): UriInfo? {
var cursor: Cursor? = null
try {
val resolver = context.contentResolver
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
parentUri,
DocumentsContract.getDocumentId(parentUri)
)
cursor = resolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
),
null,
null,
null
)
while (cursor?.moveToNext() == true) {
val documentId = cursor.getString(0)
val fileName = cursor.getString(1)
val mimeType = cursor.getString(2)
val isDirectory = DocumentsContract.Document.MIME_TYPE_DIR == mimeType
if (fileName == name) {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(parentUri, documentId)
return UriInfo(documentUri, fileName, isDirectory)
}
}
return null
} finally {
try {
cursor?.close()
} catch (_: Exception) {
}
}
}
private fun renameFileDocumentFile(df: DocumentFile, newName: String): Uri? {
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:documentfile/documentfile/src/main/java/androidx/documentfile/provider/TreeDocumentFile.java
try {
val result = DocumentsContract.renameDocument(
context.contentResolver, df.uri, newName
)
return result
} catch (err: Exception) {
return null
}
}
private fun documentFileToMap(file: DocumentFile): Map<String, Any?> {
return fileObjMap(
file.uri,
file.isDirectory,
file.name ?: "",
file.length(),
file.lastModified(),
)
}
private fun fileObjMap(
uri: Uri,
isDir: Boolean,
name: String,
length: Long,
lastMod: Long,
): Map<String, Any?> {
return mapOf(
"uri" to uri.toString(),
"isDir" to isDir,
"name" to name,
"length" to length,
"lastModified" to lastMod,
)
}
private fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) {
outputStream().use { out ->
bitmap.compress(format, quality, out)
out.flush()
}
}
}
internal data class UriInfo(val uri: Uri, val name: String, val isDir: Boolean)
internal open class PendingArguments
internal class PendingDirArguments(
val writePermission: Boolean,
val persistablePermission: Boolean,
): PendingArguments()
@@ -0,0 +1,27 @@
package com.fluttercavalry.saf_util
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.mockito.Mockito
import kotlin.test.Test
/*
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
*
* Once you have built the plugin's example app, you can run these tests from the command
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
* you can run them directly from IDEs that support JUnit such as Android Studio.
*/
internal class SafUtilPluginTest {
@Test
fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
val plugin = SafUtilPlugin()
val call = MethodCall("getPlatformVersion", null)
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
plugin.onMethodCall(call, mockResult)
Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE)
}
}
+256
View File
@@ -0,0 +1,256 @@
import 'saf_util_platform_interface.dart';
class SafUtil {
/// Shows a folder picker dialog and returns the selected folder as [SafDocumentFile].
///
/// [initialUri] is the initial URI to show in the dialog.
/// [writePermission] is true if the folder should have write permission.
/// [persistablePermission] is true if the permission should be persistable.
Future<SafDocumentFile?> pickDirectory(
{String? initialUri,
bool? writePermission,
bool? persistablePermission}) {
return SafUtilPlatform.instance.pickDirectory(
initialUri: initialUri,
writePermission: writePermission,
persistablePermission: persistablePermission);
}
/// Shows a file picker dialog and returns the selected file [SafDocumentFile].
/// This calls [pickFiles] with [multiple] set to false.
///
/// [initialUri] is the initial URI to show in the dialog.
/// [mimeTypes] is a list of MIME types to filter the files.
Future<SafDocumentFile?> pickFile({
String? initialUri,
List<String>? mimeTypes,
}) {
return SafUtilPlatform.instance.pickFile(
initialUri: initialUri,
mimeTypes: mimeTypes,
);
}
/// Shows a file picker dialog and returns a list of selected file [SafDocumentFile].
///
/// [initialUri] is the initial URI to show in the dialog.
/// [mimeTypes] is a list of MIME types to filter the files.
/// [multiple] is true if multiple files can be selected.
Future<List<SafDocumentFile>?> pickFiles({
String? initialUri,
List<String>? mimeTypes,
multiple = true,
}) {
return SafUtilPlatform.instance.pickFiles(
initialUri: initialUri,
mimeTypes: mimeTypes,
multiple: multiple,
);
}
/// Lists the contents of the specified directory URI.
/// Returns a list of [SafDocumentFile] objects.
///
/// [uri] is the URI of the directory.
Future<List<SafDocumentFile>> list(String uri) {
return SafUtilPlatform.instance.list(uri);
}
/// Gets a [SafDocumentFile] object from the specified URI.
///
/// [uri] is the URI of the file or directory.
/// [isDir] is true if the URI is a directory. [null] means
/// auto-detect.
Future<SafDocumentFile?> documentFileFromUri(String uri, bool? isDir) {
return SafUtilPlatform.instance.documentFileFromUri(uri, isDir);
}
/// Gets a [SafDocumentFile] object from the specified URI.
/// Unlike [documentFileFromUri], this method returns null
/// if the URI does not exist.
///
/// [uri] is the URI of the file or directory.
/// [isDir] is true if the URI is a directory. [null] means
/// auto-detect.
Future<SafDocumentFile?> stat(String uri, bool? isDir) {
return SafUtilPlatform.instance.stat(uri, isDir);
}
/// Checks if the specified file or directory exists.
///
/// [uri] is the URI of the file or directory.
/// [isDir] is true if the URI is a directory.
Future<bool> exists(String uri, bool isDir) {
return SafUtilPlatform.instance.exists(uri, isDir);
}
/// Deletes the specified file or directory.
///
/// [uri] is the URI of the file or directory.
/// [isDir] is true if the URI is a directory.
Future<void> delete(String uri, bool isDir) {
return SafUtilPlatform.instance.delete(uri, isDir);
}
/// Creates a directory and all its parent directories.
/// Returns the created directory as a [SafDocumentFile] object.
///
/// [uri] is the URI of the directory to create.
/// [names] is a list of directory names to create.
Future<SafDocumentFile> mkdirp(String uri, List<String> names) {
return SafUtilPlatform.instance.mkdirp(uri, names);
}
/// Gets the child file or directory with the specified name.
///
/// [uri] is the URI of the parent directory.
/// [names] is a list of directory names to traverse.
Future<SafDocumentFile?> child(String uri, List<String> names) {
return SafUtilPlatform.instance.child(uri, names);
}
/// Renames the specified file or directory.
/// Returns the renamed file or directory as a [SafDocumentFile] object.
///
/// [uri] is the URI of the file or directory.
/// [isDir] is true if the URI is a directory.
/// [newName] is the new name of the file or directory.
Future<SafDocumentFile> rename(String uri, bool isDir, String newName) {
return SafUtilPlatform.instance.rename(uri, isDir, newName);
}
/// Moves the specified file or directory to a new parent directory.
/// Returns the moved file or directory as a [SafDocumentFile] object.
///
/// [uri] is the URI of the file or directory.
/// [parentUri] is the URI of the current parent directory.
/// [newParentUri] is the URI of the new parent directory.
Future<SafDocumentFile> moveTo(
String uri, bool isDir, String parentUri, String newParentUri) {
return SafUtilPlatform.instance.moveTo(uri, isDir, parentUri, newParentUri);
}
/// Copies the specified file or directory to a new parent directory.
/// Returns the copied file or directory as a [SafDocumentFile] object.
///
/// [uri] is the URI of the file or directory.
/// [newParentUri] is the URI of the new parent directory.
Future<SafDocumentFile> copyTo(String uri, bool isDir, String newParentUri) {
return SafUtilPlatform.instance.copyTo(uri, isDir, newParentUri);
}
/// Saves the thumbnail of the specified document [uri] to a local file [destPath].
/// Image and video thumbnails are supported.
/// Returns true if the thumbnail is saved successfully, or false if no
/// thumbnail found in the document.
///
/// [uri] is the URI of the document.
/// [destPath] is the destination file path to save the thumbnail.
/// [width] is the max width of the thumbnail.
/// [height] is the max height of the thumbnail.
/// [format] is the format of the thumbnail file, default is 'jpeg'.
/// Supported formats are 'jpeg' and 'png'.
/// [quality] is the quality of the thumbnail file,
/// defaults to 80 for 'jpeg' and 100 for 'png'.
Future<bool> saveThumbnailToFile({
required String uri,
required int width,
required int height,
required String destPath,
String? format,
int? quality,
}) {
return SafUtilPlatform.instance.saveThumbnailToFile(
uri: uri,
width: width,
height: height,
destPath: destPath,
format: format,
quality: quality,
);
}
/** Deprecated functions. */
/// Shows a folder picker dialog and returns the selected folder URI.
///
/// [initialUri] is the initial URI to show in the dialog.
/// [writePermission] is true if the folder should have write permission.
/// [persistablePermission] is true if the permission should be persistable.
@Deprecated('Use [pickDirectory] instead, which returns a [SafDocumentFile].')
Future<String?> openDirectory(
{String? initialUri,
bool? writePermission,
bool? persistablePermission}) {
return SafUtilPlatform.instance.openDirectory(
initialUri: initialUri,
writePermission: writePermission,
persistablePermission: persistablePermission);
}
/// Shows a file picker dialog and returns the selected file URI.
/// This calls [openFiles] with [multiple] set to false.
///
/// [initialUri] is the initial URI to show in the dialog.
/// [mimeTypes] is a list of MIME types to filter the files.
@Deprecated('Use [pickFile] instead, which returns a [SafDocumentFile].')
Future<String?> openFile({
String? initialUri,
List<String>? mimeTypes,
}) {
return SafUtilPlatform.instance.openFile(
initialUri: initialUri,
mimeTypes: mimeTypes,
);
}
/// Shows a file picker dialog and returns a list of selected file URIs.
///
/// [initialUri] is the initial URI to show in the dialog.
/// [mimeTypes] is a list of MIME types to filter the files.
@Deprecated('Use [pickFiles] instead, which returns a [SafDocumentFile].')
Future<List<String>?> openFiles({
String? initialUri,
List<String>? mimeTypes,
multiple = true,
}) {
return SafUtilPlatform.instance.openFiles(
initialUri: initialUri,
mimeTypes: mimeTypes,
multiple: multiple,
);
}
/// Gets the file descriptor of the specified URI.
Future<int> getFileDescriptor(String uri) {
return SafUtilPlatform.instance.getFileDescriptor(uri);
}
/// Closes the specified file descriptor.
Future<void> closeFileDescriptor(int fd) {
return SafUtilPlatform.instance.closeFileDescriptor(fd);
}
/// Checks if the specified URI has persisted permission.
/// Use [checkRead] and [checkWrite] to specify the type of permission to check.
/// [checkRead] defaults to true.
/// [checkWrite] defaults to false.
Future<bool> hasPersistedPermission(
String uri, {
bool checkRead = true,
bool checkWrite = false,
}) {
return SafUtilPlatform.instance.hasPersistedPermission(uri,
checkRead: checkRead, checkWrite: checkWrite);
}
/// Releases the persisted permission of the specified URI.
/// Use [read] and [write] to specify the type of permission to release.
/// [read] defaults to true.
/// [write] defaults to false.
Future<void> releasePersistedPermission(String uri,
{bool read = true, bool write = false}) async {
return SafUtilPlatform.instance
.releasePersistedPermission(uri, read: read, write: write);
}
}
@@ -0,0 +1,292 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'saf_util_platform_interface.dart';
/// An implementation of [SafUtilPlatform] that uses method channels.
class MethodChannelSafUtil extends SafUtilPlatform {
/// The method channel used to interact with the native platform.
@visibleForTesting
final methodChannel = const MethodChannel('saf_util');
@override
Future<SafDocumentFile?> pickDirectory(
{String? initialUri,
bool? writePermission,
bool? persistablePermission}) async {
final map =
await methodChannel.invokeMapMethod<String, dynamic>('pickDirectory', {
'initialUri': initialUri,
'writePermission': writePermission,
'persistablePermission': persistablePermission,
});
if (map == null) {
return null;
}
return SafDocumentFile.fromMap(map);
}
@override
Future<String?> openDirectory(
{String? initialUri,
bool? writePermission,
bool? persistablePermission}) async {
final res = await pickDirectory(
initialUri: initialUri,
writePermission: writePermission,
persistablePermission: persistablePermission,
);
return res?.uri;
}
@override
Future<SafDocumentFile?> pickFile({
String? initialUri,
List<String>? mimeTypes,
}) async {
final res = await pickFiles(
initialUri: initialUri,
mimeTypes: mimeTypes,
multiple: false,
);
return res?.first;
}
@override
Future<String?> openFile({
String? initialUri,
List<String>? mimeTypes,
}) async {
final res = await pickFile(
initialUri: initialUri,
mimeTypes: mimeTypes,
);
return res?.uri;
}
@override
Future<List<SafDocumentFile>?> pickFiles({
String? initialUri,
List<String>? mimeTypes,
multiple = true,
}) async {
final maps = await methodChannel
.invokeListMethod<Map<dynamic, dynamic>>('pickFiles', {
'initialUri': initialUri,
'mimeTypes': mimeTypes,
'multiple': multiple,
});
return maps?.map((map) => SafDocumentFile.fromMap(map)).toList();
}
@override
Future<List<String>?> openFiles({
String? initialUri,
List<String>? mimeTypes,
multiple = true,
}) async {
final res = await pickFiles(
initialUri: initialUri,
mimeTypes: mimeTypes,
multiple: multiple,
);
return res?.map((file) => file.uri).toList();
}
@override
Future<List<SafDocumentFile>> list(String uri) async {
final maps = await methodChannel
.invokeListMethod<Map<dynamic, dynamic>>('list', {'uri': uri});
return (maps ?? []).map((map) => SafDocumentFile.fromMap(map)).toList();
}
@override
Future<SafDocumentFile?> documentFileFromUri(String uri, bool? isDir) async {
final map = await methodChannel.invokeMapMethod<String, dynamic>(
'documentFileFromUri',
{'uri': uri, 'isDir': isDir},
);
if (map == null) {
return null;
}
return SafDocumentFile.fromMap(map);
}
@override
Future<SafDocumentFile?> stat(String uri, bool? isDir) async {
final map = await methodChannel.invokeMapMethod<String, dynamic>(
'stat',
{'uri': uri, 'isDir': isDir},
);
if (map == null) {
return null;
}
return SafDocumentFile.fromMap(map);
}
@override
Future<bool> exists(String uri, bool isDir) async {
final res = await methodChannel.invokeMethod<bool>(
'exists',
{'uri': uri, 'isDir': isDir},
);
if (res == null) {
throw Exception('Failed to check if file exists: $uri');
}
return res;
}
@override
Future<void> delete(String uri, bool isDir) async {
final res = await methodChannel.invokeMethod<bool>(
'delete',
{'uri': uri, 'isDir': isDir},
);
if (res != true) {
throw Exception('Failed to delete file: $uri');
}
}
@override
Future<SafDocumentFile> mkdirp(String uri, List<String> names) async {
final map = await methodChannel.invokeMapMethod<String, dynamic>(
'mkdirp',
{'uri': uri, 'names': names},
);
if (map == null) {
throw Exception('Failed to create directory: $uri');
}
return SafDocumentFile.fromMap(map);
}
@override
Future<SafDocumentFile?> child(String uri, List<String> names) async {
final map = await methodChannel.invokeMapMethod<String, dynamic>(
'child',
{'uri': uri, 'names': names},
);
if (map == null) {
return null;
}
return SafDocumentFile.fromMap(map);
}
@override
Future<SafDocumentFile> rename(String uri, bool isDir, String newName) async {
final map = await methodChannel.invokeMapMethod<String, dynamic>(
'rename',
{'uri': uri, 'isDir': isDir, 'newName': newName},
);
if (map == null) {
throw Exception('Failed to rename: $uri');
}
return SafDocumentFile.fromMap(map);
}
@override
Future<SafDocumentFile> moveTo(
String uri, bool isDir, String parentUri, String newParentUri) async {
final map = await methodChannel.invokeMapMethod<String, dynamic>(
'moveTo',
{
'uri': uri,
'isDir': isDir,
'parentUri': parentUri,
'newParentUri': newParentUri
},
);
if (map == null) {
throw Exception('Failed to move: $uri');
}
return SafDocumentFile.fromMap(map);
}
@override
Future<SafDocumentFile> copyTo(
String uri, bool isDir, String newParentUri) async {
final map = await methodChannel.invokeMapMethod<String, dynamic>(
'copyTo',
{'uri': uri, 'isDir': isDir, 'newParentUri': newParentUri},
);
if (map == null) {
throw Exception('Failed to copy: $uri');
}
return SafDocumentFile.fromMap(map);
}
@override
Future<bool> saveThumbnailToFile({
required String uri,
required int width,
required int height,
required String destPath,
String? format,
int? quality,
}) async {
final res = await methodChannel.invokeMethod<bool>(
'saveThumbnailToFile',
{
'uri': uri.toString(),
'width': width,
'height': height,
'destPath': destPath,
'format': format,
'quality': quality,
},
);
return res ?? false;
}
@override
Future<int> getFileDescriptor(String uri) async {
final res = await methodChannel.invokeMethod<int>(
'getFileDescriptor',
{'uri': uri},
);
if (res == null) {
throw Exception('Failed to get file descriptor: $uri');
}
return res;
}
@override
Future<void> closeFileDescriptor(int fd) {
return methodChannel.invokeMethod<void>(
'closeFileDescriptor',
{'fd': fd},
);
}
@override
Future<bool> hasPersistedPermission(
String uri, {
bool checkRead = true,
bool checkWrite = false,
}) async {
final res = await methodChannel.invokeMethod<bool>(
'hasPersistedPermission',
{
'uri': uri,
'checkRead': checkRead,
'checkWrite': checkWrite,
},
);
if (res == null) {
throw Exception('Failed to check persisted permission: $uri');
}
return res;
}
@override
Future<void> releasePersistedPermission(String uri,
{bool read = true, bool write = false}) async {
await methodChannel.invokeMethod<void>(
'releasePersistedPermission',
{
'uri': uri,
'read': read,
'write': write,
},
);
}
}
@@ -0,0 +1,175 @@
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'saf_util_method_channel.dart';
class SafDocumentFile {
final String uri;
final String name;
final bool isDir;
final int length;
final int lastModified;
SafDocumentFile({
required this.uri,
required this.name,
required this.isDir,
required this.length,
required this.lastModified,
});
static SafDocumentFile fromMap(Map<dynamic, dynamic> map) {
return SafDocumentFile(
uri: map['uri'],
name: map['name'],
isDir: map['isDir'] ?? false,
length: map['length'] ?? 0,
lastModified: map['lastModified'] ?? 0,
);
}
@override
String toString() {
return 'SafDocumentFile{uri: $uri, name: $name, isDir: $isDir, length: $length, lastModified: $lastModified}';
}
}
abstract class SafUtilPlatform extends PlatformInterface {
/// Constructs a SafUtilPlatform.
SafUtilPlatform() : super(token: _token);
static final Object _token = Object();
static SafUtilPlatform _instance = MethodChannelSafUtil();
/// The default instance of [SafUtilPlatform] to use.
///
/// Defaults to [MethodChannelSafUtil].
static SafUtilPlatform get instance => _instance;
/// Platform-specific implementations should set this with their own
/// platform-specific class that extends [SafUtilPlatform] when
/// they register themselves.
static set instance(SafUtilPlatform instance) {
PlatformInterface.verifyToken(instance, _token);
_instance = instance;
}
Future<SafDocumentFile?> pickDirectory(
{String? initialUri,
bool? writePermission,
bool? persistablePermission}) {
throw UnimplementedError('pickDirectory() has not been implemented.');
}
Future<String?> openDirectory(
{String? initialUri,
bool? writePermission,
bool? persistablePermission}) {
throw UnimplementedError('openDirectory() has not been implemented.');
}
Future<SafDocumentFile?> pickFile({
String? initialUri,
List<String>? mimeTypes,
}) {
throw UnimplementedError('pickFile() has not been implemented.');
}
Future<String?> openFile({
String? initialUri,
List<String>? mimeTypes,
}) {
throw UnimplementedError('openFile() has not been implemented.');
}
Future<List<SafDocumentFile>?> pickFiles({
String? initialUri,
List<String>? mimeTypes,
multiple = true,
}) {
throw UnimplementedError('pickFiles() has not been implemented.');
}
Future<List<String>?> openFiles({
String? initialUri,
List<String>? mimeTypes,
multiple = true,
}) {
throw UnimplementedError('openFiles() has not been implemented.');
}
Future<List<SafDocumentFile>> list(String uri) {
throw UnimplementedError('list() has not been implemented.');
}
Future<SafDocumentFile?> documentFileFromUri(String uri, bool? isDir) {
throw UnimplementedError('documentFileFromUri() has not been implemented.');
}
Future<SafDocumentFile?> stat(String uri, bool? isDir) {
throw UnimplementedError('stat() has not been implemented.');
}
Future<bool> exists(String uri, bool isDir) {
throw UnimplementedError('exists() has not been implemented.');
}
Future<void> delete(String uri, bool isDir) {
throw UnimplementedError('delete() has not been implemented.');
}
Future<SafDocumentFile> mkdirp(String uri, List<String> names) {
throw UnimplementedError('mkdirp() has not been implemented.');
}
Future<SafDocumentFile?> child(String uri, List<String> names) {
throw UnimplementedError('child() has not been implemented.');
}
Future<SafDocumentFile> rename(String uri, bool isDir, String newName) {
throw UnimplementedError('rename() has not been implemented.');
}
Future<SafDocumentFile> moveTo(
String uri, bool isDir, String parentUri, String newParentUri) {
throw UnimplementedError('moveTo() has not been implemented.');
}
Future<SafDocumentFile> copyTo(String uri, bool isDir, String newParentUri) {
throw UnimplementedError('copyTo() has not been implemented.');
}
Future<bool> saveThumbnailToFile({
required String uri,
required int width,
required int height,
required String destPath,
String? format,
int? quality,
}) {
throw UnimplementedError('saveThumbnailToFile() has not been implemented.');
}
Future<int> getFileDescriptor(String uri) {
throw UnimplementedError('getFileDescriptor() has not been implemented.');
}
Future<void> closeFileDescriptor(int fd) {
throw UnimplementedError('closeFileDescriptor() has not been implemented.');
}
Future<bool> hasPersistedPermission(
String uri, {
bool checkRead = true,
bool checkWrite = false,
}) {
throw UnimplementedError(
'hasPersistedPermission() has not been implemented.');
}
Future<void> releasePersistedPermission(String uri,
{bool read = true, bool write = false}) {
throw UnimplementedError(
'releasePersistedPermission() has not been implemented.');
}
}
+213
View File
@@ -0,0 +1,213 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
plugin_platform_interface:
dependency: "direct main"
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
sdks:
dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
+70
View File
@@ -0,0 +1,70 @@
name: saf_util
description: "Util functions for SAF (Storage Access Framework)."
version: 2.0.0
homepage: https://github.com/flutter-cavalry/saf_util
environment:
sdk: ">=2.18.0 <4.0.0"
flutter: ">=2.5.0"
dependencies:
flutter:
sdk: flutter
plugin_platform_interface: ^2.0.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
# 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 packages.
flutter:
# This section identifies this Flutter project as a plugin project.
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.)
# which should be registered in the plugin registry. This is required for
# using method channels.
# The Android 'package' specifies package in which the registered class is.
# This is required for using method channels on Android.
# The 'ffiPlugin' specifies that native code should be built and bundled.
# This is required for using `dart:ffi`.
# All these are used by the tooling to maintain consistency when
# adding or updating assets for this project.
plugin:
platforms:
android:
package: com.fluttercavalry.saf_util
pluginClass: SafUtilPlugin
# To add assets to your plugin package, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
#
# For details regarding assets in packages, see
# https://flutter.dev/to/asset-from-package
#
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# To add custom fonts to your plugin package, 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 in packages, see
# https://flutter.dev/to/font-from-package