iosswiftswiftuifiles-app

How to read/write to a folder from the iOS Files app accross app restarts


I'm developing a SwiftUI app that has to sync files to a folder in Dropbox, Google Drive or any file provider that exists on the iOS Files app. I cannot use the Dropbox or Google Drive APIs because I want to offer the app for free, and those APIs become very expensive once you start getting users.

From my research I understand that I should present a UIDocumentPickerViewController to the user, to let him select the folder where to sync the files to, and that controller should return the URL to the folder chosen. Then I can use that URL to write to the folder, and I can persist the URL to reuse it everytime I need to write to the folder.

I've created a demo app that works great on the simulator, but not on a real device because the URL stops working when the user closes the app (which does not happen on the simulator).

My questions are:

I know this can be done as some other apps, like Kepassium, do the same. I even found their source code in GitHub, but it is implemented with storyboards, which I do not know, and I haven't managed to figure out how they do it, despite having dedicated several hours to study the code.

Below is the source for the demo app:

struct ContentView: View {
    
    enum Constants : String {
        case folderURLKey = "folderURL"
    }
    
    
    @State private var isShowingDocumentPicker = false
    @State private var selectedFolderURL: URL?
    @State private var fileList: [String] = []

    var body: some View {
        VStack {
            Button("Select Dropbox folder") {
                isShowingDocumentPicker = true
            }
            .padding(.bottom, 20)

            if let folderURL = selectedFolderURL {
                Text("Selected folder:")
                Text(folderURL.path)
                    .foregroundColor(.blue)
                    .padding(.bottom, 20)
                
                Button("Create random file") {
                    createRandomFile()
                }
                ForEach(self.fileList, id: \.self) { fileName in
                    Text(fileName).font(.caption2)
                }
            }
        }
        .buttonStyle(.bordered)
        .sheet(isPresented: $isShowingDocumentPicker) {
            FolderPickerView { folderURL in
                selectedFolderURL = folderURL
                if let url = folderURL {
                    let urlData = try! url.bookmarkData()
                    UserDefaults.standard.setValue(urlData, forKey: Constants.folderURLKey.rawValue)
                }
            }
        }
        .onAppear() {
            let urlData = UserDefaults.standard.data(forKey: Constants.folderURLKey.rawValue)
            if let urlData = urlData {
                var isStale: Bool = false
                let folderURL = try! URL(resolvingBookmarkData: urlData, bookmarkDataIsStale: &isStale)
                self.selectedFolderURL = folderURL
                
                self.fileList = self.listFiles(folderURL: self.selectedFolderURL!)
            }
            
        }
    }
    
    func listFiles(folderURL: URL) -> [String] {
        var list: [String] = []
        do {
            if folderURL.startAccessingSecurityScopedResource() {
                let contents = try FileManager.default.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil)
                for fileURL in contents {
                    list.append(fileURL.lastPathComponent)
                }
                folderURL.stopAccessingSecurityScopedResource()
            }
            else {
                print("Call to startAccessingSecurityScopedResource failed!!!!!")
            }
        }
        catch {
            print("ERROR: \(error)")
        }
        return list
    }
    
    func createRandomFile() {
        if selectedFolderURL!.startAccessingSecurityScopedResource() {
            FileManager.default.createFile(atPath: selectedFolderURL!.appendingPathComponent("random_file_\(UUID().uuidString).txt").path, contents: Data("Hello, world!".utf8))
            selectedFolderURL!.stopAccessingSecurityScopedResource()
            self.fileList = self.listFiles(folderURL: self.selectedFolderURL!)
        }
        else {
            print("Call to startAccessingSecurityScopedResource failed!!!!!")
        }
    }
    
}

struct FolderPickerView: UIViewControllerRepresentable {
    var onFolderSelected: (URL?) -> Void

    func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
        let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
        picker.delegate = context.coordinator
        picker.allowsMultipleSelection = false // Solo una carpeta
        return picker
    }

    func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(onFolderSelected: onFolderSelected)
    }

    class Coordinator: NSObject, UIDocumentPickerDelegate {
        var onFolderSelected: (URL?) -> Void

        init(onFolderSelected: @escaping (URL?) -> Void) {
            self.onFolderSelected = onFolderSelected
        }

        func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
            onFolderSelected(urls.first)
        }

        func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
            onFolderSelected(nil)
        }
    }
}

Any help is appreciated!


Solution

  • KeePassium author here.

    File bookmarks are rather fragile and tend to break from a light breeze. Dropbox app got updated — you get strange errors until Dropbox runs its "housekeeping". You save a large file while its previous version is still being uploaded by OneDrive or GDrive — their file providers crash and you get weird errors again. Sometimes the system decides you have not opened Dropbox in Files app too long, so it disables Dropbox there without a warning and — you guessed it — all your bookmarks become invalid. And sometimes your call to URL(resolvingBookmarkData:…) just won't return for a minute. Fun times.

    So if at all possible, do consider direct API connections instead; this will save you a lot of frustration. Dropbox and OneDrive APIs are free. Google requires an annual security assessment (~540 USD), but only for restricted scopes. Working with app-specific folder is non-sensitive, so theoretically it needs only minimal free verification instead of third-party assessment.

    If you insist on using bookmarks, the first approximation is straightforward. (I should note that KeePassium persists bookmarks to files, not folders.)

    // fileURL is returned by UIDocumentPickerViewController
    fileURL.startAccessingSecurityScopedResource()
    let bookmarkData = try fileURL.bookmarkData(
        options: [.minimalBookmark],
        includingResourceValuesForKeys: nil,
        relativeTo: nil)
    fileURL.stopAccessingSecurityScopedResource()
    
    var isStale = false
    let fileURL = try URL(
        resolvingBookmarkData: bookmarkData,
        options: [.withoutUI, .withoutMounting],
        relativeTo: nil,
        bookmarkDataIsStale: &isStale)
    fileURL.startAccessingSecurityScopedResource()
    // read/write your file here. Better wrap it with `NSFileCoordinator().coordinate(…)`
    fileURL.stopAccessingSecurityScopedResource()
    

    Looks easy enough, until you start considering all the edge cases, accounting for unresponsive system calls, etc. Then it becomes a convoluted mess that can be found in URLReference in KeePassium :)

    And yes, better test on device: simulator is way more forgiving.