【Swift, iOS】UIActivityViewControllerを使用してファイルをiCloud Driveなどのアプリ外に保存する

前回はアプリ外のファイルを読み込む場合の話だったが、今回は、アプリ外にファイルを保存する場合の話

Androidだと普通にできる(簡単とは言っていない)ため、iOSでも割と需要はあると思うのだが、それっぽいキーワードで検索しても出てこなかったためメモ

アプリ外へのファイル保存はいわゆる「共有」で使われるUIActivityViewControllerを使用する(Androidで言うと暗黙的Intent的なやつ)

以下は例としてDocumentディレクトリに保存したファイルをアプリ外に保存する処理のソースコード

func fileSave(fileName: String) {
    let dir = FileManager.default.urls( for: .documentDirectory, in: .userDomainMask ).first!
    let filePath = dir.appendingPathComponent(fileName, isDirectory: false);
    let controller = UIActivityViewController.init(activityItems: [filePath], applicationActivities: nil)
    controller.excludedActivityTypes = [
        .addToReadingList,
        .airDrop,
        .assignToContact,
        .copyToPasteboard,
        .mail,
        .markupAsPDF,
        .message,
        .openInIBooks,
        .postToFacebook,
        .postToFlickr,
        .postToTencentWeibo,
        .postToTwitter,
        .postToVimeo,
        .postToWeibo,
        .print,
        .saveToCameraRoll
    ]
    self.present(controller, animated: true, completion: nil)
}

excludedActivityTypesは除外したい共有先で、今回はファイル保存以外を出来るだけ消したいのでとりあえず定義してあるものを全部を指定しているが、SNSへの共有などを許可したいのであれば要変更

これに正しいファイル名を渡して呼ぶと以下の画像のように表示される

IMG_0004

この中からファイルに保存を選択するとiCloud Driveなどの保存先を選んでファイルを保存することができる

ただし、基本的には共有用のものであり、ユーザー側で他のものを選択されてしまうと保存ができないので、アプリ側である程度ナビゲートするようにした方が良いかもしれない

【Swift, iOS】UIDocumentBrowserViewControllerでiCloud Driveのファイルを読み込む際にNSCocoaErrorDomain Code=257のエラーが出る問題の対処法

iOSアプリを開発していてアプリ外にあるファイルを読み込みたいと思った時にUIDocumentBrowserViewControllerというクラスを見つけた

早速実装してUIDocumentBrowserViewControllerDelegateのdidPickDocumentsAtでiCloud Driveにある選択したファイルのURLをDataにしたところ、シミュレーターでは問題なく動いてホクホク顔をしていたら、実機だとエラーが出て撃沈

まあ結論から言うと、そういう用途の時はUIDocumentPickerViewControllerの方を使うべきで、そちらで探したらすぐ修正方法は見つかったのだが、UIDocumentBrowserViewControllerの方で探すとどこにもなくて無駄に時間を費やしてしまったのでメモ

まずはJSONファイルを読み込む場合を例として以下のように実装する

import UIKit

class DataLoadViewController: UIViewController, UIDocumentBrowserViewControllerDelegate {
    
    override func viewDidLoad() {
        super.viewDidLoad()

        let controller = UIDocumentBrowserViewController.init(forOpeningFilesWithContentTypes: ["public.json"])
        controller.delegate = self;
        controller.allowsDocumentCreation = false;
        controller.allowsPickingMultipleItems = false;
        self.present(controller, animated: true, completion: nil)
    }
    
    func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL]) {
        controller.dismiss(animated: true, completion: nil)
        if documentURLs.first == nil {
            return
        }
        let data = try! Data(contentsOf: documentURLs.first!);
        let jsonStr = String(data: data, encoding: .utf8)
        print(jsonStr)
        //jsonStrを使ってあーだーこーだする
    }
}

このViewControllerを呼び出すとファイル操作のできる画面が表示され、そのうちiCloud DriveのJSONファイルを選択するとシミュレーターでは画面が閉じて問題なくJSONのテキストのログが表示されるが、端末だと以下の様なエラーが発生してクラッシュする

・Thread 1: Fatal error: 'try!' expression unexpectedly raised an error: Error Domain=NSCocoaErrorDomain Code=257 "The file “ファイル名.json” couldn’t be opened because you don’t have permission to view it." UserInfo={NSFilePath=/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/ファイル名.json, NSUnderlyingError=0x283e1f1b0 {Error Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted"}}

エラー内容を見るにファイルを開く権限がないと言われているようだが、UIDocumentBrowserViewController permissionで検索するとそれらしい情報はいくつかあるもののどれも解決には至らなかった

半分諦めかけていたところ、MacのパーミッションのStack OverflowでNSURLにリソースへのアクセス要求をするメソッドがあるとのことなので、試しに真似して実装してみたら解決した

以下がその実装

func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL]) {
    controller.dismiss(animated: true, completion: nil)
    if documentURLs.first == nil || !documentURLs.first!.startAccessingSecurityScopedResource() {
        return
    }
    let data = try! Data(contentsOf: documentURLs.first!);
    let jsonStr = String(data: data, encoding: .utf8)
    //jsonStrを使ってあーだーこーだする
    documentURLs.first!.stopAccessingSecurityScopedResource()
}

startAccessingSecurityScopedResourceでリソースへのアクセス要求し、stopAccessingSecurityScopedResourceでリソースのアクセス要求を取り消している

ちなみに、UIDocumentPickerViewController permissionで検索すると速攻で以下が引っかかったので泣きそうになった

https://stackoverflow.com/questions/46440972/uidocumentpickerviewcontroller-with-uidocumentpickermodeopen-mode-not-giving-per

上記のUIDocumentBrowserViewControllerで行っている処理をUIDocumentPickerViewControllerでやると以下のようになる

import UIKit

class DataLoadViewController: UIViewController, UIDocumentPickerDelegate {
    
    override func viewDidLoad() {
        super.viewDidLoad()

        let controller = UIDocumentPickerViewController.init(documentTypes: ["public.json"], in: .open)
        controller.delegate = self;
        controller.allowsMultipleSelection = false;
        self.present(controller, animated: true, completion: nil)
    }
    
    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
        if urls.first == nil || !urls.first!.startAccessingSecurityScopedResource() {
            return
        }
        let data = try! Data(contentsOf: urls.first!);
        let jsonStr = String(data: data, encoding: .utf8)
        //jsonStrを使ってあーだーこーだする
        urls.first!.stopAccessingSecurityScopedResource()
    }
}

こちらであればviewControllerをdismissで閉じる必要もないため、UIDocumentPickerViewControllerで実装するべきだろう