diff --git a/Privitty.framework.zip b/Privitty.framework.zip deleted file mode 100644 index 2d577f511..000000000 Binary files a/Privitty.framework.zip and /dev/null differ diff --git a/deltachat-ios/Assets.xcassets/download.imageset/Contents.json b/deltachat-ios/Assets.xcassets/download.imageset/Contents.json new file mode 100644 index 000000000..26f8c1f1f --- /dev/null +++ b/deltachat-ios/Assets.xcassets/download.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "download.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/deltachat-ios/Assets.xcassets/download.imageset/download.pdf b/deltachat-ios/Assets.xcassets/download.imageset/download.pdf new file mode 100644 index 000000000..bf91d4e7a Binary files /dev/null and b/deltachat-ios/Assets.xcassets/download.imageset/download.pdf differ diff --git a/deltachat-ios/Assets.xcassets/forward.imageset/Contents.json b/deltachat-ios/Assets.xcassets/forward.imageset/Contents.json new file mode 100644 index 000000000..c52f624bc --- /dev/null +++ b/deltachat-ios/Assets.xcassets/forward.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "forward.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/deltachat-ios/Assets.xcassets/forward.imageset/forward.pdf b/deltachat-ios/Assets.xcassets/forward.imageset/forward.pdf new file mode 100644 index 000000000..181858183 Binary files /dev/null and b/deltachat-ios/Assets.xcassets/forward.imageset/forward.pdf differ diff --git a/deltachat-ios/Assets.xcassets/no_download.imageset/Contents.json b/deltachat-ios/Assets.xcassets/no_download.imageset/Contents.json new file mode 100644 index 000000000..56db894b3 --- /dev/null +++ b/deltachat-ios/Assets.xcassets/no_download.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "no_download.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/deltachat-ios/Assets.xcassets/no_download.imageset/no_download.pdf b/deltachat-ios/Assets.xcassets/no_download.imageset/no_download.pdf new file mode 100644 index 000000000..65fdcd365 Binary files /dev/null and b/deltachat-ios/Assets.xcassets/no_download.imageset/no_download.pdf differ diff --git a/deltachat-ios/Assets.xcassets/no_forward.imageset/Contents.json b/deltachat-ios/Assets.xcassets/no_forward.imageset/Contents.json new file mode 100644 index 000000000..5f7cdc553 --- /dev/null +++ b/deltachat-ios/Assets.xcassets/no_forward.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "no_forward.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/deltachat-ios/Assets.xcassets/no_forward.imageset/no_forward.pdf b/deltachat-ios/Assets.xcassets/no_forward.imageset/no_forward.pdf new file mode 100644 index 000000000..b716480a7 Binary files /dev/null and b/deltachat-ios/Assets.xcassets/no_forward.imageset/no_forward.pdf differ diff --git a/deltachat-ios/Chat/ChatViewController.swift b/deltachat-ios/Chat/ChatViewController.swift index d09236863..7cbcdf74c 100644 --- a/deltachat-ios/Chat/ChatViewController.swift +++ b/deltachat-ios/Chat/ChatViewController.swift @@ -1250,76 +1250,6 @@ class ChatViewController: UITableViewController, UITableViewDropDelegate { present(alert, animated: true, completion: nil) } - private func showRevokeAccessDialog(for message: DcMsg) { - guard message.isFromCurrentSender, let filePath = message.file else { - logger.warning("Cannot revoke access: message is not from current sender or has no file") - return - } - - let alert = UIAlertController( - title: String.localized("revoke_access_title"), - message: String.localized("revoke_access_message"), - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel)) - - alert.addAction(UIAlertAction(title: String.localized("revoke_access_title"), style: .destructive) { [weak self] _ in - guard let self else { return } - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self else { return } - - let chatIdString = String(self.chatId) - let result = PrvContext.shared.processInitAccessRevokeRequest( - chatId: chatIdString, - filePath: filePath, - reason: "Access revoked by owner" - ) - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - - if result.success { - // Send the PDU message if we have one - if let data = result.data, let pdu = data["pdu"] as? String { - logger.info("Sending revoke PDU to chat \(self.chatId)") - let responseMsg = self.dcContext.newMessage(viewType: DC_MSG_TEXT) - responseMsg.text = pdu - self.dcContext.sendMessage(chatId: self.chatId, message: responseMsg) - } - - // Show success message - let successMessage = result.message ?? String.localized("revoke_access_success") - let successAlert = UIAlertController( - title: nil, - message: successMessage, - preferredStyle: .alert - ) - successAlert.addAction(UIAlertAction(title: String.localized("ok"), style: .default)) - self.present(successAlert, animated: true) - - logger.info("Access revoked successfully for file: \(filePath)") - } else { - // Show error message - let errorMessage = result.error ?? String.localized("revoke_access_failed") - let errorAlert = UIAlertController( - title: String.localized("revoke_access_failed"), - message: errorMessage, - preferredStyle: .alert - ) - errorAlert.addAction(UIAlertAction(title: String.localized("ok"), style: .default)) - self.present(errorAlert, animated: true) - - logger.error("Failed to revoke access: \(errorMessage)") - } - } - } - }) - - present(alert, animated: true) - } - private func onMultipleSaveOrUnsave(doSave: Bool) { guard let rows = tableView.indexPathsForSelectedRows else { return } let msgIds = rows.compactMap { messageIds[$0.row] } @@ -2192,11 +2122,6 @@ class ChatViewController: UITableViewController, UITableViewDropDelegate { Utils.share(message: dcContext.getMessage(id: msgId), parentViewController: self, sourceView: view) } - private func revokeAccessSingle(_ msgId: Int) { - let message = dcContext.getMessage(id: msgId) - showRevokeAccessDialog(for: message) - } - private func resendSingle(_ msgId: Int) { dcContext.resendMessages(msgIds: [msgId]) } @@ -2476,22 +2401,6 @@ extension ChatViewController { if message.file != nil { // DISABLED: Share option // moreOptions.append(UIAction.menuAction(localizationKey: "menu_share", systemImageName: "square.and.arrow.up", with: messageId, action: shareSingle)) - - // Add Revoke Access option for outgoing Privitty files - if message.isFromCurrentSender && message.file != nil { - let filePath = message.file ?? "" - if filePath.hasSuffix(".prv") { - moreOptions.append( - UIAction.menuAction( - localizationKey: "menu_revoke_access", - attributes: [.destructive], - systemImageName: "slash.circle", - with: messageId, - action: revokeAccessSingle - ) - ) - } - } } children.append( @@ -2889,13 +2798,16 @@ extension ChatViewController: BaseMessageCellDelegate { let fileName = message.filename ?? "Unknown" // Open File Access Control screen (full page, matches Android) + // Peer 2 (recipient) when file was received from someone else; Peer 1 (owner) when we sent/shared the file. logger.info("Opening File Access Control for file: \(fileName)") + let isPeer2Mode = !message.isFromCurrentSender let fileAccessVC = FileAccessControlViewController( chatId: chatId, filePath: filePath, fileName: fileName, msgId: message.id, - dcContext: dcContext + dcContext: dcContext, + isPeer2Mode: isPeer2Mode ) // Push onto navigation stack for full-page presentation diff --git a/deltachat-ios/Chat/Views/Cells/FileTextCell.swift b/deltachat-ios/Chat/Views/Cells/FileTextCell.swift index addc11da0..fe785ba7b 100644 --- a/deltachat-ios/Chat/Views/Cells/FileTextCell.swift +++ b/deltachat-ios/Chat/Views/Cells/FileTextCell.swift @@ -72,9 +72,8 @@ public class FileTextCell: BaseMessageCell, ReusableCell { public func applyFileAccessStatus(_ status: PrvContext.FileAccessStatusData?, message: DcMsg) { fileAccessStatusData = status - // Update parent's fileAccessStatus property for status icon display - // This MUST be set before update() is called - fileAccessStatus = status?.status + let displayStatus: PrvContext.FileAccessStatus? = (message.isFromCurrentSender && status?.status == .revoked) ? .active : status?.status + fileAccessStatus = displayStatus fileView.configure(message: message, status: status) } diff --git a/deltachat-ios/Chat/Views/FileView.swift b/deltachat-ios/Chat/Views/FileView.swift index 350e4a084..74fab7d1a 100644 --- a/deltachat-ios/Chat/Views/FileView.swift +++ b/deltachat-ios/Chat/Views/FileView.swift @@ -300,6 +300,9 @@ public class FileView: UIView { let hideBellForForwarder = isOutgoing && isForwarded var hasPendingRequests = false + + let displayStatus: PrvContext.FileAccessStatus = (message.isFromCurrentSender && status?.status == .revoked) ? .active : (status?.status ?? .active) + if let status = status { // Check for WAITING_OWNER_ACTION in direct shared_info let isWaitingOwnerAction = (status.status == .waitingOwnerAction) @@ -322,25 +325,24 @@ public class FileView: UIView { // Red badge: Only show when there are pending WAITING_OWNER_ACTION requests notificationBadge.isHidden = !hasPendingRequests - // Apply colors based on access status if let status = status { - currentStatus = status.status - applyColors(for: status.status) + currentStatus = displayStatus + applyColors(for: displayStatus) // Show "Access Until" label with expiry date - if let expiryTime = status.expiryTime, status.status == .active { + if let expiryTime = status.expiryTime, displayStatus == .active { accessUntilLabel.text = "Access Until: \(formatExpiryDate(expiryTime))" accessUntilLabel.isHidden = false - } else if status.status == .expired, let expiryTime = status.expiryTime { + } else if displayStatus == .expired, let expiryTime = status.expiryTime { accessUntilLabel.text = "Expired: \(formatExpiryDate(expiryTime))" accessUntilLabel.isHidden = false - } else if status.status == .requested || status.status == .waitingOwnerAction { + } else if displayStatus == .requested || displayStatus == .waitingOwnerAction { accessUntilLabel.text = "Requesting access..." accessUntilLabel.isHidden = false - } else if status.status == .denied { + } else if displayStatus == .denied { accessUntilLabel.text = "Access denied" accessUntilLabel.isHidden = false - } else if status.status == .revoked { + } else if displayStatus == .revoked { accessUntilLabel.text = "Access revoked" accessUntilLabel.isHidden = false } else { diff --git a/deltachat-ios/Controller/FileAccessControlViewController.swift b/deltachat-ios/Controller/FileAccessControlViewController.swift index c5dbc9c75..f555f0511 100644 --- a/deltachat-ios/Controller/FileAccessControlViewController.swift +++ b/deltachat-ios/Controller/FileAccessControlViewController.swift @@ -21,31 +21,32 @@ public class FileAccessControlViewController: UIViewController { private enum Section: Int, CaseIterable { case shared = 0 case forwarded = 1 - - var title: String { - switch self { - case .shared: return "Shared / Owner" - case .forwarded: return "Forwarded" - } + } + + /// Section header title: "Owner" for shared section when Peer 2 (recipient), else "Shared". + private func sectionTitle(for section: Section) -> String { + switch section { + case .shared: return isPeer2Mode ? "Owner" : "Shared" + case .forwarded: return "Forwarded" } } // MARK: - UI Components - private lazy var titleLabel: UILabel = { + // MARK: - UI Components + + private lazy var fileNameLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.font = UIFont.preferredFont(forTextStyle: .headline) label.text = "File Access Control" return label }() - + private lazy var subtitleLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.font = UIFont.preferredFont(forTextStyle: .subheadline) - label.textColor = .secondaryLabel - label.text = fileName return label }() @@ -57,6 +58,8 @@ public class FileAccessControlViewController: UIViewController { table.register(FileAccessRequesteeCell.self, forCellReuseIdentifier: "FileAccessRequesteeCell") table.rowHeight = UITableView.automaticDimension table.estimatedRowHeight = 80 + table.backgroundColor = DcColors.chatBackgroundColor + table.separatorStyle = .none return table }() @@ -92,32 +95,44 @@ public class FileAccessControlViewController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() setupUI() + addRefreshControl() + loadAccessData() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + loadAccessData() + } + + private func addRefreshControl() { + let refresh = UIRefreshControl() + refresh.addTarget(self, action: #selector(refreshAccessData), for: .valueChanged) + tableView.refreshControl = refresh + } + + @objc private func refreshAccessData() { loadAccessData() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.tableView.refreshControl?.endRefreshing() + } } // MARK: - Setup private func setupUI() { - view.backgroundColor = .systemBackground + view.backgroundColor = DcColors.chatBackgroundColor title = "Access Control" - // Add header - let headerStackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) - headerStackView.translatesAutoresizingMaskIntoConstraints = false - headerStackView.axis = .vertical - headerStackView.spacing = 4 - headerStackView.alignment = .leading - - view.addSubview(headerStackView) + view.addSubview(fileNameLabel) view.addSubview(tableView) view.addSubview(emptyStateLabel) NSLayoutConstraint.activate([ - headerStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), - headerStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - headerStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + fileNameLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), + fileNameLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + fileNameLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), - tableView.topAnchor.constraint(equalTo: headerStackView.bottomAnchor, constant: 16), + tableView.topAnchor.constraint(equalTo: fileNameLabel.bottomAnchor, constant: 12), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), @@ -126,12 +141,11 @@ public class FileAccessControlViewController: UIViewController { emptyStateLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) - // Update subtitle with filename (without .prv extension) var displayName = fileName if displayName.hasSuffix(".prv") { displayName = String(displayName.dropLast(4)) } - subtitleLabel.text = displayName + fileNameLabel.text = displayName } // MARK: - Data Loading @@ -146,6 +160,7 @@ public class FileAccessControlViewController: UIViewController { let result = PrvContext.shared.getFileAccessStatusList(chatId: String(self.chatId), filePath: self.filePath) DispatchQueue.main.async { + self.tableView.refreshControl?.endRefreshing() if result.success, let data = result.data { logger.info("FileAccessControl: Successfully loaded access data") self.processFileAccessData(data) @@ -158,7 +173,9 @@ public class FileAccessControlViewController: UIViewController { } private func processFileAccessData(_ data: [String: Any]) { - // Parse the response similar to Android's FileAccessListResponse + // API response: file.owner_info = file owner, file.shared_info = user with whom owner shared, file.forwarded_list = forwarded entries. + // Peer 1 (owner): show shared_info in "Shared" section + Revoke/Access Control button. + // Peer 2 (recipient): show owner_info in "Owner" section, no Revoke/Access Control button. guard let fileData = data["file"] as? [String: Any] else { logger.error("FileAccessControl: No file data in response") showEmptyState() @@ -169,15 +186,21 @@ public class FileAccessControlViewController: UIViewController { var newForwardedRequestees: [FileAccessRequestee] = [] if isPeer2Mode { - // Peer 2 mode: Show owner info in shared section - if let ownerInfo = fileData["owner_info"] as? [String: Any] { - if let requestee = FileAccessRequestee.fromOwnerInfo(ownerInfo) { - newSharedRequestees.append(requestee) - } - } else if let sharedInfo = fileData["shared_info"] as? [String: Any] { - if let requestee = FileAccessRequestee.fromSharedInfo(sharedInfo) { - newSharedRequestees.append(requestee) + // Peer 2 mode: Show owner in "Owner" section; use shared_info for status/expiry (recipient's access) when present + if let ownerInfo = fileData["owner_info"] as? [String: Any], + let requestee = FileAccessRequestee.fromOwnerInfo(ownerInfo) { + var ownerRequestee = requestee + if let sharedInfo = fileData["shared_info"] as? [String: Any] { + let status = sharedInfo["status"] as? String ?? requestee.status + let expiry = FileAccessRequestee.normalizeTimestampPublic(sharedInfo["expiry_time"]) ?? requestee.expiryTime + let allowDownload = FileAccessRequestee.boolFromDictPublic(sharedInfo, trueKey: "allow_download", alternateKey: "download_allowed", default: false) + let allowForward = FileAccessRequestee.boolFromDictPublic(sharedInfo, trueKey: "allow_forward", alternateKey: "forward_allowed", default: false) + ownerRequestee = requestee.withStatusAndExpiry(status: status, expiryTime: expiry, allowDownload: allowDownload, allowForward: allowForward) } + newSharedRequestees.append(ownerRequestee) + } else if let sharedInfo = fileData["shared_info"] as? [String: Any], + let requestee = FileAccessRequestee.fromSharedInfo(sharedInfo) { + newSharedRequestees.append(requestee) } } else { // Peer 1 mode: Show shared info in shared section @@ -196,19 +219,8 @@ public class FileAccessControlViewController: UIViewController { } } - // Sort forwarded list: WAITING_OWNER_ACTION first (matches Android) - newForwardedRequestees.sort { requestee1, requestee2 in - let status1 = requestee1.status.lowercased() - let status2 = requestee2.status.lowercased() - - if status1 == "waiting_owner_action" && status2 != "waiting_owner_action" { - return true // requestee1 comes first - } else if status1 != "waiting_owner_action" && status2 == "waiting_owner_action" { - return false // requestee2 comes first - } else { - return false // maintain original order for same status - } - } + // Sort forwarded list in ascending order by name + newForwardedRequestees.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } self.sharedRequestees = newSharedRequestees @@ -240,11 +252,56 @@ public class FileAccessControlViewController: UIViewController { ) bottomSheet.onComplete = { [weak self] in - self?.loadAccessData() // Refresh the list + self?.loadAccessData() } present(bottomSheet, animated: true) } + + private func performRevoke(for requestee: FileAccessRequestee) { + let alert = UIAlertController( + title: String.localized("revoke_access_title"), + message: String.localized("revoke_access_message"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: String.localized("revoke_access_title"), style: .destructive) { [weak self] _ in + self?.executeRevoke(for: requestee) + }) + present(alert, animated: true) + } + + private func executeRevoke(for requestee: FileAccessRequestee) { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + let result = PrvContext.shared.processInitAccessRevokeRequest( + chatId: String(self.chatId), + filePath: self.filePath, + contactId: requestee.contactId + ) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if result.success { + if let data = result.data, let pdu = data["pdu"] as? String { + let msg = self.dcContext.newMessage(viewType: DC_MSG_TEXT) + msg.text = pdu + self.dcContext.sendMessage(chatId: self.chatId, message: msg) + } + let message = result.message ?? String.localized("revoke_access_success") + let okAlert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + okAlert.addAction(UIAlertAction(title: String.localized("ok"), style: .default) { [weak self] _ in + self?.loadAccessData() + }) + self.present(okAlert, animated: true) + } else { + let errorMessage = result.error ?? String.localized("revoke_access_failed") + let errAlert = UIAlertController(title: String.localized("revoke_access_failed"), message: errorMessage, preferredStyle: .alert) + errAlert.addAction(UIAlertAction(title: String.localized("ok"), style: .default)) + self.present(errAlert, animated: true) + } + } + } + } } // MARK: - UITableViewDataSource @@ -268,9 +325,57 @@ extension FileAccessControlViewController: UITableViewDataSource { } } - public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let actualSection = getActualSection(for: section) - return actualSection.title + return centeredSectionHeaderView(title: sectionTitle(for: actualSection)) + } + + public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return 44 + } + + private func centeredSectionHeaderView(title: String) -> UIView { + let container = UIView() + container.backgroundColor = DcColors.chatBackgroundColor + + let lineColor = DcColors.fileAccessOuterGray + let leftLine = UIView() + leftLine.translatesAutoresizingMaskIntoConstraints = false + leftLine.backgroundColor = lineColor + + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = DcColors.defaultTextColor + label.text = title + + let rightLine = UIView() + rightLine.translatesAutoresizingMaskIntoConstraints = false + rightLine.backgroundColor = lineColor + + container.addSubview(leftLine) + container.addSubview(label) + container.addSubview(rightLine) + + let padding: CGFloat = 16 + let lineHeight: CGFloat = 1 + + NSLayoutConstraint.activate([ + leftLine.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: padding), + leftLine.trailingAnchor.constraint(equalTo: label.leadingAnchor, constant: -12), + leftLine.centerYAnchor.constraint(equalTo: container.centerYAnchor), + leftLine.heightAnchor.constraint(equalToConstant: lineHeight), + leftLine.widthAnchor.constraint(equalTo: rightLine.widthAnchor), + + label.centerXAnchor.constraint(equalTo: container.centerXAnchor), + label.centerYAnchor.constraint(equalTo: container.centerYAnchor), + + rightLine.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 12), + rightLine.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -padding), + rightLine.centerYAnchor.constraint(equalTo: container.centerYAnchor), + rightLine.heightAnchor.constraint(equalToConstant: lineHeight) + ]) + + return container } public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -285,7 +390,40 @@ extension FileAccessControlViewController: UITableViewDataSource { requestee = forwardedRequestees[indexPath.row] } - cell.configure(with: requestee) + let isShared = (actualSection == .shared) + let isForwarded = (actualSection == .forwarded) + let requesteeStatusLower = requestee.status.lowercased() + let isActive = (requesteeStatusLower == "active") + let isRevoked = (requesteeStatusLower == "revoked") + + // Peer 2 → hide icons only for forwarded section + // Peer 1 → show icons everywhere + let hidePermissionIcons: Bool + if isPeer2Mode { + hidePermissionIcons = !isShared // Hide only in Forwarded + } else { + hidePermissionIcons = false // Owner sees icons everywhere + } + + let showRevokeOrAccessControlButton = !isPeer2Mode && ((isShared && !isRevoked) || (isForwarded && isActive)) + let showAccessStatusForRecipient = isShared && isPeer2Mode +// let isShared = (actualSection == .shared) +// let showRevokeOrAccessControlButton = isShared && !isPeer2Mode +// let showAccessStatusForRecipient = isShared && isPeer2Mode +// let hidePermissionIcons = isPeer2Mode + cell.configure( + with: requestee, + isSharedSection: isShared, + showRevokeOrAccessControlButton: showRevokeOrAccessControlButton, + showAccessStatusForRecipient: showAccessStatusForRecipient, + hidePermissionIcons: hidePermissionIcons + ) + cell.onRevokeTapped = { [weak self] in + self?.performRevoke(for: requestee) + } + cell.onAccessControlTapped = { [weak self] in + self?.showFileAccessBottomSheet(for: requestee) + } return cell } @@ -343,6 +481,8 @@ public struct FileAccessRequestee { public let name: String public let email: String? public let expiryTime: Double? + /// Unix timestamp when access was shared (if provided by API) + public let sharedAtTime: Double? public let status: String public let isForwarded: Bool public let allowDownload: Bool @@ -358,11 +498,12 @@ public struct FileAccessRequestee { contactId: contactId, name: contactName, email: dict["contact_email"] as? String, - expiryTime: dict["expiry_time"] as? Double, + expiryTime: Self.normalizeTimestamp(dict["expiry_time"]), + sharedAtTime: Self.normalizeTimestamp(dict["shared_at"]), status: dict["status"] as? String ?? "unknown", isForwarded: false, - allowDownload: dict["allow_download"] as? Bool ?? false, - allowForward: dict["allow_forward"] as? Bool ?? false + allowDownload: Self.boolFromDict(dict, trueKey: "allow_download", alternateKey: "download_allowed", default: false), + allowForward: Self.boolFromDict(dict, trueKey: "allow_forward", alternateKey: "forward_allowed", default: false) ) } @@ -376,11 +517,12 @@ public struct FileAccessRequestee { contactId: contactId, name: contactName, email: dict["contact_email"] as? String, - expiryTime: dict["expiry_time"] as? Double, + expiryTime: Self.normalizeTimestamp(dict["expiry_time"]), + sharedAtTime: Self.normalizeTimestamp(dict["shared_at"]) ?? Self.normalizeTimestamp(dict["granted_at"]), status: dict["status"] as? String ?? "active", isForwarded: false, - allowDownload: dict["allow_download"] as? Bool ?? true, - allowForward: dict["allow_forward"] as? Bool ?? true + allowDownload: Self.boolFromDict(dict, trueKey: "allow_download", alternateKey: "download_allowed", default: true), + allowForward: Self.boolFromDict(dict, trueKey: "allow_forward", alternateKey: "forward_allowed", default: true) ) } @@ -394,19 +536,107 @@ public struct FileAccessRequestee { contactId: contactId, name: contactName, email: dict["contact_email"] as? String, - expiryTime: dict["expiry_time"] as? Double, + expiryTime: Self.normalizeTimestamp(dict["expiry_time"]), + sharedAtTime: Self.normalizeTimestamp(dict["shared_at"]), status: dict["status"] as? String ?? "requested", isForwarded: true, - allowDownload: dict["allow_download"] as? Bool ?? false, - allowForward: dict["allow_forward"] as? Bool ?? false + allowDownload: Self.boolFromDict(dict, trueKey: "allow_download", alternateKey: "download_allowed", default: false), + allowForward: Self.boolFromDict(dict, trueKey: "allow_forward", alternateKey: "forward_allowed", default: false) ) } + + /// API may send timestamps in milliseconds. Convert to seconds (Unix) for Date. + private static func normalizeTimestamp(_ value: Any?) -> Double? { + normalizeTimestampPublic(value) + } + + /// Public so callers can normalize expiry/status from another dict (e.g. shared_info when merging for Peer 2). + public static func normalizeTimestampPublic(_ value: Any?) -> Double? { + guard let value = value else { return nil } + let raw: Double + if let n = value as? NSNumber { raw = n.doubleValue } + else if let d = value as? Double { raw = d } + else if let i = value as? Int { raw = Double(i) } + else { return nil } + guard raw > 0 else { return nil } + if raw >= 1e12 { + return raw / 1000.0 + } + return raw + } + + /// Returns a copy with updated status, expiry, and optionally permissions (e.g. when merging owner_info + shared_info for Peer 2). + public func withStatusAndExpiry(status: String, expiryTime: Double?, allowDownload: Bool? = nil, allowForward: Bool? = nil) -> FileAccessRequestee { + FileAccessRequestee( + contactId: contactId, + name: name, + email: email, + expiryTime: expiryTime, + sharedAtTime: sharedAtTime, + status: status, + isForwarded: isForwarded, + allowDownload: allowDownload ?? self.allowDownload, + allowForward: allowForward ?? self.allowForward + ) + } + + /// API sends download_allowed/forward_allowed as Int 0/1; also support allow_download/allow_forward as Bool. + private static func boolFromDict(_ dict: [String: Any], trueKey: String, alternateKey: String?, default: Bool) -> Bool { + boolFromDictPublic(dict, trueKey: trueKey, alternateKey: alternateKey, default: `default`) + } + + /// Public so callers can merge permissions from shared_info (e.g. for Peer 2 Owner row). + public static func boolFromDictPublic(_ dict: [String: Any], trueKey: String, alternateKey: String?, default: Bool) -> Bool { + if let b = dict[trueKey] as? Bool { return b } + if let alt = alternateKey, let b = dict[alt] as? Bool { return b } + if let n = dict[trueKey] as? Int { return n != 0 } + if let alt = alternateKey, let n = dict[alt] as? Int { return n != 0 } + return `default` + } + + /// Status subtitle for Peer 2 (recipient) viewing the Owner row: Access Denied, Expired, Revoked, Time left to Expire, or Active till <date>. + public static func accessStatusSubtitle(status: String, expiryTime: Double?) -> String { + let s = status.lowercased() + if s == "revoked" { return "Revoked" } + if s == "access_denied" || s == "denied" { return "Access Denied" } + let now = Date().timeIntervalSince1970 + if let expiry = expiryTime, expiry > 0 { + let expirySec = expiry >= 1e12 ? expiry / 1000 : expiry + if expirySec < now { return "Expired" } + if s == "expired" { return "Expired" } + let date = Date(timeIntervalSince1970: expirySec) + return "Active till \(Self.formatDateForStatus(date))" + } + if s == "expired" { return "Expired" } + if s == "active" { return "Active" } + return "Active" + } + + private static func formatDateForStatus(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "MMM d, yyyy 'at' h:mm a" + return formatter.string(from: date) + } + } // MARK: - FileAccessRequesteeCell class FileAccessRequesteeCell: UITableViewCell { + var onRevokeTapped: (() -> Void)? + var onAccessControlTapped: (() -> Void)? + + private var isAccessControlMode = false + private let avatarSize: CGFloat = 44 + + private lazy var avatarBadge: InitialsBadge = { + let badge = InitialsBadge(name: "", color: DcColors.fileAccessOuterGray, size: avatarSize) + badge.translatesAutoresizingMaskIntoConstraints = false + return badge + }() + private lazy var nameLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false @@ -414,27 +644,66 @@ class FileAccessRequesteeCell: UITableViewCell { return label }() - private lazy var emailLabel: UILabel = { + private lazy var subtitleLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .caption1) label.textColor = .secondaryLabel return label }() - private lazy var statusLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = UIFont.preferredFont(forTextStyle: .caption1) - label.textColor = .systemGray - return label + private lazy var downloadIconView: UIImageView = { + let iv = UIImageView() + iv.translatesAutoresizingMaskIntoConstraints = false + iv.contentMode = .scaleAspectFit + iv.tintColor = .secondaryLabel + iv.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) + return iv + }() + + private lazy var forwardIconView: UIImageView = { + let iv = UIImageView() + iv.translatesAutoresizingMaskIntoConstraints = false + iv.contentMode = .scaleAspectFit + iv.tintColor = .secondaryLabel + iv.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) + return iv + }() + + private lazy var permissionIconsStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [forwardIconView, downloadIconView]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .horizontal + stack.spacing = 6 + stack.alignment = .center + return stack + }() + + /// Trailing constraint to action button (active when button is visible) + private var permissionIconsTrailingToButton: NSLayoutConstraint! + /// Trailing constraint to content view (active when button is hidden so icons stay on the right) + private var permissionIconsTrailingToContent: NSLayoutConstraint! + + private static let actionButtonSymbolConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) + + private lazy var actionButton: UIButton = { + let btn = UIButton(type: .system) + btn.translatesAutoresizingMaskIntoConstraints = false + btn.backgroundColor = DcColors.privittyThemeColor + btn.tintColor = .white + btn.layer.cornerRadius = 8 + btn.addTarget(self, action: #selector(actionButtonTapped), for: .touchUpInside) + return btn + }() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() - // Add chevron to indicate cell is tappable (matches Android access control icon) - accessoryType = .disclosureIndicator + backgroundColor = .clear + contentView.backgroundColor = .clear + selectionStyle = .none } required init?(coder: NSCoder) { @@ -442,32 +711,170 @@ class FileAccessRequesteeCell: UITableViewCell { } private func setupUI() { - let stackView = UIStackView(arrangedSubviews: [nameLabel, emailLabel, statusLabel]) - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.axis = .vertical - stackView.spacing = 4 + let textStack = UIStackView(arrangedSubviews: [nameLabel, subtitleLabel]) + textStack.translatesAutoresizingMaskIntoConstraints = false + textStack.axis = .vertical + textStack.spacing = 2 + textStack.alignment = .leading + downloadIconView.widthAnchor.constraint(equalToConstant: 14).isActive = true + downloadIconView.heightAnchor.constraint(equalToConstant: 14).isActive = true + + forwardIconView.widthAnchor.constraint(equalToConstant: 14).isActive = true + forwardIconView.heightAnchor.constraint(equalToConstant: 14).isActive = true + contentView.addSubview(avatarBadge) + contentView.addSubview(textStack) + contentView.addSubview(permissionIconsStack) + contentView.addSubview(actionButton) - contentView.addSubview(stackView) + permissionIconsTrailingToButton = permissionIconsStack.trailingAnchor.constraint(equalTo: actionButton.leadingAnchor, constant: -12) + permissionIconsTrailingToContent = permissionIconsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16) NSLayoutConstraint.activate([ - stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), - stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), - stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), - stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12) + avatarBadge.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + avatarBadge.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + avatarBadge.widthAnchor.constraint(equalToConstant: avatarSize), + avatarBadge.heightAnchor.constraint(equalToConstant: avatarSize), + + textStack.leadingAnchor.constraint(equalTo: avatarBadge.trailingAnchor, constant: 12), + textStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + textStack.trailingAnchor.constraint(lessThanOrEqualTo: permissionIconsStack.leadingAnchor, constant: -8), + + permissionIconsTrailingToButton, + permissionIconsStack.centerYAnchor.constraint(equalTo: subtitleLabel.centerYAnchor), + actionButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + actionButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + actionButton.widthAnchor.constraint(equalToConstant: 44), + actionButton.heightAnchor.constraint(equalToConstant: 44), + + contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 64) ]) } - func configure(with requestee: FileAccessRequestee) { + @objc private func actionButtonTapped() { + if isAccessControlMode { + onAccessControlTapped?() + } else { + onRevokeTapped?() + } + } + + func configure(with requestee: FileAccessRequestee, isSharedSection: Bool, showRevokeOrAccessControlButton: Bool = true, showAccessStatusForRecipient: Bool = false, hidePermissionIcons: Bool = false) { nameLabel.text = requestee.name - emailLabel.text = requestee.email ?? "" - emailLabel.isHidden = requestee.email == nil || requestee.email!.isEmpty + avatarBadge.setName(requestee.name) + avatarBadge.setColor(DcColors.fileAccessOuterGray) - // Status text - var statusText = requestee.status.capitalized - if requestee.isForwarded { - statusText += " (Forwarded)" + if isSharedSection { + let isWaiting = requestee.status.lowercased() == "waiting_owner_action" + if showAccessStatusForRecipient { + subtitleLabel.text = FileAccessRequestee.accessStatusSubtitle(status: requestee.status, expiryTime: requestee.expiryTime) + } else { + if isWaiting { + subtitleLabel.text = "Access Requested" + } else if let sharedAt = requestee.sharedAtTime, sharedAt > 0 { + let date = Date(timeIntervalSince1970: sharedAt) + subtitleLabel.text = Self.formatDate(date) + } else if let expiry = requestee.expiryTime, expiry > 0 { + let date = Self.expiryToDate(expiry) + subtitleLabel.text = "Until \(Self.formatDate(date))" + } else { + subtitleLabel.text = "Shared" + } + } + if hidePermissionIcons { + downloadIconView.isHidden = true + forwardIconView.isHidden = true + } else { + // Peer 1 (owner) shared section: show themed asset icons for allowed / not allowed + downloadIconView.isHidden = false + forwardIconView.isHidden = false + let downloadImageName = requestee.allowDownload ? "download" : "no_download" + let forwardImageName = requestee.allowForward ? "forward" : "no_forward" + downloadIconView.image = UIImage(named: downloadImageName)?.withRenderingMode(.alwaysTemplate) + downloadIconView.tintColor = DcColors.defaultTextColor + forwardIconView.image = UIImage(named: forwardImageName)?.withRenderingMode(.alwaysTemplate) + forwardIconView.tintColor = DcColors.defaultTextColor + } + actionButton.isHidden = !showRevokeOrAccessControlButton + if showRevokeOrAccessControlButton { + permissionIconsTrailingToButton.isActive = true + permissionIconsTrailingToContent.isActive = false + isAccessControlMode = isWaiting + if isWaiting { + actionButton.setImage(UIImage(systemName: "lock.fill", withConfiguration: Self.actionButtonSymbolConfig), for: .normal) + } else { + actionButton.setImage(UIImage(named: "reset_qr_icon"), for: .normal) +// actionButton.setImage(UIImage(systemName: "stop.circle.fill", withConfiguration: Self.actionButtonSymbolConfig), for: .normal) + } + } else { + permissionIconsTrailingToButton.isActive = false + permissionIconsTrailingToContent.isActive = true + } + } else { + let isWaiting = requestee.status.lowercased() == "waiting_owner_action" + let s = requestee.status.lowercased() + if isWaiting { + subtitleLabel.text = "Access Requested" + } else if s == "active" { + if let expiry = requestee.expiryTime, expiry > 0 { + let date = Self.expiryToDate(expiry) + subtitleLabel.text = date < Date() ? "Expired" : "Until \(Self.formatDate(date))" + } else { + subtitleLabel.text = "Active" + } + } else if s == "expired", let expiry = requestee.expiryTime, expiry > 0 { + let date = Self.expiryToDate(expiry) + subtitleLabel.text = "Expired on \(Self.formatDate(date))" + } else if s == "expired" { + subtitleLabel.text = "Expired" + } else if s == "revoked" { + subtitleLabel.text = "Access Revoked" + } else if s == "denied" || s == "access_denied" { + subtitleLabel.text = "Access Denied" + } else { + subtitleLabel.text = requestee.status.capitalized + } + if hidePermissionIcons { + downloadIconView.isHidden = true + forwardIconView.isHidden = true + } else { + // Peer 1 (owner) forwarded section: show only download icon with themed assets + downloadIconView.isHidden = false + let downloadImageName = requestee.allowDownload ? "download" : "no_download" + downloadIconView.image = UIImage(named: downloadImageName)?.withRenderingMode(.alwaysTemplate) + downloadIconView.tintColor = DcColors.defaultTextColor + forwardIconView.isHidden = true + } + let showButton = showRevokeOrAccessControlButton || isWaiting + actionButton.isHidden = !showButton + if showButton { + permissionIconsTrailingToButton.isActive = true + permissionIconsTrailingToContent.isActive = false + if isWaiting { + actionButton.setImage(UIImage(systemName: "lock.fill", withConfiguration: Self.actionButtonSymbolConfig), for: .normal) + } else { + actionButton.setImage(UIImage(named: "reset_qr_icon"), for: .normal) + } + } else { + permissionIconsTrailingToButton.isActive = false + permissionIconsTrailingToContent.isActive = true + } + isAccessControlMode = isWaiting } - statusLabel.text = statusText + } + + /// Expiry is in seconds: either Unix timestamp (>= 1e9) or duration from now. + private static func expiryToDate(_ expiry: Double) -> Date { + if expiry >= 1_000_000_000 { + return Date(timeIntervalSince1970: expiry) + } + return Date().addingTimeInterval(expiry) + } + + private static func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "MMM d, yyyy 'at' h:mm a" + return formatter.string(from: date) } } diff --git a/deltachat-ios/Controller/PreviewController.swift b/deltachat-ios/Controller/PreviewController.swift index 63de82fb8..3c73c69c7 100644 --- a/deltachat-ios/Controller/PreviewController.swift +++ b/deltachat-ios/Controller/PreviewController.swift @@ -267,11 +267,6 @@ private final class PreviewMenuHostViewController: UIViewController { return (msg.chatId, msg.id) } - private var canShowRevokeAccess: Bool { - guard let msg = currentMessage, msg.isFromCurrentSender, let file = msg.file else { return false } - return file.lowercased().hasSuffix(".prv") - } - private var canShowFileAccessControl: Bool { guard let msg = currentMessage, let file = msg.file else { return false } return file.lowercased().hasSuffix(".prv") @@ -295,11 +290,6 @@ private final class PreviewMenuHostViewController: UIViewController { self?.openFileAccessControl(message: msg, filePath: filePath) }) } - if canShowRevokeAccess { - actions.append(UIAction(title: String.localized("menu_revoke_access"), image: UIImage(systemName: "slash.circle"), attributes: .destructive) { [weak self] _ in - self?.revokeTapped() - }) - } return UIMenu(children: actions) } @@ -328,38 +318,10 @@ private final class PreviewMenuHostViewController: UIViewController { private func openFileAccessControl(message: DcMsg, filePath: String) { let fileName = message.filename ?? "file.prv" - let vc = FileAccessControlViewController(chatId: message.chatId, filePath: filePath, fileName: fileName, msgId: message.id, dcContext: dcContext) + let isPeer2Mode = !message.isFromCurrentSender + let vc = FileAccessControlViewController(chatId: message.chatId, filePath: filePath, fileName: fileName, msgId: message.id, dcContext: dcContext, isPeer2Mode: isPeer2Mode) navigationController?.pushViewController(vc, animated: true) } - - private func revokeTapped() { - guard let message = currentMessage, message.isFromCurrentSender, let filePath = message.file else { return } - let alert = UIAlertController(title: String.localized("revoke_access_title"), message: String.localized("revoke_access_message"), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel)) - alert.addAction(UIAlertAction(title: String.localized("revoke_access_title"), style: .destructive) { [weak self] _ in - self?.performRevoke(chatIdString: String(message.chatId), filePath: filePath) - }) - navigationController?.present(alert, animated: true) - } - - private func performRevoke(chatIdString: String, filePath: String) { - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - let result = PrvContext.shared.processInitAccessRevokeRequest(chatId: chatIdString, filePath: filePath, reason: "Access revoked by owner") - DispatchQueue.main.async { - guard let self else { return } - let message = result.message ?? result.error ?? (result.success ? String.localized("revoke_access_success") : String.localized("revoke_access_failed")) - let resultAlert = UIAlertController( - title: result.success ? String.localized("revoke_access_title") : String.localized("revoke_access_failed"), - message: message, - preferredStyle: .alert - ) - resultAlert.addAction(UIAlertAction(title: String.localized("ok"), style: .default) { [weak self] _ in - if result.success { self?.onDismissPreview?() } - }) - self.navigationController?.present(resultAlert, animated: true) - } - } - } } private struct EmbedPreviewController: UIViewControllerRepresentable { diff --git a/deltachat-ios/DC/PrvContext.swift b/deltachat-ios/DC/PrvContext.swift index f1aecdd4e..6cd68a4e3 100644 --- a/deltachat-ios/DC/PrvContext.swift +++ b/deltachat-ios/DC/PrvContext.swift @@ -806,31 +806,25 @@ public class PrvContext { return (false, nil, message, errorMessage) } - /// Revoke access to a Privitty encrypted file (sender revokes access from recipient) public func processInitAccessRevokeRequest(chatId: String, filePath: String, - reason: String = "Access revoked by owner") -> (success: Bool, data: [String: Any]?, message: String?, error: String?) { + contactId: String?) -> (success: Bool, data: [String: Any]?, message: String?, error: String?) { + guard let core = getCore() else { - logger.error("Cannot revoke file access: Core not initialized") return (false, nil, nil, "Core not initialized") } guard let currentUser = getCurrentUser() else { - logger.error("Cannot revoke file access: No user selected") return (false, nil, nil, "No user selected") } - logger.info("Revoking file access for user: \(currentUser)") - logger.info("File path: \(filePath)") - logger.info("Chat ID: \(chatId)") - logger.info("Reason: \(reason)") - guard let result = core.processInitAccessRevokeRequest(withChatId: chatId, filePath: filePath, reason: reason) else { - logger.error("Failed to get access revoke response") + guard let result = core.processInitAccessRevokeRequest(withChatId: chatId, filePath: filePath, contactId: contactId) else { return (false, nil, nil, "No response returned") } let message = result["message"] as? String + let appStatus = result["app_status"] let successValue: Bool if let value = result["success"] as? Bool { @@ -845,11 +839,12 @@ public class PrvContext { if successValue { let data = result["data"] as? [String: Any] + let pduPresent = (data?["pdu"] as? String) != nil + let status = data?["status"] as? String ?? "?" return (true, data, message, nil) } let errorMessage = (result["error"] as? String) ?? message ?? "Unknown error" - logger.error("File access revoke failed: \(errorMessage)") return (false, nil, message, errorMessage) }