diff --git a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt index b5a57442..a4a08f0c 100644 --- a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt +++ b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt @@ -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( @@ -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( diff --git a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/DocumentPickerDelegate.kt b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/DocumentPickerDelegate.kt index 0f9dd708..3d6e70f8 100644 --- a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/DocumentPickerDelegate.kt +++ b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/DocumentPickerDelegate.kt @@ -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) -> 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 } } diff --git a/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/DocumentPickerDelegateTest.kt b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/DocumentPickerDelegateTest.kt new file mode 100644 index 00000000..bb1be904 --- /dev/null +++ b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/DocumentPickerDelegateTest.kt @@ -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()) + + private fun presentationController(): UIPresentationController = + UIPresentationController( + presentedViewController = UIViewController(nibName = null, bundle = null), + presentingViewController = null, + ) +}