ConversationExporter.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. // _____ _
  2. // |_ _| |_ _ _ ___ ___ _ __ __ _
  3. // | | | ' \| '_/ -_) -_) ' \/ _` |_
  4. // |_| |_||_|_| \___\___|_|_|_\__,_(_)
  5. //
  6. // Threema iOS Client
  7. // Copyright (c) 2020 Threema GmbH
  8. //
  9. // This program is free software: you can redistribute it and/or modify
  10. // it under the terms of the GNU Affero General Public License, version 3,
  11. // as published by the Free Software Foundation.
  12. //
  13. // This program is distributed in the hope that it will be useful,
  14. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. // GNU Affero General Public License for more details.
  17. //
  18. // You should have received a copy of the GNU Affero General Public License
  19. // along with this program. If not, see <https://www.gnu.org/licenses/>.
  20. import Foundation
  21. import ZipArchive
  22. class ConversationExporter: NSObject, PasswordCallback {
  23. enum CreateZipError: Error {
  24. case notEnoughStorage(storageNeeded: Int64)
  25. case generalError
  26. case cancelled
  27. }
  28. private var password: String?
  29. private var conversation: Conversation
  30. private var entityManager: EntityManager
  31. private var contact: Contact?
  32. private var withMedia: Bool
  33. private var cancelled: Bool = false
  34. private var viewController: UIViewController?
  35. private var emailSubject = ""
  36. private var timeString: String?
  37. private var zipFileContainer: ZipFileContainer?
  38. private var log: String = ""
  39. private let displayNameMaxLength = 50
  40. /// Initialize a ConversationExporter without a viewController for testing
  41. /// - Parameters:
  42. /// - password: The password for encrypting the zip
  43. /// - entityManager: the entityManager used for querying the db
  44. /// - contact: The contact for which the chat is exported
  45. /// - withMedia: Whether media should be included or not
  46. init(password: String, entityManager: EntityManager, contact: Contact?, withMedia: Bool) {
  47. self.password = password
  48. self.conversation = entityManager.entityFetcher.conversation(for: contact)
  49. self.entityManager = entityManager
  50. self.contact = contact
  51. self.withMedia = withMedia
  52. }
  53. /// Initialize a ConversationExporter with a contact whose 1-to-1 conversation will be exported
  54. /// - Parameters:
  55. /// - viewController: Will present progress indicators on this viewController
  56. /// - contact: Will export the conversation for this contact
  57. /// - entityManager: Will query the associated db
  58. /// - withMedia: Whether media should be exported
  59. @objc init(viewController: UIViewController, contact: Contact, entityManager: EntityManager, withMedia: Bool) {
  60. self.viewController = viewController
  61. self.contact = contact
  62. self.conversation = entityManager.entityFetcher.conversation(for: contact)
  63. self.entityManager = entityManager
  64. self.withMedia = withMedia
  65. }
  66. /// Initialize a ConversationExporter with a group conversation which will be exported
  67. /// - Parameters:
  68. /// - viewController: Will present progress indicators on this viewController
  69. /// - conversation: Will export this conversation.
  70. /// - entityManager: Will query the associated db
  71. /// - withMedia: Whether media should be exported
  72. @objc init(viewController: UIViewController, conversation: Conversation, entityManager: EntityManager, withMedia: Bool) {
  73. self.viewController = viewController
  74. self.conversation = conversation
  75. self.entityManager = entityManager
  76. self.withMedia = withMedia
  77. }
  78. /// Gets the subject used in the email when exporting
  79. /// - Returns: Name or Identity of the chat
  80. private func getSubjectName() -> String {
  81. var subjectName: String
  82. if self.contact!.firstName != nil {
  83. subjectName = self.contact!.firstName
  84. if self.contact!.lastName != nil {
  85. subjectName = " " + self.contact!.lastName
  86. }
  87. if self.contact!.identity != nil {
  88. subjectName = " (" + self.contact!.identity + ")"
  89. }
  90. } else {
  91. subjectName = self.contact!.identity
  92. }
  93. return subjectName
  94. }
  95. /// Exports a 1-to-1 conversation. Can not be used with group conversations!
  96. func exportConversation() {
  97. let subjectName = getSubjectName()
  98. self.emailSubject = String(format: NSLocalizedString("conversation_log_subject", comment: ""), "\(subjectName)")
  99. ZipFileContainer.cleanFiles()
  100. self.requestPassword()
  101. }
  102. /// Exports a group conversation.
  103. @objc func exportGroupConversation() {
  104. self.emailSubject = String(format: NSLocalizedString("conversation_log_group_subject", comment: ""))
  105. ZipFileContainer.cleanFiles()
  106. self.requestPassword()
  107. }
  108. }
  109. extension ConversationExporter {
  110. /// Creates the name of the zip file containing the exported chat
  111. /// - Returns: String starting with Threema, the display name of the chat and the current date.
  112. private func filenamePrefix() -> String {
  113. guard let regex = try? NSRegularExpression(pattern: "[^a-z0-9-_]", options: [.caseInsensitive]) else {
  114. return "Threema_" + DateFormatter.getNowDateString()
  115. }
  116. var displayName: String! = self.conversation.displayName
  117. if displayName!.count > displayNameMaxLength {
  118. displayName = String(displayName.prefix(displayNameMaxLength-1))
  119. }
  120. displayName = regex.stringByReplacingMatches(in: displayName,
  121. options: NSRegularExpression.MatchingOptions(rawValue: 0),
  122. range: NSRange(location: 0, length: displayName.count),
  123. withTemplate: "_")
  124. return "Threema_" + displayName + "_" + DateFormatter.getNowDateString()
  125. }
  126. /// Returns the filename with extension of the zip file
  127. /// - Returns: Filename with extension of the zip file
  128. private func zipFileName() -> String {
  129. return self.filenamePrefix()
  130. }
  131. private func conversationTextFilename() -> String {
  132. return self.filenamePrefix() + ".txt"
  133. }
  134. /// Deletes all files created for this specific export
  135. private func removeZipFileContainerList() {
  136. self.zipFileContainer?.deleteFile()
  137. }
  138. /// Creates a zip file with the contact or conversation initialized
  139. /// - Throws: CreateZipError if there is not enough storage
  140. /// - Returns: An URL to the zip file if the export was successful and nil otherwise
  141. private func createZipFiles() throws -> URL {
  142. if password == nil {
  143. throw CreateZipError.generalError
  144. }
  145. self.zipFileContainer = ZipFileContainer(password: self.password!, name: "Conversation.zip")
  146. let success = self.exportChatToZipFile()
  147. guard let conversationData = self.log.data(using: String.Encoding.utf8) else {
  148. throw CreateZipError.generalError
  149. }
  150. if !self.enoughFreeStorage(toStore: Int64(conversationData.count)) {
  151. DispatchQueue.main.async {
  152. self.removeCurrentHUD()
  153. self.showStorageAlert(Int64(conversationData.count), freeStorage: Int64(self.getFreeStorage()))
  154. }
  155. self.removeZipFileContainerList()
  156. }
  157. if self.cancelled {
  158. self.removeZipFileContainerList()
  159. throw CreateZipError.cancelled
  160. }
  161. if !(self.zipFileContainer!.addData(data: conversationData, filename: self.conversationTextFilename())) {
  162. throw CreateZipError.generalError
  163. }
  164. if !success && !self.cancelled {
  165. self.removeZipFileContainerList()
  166. DispatchQueue.main.async {
  167. self.removeCurrentHUD()
  168. MBProgressHUD.showAdded(to: (self.viewController!.view)!, animated: true)
  169. }
  170. let (storageCheckSuccess, totalStorage) = self.checkStorageNecessary()
  171. if storageCheckSuccess {
  172. throw CreateZipError.notEnoughStorage(storageNeeded: totalStorage)
  173. }
  174. throw CreateZipError.generalError
  175. }
  176. if self.cancelled {
  177. self.removeZipFileContainerList()
  178. throw CreateZipError.cancelled
  179. }
  180. guard let url = self.zipFileContainer!.getUrlWithFileName(fileName: self.zipFileName()) else {
  181. throw CreateZipError.generalError
  182. }
  183. return url
  184. }
  185. /// Creates an export of the initialized conversation or contact. Will present an error if the chat export has
  186. /// failed or a share sheet if the export was successful.
  187. private func createExport() {
  188. DispatchQueue.global(qos: .default).async {
  189. var zipUrl: URL?
  190. do {
  191. zipUrl = try self.createZipFiles()
  192. } catch CreateZipError.notEnoughStorage(storageNeeded: _) {
  193. let (success, totalStorageNeeded) = self.checkStorageNecessary()
  194. if success {
  195. DispatchQueue.main.async {
  196. self.removeCurrentHUD()
  197. self.showStorageAlert(totalStorageNeeded, freeStorage: self.getFreeStorage())
  198. }
  199. return
  200. }
  201. } catch {
  202. if !self.cancelled {
  203. DispatchQueue.main.async {
  204. self.showGeneralAlert(errorCode: 0)
  205. }
  206. }
  207. DispatchQueue.main.async {
  208. self.removeCurrentHUD()
  209. }
  210. return
  211. }
  212. DispatchQueue.main.async {
  213. self.removeCurrentHUD()
  214. if self.cancelled {
  215. return
  216. }
  217. let activityViewController = self.createActivityViewController(zipUrl: zipUrl!)
  218. self.viewController!.present(activityViewController!, animated: true)
  219. self.timeString = nil
  220. }
  221. }
  222. }
  223. /// Returns the amount of free storage in bytes
  224. /// - Returns: The amount of free storage in bytes or -1 if the amount of storage can not be determined
  225. private func getFreeStorage() -> Int64 {
  226. var dictionary: [FileAttributeKey: Any]?
  227. do {
  228. dictionary = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
  229. } catch {
  230. return -1
  231. }
  232. if dictionary != nil {
  233. let freeSpaceSize = (dictionary?[.systemFreeSize] as? NSNumber)?.int64Value ?? 0
  234. return freeSpaceSize
  235. }
  236. return -1
  237. }
  238. /// Returns true if there is enough free storage to store noBytes
  239. private func enoughFreeStorage(toStore noBytes: Int64) -> Bool {
  240. if getFreeStorage() > noBytes {
  241. return true
  242. }
  243. return false
  244. }
  245. private func incrementProgress() {
  246. DispatchQueue.main.async(execute: {
  247. guard let hud = MBProgressHUD(for: self.viewController!.view) else {
  248. return
  249. }
  250. if hud.progressObject != nil {
  251. guard let po = hud.progressObject else {
  252. return
  253. }
  254. po.completedUnitCount += 1
  255. hud.label.text = String(format: NSLocalizedString("export_progress_label", comment: ""), po.completedUnitCount, po.totalUnitCount)
  256. }
  257. })
  258. }
  259. private func addMediaBatch(with messageFetcher: MessageFetcher, from: Int, to: Int) -> Bool {
  260. guard let messages = messageFetcher.messages(atOffset: from, count: to - from) else {
  261. return false
  262. }
  263. let success = autoreleasepool { () -> Bool in
  264. for j in 0...(to - from) - 1 {
  265. if messages[j] is BaseMessage {
  266. guard let message = messages[j] as? BaseMessage else {
  267. return false
  268. }
  269. if !self.addMessage(message: message) {
  270. return false
  271. }
  272. }
  273. self.incrementProgress()
  274. }
  275. return true
  276. }
  277. return success
  278. }
  279. private func addMessage(message : BaseMessage) -> Bool {
  280. log.append(ConversationExporter.getMessageFrom(baseMessage: message))
  281. if self.withMedia, message is BlobData, ((message as! BlobData).blobGet()) != nil {
  282. let storageNecessary = Int64((message as! BlobData).blobGet()!.count)
  283. if !enoughFreeStorage(toStore: storageNecessary) {
  284. return false
  285. }
  286. if !(self.zipFileContainer!.addMediaData(mediaData:message as! BlobData)) {
  287. // Writing the file has failed
  288. return false
  289. }
  290. }
  291. self.entityManager.refreshObject(message, mergeChanges: true)
  292. return true
  293. }
  294. private func exportChatToZipFile() -> Bool {
  295. guard let messageFetcher = MessageFetcher.init(for: self.conversation, with: self.entityManager.entityFetcher) else {
  296. return false
  297. }
  298. messageFetcher.orderAscending = true
  299. let countTotal = messageFetcher.count()
  300. if self.cancelled {
  301. return false
  302. }
  303. self.initProgress(totalWork: Int64(countTotal))
  304. // Stride increment should be equal to the minimum possible memory capacity / maximum possible file size
  305. let strideInc = 15
  306. for i in stride(from: 0, to: countTotal, by: strideInc) {
  307. if self.cancelled {
  308. return false
  309. }
  310. let success = addMediaBatch(with: messageFetcher,
  311. from: i,
  312. to: min(countTotal, i + strideInc))
  313. if !success {
  314. return false
  315. }
  316. }
  317. return true
  318. }
  319. /// Returns the storage necessary for exporting the initialized chat or conversation
  320. /// - Returns: A tuple (a,b) where b indicates the storage needed for the chat export if a is true. a is false if
  321. /// checking the necessary storage has failed.
  322. private func checkStorageNecessary() -> (Bool, Int64) {
  323. guard let messageFetcher = MessageFetcher.init(for: self.conversation, with: self.entityManager.entityFetcher) else {
  324. return (false, -1)
  325. }
  326. messageFetcher.orderAscending = false
  327. let countTotal = messageFetcher.count()
  328. var totalStorageNecessary: Int64 = 0
  329. if self.cancelled {
  330. return (false, -1)
  331. }
  332. for i in 0...countTotal {
  333. guard let messages = messageFetcher.messages(atOffset: i, count: i + 1) else {
  334. return (false, -1)
  335. }
  336. let success = autoreleasepool { () -> Bool in
  337. if messages.first != nil, messages.first! is BlobData, ((messages.first! as! BlobData).blobGet()) != nil {
  338. totalStorageNecessary += Int64((messages.first! as! BlobData).blobGet()!.count)
  339. }
  340. self.entityManager.refreshObject(messages.first! as? NSManagedObject, mergeChanges: true)
  341. return true
  342. }
  343. if !success {
  344. return (false, -1)
  345. }
  346. }
  347. return (true, totalStorageNecessary)
  348. }
  349. private static func getMessageFrom(baseMessage: BaseMessage) -> String {
  350. var log = ""
  351. if baseMessage.isOwn.boolValue {
  352. log.append(">>> ")
  353. } else {
  354. log.append("<<< ")
  355. if baseMessage.sender != nil {
  356. log.append("(")
  357. log.append(baseMessage.sender.displayName)
  358. log.append(") ")
  359. }
  360. }
  361. let date = DateFormatter.longStyleDateTime(baseMessage.remoteSentDate)
  362. log.append(date)
  363. log.append(": ")
  364. if baseMessage.logText() != nil {
  365. log.append(baseMessage.logText())
  366. }
  367. log.append("\r\n")
  368. return log
  369. }
  370. }
  371. // MARK: - UI Elements
  372. extension ConversationExporter {
  373. func passwordResult(_ password: String!, from _ : UIViewController!) {
  374. self.password = password
  375. MBProgressHUD.showAdded(to: (self.viewController!.view)!, animated: true)
  376. self.viewController!.dismiss(animated: true, completion: ({
  377. self.createExport()
  378. }))
  379. }
  380. /// Shows an alert with an error code
  381. /// - Parameter errorCode: the error code shown in the alert
  382. func showGeneralAlert(errorCode: Int) {
  383. let title = String(format: NSLocalizedString("chat_export_failed_title", comment: ""))
  384. let message = String(format: NSLocalizedString("chat_export_failed_message", comment: ""), errorCode)
  385. UIAlertTemplate.showAlert(owner: self.viewController!, title: title, message: message, actionOk: nil)
  386. }
  387. /// Shows an alert indicating that there is not enough storage
  388. /// - Parameters:
  389. /// - chatSize: The size needed for the export
  390. /// - freeStorage: The current free size
  391. func showStorageAlert(_ chatSize: Int64, freeStorage: Int64) {
  392. let needed = ByteCountFormatter.string(fromByteCount: chatSize, countStyle: .file)
  393. let free = ByteCountFormatter.string(fromByteCount: freeStorage, countStyle: .file)
  394. let title = NSLocalizedString("not_enough_storage_title", comment: "")
  395. let message = String(format: NSLocalizedString("amount_of_free_storage_needed", comment: ""), needed, free)
  396. UIAlertTemplate.showAlert(owner: self.viewController!, title: title, message: message, actionOk: nil)
  397. }
  398. /// Presents the password request UI
  399. func requestPassword() {
  400. let passwordTrigger = CreatePasswordTrigger(on: self.viewController)
  401. passwordTrigger?.passwordAdditionalText = String(format: NSLocalizedString("password_description_export", comment: ""))
  402. passwordTrigger?.passwordCallback = self
  403. passwordTrigger?.presentPasswordUI()
  404. }
  405. func createActivityViewController(zipUrl: URL) -> UIActivityViewController? {
  406. let zipActivity = ZipFileActivityItemProvider(url: zipUrl, subject: self.emailSubject)
  407. let activityViewController = ActivityUtil.activityViewController(withActivityItems: [zipActivity], applicationActivities: [])
  408. if UIDevice.current.userInterfaceIdiom == UIUserInterfaceIdiom.pad {
  409. let rect = self.viewController!.view.convert(self.viewController!.view.frame, from: self.viewController!.view.superview)
  410. activityViewController?.popoverPresentationController?.sourceRect = rect
  411. activityViewController?.popoverPresentationController?.sourceView = self.viewController!.view
  412. }
  413. let defaults = AppGroup.userDefaults()
  414. defaults?.set(Utils.systemUptime(), forKey: "UIActivityViewControllerOpenTime")
  415. defaults?.synchronize()
  416. activityViewController!.completionWithItemsHandler = { activityType, completed, returnedItems, activityError in
  417. let defaults = AppGroup.userDefaults()
  418. defaults?.removeObject(forKey: "UIActivityViewControllerOpenTime")
  419. ZipFileContainer.cleanFiles()
  420. }
  421. return activityViewController
  422. }
  423. @objc func progressHUDCancelPressed() {
  424. DispatchQueue.main.async {
  425. self.cancelled = true
  426. self.cancelProgressHud()
  427. }
  428. }
  429. func removeCurrentHUD() {
  430. MBProgressHUD.hide(for: self.viewController!.view, animated: true)
  431. }
  432. func cancelProgressHud() {
  433. self.removeCurrentHUD()
  434. MBProgressHUD.showAdded(to: self.viewController!.view, animated: true)
  435. MBProgressHUD(view: self.viewController!.view).label.text = NSLocalizedString("cancelling_export", comment: "")
  436. }
  437. func initProgress(totalWork: Int64) {
  438. DispatchQueue.main.async(execute: {
  439. guard let hud = MBProgressHUD(for: self.viewController!.view) else {
  440. return
  441. }
  442. if hud.progressObject == nil {
  443. hud.mode = .annularDeterminate
  444. let progress = Progress(totalUnitCount: Int64(totalWork))
  445. hud.progressObject = progress
  446. hud.button.setTitle(NSLocalizedString("cancel", comment: ""), for: .normal)
  447. hud.button.addTarget(self, action: #selector(self.progressHUDCancelPressed), for: .touchUpInside)
  448. hud.label.text = String(format: NSLocalizedString("export_progress_label", comment: ""), 0, totalWork)
  449. }
  450. })
  451. }
  452. }