import Foundation import MobileCoreServices import TutanotaSharedFramework import UniformTypeIdentifiers class IosFileFacade: FileFacade { func openMacImportFileChooser() async throws -> [String] { fatalError("not implemented for this platform") } let chooser: TUTFileChooser let viewer: FileViewer let schemeHandler: ApiSchemeHandler let urlSession: URLSession init(chooser: TUTFileChooser, viewer: FileViewer, schemeHandler: ApiSchemeHandler, urlSession: URLSession) { self.chooser = chooser self.viewer = viewer self.schemeHandler = schemeHandler self.urlSession = urlSession } func openFolderChooser() async throws -> String? { fatalError("not implemented for this platform") } private func writeFile(_ file: String, _ content: DataWrapper) async throws { let fileURL = URL(fileURLWithPath: file) try content.data.write(to: fileURL, options: .atomic) } private func readFile(_ path: String) throws -> DataWrapper { let data = try Data(contentsOf: URL(fileURLWithPath: path)) return data.wrap() } func open(_ location: String, _ mimeType: String) async throws { await self.viewer.openFile(path: location) } func openFileChooser(_ boundingRect: IpcClientRect, _ filter: [String]?, _ isFileOnly: Bool? = false) async throws -> [String] { let anchor = CGRect(x: boundingRect.x, y: boundingRect.y, width: boundingRect.width, height: boundingRect.height) let files = try await self.chooser.open(withAnchorRect: anchor, isFileOnly: isFileOnly!) var returnfiles = [String]() for file in files { let fileUrl = URL(fileURLWithPath: file) let isDirectory: Bool = (try? fileUrl.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true // This should only be for files, but sometimes a directory masquerading as a file can slip through (such as a .band file). // In those cases we just zip and add it. if isDirectory { let destinationPath = try zipDirectory(fileUrl: fileUrl) returnfiles.append(destinationPath) } else { returnfiles.append(file) } } return returnfiles } func deleteFile(_ file: String) async throws { do { try FileManager.default.removeItem(atPath: file) } catch { if let err = error as? NSError, err.code == NSFileNoSuchFileError { return printLog("Tried to delete file \(file) that does not exist.") } throw TUTErrorFactory.wrapNativeError(withDomain: FILES_ERROR_DOMAIN, message: "Failed to delete file \(file)", error: error) } } func getName(_ file: String) async throws -> String { let fileName = (file as NSString).lastPathComponent if FileUtils.fileExists(atPath: file) { return fileName } else { throw TUTErrorFactory.createError(withDomain: FILES_ERROR_DOMAIN, message: "File does not exists") } } func getMimeType(_ file: String) async throws -> String { getFileMIMETypeWithDefault(path: file) } func getSize(_ file: String) async throws -> Int { let attrs = try FileManager.default.attributesOfItem(atPath: file) let size = attrs[.size] as! UInt64 // Technically we shouldn't do this but we are always running on 64bit devices and // max Int64 number (even signed) is pretty huge so this is safe. // If we somehow overflow we will actually crash. return Int(size) } func putFileIntoDownloadsFolder(_ localFileUri: String, _ fileNameToSave: String) async throws -> String { fatalError("not implemented on this platform") } func upload(_ sourceFileUrl: String, _ remoteUrl: String, _ method: String, _ headers: [String: String]) async throws -> UploadTaskResponse { var request = URLRequest(url: URL(string: remoteUrl)!) request.httpMethod = method request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") request.allHTTPHeaderFields = headers let (data, response) = try await self.urlSession.upload(for: self.schemeHandler.rewriteRequest(request), fromFile: URL(fileURLWithPath: sourceFileUrl)) let httpResponse = response as! HTTPURLResponse return UploadTaskResponse(httpResponse: httpResponse, responseBody: data) } func download(_ sourceUrl: String, _ filename: String, _ headers: [String: String]) async throws -> DownloadTaskResponse { let urlStruct = URL(string: sourceUrl)! var request = URLRequest(url: urlStruct) request.httpMethod = "GET" request.allHTTPHeaderFields = headers let (data, response) = try await self.urlSession.data(for: self.schemeHandler.rewriteRequest(request)) let httpResponse = response as! HTTPURLResponse let encryptedFileUri: String? if httpResponse.statusCode == 200 { encryptedFileUri = try self.writeEncryptedFile(fileName: filename, data: data) } else { encryptedFileUri = nil } return DownloadTaskResponse(httpResponse: httpResponse, encryptedFileUri: encryptedFileUri) } private func writeEncryptedFile(fileName: String, data: Data) throws -> String { let encryptedPath = try FileUtils.getEncryptedFolder() let filePath = (encryptedPath as NSString).appendingPathComponent(fileName) try data.write(to: URL(fileURLWithPath: filePath), options: .atomicWrite) return filePath } func hashFile(_ fileUri: String) async throws -> String { try await BlobUtil().hashFile(fileUri: fileUri) } func zipDirectory(fileUrl: URL) throws -> String { var returnPath: String = "" var err: NSError? var ourError: Error? NSFileCoordinator() .coordinate(readingItemAt: fileUrl, options: [.forUploading], error: &err) { (zipUrl) in do { let decryptedFolder = try FileUtils.getDecryptedFolder() let destinationPath = (decryptedFolder as NSString).appendingPathComponent((zipUrl.path as NSString).lastPathComponent) try FileManager.default.copyItem(at: zipUrl, to: URL(fileURLWithPath: destinationPath)) returnPath = destinationPath } catch { ourError = error return } } if let e = err ?? ourError { throw TutanotaError(message: "could not read directory at \(fileUrl)", underlyingError: e) } return returnPath } func clearFileData() async throws { _ = await ( try self.clearDirectory(folderPath: FileUtils.getEncryptedFolder()), try self.clearDirectory(folderPath: FileUtils.getDecryptedFolder()), try self.clearDirectory(folderPath: NSTemporaryDirectory()) ) } func joinFiles(_ filename: String, _ files: [String]) async throws -> String { try await BlobUtil().joinFiles(fileName: filename, filePathsToJoin: files) } func splitFile(_ fileUri: String, _ maxChunkSizeBytes: Int) async throws -> [String] { try await BlobUtil().splitFile(fileUri: fileUri, maxBlobSize: maxChunkSizeBytes) } func writeTempDataFile(_ file: DataFile) async throws -> String { let decryptedFolder = try FileUtils.getDecryptedFolder() let filePath = (decryptedFolder as NSString).appendingPathComponent(file.name) try await self.writeFile(filePath, file.data) return filePath } func readDataFile(_ filePath: String) async throws -> DataFile? { let data = try readFile(filePath) return DataFile(name: try await getName(filePath), mimeType: try await getMimeType(filePath), size: try await getSize(filePath), data: data) } func writeToAppDir(_ content: TutanotaSharedFramework.DataWrapper, _ name: String) async throws { let supportDir = try FileUtils.getApplicationSupportFolder() let filePath = supportDir.appendingPathComponent(name) try await self.writeFile(filePath.path, content) } func readFromAppDir(_ name: String) throws -> TutanotaSharedFramework.DataWrapper { let supportDir = try FileUtils.getApplicationSupportFolder() let filePath = supportDir.appendingPathComponent(name) return try self.readFile(filePath.path) } private func clearDirectory(folderPath: String) async throws { let fileManager = FileManager.default let folderUrl = URL(fileURLWithPath: folderPath) let files = try fileManager.contentsOfDirectory(at: folderUrl, includingPropertiesForKeys: nil, options: []) for file in files where !file.hasDirectoryPath { try fileManager.removeItem(at: file) } } } extension UploadTaskResponse { init(httpResponse: HTTPURLResponse, responseBody: Data) { self.init( statusCode: httpResponse.statusCode, errorId: httpResponse.valueForHeaderField("Error-Id"), precondition: httpResponse.valueForHeaderField("Precondition"), suspensionTime: httpResponse.valueForHeaderField("Retry-After") ?? httpResponse.valueForHeaderField("Suspension-Time"), responseBody: responseBody.wrap() ) } } extension DownloadTaskResponse { init(httpResponse: HTTPURLResponse, encryptedFileUri: String?) { self.init( statusCode: httpResponse.statusCode, errorId: httpResponse.valueForHeaderField("Error-Id"), precondition: httpResponse.valueForHeaderField("Precondition"), suspensionTime: httpResponse.valueForHeaderField("Retry-After") ?? httpResponse.valueForHeaderField("Suspension-Time"), encryptedFileUri: encryptedFileUri ) } } func getFileMIMETypeWithDefault(path: String) -> String { getFileMIMEType(path: path) ?? "application/octet-stream" } func getFileMIMEType(path: String) -> String? { // UTType is only available since iOS 15. // We take retainedValue because both functions create new object and we // are responsible for deallocating them. // see https://developer.apple.com/documentation/swift/imported_c_and_objective-c_apis/working_with_core_foundation_types // see https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFMemoryMgmt/Concepts/Ownership.html#//apple_ref/doc/uid/20001148 let UTI = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (path as NSString).pathExtension as CFString, nil)!.takeRetainedValue() let MIMEUTI = UTTypeCopyPreferredTagWithClass(UTI, kUTTagClassMIMEType)?.takeRetainedValue() return MIMEUTI as String? } /// Reading header fields from HTTPURLResponse.allHeaderFields is case-sensitive, it is a bug: https://bugs.swift.org/browse/SR-2429 /// From iOS13 we have a method to read headers case-insensitively: HTTPURLResponse.value(forHTTPHeaderField:) /// For older iOS we use this NSDictionary cast workaround as suggested by a commenter in the bug report. extension HTTPURLResponse { public func valueForHeaderField(_ headerField: String) -> String? { value(forHTTPHeaderField: headerField) } }