123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537 |
- // _____ _
- // |_ _| |_ _ _ ___ ___ _ __ __ _
- // | | | ' \| '_/ -_) -_) ' \/ _` |_
- // |_| |_||_|_| \___\___|_|_|_\__,_(_)
- //
- // Threema iOS Client
- // Copyright (c) 2020 Threema GmbH
- //
- // This program is free software: you can redistribute it and/or modify
- // it under the terms of the GNU Affero General Public License, version 3,
- // as published by the Free Software Foundation.
- //
- // This program is distributed in the hope that it will be useful,
- // but WITHOUT ANY WARRANTY; without even the implied warranty of
- // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- // GNU Affero General Public License for more details.
- //
- // You should have received a copy of the GNU Affero General Public License
- // along with this program. If not, see <https://www.gnu.org/licenses/>.
- import Foundation
- import ZipArchive
- class ConversationExporter: NSObject, PasswordCallback {
-
- enum CreateZipError: Error {
- case notEnoughStorage(storageNeeded: Int64)
- case generalError
- case cancelled
- }
-
- private var password: String?
- private var conversation: Conversation
- private var entityManager: EntityManager
- private var contact: Contact?
- private var withMedia: Bool
- private var cancelled: Bool = false
- private var viewController: UIViewController?
- private var emailSubject = ""
- private var timeString: String?
- private var zipFileContainer: ZipFileContainer?
-
- private var log: String = ""
-
- private let displayNameMaxLength = 50
-
- /// Initialize a ConversationExporter without a viewController for testing
- /// - Parameters:
- /// - password: The password for encrypting the zip
- /// - entityManager: the entityManager used for querying the db
- /// - contact: The contact for which the chat is exported
- /// - withMedia: Whether media should be included or not
- init(password: String, entityManager: EntityManager, contact: Contact?, withMedia: Bool) {
- self.password = password
- self.conversation = entityManager.entityFetcher.conversation(for: contact)
- self.entityManager = entityManager
- self.contact = contact
- self.withMedia = withMedia
- }
-
- /// Initialize a ConversationExporter with a contact whose 1-to-1 conversation will be exported
- /// - Parameters:
- /// - viewController: Will present progress indicators on this viewController
- /// - contact: Will export the conversation for this contact
- /// - entityManager: Will query the associated db
- /// - withMedia: Whether media should be exported
- @objc init(viewController: UIViewController, contact: Contact, entityManager: EntityManager, withMedia: Bool) {
- self.viewController = viewController
- self.contact = contact
- self.conversation = entityManager.entityFetcher.conversation(for: contact)
- self.entityManager = entityManager
- self.withMedia = withMedia
- }
-
- /// Initialize a ConversationExporter with a group conversation which will be exported
- /// - Parameters:
- /// - viewController: Will present progress indicators on this viewController
- /// - conversation: Will export this conversation.
- /// - entityManager: Will query the associated db
- /// - withMedia: Whether media should be exported
- @objc init(viewController: UIViewController, conversation: Conversation, entityManager: EntityManager, withMedia: Bool) {
- self.viewController = viewController
- self.conversation = conversation
- self.entityManager = entityManager
- self.withMedia = withMedia
- }
-
- /// Gets the subject used in the email when exporting
- /// - Returns: Name or Identity of the chat
- private func getSubjectName() -> String {
- var subjectName: String
-
- if self.contact!.firstName != nil {
- subjectName = self.contact!.firstName
- if self.contact!.lastName != nil {
- subjectName = " " + self.contact!.lastName
- }
- if self.contact!.identity != nil {
- subjectName = " (" + self.contact!.identity + ")"
- }
- } else {
- subjectName = self.contact!.identity
- }
- return subjectName
- }
-
- /// Exports a 1-to-1 conversation. Can not be used with group conversations!
- func exportConversation() {
- let subjectName = getSubjectName()
- self.emailSubject = String(format: NSLocalizedString("conversation_log_subject", comment: ""), "\(subjectName)")
- ZipFileContainer.cleanFiles()
- self.requestPassword()
- }
-
- /// Exports a group conversation.
- @objc func exportGroupConversation() {
- self.emailSubject = String(format: NSLocalizedString("conversation_log_group_subject", comment: ""))
- ZipFileContainer.cleanFiles()
- self.requestPassword()
- }
- }
- extension ConversationExporter {
- /// Creates the name of the zip file containing the exported chat
- /// - Returns: String starting with Threema, the display name of the chat and the current date.
- private func filenamePrefix() -> String {
- guard let regex = try? NSRegularExpression(pattern: "[^a-z0-9-_]", options: [.caseInsensitive]) else {
- return "Threema_" + DateFormatter.getNowDateString()
- }
-
- var displayName: String! = self.conversation.displayName
- if displayName!.count > displayNameMaxLength {
- displayName = String(displayName.prefix(displayNameMaxLength-1))
- }
- displayName = regex.stringByReplacingMatches(in: displayName,
- options: NSRegularExpression.MatchingOptions(rawValue: 0),
- range: NSRange(location: 0, length: displayName.count),
- withTemplate: "_")
-
- return "Threema_" + displayName + "_" + DateFormatter.getNowDateString()
- }
-
- /// Returns the filename with extension of the zip file
- /// - Returns: Filename with extension of the zip file
- private func zipFileName() -> String {
- return self.filenamePrefix()
- }
-
- private func conversationTextFilename() -> String {
- return self.filenamePrefix() + ".txt"
- }
-
- /// Deletes all files created for this specific export
- private func removeZipFileContainerList() {
- self.zipFileContainer?.deleteFile()
- }
-
- /// Creates a zip file with the contact or conversation initialized
- /// - Throws: CreateZipError if there is not enough storage
- /// - Returns: An URL to the zip file if the export was successful and nil otherwise
- private func createZipFiles() throws -> URL {
- if password == nil {
- throw CreateZipError.generalError
- }
-
- self.zipFileContainer = ZipFileContainer(password: self.password!, name: "Conversation.zip")
-
- let success = self.exportChatToZipFile()
-
- guard let conversationData = self.log.data(using: String.Encoding.utf8) else {
- throw CreateZipError.generalError
- }
-
- if !self.enoughFreeStorage(toStore: Int64(conversationData.count)) {
- DispatchQueue.main.async {
- self.removeCurrentHUD()
- self.showStorageAlert(Int64(conversationData.count), freeStorage: Int64(self.getFreeStorage()))
- }
- self.removeZipFileContainerList()
- }
-
- if self.cancelled {
- self.removeZipFileContainerList()
- throw CreateZipError.cancelled
- }
-
- if !(self.zipFileContainer!.addData(data: conversationData, filename: self.conversationTextFilename())) {
- throw CreateZipError.generalError
- }
-
- if !success && !self.cancelled {
- self.removeZipFileContainerList()
- DispatchQueue.main.async {
- self.removeCurrentHUD()
- MBProgressHUD.showAdded(to: (self.viewController!.view)!, animated: true)
- }
- let (storageCheckSuccess, totalStorage) = self.checkStorageNecessary()
- if storageCheckSuccess {
- throw CreateZipError.notEnoughStorage(storageNeeded: totalStorage)
- }
- throw CreateZipError.generalError
- }
-
- if self.cancelled {
- self.removeZipFileContainerList()
- throw CreateZipError.cancelled
- }
-
- guard let url = self.zipFileContainer!.getUrlWithFileName(fileName: self.zipFileName()) else {
- throw CreateZipError.generalError
- }
-
- return url
- }
-
- /// Creates an export of the initialized conversation or contact. Will present an error if the chat export has
- /// failed or a share sheet if the export was successful.
- private func createExport() {
- DispatchQueue.global(qos: .default).async {
- var zipUrl: URL?
- do {
- zipUrl = try self.createZipFiles()
- } catch CreateZipError.notEnoughStorage(storageNeeded: _) {
- let (success, totalStorageNeeded) = self.checkStorageNecessary()
- if success {
- DispatchQueue.main.async {
- self.removeCurrentHUD()
- self.showStorageAlert(totalStorageNeeded, freeStorage: self.getFreeStorage())
- }
- return
- }
- } catch {
- if !self.cancelled {
- DispatchQueue.main.async {
- self.showGeneralAlert(errorCode: 0)
- }
- }
- DispatchQueue.main.async {
- self.removeCurrentHUD()
- }
- return
- }
-
- DispatchQueue.main.async {
- self.removeCurrentHUD()
-
- if self.cancelled {
- return
- }
-
- let activityViewController = self.createActivityViewController(zipUrl: zipUrl!)
-
- self.viewController!.present(activityViewController!, animated: true)
- self.timeString = nil
- }
- }
- }
-
- /// Returns the amount of free storage in bytes
- /// - Returns: The amount of free storage in bytes or -1 if the amount of storage can not be determined
- private func getFreeStorage() -> Int64 {
- var dictionary: [FileAttributeKey: Any]?
- do {
- dictionary = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
- } catch {
- return -1
- }
- if dictionary != nil {
- let freeSpaceSize = (dictionary?[.systemFreeSize] as? NSNumber)?.int64Value ?? 0
-
- return freeSpaceSize
- }
- return -1
- }
-
- /// Returns true if there is enough free storage to store noBytes
- private func enoughFreeStorage(toStore noBytes: Int64) -> Bool {
- if getFreeStorage() > noBytes {
- return true
- }
- return false
- }
-
- private func incrementProgress() {
- DispatchQueue.main.async(execute: {
- guard let hud = MBProgressHUD(for: self.viewController!.view) else {
- return
- }
- if hud.progressObject != nil {
- guard let po = hud.progressObject else {
- return
- }
- po.completedUnitCount += 1
- hud.label.text = String(format: NSLocalizedString("export_progress_label", comment: ""), po.completedUnitCount, po.totalUnitCount)
- }
- })
- }
-
- private func addMediaBatch(with messageFetcher: MessageFetcher, from: Int, to: Int) -> Bool {
- guard let messages = messageFetcher.messages(atOffset: from, count: to - from) else {
- return false
- }
- let success = autoreleasepool { () -> Bool in
- for j in 0...(to - from) - 1 {
- if messages[j] is BaseMessage {
- guard let message = messages[j] as? BaseMessage else {
- return false
- }
- if !self.addMessage(message: message) {
- return false
- }
- }
- self.incrementProgress()
- }
- return true
- }
- return success
- }
-
- private func addMessage(message : BaseMessage) -> Bool {
- log.append(ConversationExporter.getMessageFrom(baseMessage: message))
-
- if self.withMedia, message is BlobData, ((message as! BlobData).blobGet()) != nil {
- let storageNecessary = Int64((message as! BlobData).blobGet()!.count)
- if !enoughFreeStorage(toStore: storageNecessary) {
- return false
- }
-
- if !(self.zipFileContainer!.addMediaData(mediaData:message as! BlobData)) {
- // Writing the file has failed
- return false
- }
- }
- self.entityManager.refreshObject(message, mergeChanges: true)
- return true
- }
-
- private func exportChatToZipFile() -> Bool {
- guard let messageFetcher = MessageFetcher.init(for: self.conversation, with: self.entityManager.entityFetcher) else {
- return false
- }
- messageFetcher.orderAscending = true
-
- let countTotal = messageFetcher.count()
-
- if self.cancelled {
- return false
- }
-
- self.initProgress(totalWork: Int64(countTotal))
-
- // Stride increment should be equal to the minimum possible memory capacity / maximum possible file size
- let strideInc = 15
-
- for i in stride(from: 0, to: countTotal, by: strideInc) {
- if self.cancelled {
- return false
- }
-
- let success = addMediaBatch(with: messageFetcher,
- from: i,
- to: min(countTotal, i + strideInc))
- if !success {
- return false
- }
- }
-
- return true
- }
-
- /// Returns the storage necessary for exporting the initialized chat or conversation
- /// - Returns: A tuple (a,b) where b indicates the storage needed for the chat export if a is true. a is false if
- /// checking the necessary storage has failed.
- private func checkStorageNecessary() -> (Bool, Int64) {
- guard let messageFetcher = MessageFetcher.init(for: self.conversation, with: self.entityManager.entityFetcher) else {
- return (false, -1)
- }
- messageFetcher.orderAscending = false
-
- let countTotal = messageFetcher.count()
- var totalStorageNecessary: Int64 = 0
-
- if self.cancelled {
- return (false, -1)
- }
-
- for i in 0...countTotal {
- guard let messages = messageFetcher.messages(atOffset: i, count: i + 1) else {
- return (false, -1)
- }
- let success = autoreleasepool { () -> Bool in
- if messages.first != nil, messages.first! is BlobData, ((messages.first! as! BlobData).blobGet()) != nil {
- totalStorageNecessary += Int64((messages.first! as! BlobData).blobGet()!.count)
- }
-
- self.entityManager.refreshObject(messages.first! as? NSManagedObject, mergeChanges: true)
- return true
- }
- if !success {
- return (false, -1)
- }
- }
- return (true, totalStorageNecessary)
- }
-
- private static func getMessageFrom(baseMessage: BaseMessage) -> String {
- var log = ""
- if baseMessage.isOwn.boolValue {
- log.append(">>> ")
- } else {
- log.append("<<< ")
- if baseMessage.sender != nil {
- log.append("(")
- log.append(baseMessage.sender.displayName)
- log.append(") ")
- }
- }
-
- let date = DateFormatter.longStyleDateTime(baseMessage.remoteSentDate)
- log.append(date)
- log.append(": ")
-
- if baseMessage.logText() != nil {
- log.append(baseMessage.logText())
- }
-
- log.append("\r\n")
-
- return log
- }
- }
- // MARK: - UI Elements
- extension ConversationExporter {
- func passwordResult(_ password: String!, from _ : UIViewController!) {
- self.password = password
-
- MBProgressHUD.showAdded(to: (self.viewController!.view)!, animated: true)
-
- self.viewController!.dismiss(animated: true, completion: ({
- self.createExport()
- }))
- }
-
- /// Shows an alert with an error code
- /// - Parameter errorCode: the error code shown in the alert
- func showGeneralAlert(errorCode: Int) {
- let title = String(format: NSLocalizedString("chat_export_failed_title", comment: ""))
- let message = String(format: NSLocalizedString("chat_export_failed_message", comment: ""), errorCode)
-
- UIAlertTemplate.showAlert(owner: self.viewController!, title: title, message: message, actionOk: nil)
- }
-
- /// Shows an alert indicating that there is not enough storage
- /// - Parameters:
- /// - chatSize: The size needed for the export
- /// - freeStorage: The current free size
- func showStorageAlert(_ chatSize: Int64, freeStorage: Int64) {
- let needed = ByteCountFormatter.string(fromByteCount: chatSize, countStyle: .file)
- let free = ByteCountFormatter.string(fromByteCount: freeStorage, countStyle: .file)
-
- let title = NSLocalizedString("not_enough_storage_title", comment: "")
- let message = String(format: NSLocalizedString("amount_of_free_storage_needed", comment: ""), needed, free)
-
- UIAlertTemplate.showAlert(owner: self.viewController!, title: title, message: message, actionOk: nil)
- }
-
- /// Presents the password request UI
- func requestPassword() {
- let passwordTrigger = CreatePasswordTrigger(on: self.viewController)
- passwordTrigger?.passwordAdditionalText = String(format: NSLocalizedString("password_description_export", comment: ""))
- passwordTrigger?.passwordCallback = self
-
- passwordTrigger?.presentPasswordUI()
- }
-
- func createActivityViewController(zipUrl: URL) -> UIActivityViewController? {
- let zipActivity = ZipFileActivityItemProvider(url: zipUrl, subject: self.emailSubject)
-
- let activityViewController = ActivityUtil.activityViewController(withActivityItems: [zipActivity], applicationActivities: [])
-
- if UIDevice.current.userInterfaceIdiom == UIUserInterfaceIdiom.pad {
- let rect = self.viewController!.view.convert(self.viewController!.view.frame, from: self.viewController!.view.superview)
- activityViewController?.popoverPresentationController?.sourceRect = rect
- activityViewController?.popoverPresentationController?.sourceView = self.viewController!.view
- }
-
- let defaults = AppGroup.userDefaults()
- defaults?.set(Utils.systemUptime(), forKey: "UIActivityViewControllerOpenTime")
- defaults?.synchronize()
-
- activityViewController!.completionWithItemsHandler = { activityType, completed, returnedItems, activityError in
- let defaults = AppGroup.userDefaults()
- defaults?.removeObject(forKey: "UIActivityViewControllerOpenTime")
- ZipFileContainer.cleanFiles()
- }
-
- return activityViewController
- }
-
- @objc func progressHUDCancelPressed() {
- DispatchQueue.main.async {
- self.cancelled = true
- self.cancelProgressHud()
- }
- }
-
- func removeCurrentHUD() {
- MBProgressHUD.hide(for: self.viewController!.view, animated: true)
- }
-
- func cancelProgressHud() {
- self.removeCurrentHUD()
- MBProgressHUD.showAdded(to: self.viewController!.view, animated: true)
- MBProgressHUD(view: self.viewController!.view).label.text = NSLocalizedString("cancelling_export", comment: "")
- }
-
- func initProgress(totalWork: Int64) {
- DispatchQueue.main.async(execute: {
- guard let hud = MBProgressHUD(for: self.viewController!.view) else {
- return
- }
-
- if hud.progressObject == nil {
- hud.mode = .annularDeterminate
-
- let progress = Progress(totalUnitCount: Int64(totalWork))
- hud.progressObject = progress
-
- hud.button.setTitle(NSLocalizedString("cancel", comment: ""), for: .normal)
- hud.button.addTarget(self, action: #selector(self.progressHUDCancelPressed), for: .touchUpInside)
-
- hud.label.text = String(format: NSLocalizedString("export_progress_label", comment: ""), 0, totalWork)
- }
- })
- }
- }
|