AttachmentManager.swift 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. import Foundation
  2. import MobileCoreServices
  3. enum DownloadError: Error {
  4. case invalidUrlOrDirectory
  5. case maxSizeReached
  6. case unexpectedResponse
  7. }
  8. struct AttachmentManager {
  9. static private let tag = "AttachmentManager"
  10. static private let attachmentDir = "attachments"
  11. static private let bufferSize = 131072 // 128 KB
  12. static func download(url: String, id: String, maxLength: Int64, timeout: TimeInterval, completionHandler: @escaping (URL?, Error?) -> Void) {
  13. if #available(iOS 15, *) {
  14. downloadStream(url: url, id: id, maxLength: maxLength, timeout: timeout, completionHandler: completionHandler)
  15. } else {
  16. downloadNoStream(url: url, id: id, maxLength: maxLength, timeout: timeout, completionHandler: completionHandler)
  17. }
  18. }
  19. @available(iOS 15, *)
  20. private static func downloadStream(url: String, id: String, maxLength: Int64, timeout: TimeInterval, completionHandler: @escaping (URL?, Error?) -> Void) {
  21. Task {
  22. Log.d(self.tag, "Streaming \(url)")
  23. guard let url = URL(string: url), let attachmentDir = createAttachmentDir() else {
  24. completionHandler(nil, DownloadError.invalidUrlOrDirectory)
  25. return
  26. }
  27. do {
  28. let sessionConfig = URLSessionConfiguration.default
  29. sessionConfig.timeoutIntervalForRequest = timeout
  30. sessionConfig.timeoutIntervalForResource = timeout
  31. let session = URLSession.init(configuration: sessionConfig)
  32. let (asyncBytes, urlResponse) = try await session.bytes(from: url)
  33. let expectedLength = urlResponse.expectedContentLength
  34. // Fail fast: If Content-Length header set and it's >maxLength, fail
  35. if maxLength > 0 && expectedLength > maxLength {
  36. throw DownloadError.maxSizeReached
  37. }
  38. // Open temporary file handle
  39. let fileManager = FileManager.default
  40. var contentUrl = attachmentDir.appendingPathComponent(id)
  41. try? fileManager.removeItem(atPath: contentUrl.path)
  42. fileManager.createFile(atPath: contentUrl.path, contents: nil)
  43. let fileHandle = try FileHandle(forWritingTo: contentUrl)
  44. Log.d(self.tag, "Writing to \(contentUrl.path)")
  45. // Stream to file
  46. var ext = ""
  47. var data = Data()
  48. var written = Int64(0)
  49. var lastProgress = NSDate().timeIntervalSince1970
  50. for try await byte in asyncBytes {
  51. data.append(byte)
  52. written += 1
  53. if data.count == bufferSize {
  54. if ext.isEmpty {
  55. ext = data.guessExtension()
  56. }
  57. try fileHandle.write(contentsOf: data)
  58. data = Data()
  59. if NSDate().timeIntervalSince1970 - lastProgress >= 1 {
  60. if expectedLength > 0 {
  61. Log.d(self.tag, "Download progress: \(formatSize(written)) (\(Double(written) / Double(expectedLength) * 100.0)%)")
  62. } else {
  63. Log.d(self.tag, "Download progress: \(formatSize(written))")
  64. }
  65. lastProgress = NSDate().timeIntervalSince1970
  66. }
  67. }
  68. }
  69. if !data.isEmpty {
  70. if ext.isEmpty {
  71. ext = data.guessExtension()
  72. }
  73. try fileHandle.write(contentsOf: data)
  74. }
  75. try fileHandle.close()
  76. Log.d(self.tag, "Download complete, written \(formatSize(written))")
  77. // Rename temp file to add extension (required for it to be displayed correctly!)
  78. let contentUrlWithExt = URL(fileURLWithPath: contentUrl.path + ext)
  79. if contentUrl != contentUrlWithExt {
  80. try? fileManager.removeItem(at: contentUrlWithExt)
  81. try fileManager.moveItem(at: contentUrl, to: contentUrlWithExt)
  82. contentUrl = contentUrlWithExt
  83. }
  84. Log.d(self.tag, "Attachment successfully saved to \(contentUrl.path)")
  85. completionHandler(contentUrl, nil)
  86. } catch {
  87. Log.w(self.tag, "Error when streaming \(url)", error)
  88. completionHandler(nil, error)
  89. }
  90. }
  91. }
  92. private static func downloadNoStream(url: String, id: String, maxLength: Int64, timeout: TimeInterval, completionHandler: @escaping (URL?, Error?) -> Void) {
  93. guard let url = URL(string: url), let attachmentDir = createAttachmentDir() else {
  94. completionHandler(nil, DownloadError.invalidUrlOrDirectory)
  95. return
  96. }
  97. // FIXME: Do a HEAD request first to bail out early
  98. URLSession.shared.downloadTask(with: url) { (tempFileUrl, response, error) in
  99. Log.d(self.tag, "Attachment download complete", tempFileUrl, response, error)
  100. guard
  101. let response = response,
  102. let httpResponse = response as? HTTPURLResponse,
  103. let tempFileUrl = tempFileUrl,
  104. (200...299).contains(httpResponse.statusCode),
  105. error == nil
  106. else {
  107. Log.w(self.tag, "Attachment download failed")
  108. completionHandler(nil, DownloadError.unexpectedResponse)
  109. return
  110. }
  111. do {
  112. let fileManager = FileManager.default
  113. let data = try Data(contentsOf: tempFileUrl)
  114. let ext = data.guessExtension()
  115. // Sad sad late fail: Bail out if t
  116. if maxLength > 0 && data.count > maxLength {
  117. throw DownloadError.maxSizeReached
  118. }
  119. // Rename temp file to target URL (with extension, required for it to be displayed correctly!)
  120. let contentUrl = attachmentDir.appendingPathComponent(id + ext)
  121. try fileManager.moveItem(at: tempFileUrl, to: contentUrl)
  122. Log.d(self.tag, "Attachment successfully saved to \(contentUrl.path)")
  123. completionHandler(contentUrl, nil)
  124. } catch {
  125. Log.w(self.tag, "Error saving attachment", error)
  126. completionHandler(nil, error)
  127. }
  128. }.resume()
  129. }
  130. private static func createAttachmentDir() -> URL? {
  131. do {
  132. let fileManager = FileManager.default
  133. let directory = try fileManager
  134. .containerURL(forSecurityApplicationGroupIdentifier: Store.appGroup).orThrow()
  135. .appendingPathComponent(attachmentDir)
  136. if !fileManager.fileExists(atPath: directory.path) {
  137. try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
  138. }
  139. return directory
  140. } catch {
  141. Log.e(tag, "Unable to get or create attachment directory", error)
  142. return nil
  143. }
  144. }
  145. }