diff --git a/FlowCrypt.xcodeproj/project.pbxproj b/FlowCrypt.xcodeproj/project.pbxproj index 460ff4641..aea895c05 100644 --- a/FlowCrypt.xcodeproj/project.pbxproj +++ b/FlowCrypt.xcodeproj/project.pbxproj @@ -11,6 +11,11 @@ 04B472951ECE29F600B8266F /* MyMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B472921ECE29F600B8266F /* MyMenuViewController.swift */; }; 04B472961ECE29F600B8266F /* SideMenuNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B472931ECE29F600B8266F /* SideMenuNavigationController.swift */; }; 211392A5266511E6009202EC /* PubLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 211392A4266511E6009202EC /* PubLookup.swift */; }; + 21489B6B267B7BD800BDE4AC /* FilesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21489B6A267B7BD800BDE4AC /* FilesManagerTests.swift */; }; + 21489B6C267B7C6A00BDE4AC /* FilesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 215897E7267A553200423694 /* FilesManager.swift */; }; + 21489B6E267B7D5000BDE4AC /* FileMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21489B6D267B7D5000BDE4AC /* FileMock.swift */; }; + 215897E8267A553300423694 /* FilesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 215897E7267A553200423694 /* FilesManager.swift */; }; + 2196A2202684B9BE001B9E00 /* URLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2196A21F2684B9BE001B9E00 /* URLExtension.swift */; }; 21489B78267CB42400BDE4AC /* ClientConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21489B77267CB42400BDE4AC /* ClientConfigurationProvider.swift */; }; 21489B7A267CB4DF00BDE4AC /* ClientConfigurationObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21489B79267CB4DF00BDE4AC /* ClientConfigurationObject.swift */; }; 21489B7C267CBA0E00BDE4AC /* ClientConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21489B7B267CBA0E00BDE4AC /* ClientConfiguration.swift */; }; @@ -359,6 +364,10 @@ 113F04B20ECC35FC59A81A6C /* Pods-FlowCryptTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FlowCryptTests.release.xcconfig"; path = "Target Support Files/Pods-FlowCryptTests/Pods-FlowCryptTests.release.xcconfig"; sourceTree = ""; }; 11C1375F41411882DC4C9431 /* Pods-FlowCryptUIApplication.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FlowCryptUIApplication.release.xcconfig"; path = "Target Support Files/Pods-FlowCryptUIApplication/Pods-FlowCryptUIApplication.release.xcconfig"; sourceTree = ""; }; 211392A4266511E6009202EC /* PubLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PubLookup.swift; sourceTree = ""; }; + 21489B6A267B7BD800BDE4AC /* FilesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesManagerTests.swift; sourceTree = ""; }; + 21489B6D267B7D5000BDE4AC /* FileMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMock.swift; sourceTree = ""; }; + 215897E7267A553200423694 /* FilesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesManager.swift; sourceTree = ""; }; + 2196A21F2684B9BE001B9E00 /* URLExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtension.swift; sourceTree = ""; }; 21489B77267CB42400BDE4AC /* ClientConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientConfigurationProvider.swift; sourceTree = ""; }; 21489B79267CB4DF00BDE4AC /* ClientConfigurationObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientConfigurationObject.swift; sourceTree = ""; }; 21489B7B267CBA0E00BDE4AC /* ClientConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientConfiguration.swift; sourceTree = ""; }; @@ -765,6 +774,23 @@ path = SideMenu; sourceTree = ""; }; + 21489B69267B7BC300BDE4AC /* FilesManager */ = { + isa = PBXGroup; + children = ( + 21489B6A267B7BD800BDE4AC /* FilesManagerTests.swift */, + 21489B6D267B7D5000BDE4AC /* FileMock.swift */, + ); + path = FilesManager; + sourceTree = ""; + }; + 215897E6267A551300423694 /* FilesManager */ = { + isa = PBXGroup; + children = ( + 215897E7267A553200423694 /* FilesManager.swift */, + ); + path = FilesManager; + sourceTree = ""; + }; 21489B81267CC3BC00BDE4AC /* Organisational Rules Service */ = { isa = PBXGroup; children = ( @@ -798,6 +824,7 @@ 21F836A62652A1B700B2448C /* Functionallity */ = { isa = PBXGroup; children = ( + 21489B69267B7BC300BDE4AC /* FilesManager */, 9F4164162665757700106194 /* PGP */, 21F836A72652A1CD00B2448C /* WKDURLs */, 9F4163F3266574CF00106194 /* Services */, @@ -1450,6 +1477,7 @@ C132B9C61EC2DCC000763715 /* Functionality */ = { isa = PBXGroup; children = ( + 215897E6267A551300423694 /* FilesManager */, 21CE25D32650034500ADFF4B /* WKDURLs */, A370EAB6238697E000685215 /* Pgp */, D29AFFEE24092F4900C1387D /* DataManager */, @@ -1568,6 +1596,7 @@ 9F56BD3723438C7000A7371A /* DateFormattingExtensions.swift */, 21C7DEFB26669A3700C44800 /* CalendarExtension.swift */, 9F716304234FC7200031645E /* LocalizationExtensions.swift */, + 2196A21F2684B9BE001B9E00 /* URLExtension.swift */, 9F56BD3923438D3700A7371A /* StyleExtension.swift */, 9F0C3C2723194E8500299985 /* CommonExtensions.swift */, 32DCA63656CB3323C26BC084 /* UIVIewExtensions.swift */, @@ -2456,6 +2485,7 @@ 9F0C3C1A231819C500299985 /* MessageKindProviderType.swift in Sources */, 21C7DF09266C0D8F00C44800 /* EnterpriseServerApi.swift in Sources */, 9FF0670825520CF800FCC9E6 /* GmailService.swift in Sources */, + 215897E8267A553300423694 /* FilesManager.swift in Sources */, 9F5C2A77257D705100DE9B4B /* MessageLabel.swift in Sources */, 9F93623F2573D16F0009912F /* Gmail+Message.swift in Sources */, 9FC4112E2595EA8B001180A8 /* Gmail+Search.swift in Sources */, @@ -2605,6 +2635,7 @@ D29AFFF6240939AE00C1387D /* Then.swift in Sources */, 21C7DEFC26669A3700C44800 /* CalendarExtension.swift in Sources */, D2531F3423FEEF5F007E5198 /* StyleExtension.swift in Sources */, + 2196A2202684B9BE001B9E00 /* URLExtension.swift in Sources */, D254AA6024092AB80041CAE0 /* TapTicFeedback.swift in Sources */, D2531F472402C9DE007E5198 /* IntExtensions.swift in Sources */, D2FD0F692453245E00259FF0 /* Either.swift in Sources */, diff --git a/FlowCrypt/Controllers/Compose/ComposeViewController.swift b/FlowCrypt/Controllers/Compose/ComposeViewController.swift index 7685dd38e..08ee7a173 100644 --- a/FlowCrypt/Controllers/Compose/ComposeViewController.swift +++ b/FlowCrypt/Controllers/Compose/ComposeViewController.swift @@ -471,7 +471,7 @@ extension ComposeViewController { .onShouldReturn { [weak self] textField -> Bool in self?.shouldReturn(with: textField) ?? true } - .onShouldChangeCharacters { [weak self] (textField, character) -> (Bool) in + .onShouldChangeCharacters { [weak self] textField, character -> (Bool) in self?.shouldChange(with: textField, and: character) ?? true } .then { diff --git a/FlowCrypt/Controllers/Msg/MessageViewController.swift b/FlowCrypt/Controllers/Msg/MessageViewController.swift index bdc18b863..d09328065 100644 --- a/FlowCrypt/Controllers/Msg/MessageViewController.swift +++ b/FlowCrypt/Controllers/Msg/MessageViewController.swift @@ -3,6 +3,7 @@ // import AsyncDisplayKit +import FlowCryptCommon import FlowCryptUI import Promises @@ -55,6 +56,7 @@ final class MessageViewController: TableNodeViewController { private let messageService: MessageService private let messageOperationsProvider: MessageOperationsProvider private let trashFolderProvider: TrashFolderProviderType + private let filesManager: FilesManagerType private var processedMessage: ProcessedMessage = .empty init( @@ -62,6 +64,7 @@ final class MessageViewController: TableNodeViewController { messageOperationsProvider: MessageOperationsProvider = MailProvider.shared.messageOperationsProvider, decorator: MessageViewDecorator = MessageViewDecorator(dateFormatter: DateFormatter()), trashFolderProvider: TrashFolderProviderType = TrashFolderProvider(), + filesManager: FilesManagerType = FilesManager(), input: MessageViewController.Input, completion: MsgViewControllerCompletion? ) { @@ -71,6 +74,7 @@ final class MessageViewController: TableNodeViewController { self.decorator = decorator self.trashFolderProvider = trashFolderProvider self.onCompletion = completion + self.filesManager = filesManager super.init(node: TableNode()) } @@ -427,7 +431,47 @@ extension MessageViewController: ASTableDelegate, ASTableDataSource { AttachmentNode( input: .init( msgAttachment: processedMessage.attachments[index] - ) + ), + onDownloadTap: { [weak self] in + guard let self = self else { return } + self.filesManager.saveToFilesApp(file: self.processedMessage.attachments[index], from: self) + .catch { error in + self.showToast( + "\("message_attachment_saved_with_error".localized) \(error.localizedDescription)" + ) + } + } + ) + } +} + +// MARK: - UIDocumentPickerDelegate + +extension MessageViewController: UIDocumentPickerDelegate { + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + + guard let savedUrl = urls.first, + let sharedDocumentUrl = savedUrl.sharedDocumentURL else { + return + } + showFileSharedAlert(with: sharedDocumentUrl) + } + + private func showFileSharedAlert(with url: URL) { + let alert = UIAlertController( + title: "message_attachment_saved_successfully_title".localized, + message: "message_attachment_saved_successfully_message".localized, + preferredStyle: .alert ) + + let cancel = UIAlertAction(title: "cancel".localized, style: .cancel) { _ in } + let open = UIAlertAction(title: "open".localized, style: .default) { _ in + UIApplication.shared.open(url) + } + + alert.addAction(cancel) + alert.addAction(open) + + present(alert, animated: true) } } diff --git a/FlowCrypt/Functionality/FilesManager/FilesManager.swift b/FlowCrypt/Functionality/FilesManager/FilesManager.swift new file mode 100644 index 000000000..7e70fcafb --- /dev/null +++ b/FlowCrypt/Functionality/FilesManager/FilesManager.swift @@ -0,0 +1,67 @@ +// +// FilesManager.swift +// FlowCrypt +// +// Created by Yevhen Kyivskyi on 16.06.2021. +// Copyright © 2021 FlowCrypt Limited. All rights reserved. +// + +import Promises +import UIKit + +protocol FileType { + var name: String { get } + var size: Int { get } + var data: Data { get } +} + +protocol FilesManagerType { + func save(file: FileType) -> Promise + func saveToFilesApp(file: FileType, from viewController: UIViewController & UIDocumentPickerDelegate) -> Promise +} + +class FilesManager: FilesManagerType { + + private let documentsDirectoryURL: URL = { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + }() + + private let queue: DispatchQueue = DispatchQueue.global(qos: .background) + + func save(file: FileType) -> Promise { + Promise { [weak self] resolve, reject in + guard let self = self else { + throw AppErr.nilSelf + } + + let url = self.documentsDirectoryURL.appendingPathComponent(file.name) + self.queue.async { + + do { + try file.data.write(to: url) + resolve(url) + } catch { + reject(error) + } + } + } + } + + func saveToFilesApp( + file: FileType, + from viewController: UIViewController & UIDocumentPickerDelegate + ) -> Promise { + Promise { [weak self] resolve, _ in + guard let self = self else { + throw AppErr.nilSelf + } + let url = try? awaitPromise(self.save(file: file)) + DispatchQueue.main.async { + let documentController = UIDocumentPickerViewController(url: url!, in: .exportToService) + documentController.delegate = viewController + viewController.present(documentController, animated: true) + resolve(()) + } + } + } +} diff --git a/FlowCrypt/Functionality/Mail Provider/Message Provider/MessageService.swift b/FlowCrypt/Functionality/Mail Provider/Message Provider/MessageService.swift index ff4d50434..72e09e19b 100644 --- a/FlowCrypt/Functionality/Mail Provider/Message Provider/MessageService.swift +++ b/FlowCrypt/Functionality/Mail Provider/Message Provider/MessageService.swift @@ -10,9 +10,10 @@ import Foundation import Promises // MARK: - MessageAttachment -struct MessageAttachment { +struct MessageAttachment: FileType { let name: String let size: Int + let data: Data } // MARK: - ProcessedMessage @@ -162,5 +163,6 @@ private extension MessageAttachment { init(block: MsgBlock) { self.name = block.attMeta?.name ?? "Attachment" self.size = block.attMeta?.length ?? 0 + self.data = block.attMeta?.data ?? Data() } } diff --git a/FlowCrypt/Functionality/Services/Folders Services/RemoteFoldersProviderType/GmailService+folders.swift b/FlowCrypt/Functionality/Services/Folders Services/RemoteFoldersProviderType/GmailService+folders.swift index d91c488ef..a03456c60 100644 --- a/FlowCrypt/Functionality/Services/Folders Services/RemoteFoldersProviderType/GmailService+folders.swift +++ b/FlowCrypt/Functionality/Services/Folders Services/RemoteFoldersProviderType/GmailService+folders.swift @@ -32,7 +32,7 @@ extension GmailService: RemoteFoldersProviderType { // TODO: - TOM - Implement categories if needed let folders = labels - .compactMap { (label) -> GTLRGmail_Label? in + .compactMap { label -> GTLRGmail_Label? in guard let identifier = label.identifier, identifier.isNotEmpty else { logger.logInfo("skip label with \(label.identifier ?? "")") return nil diff --git a/FlowCrypt/Functionality/Services/Key Services/KeyService.swift b/FlowCrypt/Functionality/Services/Key Services/KeyService.swift index e8855bcb8..58b4d9572 100644 --- a/FlowCrypt/Functionality/Services/Key Services/KeyService.swift +++ b/FlowCrypt/Functionality/Services/Key Services/KeyService.swift @@ -68,7 +68,7 @@ final class KeyService: KeyServiceType { // get all private keys with already saved pass phrases var privateKeys = keysInfo - .compactMap { (keyInfo) -> PrvKeyInfo? in + .compactMap { keyInfo -> PrvKeyInfo? in guard let passPhrase = storedPassPhrases.first(where: { $0.longid == keyInfo.longid }) else { return nil } diff --git a/FlowCrypt/Resources/en.lproj/Localizable.strings b/FlowCrypt/Resources/en.lproj/Localizable.strings index 230db00fe..b08ba6f09 100644 --- a/FlowCrypt/Resources/en.lproj/Localizable.strings +++ b/FlowCrypt/Resources/en.lproj/Localizable.strings @@ -5,6 +5,7 @@ "retry_title" = "retry"; "ok" = "Ok"; "cancel" = "Cancel"; +"open" = "Open"; // EMAIL "email_removed" = "Email moved to Trash"; @@ -18,6 +19,9 @@ "message_compose_secure" = "Compose Secure Message"; "message_unknown_sender" = "(unknown sender)"; "message_missed_subject" = "No subject"; +"message_attachment_saved_successfully_title" = "Attachment Saved"; +"message_attachment_saved_successfully_message" = "Your attachment was saved in Files. Would you like to open it?"; +"message_attachment_saved_with_error" = "Attachment could not be saved."; // REPLY "reply_title" = "Your reply"; // not used diff --git a/FlowCryptAppTests/Functionallity/FilesManager/FileMock.swift b/FlowCryptAppTests/Functionallity/FilesManager/FileMock.swift new file mode 100644 index 000000000..37abe8d3f --- /dev/null +++ b/FlowCryptAppTests/Functionallity/FilesManager/FileMock.swift @@ -0,0 +1,23 @@ +// +// FileMock.swift +// FlowCryptTests +// +// Created by Yevhen Kyivskyi on 17.06.2021. +// Copyright © 2021 FlowCrypt Limited. All rights reserved. +// + +import Foundation + +struct FileMock: FileType { + let name: String + let size: Int + let data: Data +} + +extension FileMock { + static let stringedFile = FileMock( + name: "mock_file.pdf", + size: 125, + data: "mocktext".data(using: .utf8)! + ) +} diff --git a/FlowCryptAppTests/Functionallity/FilesManager/FilesManagerTests.swift b/FlowCryptAppTests/Functionallity/FilesManager/FilesManagerTests.swift new file mode 100644 index 000000000..543308ee9 --- /dev/null +++ b/FlowCryptAppTests/Functionallity/FilesManager/FilesManagerTests.swift @@ -0,0 +1,47 @@ +// +// FilesManagerTests.swift +// FlowCryptTests +// +// Created by Yevhen Kyivskyi on 17.06.2021. +// Copyright © 2021 FlowCrypt Limited. All rights reserved. +// + +import XCTest + +class FilesManagerTests: XCTestCase { + + var filesManager: FilesManagerType! + + override func setUp() { + filesManager = FilesManager() + } + + override func tearDown() { + filesManager = nil + } + + func test_save_file() { + var isFileSaved = false + let file = FileMock.stringedFile + let expectation = XCTestExpectation() + + filesManager.save(file: file) + .then { _ in + isFileSaved = true + expectation.fulfill() + } + + let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let pathComponent = url.appendingPathComponent(file.name) + let filePath = pathComponent.path + + wait(for: [expectation], timeout: 2) + + XCTAssertTrue( + isFileSaved, "filesManager.save should call then block if file succesfully saved" + ) + XCTAssertTrue( + FileManager.default.fileExists(atPath: filePath), "file should exist in documents directory" + ) + } +} diff --git a/FlowCryptCommon/Extensions/URLExtension.swift b/FlowCryptCommon/Extensions/URLExtension.swift new file mode 100644 index 000000000..7accb961a --- /dev/null +++ b/FlowCryptCommon/Extensions/URLExtension.swift @@ -0,0 +1,23 @@ +// +// URLExtension.swift +// FlowCryptCommon +// +// Created by Yevhen Kyivskyi on 24.06.2021. +// Copyright © 2021 FlowCrypt Limited. All rights reserved. +// + +import Foundation + +public extension URL { + var sharedDocumentURL: URL? { + guard let scheme = self.scheme else { return nil } + + let urlString = self.absoluteString + let sharedDocumentUrlString = urlString.replacingOccurrences( + of: scheme, + with: "shareddocuments://" + ) + + return URL(string: sharedDocumentUrlString) + } +} diff --git a/FlowCryptUI/Nodes/AttachmentNode.swift b/FlowCryptUI/Nodes/AttachmentNode.swift index d80e529e0..2b99d4cc8 100644 --- a/FlowCryptUI/Nodes/AttachmentNode.swift +++ b/FlowCryptUI/Nodes/AttachmentNode.swift @@ -22,12 +22,19 @@ public final class AttachmentNode: CellNode { private let buttonNode = ASButtonNode() private let borderNode = ASDisplayNode() - public init(input: Input) { + private var onDownloadTap: (() -> Void)? + + public init( + input: Input, + onDownloadTap: (() -> Void)? + ) { + self.onDownloadTap = onDownloadTap super.init() - + automaticallyManagesSubnodes = true borderNode.borderWidth = 1.0 borderNode.cornerRadius = 8.0 borderNode.borderColor = UIColor.lightGray.cgColor + borderNode.isUserInteractionEnabled = false imageNode.tintColor = .gray buttonNode.tintColor = .gray @@ -36,6 +43,13 @@ public final class AttachmentNode: CellNode { buttonNode.setImage(UIImage(named: "download")?.tinted(.gray), for: .normal) titleNode.attributedText = input.name subtitleNode.attributedText = input.size + + buttonNode.addTarget(self, action: #selector(onDownloadButtonTap), forControlEvents: .touchUpInside) + + } + + @objc private func onDownloadButtonTap() { + onDownloadTap?() } public override func layoutSpecThatFits(_: ASSizeRange) -> ASLayoutSpec {