I'm developing an iOS app (latest Swift, iOS, and Xcode versions atm of writing) that needs to rename audio files (and later modify its metadata) located anywhere (local storage, external storage, cloud, etc.).
The trouble comes when testing on a physical device while trying to rename a file in some folder On My iPhone, the app does as intended when testing on the Simulator. On my physical iPhone, I get an error logged saying that I don't have permission to do that.
“filename.mp3” couldn’t be moved because you don’t have permission to access “randomFolder".
I did my googling and asked GPTs about it, learned about FileCoordinator
, UIDocumentPickerViewController
, startAccessingSecurityScopedResource
. I also saw this video, and finally followed this other one.
When running on my device, I see url.startAccessingSecurityScopedResource
throwing:
Failed to obtain access to the security-scoped resource.
I have also tried getting access to the parent directory like this:
let parentURL = url.deletingLastPathComponent()
and then using parentURL
instead, but it also fails.
Something I noticed is that my app is not shown in the Settings -> Privacy & Security -> Files and Folders list. My app didn't trigger any dialog to be added here given user consent.
Fiddling with entitlements in Info.plist
and Capabilities did not help either, but I'm not so sure about which ones should I add.
So:
Could it be that the problem I'm seeing about permissions and my app not appearing at the Files and Folders privacy settings on my iPhone is because my app is signed with a developer account which is not in the Apple Developer Program? this is my last thought since I do not understand what else should I try.
Here is a minimal reproducible example, also found here:
// ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var isFilePickerPresented: Bool = false
@State private var newName: String = ""
@EnvironmentObject var bookmarkController: BookmarkController
var body: some View {
VStack {
TextField("Enter new name", text: $newName)
.padding()
.border(Color.black, width: 1)
.background(.gray.opacity(0.2))
Button("Show document picker") {
isFilePickerPresented.toggle()
}
.padding()
.frame(maxWidth: .infinity)
.background(.blue)
.foregroundStyle(.white)
.sheet(isPresented: $isFilePickerPresented, content: {
DocumentPicker(newName: $newName)
})
}
.padding()
}
}
// DocumentPicker.swift
import SwiftUI
import UIKit
import UniformTypeIdentifiers
import MobileCoreServices
/// Rename selected file from browser
func renameFile(at fileURL: URL, to newName: String) throws {
let fileExtension = fileURL.pathExtension
let directory = fileURL.deletingLastPathComponent()
// Create a new URL with the updated name and the original extension
let renamedURL = directory.appendingPathComponent(newName).appendingPathExtension(fileExtension)
try FileManager.default.moveItem(at: fileURL, to: renamedURL)
}
/// ATM we use UTType.audio but could add specific audio formats later, if needed.
let supportedTypes: [UTType] = [UTType.audio]
struct BrowserView: View {
@State private var fileBrowserIsShown = false
@Binding var newName: String
var body: some View {
DocumentPicker(newName: $newName)
.environmentObject(BookmarkController())
}
}
struct DocumentPicker: UIViewControllerRepresentable {
@Binding var newName: String
@EnvironmentObject private var bookmarkController: BookmarkController
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: supportedTypes)
documentPicker.delegate = context.coordinator
return documentPicker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {
print("updateUIViewController documentPicker")
}
func makeCoordinator() -> Coordinator {
Coordinator(self, newName)
}
class Coordinator: NSObject, UIDocumentPickerDelegate {
var parent: DocumentPicker
var newName: String
init(_ parent: DocumentPicker, _ newName: String = "") {
self.parent = parent
self.newName = newName
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
// save bookmark
print("documentPicker \(urls[0])")
parent.bookmarkController.addBookmark(for: urls[0])
// Rename the file
var error: NSError?
NSFileCoordinator().coordinate(readingItemAt: urls[0], options: [], error: &error) { coordinatedURL in
do {
// let data = try Data(contentsOf: newURL)
print("urls[0]: \(urls[0])")
print("coordinatedURL: \(coordinatedURL)")
print("renamedURL: \(newName)")
try renameFile(at: coordinatedURL, to: newName)
} catch {
print("Error: \(error.localizedDescription)")
}
}
}
}
}
// BookmarkController.swift
import SwiftUI
import MobileCoreServices
class BookmarkController: ObservableObject {
@Published var urls: [URL] = []
init() {
loadAllBookmarks()
}
func addBookmark(for url: URL) {
let parentURL = url.deletingLastPathComponent()
print("adding bookmark for \(parentURL)")
do {
guard parentURL.startAccessingSecurityScopedResource() else {
print("Failed to obtain access to the security-scoped resource.")
return
}
defer { parentURL.stopAccessingSecurityScopedResource() }
let bookmarkData = try parentURL.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil)
let uuid = UUID().uuidString
try bookmarkData.write(to: getAppSandboxDir().appendingPathComponent(uuid))
urls.append(parentURL)
} catch {
print("Error Adding Bookmark: \(error.localizedDescription)")
}
}
func loadAllBookmarks() {
// Get all the bookmark files
let files = try? FileManager.default.contentsOfDirectory(at: getAppSandboxDir(), includingPropertiesForKeys: nil)
// Map over the bookmark files
self.urls = files?.compactMap { file in
do {
let bookmarkData = try Data(contentsOf: file)
var isStale = false
// Get the URL from each bookmark
let url = try URL(resolvingBookmarkData: bookmarkData, bookmarkDataIsStale: &isStale)
guard !isStale else {
// Handle stale bookmarks
return nil
}
print("loaded bookmark: \(url)")
// Return URL
return url
} catch {
print("Error Loading Bookmark: \(error.localizedDescription)")
return nil
}
} ?? []
}
private func getAppSandboxDir() -> URL {
// TODO see 0 index
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
}
// App.swift
import SwiftUI
@main
struct renamerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(BookmarkController())
}
}
}
Any pointers and help will be much appreciated.
I finally found a solution. According to this document the user first needs to pick a folder to give the app access by returning a security-scoped URL (which you can then bookmark for later use) for it and all of its content. After the user picks the folder, they can do whatever they want on another picker for the specified file type.
It's still way too complicated for users to understand why they have to do this, so I made a user-friendly flow so that users are informed on how to continue. It still lacks some toast notifications for success but you'll get the idea. The code is on the repo I posted before.
If there's a way to make this in one step instead of having to prompt the user for a folder selection, it would be great. Please do let me know. Until then, this is what I could come up with.