Ver código fonte

Merge pull request #1668 from deltachat/webxdc_selector

Webxdc selector
cyBerta 2 anos atrás
pai
commit
c0b0a39050

+ 15 - 0
DcCore/DcCore/Extensions/UIView+Extensions.swift

@@ -119,6 +119,21 @@ public extension UIView {
         return constraint
     }
 
+    func constraintAlignBottomMaxTo(_ view: UIView, paddingBottom: CGFloat = 0.0, priority: UILayoutPriority? = .none) -> NSLayoutConstraint {
+        let constraint = NSLayoutConstraint(
+            item: self,
+            attribute: .bottom,
+            relatedBy: .lessThanOrEqual,
+            toItem: view,
+            attribute: .bottom,
+            multiplier: 1.0,
+            constant: -paddingBottom)
+        if let priority = priority {
+            constraint.priority = priority
+        }
+        return constraint
+    }
+
     func constraintAlignLeadingTo(_ view: UIView, paddingLeading: CGFloat = 0.0, priority: UILayoutPriority? = .none) -> NSLayoutConstraint {
         let constraint = NSLayoutConstraint(
             item: self,

+ 14 - 0
deltachat-ios.xcodeproj/project.pbxproj

@@ -21,6 +21,10 @@
 		30152CA025A5D97900377714 /* UIEdgeInsets+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305961832346125000C80F33 /* UIEdgeInsets+Extensions.swift */; };
 		3015634423A003BA00E9DEF4 /* AudioRecorderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3015634323A003BA00E9DEF4 /* AudioRecorderController.swift */; };
 		3022E6BE22E8768800763272 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3022E6C022E8768800763272 /* InfoPlist.strings */; };
+		30238CFB28A501C300EF14AC /* WebxdcSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30238CFA28A501C300EF14AC /* WebxdcSelector.swift */; };
+		30238CFD28A5028300EF14AC /* WebxdcGridCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30238CFC28A5028300EF14AC /* WebxdcGridCell.swift */; };
+		30238CFF28A5554C00EF14AC /* FileHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30238CFE28A5554C00EF14AC /* FileHelper.swift */; };
+		30238D0028A557E800EF14AC /* FileHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30238CFE28A5554C00EF14AC /* FileHelper.swift */; };
 		302589FF2452FA280086C1CD /* ShareAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302589FE2452FA280086C1CD /* ShareAttachment.swift */; };
 		30260CA7238F02F700D8D52C /* MultilineTextFieldCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30260CA6238F02F700D8D52C /* MultilineTextFieldCell.swift */; };
 		302B84C6239676F0001C261F /* AvatarHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AC265E237F1807002A943F /* AvatarHelper.swift */; };
@@ -265,6 +269,9 @@
 		3022E6D022E8769D00763272 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
 		3022E6D122E8769E00763272 /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/InfoPlist.strings; sourceTree = "<group>"; };
 		3022E6D322E876A100763272 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
+		30238CFA28A501C300EF14AC /* WebxdcSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebxdcSelector.swift; sourceTree = "<group>"; };
+		30238CFC28A5028300EF14AC /* WebxdcGridCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebxdcGridCell.swift; sourceTree = "<group>"; };
+		30238CFE28A5554C00EF14AC /* FileHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileHelper.swift; sourceTree = "<group>"; };
 		302589FE2452FA280086C1CD /* ShareAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAttachment.swift; sourceTree = "<group>"; };
 		30260CA6238F02F700D8D52C /* MultilineTextFieldCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextFieldCell.swift; sourceTree = "<group>"; };
 		302B84C42396627F001C261F /* RelayHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayHelper.swift; sourceTree = "<group>"; };
@@ -871,6 +878,7 @@
 			isa = PBXGroup;
 			children = (
 				AE0AA951247800E700D42A7F /* GalleryCell.swift */,
+				30238CFC28A5028300EF14AC /* WebxdcGridCell.swift */,
 				AE8DD450249D1DFB009A4BC1 /* DocumentGalleryFileCell.swift */,
 			);
 			path = Cell;
