8

The documentation for documentBrowser(_:didRequestDocumentCreationWithHandler:) says, "Create a new document and save it to a temporary location. If you use a UIDocument subclass to create the document, you must close it before calling the importHandler block."

So I created a file URL by taking the URL for the user's temporary directory (FileManager.default.temporaryDirectory) and appending a name and extension (getting a path like "file:///private/var/mobile/Containers/Data/Application/C1DE454D-EA1E-4166-B137-5B43185169D8/tmp/Untitled.uti"). But when I call save(to:for:completionHandler:) passing this URL, the completion handler is never called back. I also tried using url(for:in:appropriateFor:create:) to pass a subdirectory in the user's temporary directory—the completion handler was still never called.

I understand the document browser view controller is managed by a separate process, which has its own read / write permissions. Beyond that though, I'm having a hard time understanding what the problem is. Where can new documents be temporarily saved so that the document browser process can move them?

Update: as of the current betas, I now see an error with domain NSFileProviderInternalErrorDomain and code 1 getting logged: "The reader is not permitted to access the URL." At least that's confirmation of what's happening…

  • Had the same problem, but got this working eventually. Are you using a custom UTI? – Ashley Mills Aug 03 '17 at 08:53
  • Yes, I have an exported UTI which is also used as the document type. Did you find there was anything you had to do differently from what the UTI docs advise? –  Aug 03 '17 at 16:48
  • 1
    Did you create the temporary directory? IIRC, by default FileManager.temporaryDirectory returns an URL for a directory which is not created yet. You need to create it. Also, why did you use save(to:) instead of just close(completionHandler:)? How did you create your UIDocument? – Thomas Deniau Apr 11 '18 at 15:42

3 Answers3

12

So, to start with, if you're using a custom UTI, it's got to be set up correctly. Mine look like this…

<key>CFBundleDocumentTypes</key>
<array>
    <dict>
        <key>CFBundleTypeIconFiles</key>
        <array>
            <string>icon-file-name</string> // Can be excluded, but keep the array
        </array>
        <key>CFBundleTypeName</key>
        <string>Your Document Name</string>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>LSHandlerRank</key>
        <string>Owner</string>
        <key>LSItemContentTypes</key>
        <array>
            <string>com.custom-uti</string>
        </array>
    </dict>
</array>

and

<key>UTExportedTypeDeclarations</key>
<array>
    <dict>
        <key>UTTypeConformsTo</key>
        <array>
            <string>public.data</string>  // My doc is saved as Data, not a file wrapper
        </array>
        <key>UTTypeDescription</key>
        <string>Your Document Name</string>
        <key>UTTypeIdentifier</key>
        <string>com.custom-uti</string>
        <key>UTTypeTagSpecification</key>
        <dict>
            <key>public.filename-extension</key>
            <array>
                <string>doc-extension</string>
            </array>
        </dict>
    </dict>
</array>

Also

<key>UISupportsDocumentBrowser</key>
<true/>

I subclass UIDocument as MyDocument and add the following method to create a new temp document…

static func create(completion: @escaping Result<MyDocument> -> Void) throws {

    let targetURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("Untitled").appendingPathExtension("doc-extension")

    coordinationQueue.async {
        let document = MyDocument(fileURL: targetURL)
        var error: NSError? = nil
        NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: targetURL, error: &error) { url in
            document.save(to: url, for: .forCreating) { success in
                DispatchQueue.main.async {
                    if success {
                        completion(.success(document))
                    } else {
                        completion(.failure(MyDocumentError.unableToSaveDocument))
                    }
                }
            }
        }
        if let error = error {
            DispatchQueue.main.async {
                completion(.failure(error))
            }
        }
    }
}

Then init and display the DBVC as follows:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    lazy var documentBrowser: UIDocumentBrowserViewController = {
        let utiDecs = Bundle.main.object(forInfoDictionaryKey: kUTExportedTypeDeclarationsKey as String) as! [[String: Any]]
        let uti = utiDecs.first?[kUTTypeIdentifierKey as String] as! String
        let dbvc = UIDocumentBrowserViewController(forOpeningFilesWithContentTypes:[uti])

        dbvc.delegate = self
        return dbvc
    }()

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {

        window = UIWindow(frame: UIScreen.main.bounds)
        window?.rootViewController = documentBrowser
        window?.makeKeyAndVisible()

        return true
    }
}

And my delegate methods are as follows:

func documentBrowser(_ controller: UIDocumentBrowserViewController, didRequestDocumentCreationWithHandler importHandler:    @escaping (URL?, UIDocumentBrowserViewController.ImportMode) -> Swift.Void) {

    do {
        try MyDocument.create() { result in
            switch result {
            case let .success(document):
                // .move as I'm moving a temp file, if you're using a template
                // this will be .copy 
                importHandler(document.fileURL, .move) 
            case let .failure(error):
                // Show error
                importHandler(nil, .none)
            }
        }
    } catch {
        // Show error
        importHandler(nil, .none)
    }
}

func documentBrowser(_ controller: UIDocumentBrowserViewController, didImportDocumentAt sourceURL: URL, toDestinationURL destinationURL: URL) {
    let document = MyDocument(fileURL: destinationURL)
    document.open { success in
        if success {
            // Modally present DocumentViewContoller for document
        } else {
            // Show error
        }
    }
}

And that's pretty much it. Let me know how you get on!

