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
+
+[](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