@@ -939,6 +947,7 @@
 				302D5453268B84CB00A8B271 /* SettingsVideoChatViewController.swift */,
 				30DAF71B275901610073C154 /* SettingsBackgroundSelectionController.swift */,
 				AE8F503424753DFE007FEE0B /* GalleryViewController.swift */,
+				30238CFA28A501C300EF14AC /* WebxdcSelector.swift */,
 				30734325249A280B00BF9AD1 /* MediaQualityController.swift */,
 				30860EE826F49E64002651A6 /* DownloadOnDemandViewController.swift */,
 				AED423D2249F578B00B6B2BB /* AddGroupMembersViewController.swift */,
@@ -975,6 +984,7 @@
 				21D6C9392606190600D0755A /* NotificationManager.swift */,
 				302D544F268B6B2300A8B271 /* MessageUtils.swift */,
 				3011E8042787365D00214221 /* KeychainManager.swift */,
+				30238CFE28A5554C00EF14AC /* FileHelper.swift */,
 			);
 			path = Helper;
 			sourceTree = "<group>";
@@ -1381,6 +1391,7 @@
 				3057029F24C6445000D84EFC /* EmptyStateLabel.swift in Sources */,
 				305702A224C6455400D84EFC /* TypeAlias.swift in Sources */,
 				308850A0282A914F00204623 /* DcMsg+Extension.swift in Sources */,
+				30238D0028A557E800EF14AC /* FileHelper.swift in Sources */,
 				30152C9D25A5D95400377714 /* MessageLabelDelegate.swift in Sources */,
 				30E8F2442449C64100CE2C90 /* ChatListCell.swift in Sources */,
 				30E8F2132447285600CE2C90 /* ShareViewController.swift in Sources */,
@@ -1413,6 +1424,7 @@
 				7070FB9B2101ECBB000DC258 /* NewGroupController.swift in Sources */,
 				3080A037277DE30100E74565 /* UITextView+Extensions.swift in Sources */,
 				3080A027277DE12D00E74565 /* InputBarButtonItem.swift in Sources */,
+				30238CFB28A501C300EF14AC /* WebxdcSelector.swift in Sources */,
 				AE57C084255310BB003CFE70 /* ContextMenuController.swift in Sources */,
 				304219D92440734A00516852 /* DcMsg+Extension.swift in Sources */,
 				AE52EA19229EB53C00C586C9 /* ContactDetailHeader.swift in Sources */,
@@ -1452,6 +1464,7 @@
 				B26B3BC7236DC3DC008ED35A /* SwitchCell.swift in Sources */,
 				AEE700252438E0E500D6992E /* ProgressAlertHandler.swift in Sources */,
 				30E348E524F6647D005C93D1 /* FileTextCell.swift in Sources */,
+				30238CFD28A5028300EF14AC /* WebxdcGridCell.swift in Sources */,
 				306C32322445CDE9001D89F3 /* DcLogger.swift in Sources */,
 				3080A036277DE30100E74565 /* NSMutableAttributedString+Extensions.swift in Sources */,
 				307A82CC25B8D26700748B57 /* ChatEditingBar.swift in Sources */,
@@ -1533,6 +1546,7 @@
 				3080A028277DE12D00E74565 /* InputBarSendButton.swift in Sources */,
 				AE0AA958247834A400D42A7F /* Date+Extension.swift in Sources */,
 				307D822E241669C7006D2490 /* LocationManager.swift in Sources */,
+				30238CFF28A5554C00EF14AC /* FileHelper.swift in Sources */,
 				AE851AD0227DF50900ED86F0 /* GroupChatDetailViewController.swift in Sources */,
 				30FDB72124D838240066C48D /* BaseMessageCell.swift in Sources */,
 				7A451DB01FB1F84900177250 /* AppCoordinator.swift in Sources */,

+ 48 - 0
deltachat-ios/Chat/ChatViewController.swift

