How to enable files and folders permission on iOS
Asked Answered
B

3

6

I am trying to download a file using AlamoFire and save it to a downloads directory of the user's choice (like safari). However, whenever I set the download directory to a folder outside of my app's documents, I get the following error (on a real iOS device):

downloadedFileMoveFailed(error: Error Domain=NSCocoaErrorDomain Code=513 "“CFNetworkDownload_dlIcno.tmp” couldn’t be moved because you don’t have permission to access “Downloads”." UserInfo={NSSourceFilePathErrorKey=/private/var/mobile/Containers/Data/Application/A24D885A-1306-4CE4-9B15-952AF92B7E6C/tmp/CFNetworkDownload_dlIcno.tmp, NSUserStringVariant=(Move), NSDestinationFilePath=/private/var/mobile/Containers/Shared/AppGroup/E6303CBC-62A3-4206-9C84-E37041894DEC/File Provider Storage/Downloads/100MB.bin, NSFilePath=/private/var/mobile/Containers/Data/Application/A24D885A-1306-4CE4-9B15-952AF92B7E6C/tmp/CFNetworkDownload_dlIcno.tmp, NSUnderlyingError=0x281d045d0 {Error Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted"}}, source: file:///private/var/mobile/Containers/Data/Application/A24D885A-1306-4CE4-9B15-952AF92B7E6C/tmp/CFNetworkDownload_dlIcno.tmp, destination: file:///private/var/mobile/Containers/Shared/AppGroup/E6303CBC-62A3-4206-9C84-E37041894DEC/File%20Provider%20Storage/Downloads/100MB.bin)

The summary of that error is that I don't have permission to access the folder I just granted access to.

Here's my attached code:

import SwiftUI
import UniformTypeIdentifiers
import Alamofire

struct ContentView: View {
    @AppStorage("downloadsDirectory") var downloadsDirectory = ""
    
    @State private var showFileImporter = false
    
    var body: some View {
        VStack {
            Button("Set downloads directory") {
                showFileImporter.toggle()
            }
            
            Button("Save to downloads directory") {
                Task {
                    do {
                        let destination: DownloadRequest.Destination = { _, response in
                            let documentsURL = URL(string: downloadsDirectory)!
                            let suggestedName = response.suggestedFilename ?? "unknown"

                            let fileURL = documentsURL.appendingPathComponent(suggestedName)

                            return (fileURL, [.removePreviousFile, .createIntermediateDirectories])
                        }

                        let _ = try await AF.download(URL(string: "https://i.imgur.com/zaVQDFJ.png")!, to: destination).serializingDownloadedFileURL().value
                    } catch {
                        print("Downloading error!: \(error)")
                    }
                }
            }
        }
        .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [UTType.folder]) { result in
            switch result {
            case .success(let url):
                downloadsDirectory = url.absoluteString
            case .failure(let error):
                print("Download picker error: \(error)")
            }
        }
    }
}

To reproduce (Run on a REAL iOS device!):

  1. Click the Set downloads directory button to On my iPhone
  2. Click the Save to downloads directory button
  3. Error occurs

Upon further investigation, I found that safari uses the Files and Folders privacy permission (Located in Settings > Privacy > Files and folders on an iPhone) to access folders outside the app sandbox (This link for the image of what I'm talking about). I scoured the web as much as I can and I couldn't find any documentation for this exact permission.

I have seen non-apple apps (such as VLC) use this permission, but I cannot figure out how it's granted.

I tried enabling the following plist properties, but none of them work (because I later realized these are for macOS only)

<key>NSDocumentsFolderUsageDescription</key>
<string>App wants to access your documents folder</string>
<key>NSDownloadsFolderUsageDescription</key>
<string>App wants to access your downloads folder</string>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>

Can someone please help me figure out how to grant the files and folder permission and explain what it does? I would really appreciate the help.

Bogeyman answered 18/1, 2022 at 4:24 Comment(5)
I don't think the files are available outside the sandbox. You are simply exposing the files stored in your sandbox via the filesapp. The permissions you need are LSSupportsOpeningDocumentsInPlace and UIFileSharingEnabled and it seems you have done this so it should be working. I would say delete your app do a clean build and run it again and check if a folder with your app name and logo appears on the files app.Scheelite
Yes, I have done that and the folder does show up. However, I want to save the downloaded file outside the app's sandbox which is possible in Safari. That is achieved through that weird Files and Folders permission in settings. I'm just not sure how to grant it. Thank you nevertheless.Bogeyman
I could be wrong but I believe you have access to your sandbox only as a third party app. The only place you can store data outside your sandbox per se are things like iCloud, Photos app and so I don't think VLC has access beyond their own sandbox. Safari is not a third party app and is developed by Apple themselves and so might have way more permissions than given to us. More on this here: support.apple.com/en-ae/guide/security/sec15bfe098e/web - however keep digging, there might be a solution I am unaware of.Scheelite
You can’t save anything outside your app bundle. What you can do is let the user choose where to export the downloaded file. You don’t have permission to write outside the app bundleLohse
Figured out how apple does this. Posted the answer down below but I can't mark it as correct until tomorrow.Bogeyman
B
11

After some research, I stumbled onto this Apple documentation page (which wasn't found after my hours of google searching when I posted this question)

https://developer.apple.com/documentation/uikit/view_controllers/providing_access_to_directories

Navigate to the Save the URL as a Bookmark part of the article.

Utilizing a SwiftUI fileImporter gives one-time read/write access to the user-selected directory. To persist this read/write access, I had to make a bookmark and store it somewhere to access later.

Since I only needed one bookmark for the user's downloads directory, I saved it in UserDefaults (a bookmark is very small in terms of size).

When saving a bookmark, the app is added into the Files and folders in the user's settings, so the user can revoke file permissions for the app immediately (hence all the guard statements in my code snippet).

Here's the code snippet which I used and with testing, downloading does persist across app launches and multiple downloads.

import SwiftUI
import UniformTypeIdentifiers
import Alamofire

struct ContentView: View {
    @AppStorage("bookmarkData") var downloadsBookmark: Data?
    
    @State private var showFileImporter = false
    
    var body: some View {
        VStack {
            Button("Set downloads directory") {
                showFileImporter.toggle()
            }
            
            Button("Save to downloads directory") {
                Task {
                    do {
                        let destination: DownloadRequest.Destination = { _, response in
                            // Save to a temp directory in app documents
                            let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("Downloads")
                            let suggestedName = response.suggestedFilename ?? "unknown"

                            let fileURL = documentsURL.appendingPathComponent(suggestedName)

                            return (fileURL, [.removePreviousFile, .createIntermediateDirectories])
                        }

                        let tempUrl = try await AF.download(URL(string: "https://i.imgur.com/zaVQDFJ.png")!, to: destination).serializingDownloadedFileURL().value
                        
                        // Get the bookmark data from the AppStorage call
                        guard let bookmarkData = downloadsBookmark else {
                            return
                        }
                        var isStale = false
                        let downloadsUrl = try URL(resolvingBookmarkData: bookmarkData, bookmarkDataIsStale: &isStale)
                        
                        guard !isStale else {
                            // Log that the bookmark is stale
                            
                            return
                        }
                        
                        // Securely access the URL from the bookmark data
                        guard downloadsUrl.startAccessingSecurityScopedResource() else {
                            print("Can't access security scoped resource")
                            
                            return
                        }
                        
                        // We have to stop accessing the resource no matter what
                        defer { downloadsUrl.stopAccessingSecurityScopedResource() }
                        
                        do {
                            try FileManager.default.moveItem(at: tempUrl, to: downloadsUrl.appendingPathComponent(tempUrl.lastPathComponent))
                        } catch {
                            print("Move error: \(error)")
                        }
                    } catch {
                        print("Downloading error!: \(error)")
                    }
                }
            }
        }
        .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [UTType.folder]) { result in
            switch result {
            case .success(let url):
                // Securely access the URL to save a bookmark
                guard url.startAccessingSecurityScopedResource() else {
                    // Handle the failure here.
                    return
                }
                
                // We have to stop accessing the resource no matter what
                defer { url.stopAccessingSecurityScopedResource() }
                
                do {
                    // Make sure the bookmark is minimal!
                    downloadsBookmark = try url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil)
                } catch {
                    print("Bookmark error \(error)")
                }
            case .failure(let error):
                print("Importer error: \(error)")
            }
        }
    }
}
Bogeyman answered 18/1, 2022 at 22:24 Comment(4)
does the bookmark work across devices? so if I were to store it in icloud then sync it to another device under the same iCloud account and try and use the bookmark there?Kerstinkerwin
Somehow this only works for me once. After the first time I access the bookmark it no longer works.Farthingale
Update: it was my bad, I was supposed to create the bookmark AFTER startAccessingSecurityScopedResource.Farthingale
@Volnutt glad I could help. Took me some time before to figure it out as well. I couldn't find any similar question/answer so I figured it must be something wrong with my code and that was it!Farthingale
F
2

code24's answer is correct but I want to add additional context in case you have problem with your bookmark only work once or not working at all (or maybe expires after some time).

You need to create the bookmark AFTER calling startAccessingSecurityScopedResource as well or else the bookmark stops working after a single use or maybe some time after.

guard url.startAccessingSecurityScopedResource() else {
    // Handle the failure here.
    return
}

// ...
// Call bookmarkData() here

Farthingale answered 11/5, 2023 at 5:25 Comment(0)
D
0

You may also want to try to add this to your info.plist file:

Application supports iTunes file sharing -> Yes
Diuretic answered 6/10, 2023 at 20:26 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.