From 173117602a9da0c2197851c60346944b90b3be18 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:51:32 +0200 Subject: [PATCH] fix(android): vendor saf_util with a sound pendingResult lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/saf_util/CHANGELOG.md | 77 ++ packages/saf_util/LICENSE | 29 + packages/saf_util/README.md | 9 + packages/saf_util/analysis_options.yaml | 4 + packages/saf_util/android/build.gradle.kts | 77 ++ packages/saf_util/android/settings.gradle | 1 + packages/saf_util/android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../fluttercavalry/saf_util/SafUtilPlugin.kt | 862 ++++++++++++++++++ .../saf_util/SafUtilPluginTest.kt | 27 + packages/saf_util/lib/saf_util.dart | 256 ++++++ .../saf_util/lib/saf_util_method_channel.dart | 292 ++++++ .../lib/saf_util_platform_interface.dart | 175 ++++ packages/saf_util/pubspec.lock | 213 +++++ packages/saf_util/pubspec.yaml | 70 ++ pubspec.lock | 7 +- pubspec.yaml | 5 +- 17 files changed, 2103 insertions(+), 5 deletions(-) create mode 100644 packages/saf_util/CHANGELOG.md create mode 100644 packages/saf_util/LICENSE create mode 100644 packages/saf_util/README.md create mode 100644 packages/saf_util/analysis_options.yaml create mode 100644 packages/saf_util/android/build.gradle.kts create mode 100644 packages/saf_util/android/settings.gradle create mode 100644 packages/saf_util/android/settings.gradle.kts create mode 100644 packages/saf_util/android/src/main/AndroidManifest.xml create mode 100644 packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt create mode 100644 packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt create mode 100644 packages/saf_util/lib/saf_util.dart create mode 100644 packages/saf_util/lib/saf_util_method_channel.dart create mode 100644 packages/saf_util/lib/saf_util_platform_interface.dart create mode 100644 packages/saf_util/pubspec.lock create mode 100644 packages/saf_util/pubspec.yaml diff --git a/packages/saf_util/CHANGELOG.md b/packages/saf_util/CHANGELOG.md new file mode 100644 index 00000000..5c525b57 --- /dev/null +++ b/packages/saf_util/CHANGELOG.md @@ -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. diff --git a/packages/saf_util/LICENSE b/packages/saf_util/LICENSE new file mode 100644 index 00000000..5ffdcacb --- /dev/null +++ b/packages/saf_util/LICENSE @@ -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. diff --git a/packages/saf_util/README.md b/packages/saf_util/README.md new file mode 100644 index 00000000..2bf770d5 --- /dev/null +++ b/packages/saf_util/README.md @@ -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/) diff --git a/packages/saf_util/analysis_options.yaml b/packages/saf_util/analysis_options.yaml new file mode 100644 index 00000000..a5744c1c --- /dev/null +++ b/packages/saf_util/analysis_options.yaml @@ -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 diff --git a/packages/saf_util/android/build.gradle.kts b/packages/saf_util/android/build.gradle.kts new file mode 100644 index 00000000..b0b3ef9b --- /dev/null +++ b/packages/saf_util/android/build.gradle.kts @@ -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") +} diff --git a/packages/saf_util/android/settings.gradle b/packages/saf_util/android/settings.gradle new file mode 100644 index 00000000..420f0d58 --- /dev/null +++ b/packages/saf_util/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'saf_util' diff --git a/packages/saf_util/android/settings.gradle.kts b/packages/saf_util/android/settings.gradle.kts new file mode 100644 index 00000000..420f0d58 --- /dev/null +++ b/packages/saf_util/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = 'saf_util' diff --git a/packages/saf_util/android/src/main/AndroidManifest.xml b/packages/saf_util/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0c8731b3 --- /dev/null +++ b/packages/saf_util/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt new file mode 100644 index 00000000..6500c71f --- /dev/null +++ b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt @@ -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() + + 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("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>() + + 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("uri") as String + val isDir = call.argument("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("uri") as String + val isDir = call.argument("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("uri") as String + val isDir = call.argument("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("uri") as String + val isDir = call.argument("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("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("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("uri") as String + val names = + call.argument>("names") as ArrayList + + 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("uri") as String + val names = call.argument>("names") as ArrayList + + 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("uri") as String + val isDir = call.argument("isDir") as Boolean + val newName = call.argument("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("uri") as String + val isDir = call.argument("isDir") as Boolean + val parentUri = call.argument("parentUri") as String + val newParentUri = call.argument("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("uri") as String + val isDir = call.argument("isDir") as Boolean + val newParentUri = call.argument("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("initialUri") + val writePermission = call.argument("writePermission") ?: false + val persistablePermission = call.argument("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("initialUri") + val multiple = call.argument("multiple") ?: false + val mimeTypes = call.argument>("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("uri") as String + val checkRead = call.argument("checkRead") ?: true + val checkWrite = call.argument("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("uri") as String + val read = call.argument("read") ?: true + val write = call.argument("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("uri") as String).toUri() + val dest = call.argument("destPath")!! + val format = call.argument("format") ?: "jpeg" + val isPng = format == "png" + val quality = call.argument("quality") ?: if (isPng) 100 else 80 + + val width = call.argument("width")!! + val height = call.argument("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 = if (data.clipData != null) { + val clipData = data.clipData + val uris = mutableListOf() + for (i in 0 until clipData!!.itemCount) { + uris.add(clipData.getItemAt(i).uri) + } + uris + } else { + listOf(data.data!!) + } + + val documentFileMaps: MutableList> = 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 { + 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 { + 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() diff --git a/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt b/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt new file mode 100644 index 00000000..38e29c5e --- /dev/null +++ b/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt @@ -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) + } +} diff --git a/packages/saf_util/lib/saf_util.dart b/packages/saf_util/lib/saf_util.dart new file mode 100644 index 00000000..5513228f --- /dev/null +++ b/packages/saf_util/lib/saf_util.dart @@ -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 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 pickFile({ + String? initialUri, + List? 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?> pickFiles({ + String? initialUri, + List? 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(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 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 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 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 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 mkdirp(String uri, List 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 child(String uri, List 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 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 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 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 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 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 openFile({ + String? initialUri, + List? 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?> openFiles({ + String? initialUri, + List? mimeTypes, + multiple = true, + }) { + return SafUtilPlatform.instance.openFiles( + initialUri: initialUri, + mimeTypes: mimeTypes, + multiple: multiple, + ); + } + + /// Gets the file descriptor of the specified URI. + Future getFileDescriptor(String uri) { + return SafUtilPlatform.instance.getFileDescriptor(uri); + } + + /// Closes the specified file descriptor. + Future 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 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 releasePersistedPermission(String uri, + {bool read = true, bool write = false}) async { + return SafUtilPlatform.instance + .releasePersistedPermission(uri, read: read, write: write); + } +} diff --git a/packages/saf_util/lib/saf_util_method_channel.dart b/packages/saf_util/lib/saf_util_method_channel.dart new file mode 100644 index 00000000..bfa695a5 --- /dev/null +++ b/packages/saf_util/lib/saf_util_method_channel.dart @@ -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 pickDirectory( + {String? initialUri, + bool? writePermission, + bool? persistablePermission}) async { + final map = + await methodChannel.invokeMapMethod('pickDirectory', { + 'initialUri': initialUri, + 'writePermission': writePermission, + 'persistablePermission': persistablePermission, + }); + if (map == null) { + return null; + } + return SafDocumentFile.fromMap(map); + } + + @override + Future openDirectory( + {String? initialUri, + bool? writePermission, + bool? persistablePermission}) async { + final res = await pickDirectory( + initialUri: initialUri, + writePermission: writePermission, + persistablePermission: persistablePermission, + ); + return res?.uri; + } + + @override + Future pickFile({ + String? initialUri, + List? mimeTypes, + }) async { + final res = await pickFiles( + initialUri: initialUri, + mimeTypes: mimeTypes, + multiple: false, + ); + return res?.first; + } + + @override + Future openFile({ + String? initialUri, + List? mimeTypes, + }) async { + final res = await pickFile( + initialUri: initialUri, + mimeTypes: mimeTypes, + ); + return res?.uri; + } + + @override + Future?> pickFiles({ + String? initialUri, + List? mimeTypes, + multiple = true, + }) async { + final maps = await methodChannel + .invokeListMethod>('pickFiles', { + 'initialUri': initialUri, + 'mimeTypes': mimeTypes, + 'multiple': multiple, + }); + return maps?.map((map) => SafDocumentFile.fromMap(map)).toList(); + } + + @override + Future?> openFiles({ + String? initialUri, + List? mimeTypes, + multiple = true, + }) async { + final res = await pickFiles( + initialUri: initialUri, + mimeTypes: mimeTypes, + multiple: multiple, + ); + return res?.map((file) => file.uri).toList(); + } + + @override + Future> list(String uri) async { + final maps = await methodChannel + .invokeListMethod>('list', {'uri': uri}); + return (maps ?? []).map((map) => SafDocumentFile.fromMap(map)).toList(); + } + + @override + Future documentFileFromUri(String uri, bool? isDir) async { + final map = await methodChannel.invokeMapMethod( + 'documentFileFromUri', + {'uri': uri, 'isDir': isDir}, + ); + if (map == null) { + return null; + } + return SafDocumentFile.fromMap(map); + } + + @override + Future stat(String uri, bool? isDir) async { + final map = await methodChannel.invokeMapMethod( + 'stat', + {'uri': uri, 'isDir': isDir}, + ); + if (map == null) { + return null; + } + return SafDocumentFile.fromMap(map); + } + + @override + Future exists(String uri, bool isDir) async { + final res = await methodChannel.invokeMethod( + 'exists', + {'uri': uri, 'isDir': isDir}, + ); + if (res == null) { + throw Exception('Failed to check if file exists: $uri'); + } + return res; + } + + @override + Future delete(String uri, bool isDir) async { + final res = await methodChannel.invokeMethod( + 'delete', + {'uri': uri, 'isDir': isDir}, + ); + if (res != true) { + throw Exception('Failed to delete file: $uri'); + } + } + + @override + Future mkdirp(String uri, List names) async { + final map = await methodChannel.invokeMapMethod( + 'mkdirp', + {'uri': uri, 'names': names}, + ); + if (map == null) { + throw Exception('Failed to create directory: $uri'); + } + return SafDocumentFile.fromMap(map); + } + + @override + Future child(String uri, List names) async { + final map = await methodChannel.invokeMapMethod( + 'child', + {'uri': uri, 'names': names}, + ); + if (map == null) { + return null; + } + return SafDocumentFile.fromMap(map); + } + + @override + Future rename(String uri, bool isDir, String newName) async { + final map = await methodChannel.invokeMapMethod( + 'rename', + {'uri': uri, 'isDir': isDir, 'newName': newName}, + ); + if (map == null) { + throw Exception('Failed to rename: $uri'); + } + return SafDocumentFile.fromMap(map); + } + + @override + Future moveTo( + String uri, bool isDir, String parentUri, String newParentUri) async { + final map = await methodChannel.invokeMapMethod( + 'moveTo', + { + 'uri': uri, + 'isDir': isDir, + 'parentUri': parentUri, + 'newParentUri': newParentUri + }, + ); + if (map == null) { + throw Exception('Failed to move: $uri'); + } + return SafDocumentFile.fromMap(map); + } + + @override + Future copyTo( + String uri, bool isDir, String newParentUri) async { + final map = await methodChannel.invokeMapMethod( + 'copyTo', + {'uri': uri, 'isDir': isDir, 'newParentUri': newParentUri}, + ); + if (map == null) { + throw Exception('Failed to copy: $uri'); + } + return SafDocumentFile.fromMap(map); + } + + @override + Future saveThumbnailToFile({ + required String uri, + required int width, + required int height, + required String destPath, + String? format, + int? quality, + }) async { + final res = await methodChannel.invokeMethod( + 'saveThumbnailToFile', + { + 'uri': uri.toString(), + 'width': width, + 'height': height, + 'destPath': destPath, + 'format': format, + 'quality': quality, + }, + ); + return res ?? false; + } + + @override + Future getFileDescriptor(String uri) async { + final res = await methodChannel.invokeMethod( + 'getFileDescriptor', + {'uri': uri}, + ); + if (res == null) { + throw Exception('Failed to get file descriptor: $uri'); + } + return res; + } + + @override + Future closeFileDescriptor(int fd) { + return methodChannel.invokeMethod( + 'closeFileDescriptor', + {'fd': fd}, + ); + } + + @override + Future hasPersistedPermission( + String uri, { + bool checkRead = true, + bool checkWrite = false, + }) async { + final res = await methodChannel.invokeMethod( + 'hasPersistedPermission', + { + 'uri': uri, + 'checkRead': checkRead, + 'checkWrite': checkWrite, + }, + ); + if (res == null) { + throw Exception('Failed to check persisted permission: $uri'); + } + return res; + } + + @override + Future releasePersistedPermission(String uri, + {bool read = true, bool write = false}) async { + await methodChannel.invokeMethod( + 'releasePersistedPermission', + { + 'uri': uri, + 'read': read, + 'write': write, + }, + ); + } +} diff --git a/packages/saf_util/lib/saf_util_platform_interface.dart b/packages/saf_util/lib/saf_util_platform_interface.dart new file mode 100644 index 00000000..44e80df3 --- /dev/null +++ b/packages/saf_util/lib/saf_util_platform_interface.dart @@ -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 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 pickDirectory( + {String? initialUri, + bool? writePermission, + bool? persistablePermission}) { + throw UnimplementedError('pickDirectory() has not been implemented.'); + } + + Future openDirectory( + {String? initialUri, + bool? writePermission, + bool? persistablePermission}) { + throw UnimplementedError('openDirectory() has not been implemented.'); + } + + Future pickFile({ + String? initialUri, + List? mimeTypes, + }) { + throw UnimplementedError('pickFile() has not been implemented.'); + } + + Future openFile({ + String? initialUri, + List? mimeTypes, + }) { + throw UnimplementedError('openFile() has not been implemented.'); + } + + Future?> pickFiles({ + String? initialUri, + List? mimeTypes, + multiple = true, + }) { + throw UnimplementedError('pickFiles() has not been implemented.'); + } + + Future?> openFiles({ + String? initialUri, + List? mimeTypes, + multiple = true, + }) { + throw UnimplementedError('openFiles() has not been implemented.'); + } + + Future> list(String uri) { + throw UnimplementedError('list() has not been implemented.'); + } + + Future documentFileFromUri(String uri, bool? isDir) { + throw UnimplementedError('documentFileFromUri() has not been implemented.'); + } + + Future stat(String uri, bool? isDir) { + throw UnimplementedError('stat() has not been implemented.'); + } + + Future exists(String uri, bool isDir) { + throw UnimplementedError('exists() has not been implemented.'); + } + + Future delete(String uri, bool isDir) { + throw UnimplementedError('delete() has not been implemented.'); + } + + Future mkdirp(String uri, List names) { + throw UnimplementedError('mkdirp() has not been implemented.'); + } + + Future child(String uri, List names) { + throw UnimplementedError('child() has not been implemented.'); + } + + Future rename(String uri, bool isDir, String newName) { + throw UnimplementedError('rename() has not been implemented.'); + } + + Future moveTo( + String uri, bool isDir, String parentUri, String newParentUri) { + throw UnimplementedError('moveTo() has not been implemented.'); + } + + Future copyTo(String uri, bool isDir, String newParentUri) { + throw UnimplementedError('copyTo() has not been implemented.'); + } + + Future 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 getFileDescriptor(String uri) { + throw UnimplementedError('getFileDescriptor() has not been implemented.'); + } + + Future closeFileDescriptor(int fd) { + throw UnimplementedError('closeFileDescriptor() has not been implemented.'); + } + + Future hasPersistedPermission( + String uri, { + bool checkRead = true, + bool checkWrite = false, + }) { + throw UnimplementedError( + 'hasPersistedPermission() has not been implemented.'); + } + + Future releasePersistedPermission(String uri, + {bool read = true, bool write = false}) { + throw UnimplementedError( + 'releasePersistedPermission() has not been implemented.'); + } +} diff --git a/packages/saf_util/pubspec.lock b/packages/saf_util/pubspec.lock new file mode 100644 index 00000000..159565b3 --- /dev/null +++ b/packages/saf_util/pubspec.lock @@ -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" diff --git a/packages/saf_util/pubspec.yaml b/packages/saf_util/pubspec.yaml new file mode 100644 index 00000000..7044d1dc --- /dev/null +++ b/packages/saf_util/pubspec.yaml @@ -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 diff --git a/pubspec.lock b/pubspec.lock index 7c63f1b3..9ebe535c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -984,10 +984,9 @@ packages: saf_util: dependency: "direct main" description: - name: saf_util - sha256: "0cf5fc462fc7e466e1d5608dd5a331d88d6dd6c7b01f62a305aaf469bf3309ea" - url: "https://pub.dev" - source: hosted + path: "packages/saf_util" + relative: true + source: path version: "2.0.0" screen_retriever: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 98c61499..a95277e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,7 +50,10 @@ dependencies: crypto: ^3.0.7 cryptography: ^2.9.0 file_picker: ^10.3.10 - saf_util: ^2.0.0 + saf_util: + # Vendored 2.0.0 with a pendingResult lifecycle fix (see its CHANGELOG) — + # upstream double-replies MethodChannel results and crashes the activity. + path: packages/saf_util saf_stream: ^2.0.0 material_symbols_icons: ^4.2906.0 web_socket_channel: ^3.0.1