@@ -1340,6 +1340,7 @@ class ChatViewController: UITableViewController {
         let galleryAction = PhotoPickerAlertAction(title: String.localized("gallery"), style: .default, handler: galleryButtonPressed(_:))
         let cameraAction = PhotoPickerAlertAction(title: String.localized("camera"), style: .default, handler: cameraButtonPressed(_:))
         let documentAction = UIAlertAction(title: String.localized("files"), style: .default, handler: documentActionPressed(_:))
+        let webxdcAction = UIAlertAction(title: String.localized("webxdcs"), style: .default, handler: webxdcButtonPressed(_:))
         let voiceMessageAction = UIAlertAction(title: String.localized("voice_message"), style: .default, handler: voiceMessageButtonPressed(_:))
         let isLocationStreaming = dcContext.isSendingLocationsToChat(chatId: chatId)
         let locationStreamingAction = UIAlertAction(title: isLocationStreaming ? String.localized("stop_sharing_location") : String.localized("location"),
@@ -1349,6 +1350,7 @@ class ChatViewController: UITableViewController {
         alert.addAction(cameraAction)
         alert.addAction(galleryAction)
         alert.addAction(documentAction)
+        alert.addAction(webxdcAction)
         alert.addAction(voiceMessageAction)
 
         if let config = dcContext.getConfig("webrtc_instance"), !config.isEmpty {
@@ -1482,6 +1484,21 @@ class ChatViewController: UITableViewController {
         }
     }
 
+    private func showWebxdcSelector() {
+        let msgIds = dcContext.getChatMedia(chatId: 0, messageType: DC_MSG_WEBXDC, messageType2: 0, messageType3: 0)
+        let webxdcSelector = WebxdcSelector(context: dcContext, mediaMessageIds: msgIds.reversed())
+        webxdcSelector.delegate = self
+        let webxdcSelectorNavigationController = UINavigationController(rootViewController: webxdcSelector)
+        if #available(iOS 15.0, *) {
+            if let sheet = webxdcSelectorNavigationController.sheetPresentationController {
+                sheet.detents = [.medium()]
+                sheet.preferredCornerRadius = 20
+            }
+        }
+
+        self.present(webxdcSelectorNavigationController, animated: true)
+    }
+
     private func showDocumentLibrary() {
         mediaPicker?.showDocumentLibrary()
     }
@@ -1532,6 +1549,10 @@ class ChatViewController: UITableViewController {
         navigationController?.present(nav, animated: true)
     }
 
+    private func webxdcButtonPressed(_ action: UIAlertAction) {
+        showWebxdcSelector()
+    }
+
     private func documentActionPressed(_ action: UIAlertAction) {
         showDocumentLibrary()
     }
@@ -2386,3 +2407,30 @@ extension ChatViewController: ChatInputTextViewPasteDelegate {
         sendSticker(image)
     }
 }
+
+
+extension ChatViewController: WebxdcSelectorDelegate {
+    func onWebxdcFromFilesSelected(url: NSURL) {
+        DispatchQueue.main.async { [weak self] in
+            guard let self = self else { return }
+            self.tableView.becomeFirstResponder()
+            self.onDocumentSelected(url: url)
+        }
+    }
+
+    func onWebxdcSelected(msgId: Int) {
+        keepKeyboard = true
+        DispatchQueue.main.async { [weak self] in
+            guard let self = self else { return }
+            let message = self.dcContext.getMessage(id: msgId)
+            if let filename = message.fileURL {
+                let nsdata = NSData(contentsOf: filename)
+                guard let data = nsdata as? Data else { return }
+                let url = FileHelper.saveData(data: data, suffix: "xdc", directory: .cachesDirectory)
+                self.draft.setAttachment(viewType: DC_MSG_WEBXDC, path: url)
+                self.configureDraftArea(draft: self.draft)
+                self.focusInputTextView()
+            }
+        }
+    }
+}

+ 248 - 0
deltachat-ios/Controller/WebxdcSelector.swift

@@ -0,0 +1,248 @@
+import UIKit
+import DcCore
+import QuickLook
+
+protocol WebxdcSelectorDelegate: AnyObject {
+    func onWebxdcSelected(msgId: Int)
+    func onWebxdcFromFilesSelected(url: NSURL)
+}
+
+class WebxdcSelector: UIViewController {
+
+    private let dcContext: DcContext
+    // MARK: - data
+    private var mediaMessageIds: [Int]
+    private var deduplicatedMessageHashes: [String: Int]
+    private var deduplicatedMessageIds: [Int]
+    private var items: [Int: GalleryItem] = [:]
+
+    // MARK: - subview specs
+    private let gridDefaultSpacing: CGFloat = 5
+    weak var delegate: WebxdcSelectorDelegate?
+
+    private lazy var gridLayout: GridCollectionViewFlowLayout = {
+        let layout = GridCollectionViewFlowLayout()
+        layout.minimumLineSpacing = gridDefaultSpacing
+        layout.minimumInteritemSpacing = gridDefaultSpacing
+        layout.format = .rect(ratio: 1.3)
+        return layout
+    }()
+
+    private lazy var grid: UICollectionView = {
+        let collection = UICollectionView(frame: .zero, collectionViewLayout: gridLayout)
+        collection.dataSource = self
+        collection.delegate = self
+        collection.register(WebxdcGridCell.self, forCellWithReuseIdentifier: WebxdcGridCell.reuseIdentifier)
+        collection.contentInset = UIEdgeInsets(top: gridDefaultSpacing, left: gridDefaultSpacing, bottom: gridDefaultSpacing, right: gridDefaultSpacing)
+        collection.backgroundColor = DcColors.defaultBackgroundColor
+        collection.delaysContentTouches = false
+        collection.alwaysBounceVertical = true
+        collection.isPrefetchingEnabled = true
+        collection.prefetchDataSource = self
+        return collection
+    }()
+
+    private lazy var emptyStateView: EmptyStateLabel = {
+        let label = EmptyStateLabel()
+        label.text = String.localized("webxdc_empty_hint")
+        label.isHidden = true
+        return label
+    }()
+
+    private lazy var leftBarBtn: UIBarButtonItem = {
+        let btn = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.cancel,
+                                                             target: self,
+                                               action: #selector(cancelAction))
+        return btn
+    }()
+
+    private lazy var rightBarBtn: UIBarButtonItem = {
+        let btn = UIBarButtonItem(title: String.localized("files"),
+                                  style: .plain,
+                                  target: self,
+                                  action: #selector(filesAction))
+        return btn
+    }()
+
+    private lazy var mediaPicker: MediaPicker? = {
+        let mediaPicker = MediaPicker(navigationController: navigationController)
+        mediaPicker.delegate = self
+        return mediaPicker
+    }()
+
+    init(context: DcContext, mediaMessageIds: [Int]) {
+        self.dcContext = context
+        self.mediaMessageIds = mediaMessageIds
+        self.deduplicatedMessageHashes = [:]
+        self.deduplicatedMessageIds = []
+        super.init(nibName: nil, bundle: nil)
+        deduplicateWebxdcs()
+    }
+
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    // MARK: - lifecycle
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        setupSubviews()
+        title = String.localized("webxdcs")
+        navigationItem.setLeftBarButton(leftBarBtn, animated: false)
+        navigationItem.setRightBarButton(rightBarBtn, animated: false)
+        if mediaMessageIds.isEmpty {
+            emptyStateView.isHidden = false
+        }
+    }
+
+    override func viewWillAppear(_ animated: Bool) {
+        grid.reloadData()
+    }
+
+    override func viewWillLayoutSubviews() {
+        super.viewWillLayoutSubviews()
+        self.reloadCollectionViewLayout()
+    }
+
+    // MARK: - setup
+    private func setupSubviews() {
+        view.addSubview(grid)
+        grid.translatesAutoresizingMaskIntoConstraints = false
+        grid.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 0).isActive = true
+        grid.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
+        grid.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: 0).isActive = true
+        grid.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
+
+        emptyStateView.addCenteredTo(parentView: view)
+    }
+
+    func deduplicateWebxdcs() {
+        DispatchQueue.global(qos: .userInteractive).async { [weak self] in
+            guard let self = self else { return }
+            for id in self.mediaMessageIds {
+                guard let filename = self.dcContext.getMessage(id: id).fileURL else { continue }
+                if let hash = try? NSData(contentsOf: filename).sha1() {
+                    DispatchQueue.main.async {
+                        if self.deduplicatedMessageHashes[hash] == nil {
+                            self.deduplicatedMessageHashes[hash] = id
+                            self.deduplicatedMessageIds.append(id)
+                            self.grid.insertItems(at: [IndexPath(row: self.deduplicatedMessageIds.count - 1, section: 0)])
+                        }
+                    }
+                }
+            }
+
+            DispatchQueue.main.async {
+                if self.deduplicatedMessageIds.isEmpty {
+                    self.emptyStateView.isHidden = false
+                }
+            }
+        }
+    }
+    
+    @objc func cancelAction() {
+        dismiss(animated: true, completion: nil)
+    }
+
+    @objc func filesAction() {
+        mediaPicker?.showDocumentLibrary()
+    }
+}
+
+extension WebxdcSelector: UICollectionViewDataSourcePrefetching {
+    func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
+        indexPaths.forEach { if items[$0.row] == nil {
+            let message = dcContext.getMessage(id: deduplicatedMessageIds[$0.row])
+            let item = GalleryItem(msg: message)
+            items[$0.row] = item
+        }}
+    }
+}
+
+// MARK: - UICollectionViewDataSource, UICollectionViewDelegate
+extension WebxdcSelector: UICollectionViewDataSource, UICollectionViewDelegate {
+
+    func numberOfSections(in collectionView: UICollectionView) -> Int {
+        return 1
+    }
+
+    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+        return deduplicatedMessageIds.count
+    }
+
+    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+        guard let webxdcGridCell = collectionView.dequeueReusableCell(
+                withReuseIdentifier: WebxdcGridCell.reuseIdentifier,
+                for: indexPath) as? WebxdcGridCell else {
+            return UICollectionViewCell()
+        }
+
+        let msgId = deduplicatedMessageIds[indexPath.row]
+        var item: GalleryItem
+        if let galleryItem = items[indexPath.row] {
+            item = galleryItem
+        } else {
+            let message = dcContext.getMessage(id: msgId)
+            let galleryItem = GalleryItem(msg: message)
+            items[indexPath.row] = galleryItem
+            item = galleryItem
+        }
+        webxdcGridCell.update(item: item)
+        UIMenuController.shared.setMenuVisible(false, animated: true)
+        return webxdcGridCell
+    }
+
+    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+        let msgId = deduplicatedMessageIds[indexPath.row]
+        delegate?.onWebxdcSelected(msgId: msgId)
+        collectionView.deselectItem(at: indexPath, animated: true)
+        self.dismiss(animated: true, completion: nil)
+    }
+}
+
+// MARK: - grid layout + updates
+private extension WebxdcSelector {
+    func reloadCollectionViewLayout() {
+
+        // columns specification
+        let phonePortrait = 3
+        let phoneLandscape = 4
+        let padPortrait = 5
+        let padLandscape = 8
+
+        let orientation = UIApplication.shared.statusBarOrientation
+        let deviceType = UIDevice.current.userInterfaceIdiom
+
+        var gridDisplay: GridDisplay?
+        if deviceType == .phone {
+            if orientation.isPortrait {
+                gridDisplay = .grid(columns: phonePortrait)
+            } else {
+                gridDisplay = .grid(columns: phoneLandscape)
+            }
+        } else if deviceType == .pad {
+            if orientation.isPortrait {
+                gridDisplay = .grid(columns: padPortrait)
+            } else {
+                gridDisplay = .grid(columns: padLandscape)
+            }
+        }
+
+        if let gridDisplay = gridDisplay {
+            gridLayout.display = gridDisplay
+        } else {
+            safe_fatalError("undefined format")
+        }
+        let containerWidth = view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right - 2 * gridDefaultSpacing
+        gridLayout.containerWidth = containerWidth
+    }
+}
+
+extension WebxdcSelector: MediaPickerDelegate {
+    func onImageSelected(image: UIImage) {}
+
+    func onDocumentSelected(url: NSURL) {
+        delegate?.onWebxdcFromFilesSelected(url: url)
+        dismiss(animated: true)
+    }
+}

