Search code examples
iosswiftiphonepermissions

Cannot modify files outside my iOS app's sandbox. Permissions issue


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.


Solution

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