Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,10 @@ internal actual suspend fun FileKit.platformOpenFileSaver(
// Set the initial directory
directory?.let { pickerController.directoryURL = NSURL.fileURLWithPath(it.path) }

// Assign the delegate to the picker controller
// Assign the delegate to the picker controller. It answers for the presentation as well,
// so a swipe that never reaches documentPickerWasCancelled still ends the wait.
pickerController.delegate = documentPickerDelegate
pickerController.presentationController?.delegate = documentPickerDelegate

// Present the picker controller
presenter.presentViewController(
Expand Down Expand Up @@ -553,8 +555,10 @@ private suspend fun callPicker(
// Set up the picker mode
pickerController.allowsMultipleSelection = mode == Mode.Multiple

// Assign the delegate to the picker controller
// Assign the delegate to the picker controller. It answers for the presentation as well,
// so a swipe that never reaches documentPickerWasCancelled still ends the wait.
pickerController.delegate = documentPickerDelegate
pickerController.presentationController?.delegate = documentPickerDelegate

// Present the picker controller
presentApplePickerController(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,62 @@
package io.github.vinceglb.filekit.dialogs.util

import platform.Foundation.NSURL
import platform.UIKit.UIAdaptivePresentationControllerDelegateProtocol
import platform.UIKit.UIDocumentPickerDelegateProtocol
import platform.UIKit.UIDocumentPickerViewController
import platform.UIKit.UIPresentationController
import platform.darwin.NSObject

internal class DocumentPickerDelegate(
private val onFilesPicked: (List<NSURL>) -> Unit,
private val onPickerCancelled: () -> Unit,
) : NSObject(),
UIDocumentPickerDelegateProtocol {
UIDocumentPickerDelegateProtocol,
UIAdaptivePresentationControllerDelegateProtocol {
private var hasFinished = false

override fun documentPicker(
controller: UIDocumentPickerViewController,
didPickDocumentAtURL: NSURL,
) {
onFilesPicked(listOf(didPickDocumentAtURL))
if (finishOnce()) {
onFilesPicked(listOf(didPickDocumentAtURL))
}
}

override fun documentPicker(
controller: UIDocumentPickerViewController,
didPickDocumentsAtURLs: List<*>,
) {
val res = didPickDocumentsAtURLs.mapNotNull { it as? NSURL }
onFilesPicked(res)
if (finishOnce()) {
onFilesPicked(didPickDocumentsAtURLs.mapNotNull { it as? NSURL })
}
}

override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
onPickerCancelled()
if (finishOnce()) {
onPickerCancelled()
}
}

/**
* Swiping the sheet away does not always reach [documentPickerWasCancelled] β€” notably when the
* dismissal starts before the presentation animation has finished. Without this the caller's
* continuation waits for a delegate call that never comes. See #138.
*/
override fun presentationControllerDidDismiss(presentationController: UIPresentationController) {
if (finishOnce()) {
onPickerCancelled()
}
}

/**
* A dismissal often reports through both protocols, and the caller resumes a continuation that
* only accepts one answer, so every entry point above goes through this.
*/
private fun finishOnce(): Boolean {
if (hasFinished) return false
hasFinished = true
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
@file:Suppress("ktlint:standard:function-naming", "TestFunctionName")

package io.github.vinceglb.filekit.dialogs

import io.github.vinceglb.filekit.dialogs.util.DocumentPickerDelegate
import platform.Foundation.NSURL
import platform.UIKit.UIDocumentPickerViewController
import platform.UIKit.UIPresentationController
import platform.UIKit.UIViewController
import kotlin.test.Test
import kotlin.test.assertEquals

/**
* The caller resumes a continuation from these callbacks, and a continuation only accepts one
* answer, so what matters is that exactly one of them ever gets through.
*/
class DocumentPickerDelegateTest {
@Test
fun DocumentPickerDelegate_swipeDismissWithoutCancelCallback_reportsCancelled() {
val recorder = Recorder()

recorder.delegate.presentationControllerDidDismiss(presentationController())

assertEquals(expected = 0, actual = recorder.pickedCount)
assertEquals(expected = 1, actual = recorder.cancelledCount)
}

@Test
fun DocumentPickerDelegate_cancelThenSwipeDismiss_reportsCancelledOnce() {
val recorder = Recorder()

recorder.delegate.documentPickerWasCancelled(picker())
recorder.delegate.presentationControllerDidDismiss(presentationController())

assertEquals(expected = 1, actual = recorder.cancelledCount)
}

@Test
fun DocumentPickerDelegate_pickThenSwipeDismiss_reportsOnlyThePick() {
val recorder = Recorder()

recorder.delegate.documentPicker(picker(), didPickDocumentAtURL = NSURL(string = "file:///tmp/a.txt"))
recorder.delegate.presentationControllerDidDismiss(presentationController())

assertEquals(expected = 1, actual = recorder.pickedCount)
assertEquals(expected = 0, actual = recorder.cancelledCount)
}

private class Recorder {
var pickedCount = 0
var cancelledCount = 0

val delegate = DocumentPickerDelegate(
onFilesPicked = { pickedCount++ },
onPickerCancelled = { cancelledCount++ },
)
}

private fun picker(): UIDocumentPickerViewController =
UIDocumentPickerViewController(forOpeningContentTypes = emptyList<Any>())

private fun presentationController(): UIPresentationController =
UIPresentationController(
presentedViewController = UIViewController(nibName = null, bundle = null),
presentingViewController = null,
)
}