+ 10 - 0
deltachat-ios/Extensions/Extensions.swift

@@ -1,5 +1,6 @@
 import UIKit
 import Foundation
+import CommonCrypto
 
 extension Dictionary {
     func percentEscaped() -> String {
@@ -132,3 +133,12 @@ extension UIScrollView {
         setContentOffset(bottomOffset, animated: animated)
     }
 }
+
+extension NSData {
+    func sha1() -> String {
+         var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
+         CC_SHA1(bytes, CC_LONG(self.count), &digest)
+         let hexBytes = digest.map { String(format: "%02hhx", $0) }
+         return hexBytes.joined()
+    }
+}

+ 59 - 0
deltachat-ios/Helper/FileHelper.swift

@@ -0,0 +1,59 @@
+import Foundation
+
+public class FileHelper {
+    
+    // implementation is following Apple's recommendations
+    // https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/AccessingFilesandDirectories/AccessingFilesandDirectories.html
+    public static func saveData(data: Data, name: String? = nil, suffix: String, directory: FileManager.SearchPathDirectory = .applicationSupportDirectory) -> String? {
+        var path: URL?
+
+        // ensure directory exists (application support dir doesn't exist per default)
+        let fileManager = FileManager.default
+        let urls = fileManager.urls(for: directory, in: .userDomainMask) as [URL]
+        guard let identifier = Bundle.main.bundleIdentifier else {
+            print("err: Could not find bundle identifier")
+            return nil
+        }
+        guard let directoryURL = urls.first else {
+            print("err: Could not find directory url for \(String(describing: directory)) in .userDomainMask")
+            return nil
+        }
+        var subdirectoryURL = directoryURL.appendingPathComponent(identifier)
+        do {
+            if !fileManager.fileExists(atPath: subdirectoryURL.path) {
+                try fileManager.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil)
+            }
+        } catch {
+            print("err: \(error.localizedDescription)")
+            return nil
+        }
+
+        // Opt out from iCloud backup
+        var resourceValues: URLResourceValues = URLResourceValues()
+        resourceValues.isExcludedFromBackup = true
+        do {
+            try subdirectoryURL.setResourceValues(resourceValues)
+        } catch {
+            print("err: \(error.localizedDescription)")
+            return nil
+        }
+
+        // add file name to path
+        if let name = name {
+            path = subdirectoryURL.appendingPathComponent("\(name).\(suffix)")
+        } else {
+            let timestamp = Double(Date().timeIntervalSince1970)
+            path = subdirectoryURL.appendingPathComponent("\(timestamp).\(suffix)")
+        }
+        guard let path = path else { return nil }
+
+        // write data
+        do {
+            try data.write(to: path)
+            return path.relativePath
+        } catch {
+            print("err: \(error.localizedDescription)")
+            return nil
+        }
+    }
+}

