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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
# saf_util
|
||||
|
||||
[](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/)
|
||||
@@ -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>
|
||||
+862
@@ -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()
|
||||
+27
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user