Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement directory picking and related functions #20

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cfe3583
Fix typo
amake Apr 8, 2021
8964103
Introduce EntityInfo and DirectoryInfo
amake Apr 8, 2021
028462e
Remove redundant FileInfo factory method
amake Apr 8, 2021
e311a64
Move query to separate file
amake Apr 8, 2021
1015ba8
Implement FilePickerWritable.openDirectory
amake Apr 8, 2021
61cbaa1
Implement getDirectory, resolveRelativePath
amake Apr 8, 2021
bfc9937
Implement isDirectoryAccessSupported
amake Apr 11, 2021
09e96ee
Update example with directory stuff
amake Apr 12, 2021
a74e9ec
Coordinate file read in resolveRelativePath
amake Apr 18, 2021
966ce1a
Don't let invalid initial dir URI kill dir picker call
amake May 6, 2021
e1fe4af
Obtain iOS bookmark only after ensuring the file exists locally
amake Feb 24, 2022
535f377
Fix build with Flutter 3.0
amake May 12, 2022
89608a6
Fix build with Android Gradle Plugin 7.3.0
amake Sep 23, 2022
6a62dfe
Fix finding parent when findDocumentPath is unsupported
amake Oct 12, 2022
b2a1135
Merge branch 'main' into directory-picker
amake Jan 13, 2023
de0d3e2
Merge remote-tracking branch 'official/main' into directory-picker
amake Sep 21, 2023
d8bdd1a
Restore accidental deletions from Android plugin
amake Oct 30, 2023
bd690b2
Clean whitespace
amake Aug 27, 2024
d5b42d2
Do long-running tasks off of UI thread on iOS
amake Aug 27, 2024
5a645f1
Merge branch 'feature/ios-async' into directory-picker
amake Aug 27, 2024
a845daf
Fix getDirectory security scope access
amake Sep 9, 2024
289b0fa
Ensure error is reported on main thread
amake Sep 9, 2024
8ebcd7b
Ensure error is reported on main thread
amake Sep 9, 2024
e66bc58
Access security scope in same thread so defer fires at right time
amake Sep 9, 2024
3158653
Ensure events are sent on main thread
amake Sep 9, 2024
9a36c48
Merge branch 'feature/ios-async' into directory-picker
amake Sep 9, 2024
ff1a071
Specify Kotlin JVM target
amake Sep 12, 2024
641fad5
Merge branch 'feature/fix-kotlin-issue' into directory-picker
amake Sep 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ android {
namespace "codeux.design.filepicker.file_picker_writable"
compileSdk 33

kotlinOptions {
jvmTarget = '1.8'
}

sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import android.app.Activity.RESULT_OK
import android.content.ActivityNotFoundException
import android.content.ContentResolver
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.provider.OpenableColumns
import android.provider.DocumentsContract
import androidx.annotation.MainThread
import androidx.annotation.RequiresApi
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
Expand All @@ -35,6 +35,7 @@ class FilePickerWritableImpl(
companion object {
const val REQUEST_CODE_OPEN_FILE = 40832
const val REQUEST_CODE_CREATE_FILE = 40833
const val REQUEST_CODE_OPEN_DIRECTORY = 40834
}

private var filePickerCreateFile: File? = null
Expand All @@ -59,7 +60,7 @@ class FilePickerWritableImpl(
activity.startActivityForResult(intent, REQUEST_CODE_OPEN_FILE)
} catch (e: ActivityNotFoundException) {
filePickerResult = null
plugin.logDebug("exception while launcing file picker", e)
plugin.logDebug("exception while launching file picker", e)
result.error(
"FilePickerNotAvailable",
"Unable to start file picker, $e",
Expand All @@ -68,6 +69,49 @@ class FilePickerWritableImpl(
}
}

@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
@MainThread
fun openDirectoryPicker(result: MethodChannel.Result, initialDirUri: String?) {
if (filePickerResult != null) {
throw FilePickerException("Invalid lifecycle, only one call at a time.")
}
filePickerResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (initialDirUri != null) {
try {
val parsedUri = Uri.parse(initialDirUri).let {
val context = requireActivity().applicationContext
if (DocumentsContract.isDocumentUri(context, it)) {
it
} else {
DocumentsContract.buildDocumentUriUsingTree(
it,
DocumentsContract.getTreeDocumentId(it)
)
}
}
putExtra(DocumentsContract.EXTRA_INITIAL_URI, parsedUri)
} catch (e: Exception) {
plugin.logDebug("exception while preparing document picker initial dir", e)
}
}
}
}
val activity = requireActivity()
try {
activity.startActivityForResult(intent, REQUEST_CODE_OPEN_DIRECTORY)
} catch (e: ActivityNotFoundException) {
filePickerResult = null
plugin.logDebug("exception while launching directory picker", e)
result.error(
"DirectoryPickerNotAvailable",
"Unable to start directory picker, $e",
null
)
}
}

@MainThread
fun openFilePickerForCreate(result: MethodChannel.Result, path: String) {
if (filePickerResult != null) {
Expand Down Expand Up @@ -101,7 +145,7 @@ class FilePickerWritableImpl(
resultCode: Int,
data: Intent?
): Boolean {
if (!arrayOf(REQUEST_CODE_OPEN_FILE, REQUEST_CODE_CREATE_FILE).contains(
if (!arrayOf(REQUEST_CODE_OPEN_FILE, REQUEST_CODE_CREATE_FILE, REQUEST_CODE_OPEN_DIRECTORY).contains(
requestCode
)) {
plugin.logDebug("Unknown requestCode $requestCode - ignore")
Expand Down Expand Up @@ -152,6 +196,19 @@ class FilePickerWritableImpl(
initialFileContent
)
}
REQUEST_CODE_OPEN_DIRECTORY -> {
val directoryUri = data?.data
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
throw FilePickerException("illegal state - get a directory response on an unsupported OS version")
}
if (directoryUri != null) {
plugin.logDebug("Got result $directoryUri")
handleDirectoryUriResponse(result, directoryUri)
} else {
plugin.logDebug("Got RESULT_OK with null directoryUri?")
result.success(null)
}
}
else -> {
// can never happen, we already checked the result code.
throw IllegalStateException("Unexpected requestCode $requestCode")
Expand Down Expand Up @@ -192,6 +249,17 @@ class FilePickerWritableImpl(
copyContentUriAndReturn(result, fileUri)
}

@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
@MainThread
private suspend fun handleDirectoryUriResponse(
result: MethodChannel.Result,
directoryUri: Uri
) {
result.success(
getDirectoryInfo(directoryUri)
)
}

@MainThread
suspend fun readFileWithIdentifier(
result: MethodChannel.Result,
Expand All @@ -200,6 +268,70 @@ class FilePickerWritableImpl(
copyContentUriAndReturn(result, Uri.parse(identifier))
}

@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
@MainThread
suspend fun getDirectory(
result: MethodChannel.Result,
rootUri: String,
fileUri: String
) {
val activity = requireActivity()

val root = Uri.parse(rootUri)
val leaf = Uri.parse(fileUri)
val leafUnderRoot = DocumentsContract.buildDocumentUriUsingTree(
root,
DocumentsContract.getDocumentId(leaf)
)

if (!fileExists(leafUnderRoot, activity.applicationContext.contentResolver)) {
result.error(
"InvalidArguments",
"The supplied fileUri $fileUri is not a child of $rootUri",
null
)
return
}

val ret = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getParent(leafUnderRoot, activity.applicationContext)
} else {
null
} ?: findParent(root, leaf, activity.applicationContext)


result.success(mapOf(
"identifier" to ret.toString(),
"persistable" to "true",
"uri" to ret.toString()
))
}

@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
@MainThread
suspend fun resolveRelativePath(
result: MethodChannel.Result,
parentIdentifier: String,
relativePath: String
) {
val activity = requireActivity()

val resolvedUri = resolveRelativePath(Uri.parse(parentIdentifier), relativePath, activity.applicationContext)
if (resolvedUri != null) {
val displayName = getDisplayName(resolvedUri, activity.applicationContext.contentResolver)
val isDirectory = isDirectory(resolvedUri, activity.applicationContext.contentResolver)
result.success(mapOf(
"identifier" to resolvedUri.toString(),
"persistable" to "true",
"fileName" to displayName,
"uri" to resolvedUri.toString(),
"isDirectory" to isDirectory.toString()
))
} else {
result.error("FileNotFound", "$relativePath could not be located relative to $parentIdentifier", null)
}
}

@MainThread
private suspend fun copyContentUriAndReturn(
result: MethodChannel.Result,
Expand Down Expand Up @@ -228,7 +360,7 @@ class FilePickerWritableImpl(
plugin.logDebug("Couldn't take persistable URI permission on $fileUri", e)
}

val fileName = readFileInfo(fileUri, contentResolver)
val fileName = getDisplayName(fileUri, contentResolver)

val tempFile =
File.createTempFile(
Expand All @@ -254,31 +386,35 @@ class FilePickerWritableImpl(
}
}

private suspend fun readFileInfo(
uri: Uri,
contentResolver: ContentResolver
): String = withContext(Dispatchers.IO) {
// The query, because it only applies to a single document, returns only
// one row. There's no need to filter, sort, or select fields,
// because we want all fields for one document.
val cursor: Cursor? = contentResolver.query(
uri, null, null, null, null, null
)

cursor?.use {
if (!it.moveToFirst()) {
throw FilePickerException("Cursor returned empty while trying to read file info for $uri")
}

// Note it's called "Display Name". This is
// provider-specific, and might not necessarily be the file name.
val displayName: String =
it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
plugin.logDebug("Display Name: $displayName")
displayName
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
@MainThread
private suspend fun getDirectoryInfo(directoryUri: Uri): Map<String, String> {
val activity = requireActivity()

} ?: throw FilePickerException("Unable to load file info from $uri")
val contentResolver = activity.applicationContext.contentResolver

return withContext(Dispatchers.IO) {
var persistable = false
try {
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(directoryUri, takeFlags)
persistable = true
} catch (e: SecurityException) {
plugin.logDebug("Couldn't take persistable URI permission on $directoryUri", e)
}
// URI as returned from picker is just a tree URI, but we need a document URI for getting the display name
val treeDocUri = DocumentsContract.buildDocumentUriUsingTree(
directoryUri,
DocumentsContract.getTreeDocumentId(directoryUri)
)
mapOf(
"identifier" to directoryUri.toString(),
"persistable" to persistable.toString(),
"uri" to directoryUri.toString(),
"fileName" to getDisplayName(treeDocUri, contentResolver)
)
}
}

fun onDetachedFromActivity(binding: ActivityPluginBinding) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package codeux.design.filepicker.file_picker_writable

import android.app.Activity
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.annotation.MainThread
import androidx.annotation.NonNull
Expand Down Expand Up @@ -89,11 +90,44 @@ class FilePickerWritablePlugin : FlutterPlugin, MethodCallHandler,
?: throw FilePickerException("Expected argument 'path'")
impl.openFilePickerForCreate(result, path)
}
"isDirectoryAccessSupported" -> {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
}
"openDirectoryPicker" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val initialDirUri = call.argument<String>("initialDirUri")
impl.openDirectoryPicker(result, initialDirUri)
} else {
throw FilePickerException("${call.method} is not supported on Android ${Build.VERSION.RELEASE}")
}
}
"readFileWithIdentifier" -> {
val identifier = call.argument<String>("identifier")
?: throw FilePickerException("Expected argument 'identifier'")
impl.readFileWithIdentifier(result, identifier)
}
"getDirectory" -> {
val rootIdentifier = call.argument<String>("rootIdentifier")
?: throw FilePickerException("Expected argument 'rootIdentifier'")
val fileIdentifier = call.argument<String>("fileIdentifier")
?: throw FilePickerException("Expected argument 'fileIdentifier'")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
impl.getDirectory(result, rootIdentifier, fileIdentifier)
} else {
throw FilePickerException("${call.method} is not supported on Android ${Build.VERSION.RELEASE}")
}
}
"resolveRelativePath" -> {
val directoryIdentifier = call.argument<String>("directoryIdentifier")
?: throw FilePickerException("Expected argument 'directoryIdentifier'")
val relativePath = call.argument<String>("relativePath")
?: throw FilePickerException("Expected argument 'relativePath'")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
impl.resolveRelativePath(result, directoryIdentifier, relativePath)
} else {
throw FilePickerException("${call.method} is not supported on Android ${Build.VERSION.RELEASE}")
}
}
"writeFileWithIdentifier" -> {
val identifier = call.argument<String>("identifier")
?: throw FilePickerException("Expected argument 'identifier'")
Expand Down
Loading