+ 2 - 57
deltachat-ios/Helper/ImageFormat.swift

@@ -73,7 +73,7 @@ extension ImageFormat {
            let data = image.sd_imageData() {
             let format = ImageFormat.get(from: data)
             if format != .unknown {
-                return ImageFormat.saveImage(data: data, name: name, suffix: format.rawValue, directory: directory)
+                return FileHelper.saveData(data: data, name: name, suffix: format.rawValue, directory: directory)
             }
         }
         let suffix = image.isTransparent() ? "png" : "jpg"
@@ -81,62 +81,7 @@ extension ImageFormat {
             return nil
         }
 
-        return saveImage(data: data, name: name, suffix: suffix)
-    }
-
-    // implementation is following Apple's recommendations
-    // https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/AccessingFilesandDirectories/AccessingFilesandDirectories.html
-    public static func saveImage(data: Data, name: String? = nil, suffix: String, directory: FileManager.SearchPathDirectory = .applicationSupportDirectory) -> String? {
-        var path: URL?
-
-        // ensure directory exists (application support dir doesn't exist per default)
-        let fileManager = FileManager.default
-        let urls = fileManager.urls(for: directory, in: .userDomainMask) as [URL]
-        guard let identifier = Bundle.main.bundleIdentifier else {
-            print("err: Could not find bundle identifier")
-            return nil
-        }
-        guard let directoryURL = urls.first else {
-            print("err: Could not find directory url for \(String(describing: directory)) in .userDomainMask")
-            return nil
-        }
-        var subdirectoryURL = directoryURL.appendingPathComponent(identifier)
-        do {
-            if !fileManager.fileExists(atPath: subdirectoryURL.path) {
-                try fileManager.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil)
-            }
-        } catch {
-            print("err: \(error.localizedDescription)")
-            return nil
-        }
-
-        // Opt out from iCloud backup
-        var resourceValues: URLResourceValues = URLResourceValues()
-        resourceValues.isExcludedFromBackup = true
-        do {
-            try subdirectoryURL.setResourceValues(resourceValues)
-        } catch {
-            print("err: \(error.localizedDescription)")
-            return nil
-        }
-
-        // add file name to path
-        if let name = name {
-            path = subdirectoryURL.appendingPathComponent("\(name).\(suffix)")
-        } else {
-            let timestamp = Double(Date().timeIntervalSince1970)
-            path = subdirectoryURL.appendingPathComponent("\(timestamp).\(suffix)")
-        }
-        guard let path = path else { return nil }
-
-        // write data
-        do {
-            try data.write(to: path)
-            return path.relativePath
-        } catch {
-            print("err: \(error.localizedDescription)")
-            return nil
-        }
+        return FileHelper.saveData(data: data, name: name, suffix: suffix)
     }
 
     // This scaling method is more memory efficient than UIImage.scaleDownImage(toMax: CGFloat)

