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