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!
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.