+ 8 - 2
deltachat-ios/Helper/MediaPicker.swift

@@ -77,8 +77,14 @@ class MediaPicker: NSObject, UINavigationControllerDelegate {
 
     func showDocumentLibrary() {
         // TODO: instead of adding kUTTypeData, we probably should implement a Document provider for webxdc's https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/FileProvider.html#//apple_ref/doc/uid/TP40014214-CH18
-        let types = [kUTTypePDF, kUTTypeText, kUTTypeRTF, kUTTypeSpreadsheet, kUTTypeVCard, kUTTypeZipArchive, kUTTypeImage, kUTTypeData]
-        let documentPicker = UIDocumentPickerViewController(documentTypes: types as [String], in: .import)
+        let documentPicker: UIDocumentPickerViewController
+        if #available(iOS 15.0, *) {
+            let types = [UTType.pdf, UTType.text, UTType.rtf, UTType.spreadsheet, UTType.vCard, UTType.zip, UTType.image, UTType.data]
+            documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
+        } else {
+            let types = [kUTTypePDF, kUTTypeText, kUTTypeRTF, kUTTypeSpreadsheet, kUTTypeVCard, kUTTypeZipArchive, kUTTypeImage, kUTTypeData]
+            documentPicker = UIDocumentPickerViewController(documentTypes: types as [String], in: .import)
+        }
         documentPicker.delegate = self
         documentPicker.allowsMultipleSelection = false
         documentPicker.modalPresentationStyle = .formSheet

+ 18 - 0
deltachat-ios/Model/GalleryItem.swift

@@ -12,6 +12,8 @@ class GalleryItem: ContextMenuItem {
         return msg.fileURL
     }
 
+    var description: String?
+
     var thumbnailImage: UIImage? {
         get {
             if let fileUrl = self.fileUrl {
@@ -51,6 +53,9 @@ class GalleryItem: ContextMenuItem {
         } else {
             loadThumbnail()
         }
+        if msg.viewtype == .webxdc {
+            description = msg.getWebxdcInfoDict()["name"] as? String ?? "ErrName"
+        }
     }
 
     private func loadThumbnail() {
@@ -62,6 +67,8 @@ class GalleryItem: ContextMenuItem {
             loadImageThumbnail(from: url)
         case .video:
             loadVideoThumbnail(from: url)
+        case .webxdc:
+            loadWebxdcThumbnail(from: msg)
         default:
             safe_fatalError("unsupported viewtype - viewtype \(viewtype) not supported.")
         }
@@ -86,4 +93,15 @@ class GalleryItem: ContextMenuItem {
             }
         }
     }
+
+    private func loadWebxdcThumbnail(from message: DcMsg) {
+        DispatchQueue.global(qos: .userInteractive).async {
+            let image = message.getWebxdcPreviewImage()
+            if let image = image {
+                DispatchQueue.main.async { [weak self] in
+                    self?.thumbnailImage = image
+                }
+            }
+        }
+    }
 }

+ 96 - 0
deltachat-ios/View/Cell/WebxdcGridCell.swift

@@ -0,0 +1,96 @@
+import UIKit
+import DcCore
+import SDWebImage
+
+class WebxdcGridCell: UICollectionViewCell {
+    static let reuseIdentifier = "webxdc_cell"
+
+    weak var item: GalleryItem?
+
+    private var font: UIFont {
+        let regularFont = UIFont.preferredFont(forTextStyle: .subheadline)
+        if regularFont.pointSize > 28 {
+            return UIFont.systemFont(ofSize: 28)
+        }
+        return regularFont
+    }
+
+    private lazy var imageView: SDAnimatedImageView = {
+        let view = SDAnimatedImageView()
+        view.contentMode = .scaleAspectFill
+        view.clipsToBounds = true
+        view.isAccessibilityElement = false
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.layer.cornerRadius = 6
+        view.clipsToBounds = true
+        return view
+    }()
+
+    private lazy var descriptionLabel: UILabel = {
+        let label = UILabel()
+        label.translatesAutoresizingMaskIntoConstraints = false
+        label.font = font
+        label.lineBreakMode = .byTruncatingTail
+        label.textColor = DcColors.defaultInverseColor
+        label.backgroundColor = DcColors.defaultTransparentBackgroundColor
+        label.textAlignment = .center
+        return label
+    }()
+
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        backgroundColor = DcColors.defaultBackgroundColor
+        setupSubviews()
+    }
+
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    override func prepareForReuse() {
+        super.prepareForReuse()
+        item?.onImageLoaded = nil
+        item = nil
+        imageView.image = nil
+        descriptionLabel.text = nil
+    }
+
+    private func setupSubviews() {
+        contentView.addSubview(imageView)
+        contentView.addSubview(descriptionLabel)
+        addConstraints([
+            imageView.constraintAlignLeadingToAnchor(contentView.leadingAnchor),
+            imageView.constraintAlignTrailingToAnchor(contentView.trailingAnchor),
+            imageView.constraintAlignTopToAnchor(contentView.topAnchor),
+            imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor),
+            descriptionLabel.constraintAlignTrailingMaxTo(contentView),
+            descriptionLabel.constraintCenterXTo(contentView),
+            descriptionLabel.widthAnchor.constraint(lessThanOrEqualTo: imageView.widthAnchor),
+            descriptionLabel.constraintToBottomOf(imageView, paddingTop: 4),
+            descriptionLabel.constraintAlignBottomMaxTo(contentView)
+        ])
+    }
+
+    func update(item: GalleryItem) {
+        self.item = item
+        item.onImageLoaded = { [weak self] image in
+            self?.imageView.image = image
+        }
+        imageView.image = item.thumbnailImage
+        descriptionLabel.text = item.description
+    }
+
+    override var isSelected: Bool {
+        willSet {
+            // to provide visual feedback on select events
+            imageView.alpha = newValue ? 0.75 : 1.0
+        }
+    }
+
+    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+        if previousTraitCollection?.preferredContentSizeCategory !=
+            traitCollection.preferredContentSizeCategory {
+                descriptionLabel.font = font
+        }
+    }
+}

+ 2 - 0
deltachat-ios/en.lproj/Localizable.strings

@@ -892,3 +892,5 @@
 // device messages for updates
 "update_1_30" = "Faster. More stable.\n\nFor 1.30 releases, we focused on speed and reliability, fixing dozens of bugs. Check our changelogs if your favorite one is fixed: https://get.delta.chat/#changelogs 🚀";
 
+"webxdcs" = "Apps";
+"webxdc_empty_hint" = "Webxdc apps you have received or sent will appear here. You can tap on files to select downloaded apps.";

+ 3 - 1
scripts/untranslated.xml

@@ -1,5 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
     <!-- iOS specific untranslated strings -->
-    "a11y_connectivity_hint" = "Double tap to view connectivity details.";
+    <string name ="a11y_connectivity_hint">Double tap to view connectivity details.</string>
+    <string name ="webxdcs">Apps</string>
+    <string name ="webxdc_empty_hint">Received or sent apps will appear here. Tap \"Files\" to select downloaded apps.</string>
 </resources>