Ashley Mills
  • 50,474
  • 16
  • 129
  • 160
  • Your UTI definitions exactly match how mine are configured, except that I use a `FileWrapper` instance to read and write the data, and therefore my UTI conforms to `com.apple.package`. The only difference I see with your method implementation is the `create(completion:)` helper, which uses a separate file coordinator to write the file. I tried adding that to my implementation and didn't see a difference. As far as I can tell, it shouldn't be necessary for subclasses of `UIDocument` to wrap their file operations in a separate coordinator, since `UIDocument` adopts `NSFilePresenter`. –  Aug 03 '17 at 19:11
  • 1
    Did you set `UISupportsDocumentBrowser = true` in info.plist? – Ashley Mills Aug 03 '17 at 19:49
  • Yep, that's in there. –  Aug 03 '17 at 22:21
  • Using `UIDocumentBrowserViewController` as your root view controller? – Ashley Mills Aug 04 '17 at 08:05
  • Yes. I used Xcode's document-based app template as my starting point, and haven't diverged from the way it organizes the project. –  Aug 04 '17 at 16:03
  • @AshleyMills are you initiating the DBVC in code? (where you wrote `UIDocumentBrowserViewController(forOpeningFilesWithContentTypes:["com.custom-uti"])`) Is that possible if the DBVC is the root view controller? – OliverD Sep 11 '17 at 12:15
  • @yojimbo2000 I added some more detail to that part of my answer – Ashley Mills Sep 11 '17 at 13:01
  • @AshleyMills thanks, that's awesome. I haven't tried your document creation code yet, does it enforce that new documents must be created in the app's container? What I find with the DBVC is that the "Create document" button is available everywhere, which I think would be super confusing for users. They'd be able to fill, say, their "Pixelmator" folder or whatever with random documents that Pixelmator wouldn't be able to open. This can't be what Apple intended, can it? – OliverD Sep 12 '17 at 09:47
  • In iOS 11, it seem like you can create documents anywhere - just like any file system – Ashley Mills Sep 12 '17 at 10:04
  • @AshleyMills so I guess this means that there's not much point using a DBVC with a conventional iCloud-enabled app. The iCloud entitlement would create a ubiquitous container in the iCloud drive, but you'd have no guarantee that the user would use the container, or even discover it, as they can use BrowserVC to save their documents anywhere. Have I got that right? I think I interpreted the WWDC 2017 "document-based app" session as a continuation of the WWDC 2015 one, whereas actually I think it's a totally new way of building document-based apps on iOS. – OliverD Sep 13 '17 at 07:56
  • @yojimbo2000 That's pretty much my understanding. The app I'm working on began as an iOS 10 app using a ubiquitous container, so it still has its dedicated folder, although this seems superfluous now. – Ashley Mills Sep 13 '17 at 08:42
  • Why do you need to wrap the creation in `coordinationQueue.async { }`? – rawbee Nov 16 '17 at 21:00
  • Per Apple's documentation, you should call `NSFileCoordinator().coordinate` on a background queue. – Ashley Mills Nov 17 '17 at 10:52
  • Updating this, how? – Ashley Mills Sep 30 '18 at 09:03
3

I had the same issue, but then I realized that the recommended way was to simply copy the package/folder from the Bundle, like so:

func documentBrowser(_ controller: UIDocumentBrowserViewController, didRequestDocumentCreationWithHandler importHandler: @escaping (URL?, UIDocumentBrowserViewController.ImportMode) -> Void) {
    if let url = Bundle.main.url(forResource: "Your Already Created Package", withExtension: "your-package-extension") {
        importHandler(url, .copy)
    } else {
        importHandler(nil, .none)
    }
}

To clarify, this package is just a folder that you've created and plopped into Xcode.

This approach makes sense, if you think about it, for a few reasons:

  1. Apple File System (AFS). The move towards AFS means copying is (almost) free.

  2. Permissions. Copying from the Bundle is always permissible, and the user is specifying the location to copy to.

  3. New document browser paradigm. Since we're using the new UIDocumentBrowserViewController paradigm (which is due to iOS11 and the new Files app), it is even handling the naming (c.f. Apple's Pages) and moving and arranging of files. We don't have to worry about which thread to run things on either.

So. Simpler, easier, and probably better. I can't think of a reason to manually create all the files (or use the temp folder, etc).

tjklemz
  • 1,170
  • 12
  • 19
  • 1
    Yes, you can certainly do it this way. Copying from the main bundle would be the clear way to go if you implement a template chooser. Your point about leveraging cloning in APFS is a good one. I'd be interested in hearing what others think should be preferred. –  Dec 13 '17 at 01:25
  • I _think_ with this approach document creation date will be incorrect — i.e. it will not be updated during copying, and will use your bundled template document's creation date. – Alex Staravoitau Jul 06 '19 at 12:05
1

Test on the device, not in the Simulator. The minute I switched to testing on the device, everything just started working correctly. (NOTE: It may be that the Simulator failures occur only for Sierra users like myself.)

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • I have been…I've barely used the simulator to test this. Does your answer mean that, in your case, the user's temporary directory was accessible to the document browser view controller process? Or did you use a different directory? –  Nov 04 '17 at 21:02
  • 1
    @ErikFoss I used the temporary directory. Worked fine. My code was just like the sample code shown in the documentation: https://developer.apple.com/documentation/uikit/uidocumentbrowserviewcontrollerdelegate/2874199-documentbrowser As soon as I switched to testing on the device, the "reader is not permitted to access the URL" error went away and the document became openable on creation and afterwards. – matt Nov 04 '17 at 22:01
  • Thanks for the confirmation—I had concluded that (for whatever reason) the document browser view controller process did *not* have permission to access the directory. After reading your comment, I decided (on a whim) to try deleting and recreating my app's iCloud credentials…and it now seems that everything's working correctly. –  Nov 06 '17 at 19:28
  • @ErikFoss Great that you got it sorted! – matt Nov 06 '17 at 19:44