diff --git a/Privitty.framework/Headers/Privitty.h b/Privitty.framework/Headers/Privitty.h index 0fe051d7c..cc8a19cf6 100644 --- a/Privitty.framework/Headers/Privitty.h +++ b/Privitty.framework/Headers/Privitty.h @@ -131,6 +131,77 @@ FOUNDATION_EXPORT const unsigned char PrivittyVersionString[]; - (nullable NSDictionary*)getConfigWithKey:(NSString*)key; +// ============================================================================= +// FORWARD ACCESS CONTROL (Three-party Trust Model) +// ============================================================================= + +/** + * Forward peer add request - Add a forwardee to forward file access + * @param chatId Chat identifier with the relay/owner + * @param forwardeeChatId Chat identifier with the forwardee + * @param prvFile Path to the .prv file to forward + * @return Result dictionary with PDU (nullable on error) + */ +- (nullable NSDictionary*)processInitForwardPeerAddRequestWithChatId:(NSString*)chatId + forwardeeChatId:(NSString*)forwardeeChatId + prvFile:(NSString*)prvFile; + +/** + * Forwardee initiates forward access request - Request access to a forwarded file + * @param chatId Chat identifier with the relay + * @param filePath Path to the .prv file + * @return Result dictionary with PDU (nullable on error) + */ +- (nullable NSDictionary*)processInitForwardAccessRequestWithChatId:(NSString*)chatId + filePath:(NSString*)filePath; + +/** + * Original owner accepts relay forward access request + * @param chatId Chat identifier with the relay + * @param filePath Path to the .prv file + * @param contactId ID of the forwarder/contact requesting access + * @param accessDuration Duration in seconds for access validity (0 for unlimited) + * @param allowDownload Whether to allow download of the file + * @return Result dictionary with PDU (nullable on error) + */ +- (nullable NSDictionary*)processInitRevertRelayForwardAccessAcceptWithChatId:(NSString*)chatId + filePath:(NSString*)filePath + contactId:(NSString*)contactId + accessDuration:(NSInteger)accessDuration + allowDownload:(BOOL)allowDownload; + +/** + * Original owner denies relay forward access request + * @param chatId Chat identifier with the relay + * @param filePath Path to the .prv file + * @param contactId ID of the forwarder/contact requesting access + * @param denialReason Optional reason for denial (can be nil) + * @return Result dictionary with PDU (nullable on error) + */ +- (nullable NSDictionary*)processInitRevertRelayForwardAccessDeniedWithChatId:(NSString*)chatId + filePath:(NSString*)filePath + contactId:(NSString*)contactId + denialReason:(nullable NSString*)denialReason; + +/** + * Decrypt a forwarded file (file that was forwarded from another peer) + * @param fileId File identifier (actually the prv_file path) + * @param forwarderPeer Peer who forwarded the file (actually the chat_id) + * @return Result dictionary with decryption result (nullable on error) + */ +- (nullable NSDictionary*)processForwardedFileDecryptRequestWithFileId:(NSString*)fileId + forwarderPeer:(NSString*)forwarderPeer; + +/** + * Get detailed file access status list with owner, shared, and forwarded information + * Returns comprehensive view of the file's access control chain in three-party trust model + * @param chatId Chat identifier + * @param filePath Path to the .prv file + * @return Result dictionary with owner_info, shared_info, and forwarded_list (nullable on error) + */ +- (nullable NSDictionary*)getFileAccessStatusListWithChatId:(NSString*)chatId + filePath:(NSString*)filePath; + // ============================================================================= // UNIFIED MESSAGE PROCESSING (PRIMARY METHOD) // ============================================================================= diff --git a/Privitty.framework/Info.plist b/Privitty.framework/Info.plist index 434f3947d..1aa38094f 100644 --- a/Privitty.framework/Info.plist +++ b/Privitty.framework/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 0.0.3 + 0.0.4 CFBundleVersion 1 MinimumOSVersion diff --git a/Privitty.framework/Privitty b/Privitty.framework/Privitty index 9260d3dec..e73904752 100644 Binary files a/Privitty.framework/Privitty and b/Privitty.framework/Privitty differ diff --git a/Privitty.framework/_CodeSignature/CodeDirectory b/Privitty.framework/_CodeSignature/CodeDirectory index 8a21759bd..ee1433bfd 100644 Binary files a/Privitty.framework/_CodeSignature/CodeDirectory and b/Privitty.framework/_CodeSignature/CodeDirectory differ diff --git a/Privitty.framework/_CodeSignature/CodeRequirements-1 b/Privitty.framework/_CodeSignature/CodeRequirements-1 index 9057f8be5..dec6abb8a 100644 Binary files a/Privitty.framework/_CodeSignature/CodeRequirements-1 and b/Privitty.framework/_CodeSignature/CodeRequirements-1 differ diff --git a/Privitty.framework/_CodeSignature/CodeResources b/Privitty.framework/_CodeSignature/CodeResources index 8794f0466..def9a0785 100644 --- a/Privitty.framework/_CodeSignature/CodeResources +++ b/Privitty.framework/_CodeSignature/CodeResources @@ -6,11 +6,11 @@ Headers/Privitty.h - XOZiDTCe+NAWBhmcV7UHd7cQLlA= + 7XJwNRZwtCUxomO8O7IL7fBgT74= Info.plist - gVvxK6BoiqDGhhpTsPceMUMZ6Fg= + g3lk23mWzYzi7ThM/ASFGdIBWjw= Modules/module.modulemap @@ -23,11 +23,11 @@ hash - XOZiDTCe+NAWBhmcV7UHd7cQLlA= + 7XJwNRZwtCUxomO8O7IL7fBgT74= hash2 - lOUboneq2t71vqTNSqqXPH0sq0AwaZ1Nj9DQ6cW0Di8= + Ctj7MZKa+vxsHYPazciSO4F29ZdWOQJWl4BdECBZvS0= Modules/module.modulemap diff --git a/Privitty.framework/_CodeSignature/CodeSignature b/Privitty.framework/_CodeSignature/CodeSignature index 3b5a3bb69..1907fcfe2 100644 Binary files a/Privitty.framework/_CodeSignature/CodeSignature and b/Privitty.framework/_CodeSignature/CodeSignature differ diff --git a/deltachat-ios.xcodeproj/project.pbxproj b/deltachat-ios.xcodeproj/project.pbxproj index 8af8fde17..f9a3207c3 100644 --- a/deltachat-ios.xcodeproj/project.pbxproj +++ b/deltachat-ios.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ 2F11E3E62E9FABD900CA4BB4 /* PrvContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F11E3E52E9FABD900CA4BB4 /* PrvContext.swift */; }; 2F2A09F22EC24FCC00A37097 /* Privitty.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2F437DF72E94F68900297EED /* Privitty.framework */; }; 2F2A09F32EC24FCC00A37097 /* Privitty.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 2F437DF72E94F68900297EED /* Privitty.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 2FB219D92EF167CB00A7E8A8 /* FileAccessControlViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB219D82EF167CB00A7E8A8 /* FileAccessControlViewController.swift */; }; + 2FB219DC2EF1682500A7E8A8 /* FileAttachmentOptionsBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB219DB2EF1682500A7E8A8 /* FileAttachmentOptionsBottomSheet.swift */; }; + 2FB219DD2EF1682500A7E8A8 /* FileAccessRequestBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB219DA2EF1682500A7E8A8 /* FileAccessRequestBottomSheet.swift */; }; 3008CB7224F93EB900E6A617 /* AudioMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3008CB7124F93EB900E6A617 /* AudioMessageCell.swift */; }; 3008CB7424F9436C00E6A617 /* AudioPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3008CB7324F9436C00E6A617 /* AudioPlayerView.swift */; }; 3008CB7624F95B6D00E6A617 /* AudioController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3008CB7524F95B6D00E6A617 /* AudioController.swift */; }; @@ -333,6 +336,9 @@ 21D6C9392606190600D0755A /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 2F11E3E52E9FABD900CA4BB4 /* PrvContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrvContext.swift; sourceTree = ""; }; 2F437DF72E94F68900297EED /* Privitty.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Privitty.framework; sourceTree = ""; }; + 2FB219D82EF167CB00A7E8A8 /* FileAccessControlViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAccessControlViewController.swift; sourceTree = ""; }; + 2FB219DA2EF1682500A7E8A8 /* FileAccessRequestBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAccessRequestBottomSheet.swift; sourceTree = ""; }; + 2FB219DB2EF1682500A7E8A8 /* FileAttachmentOptionsBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAttachmentOptionsBottomSheet.swift; sourceTree = ""; }; 3008CB7124F93EB900E6A617 /* AudioMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessageCell.swift; sourceTree = ""; }; 3008CB7324F9436C00E6A617 /* AudioPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerView.swift; sourceTree = ""; }; 3008CB7524F95B6D00E6A617 /* AudioController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioController.swift; sourceTree = ""; }; @@ -699,8 +705,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 643B44782E0C2BD900AEE026 /* DcTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = DcTests; sourceTree = ""; }; @@ -1152,6 +1156,9 @@ 30DED712292EE4280040835D /* LogViewController.swift */, 0DACEEFB2EE47A7400043D27 /* ContentDetailsViewController.swift */, 0DACEF662EE4A7C600043D27 /* ContentDetailsViewController.swift */, + 2FB219D82EF167CB00A7E8A8 /* FileAccessControlViewController.swift */, + 2FB219DA2EF1682500A7E8A8 /* FileAccessRequestBottomSheet.swift */, + 2FB219DB2EF1682500A7E8A8 /* FileAttachmentOptionsBottomSheet.swift */, ); path = Controller; sourceTree = ""; @@ -1829,6 +1836,8 @@ B259D64329B771D5008FB706 /* BackupTransferViewController.swift in Sources */, AE8519EA2272FDCA00ED86F0 /* DeviceContactsHandler.swift in Sources */, 302E592426A5CF4800DD4F58 /* ConnectivityViewController.swift in Sources */, + 2FB219DC2EF1682500A7E8A8 /* FileAttachmentOptionsBottomSheet.swift in Sources */, + 2FB219DD2EF1682500A7E8A8 /* FileAccessRequestBottomSheet.swift in Sources */, 78ED838321D5379000243125 /* TextFieldCell.swift in Sources */, 305702A124C6453700D84EFC /* TypeAlias.swift in Sources */, AE19887523EB264000B4CD5F /* HelpViewController.swift in Sources */, @@ -1872,6 +1881,7 @@ D8C1B0DD2CE7421C00C233A7 /* ShareProxyViewController.swift in Sources */, AE76E5EE242BF2EA003CF461 /* WelcomeViewController.swift in Sources */, 3052C60A253F082E007D13EA /* MessageLabelDelegate.swift in Sources */, + 2FB219D92EF167CB00A7E8A8 /* FileAccessControlViewController.swift in Sources */, AE0AA9562478191900D42A7F /* GridCollectionViewFlowLayout.swift in Sources */, 303492A5257546B400A523D0 /* DraftPreview.swift in Sources */, 305961D02346125100C80F33 /* NSAttributedString+Extensions.swift in Sources */, diff --git a/deltachat-ios/AppDelegate.swift b/deltachat-ios/AppDelegate.swift index c056342c6..b5ae80891 100644 --- a/deltachat-ios/AppDelegate.swift +++ b/deltachat-ios/AppDelegate.swift @@ -652,20 +652,74 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Process the Privitty message let result = PrvContext.shared.processIncomingMessage(chatId: chatId, pdu: messageText, direction: "incoming") + logger.debug("Privitty: Result: \(result)") + if result.success { logger.info("Privitty: Message processed successfully") - // Check if response has PDU to send back - if let data = result.data, - let dataData = data["data"] as? [String: Any], - let pdu = dataData["pdu"] as? String { - logger.debug("Privitty: PDU size: \(pdu.count)") - - // Send the PDU message back to the chat - let responseMsg = dcContext.newMessage(viewType: DC_MSG_TEXT) - responseMsg.text = pdu - dcContext.sendMessage(chatId: chatId, message: responseMsg) - logger.info("Privitty: Response message sent to chat \(chatId)") + // Parse response structure: data.data.pdu and data.data.chat_id (matches Android line 206) + if let data = result.data { + logger.debug("Privitty: Response data exists, keys: \(Array(data.keys))") + + // Check if response has "data" key (matches Android: responseJson.has("data")) + if let dataDataJson = data["data"] as? [String: Any] { + logger.debug("Privitty: Found data.data, keys: \(Array(dataDataJson.keys))") + + // Check if dataDataJson has "data" key (matches Android: dataDataJson.has("data")) + // Try data.data.data first (Android structure), then fall back to data.data (iOS structure) + var dataJson: [String: Any]? + + if let nestedData = dataDataJson["data"] as? [String: Any] { + // Triple nested: data.data.data (Android structure) + dataJson = nestedData + logger.debug("Privitty: Found data.data.data (triple nested - Android structure)") + } else if dataDataJson["pdu"] != nil { + // Double nested: data.data (iOS structure - pdu and chat_id are directly here) + dataJson = dataDataJson + logger.debug("Privitty: Using data.data directly (double nested - iOS structure)") + } + + if let dataJson = dataJson { + logger.debug("Privitty: Processing dataJson, keys: \(Array(dataJson.keys))") + + // Check if dataJson has "pdu" key (matches Android: dataJson.has("pdu")) + if let pdu = dataJson["pdu"] as? String { + logger.debug("Privitty: Found PDU, size: \(pdu.count)") + + // Extract chat_id from response (matches Android: dataJson.has("chat_id")) + // Note: chat_id might be Int or String, handle both cases + var puntChatId: Int? + if let chatIdInt = dataJson["chat_id"] as? Int { + puntChatId = chatIdInt + } else if let chatIdStr = dataJson["chat_id"] as? String, + let chatIdInt = Int(chatIdStr) { + puntChatId = chatIdInt + } + + if let puntChatId = puntChatId { + // Use chat_id from response to send PDU (matches Android: puntChatId) + let msg = dcContext.newMessage(viewType: DC_MSG_TEXT) + msg.text = pdu + dcContext.sendMessage(chatId: puntChatId, message: msg) + logger.debug("Privitty: Privitty PDU sent - chatId: \(chatId), puntChatId: \(puntChatId)") + } else { + logger.error("Privitty: chat ID is missing or invalid for the requested PDU") + logger.error("Privitty: chat_id value: \(String(describing: dataJson["chat_id"])), type: \(type(of: dataJson["chat_id"]))") + } + } else { + logger.error("Privitty: PDU is missing in dataJson") + logger.error("Privitty: pdu value: \(String(describing: dataJson["pdu"])), type: \(type(of: dataJson["pdu"]))") + } + } else { + logger.error("Privitty: Could not extract dataJson from response") + logger.error("Privitty: dataDataJson does not have nested 'data' key and does not contain 'pdu' directly") + } + } else { + logger.error("Privitty: data.data is missing or not a dictionary") + logger.error("Privitty: data[\"data\"] value: \(String(describing: data["data"])), type: \(type(of: data["data"]))") + } + } else { + logger.error("Privitty: Response data is nil") } } else { logger.error("Privitty: Failed to process message: \(result.error ?? "Unknown error")") diff --git a/deltachat-ios/Base.lproj/LaunchScreen.storyboard b/deltachat-ios/Base.lproj/LaunchScreen.storyboard index cbb40c6cd..6d8863ecf 100644 --- a/deltachat-ios/Base.lproj/LaunchScreen.storyboard +++ b/deltachat-ios/Base.lproj/LaunchScreen.storyboard @@ -35,10 +35,10 @@ - + - - + + diff --git a/deltachat-ios/Chat/ChatViewController.swift b/deltachat-ios/Chat/ChatViewController.swift index 82bd20d45..d993feb4b 100644 --- a/deltachat-ios/Chat/ChatViewController.swift +++ b/deltachat-ios/Chat/ChatViewController.swift @@ -1683,100 +1683,30 @@ class ChatViewController: UITableViewController, UITableViewDropDelegate { } private func showFileEncryptionOptions() { - let alert = UIAlertController( - title: "File Encryption Options", - message: "Set access time and permissions for the file", - preferredStyle: .alert - ) - - // Add text field for expiry time - alert.addTextField { textField in - textField.placeholder = "Expiry time (seconds)" - textField.keyboardType = .numberPad - textField.text = self.attachmentExpiryTime - } - - // Create a custom view for checkboxes - let containerView = UIView(frame: CGRect(x: 0, y: 0, width: 270, height: 80)) - - // Allow Download Switch - let downloadLabel = UILabel(frame: CGRect(x: 16, y: 10, width: 180, height: 30)) - downloadLabel.text = "Allow Download" - downloadLabel.font = AppFont.regular(size: 14) - containerView.addSubview(downloadLabel) - - let downloadSwitch = UISwitch(frame: CGRect(x: 190, y: 10, width: 51, height: 31)) - downloadSwitch.isOn = self.attachmentAllowDownload - downloadSwitch.tag = 1 - containerView.addSubview(downloadSwitch) + let bottomSheet = FileAttachmentOptionsBottomSheet() - // Allow Forward Switch (disabled for now, matching Android) - let forwardLabel = UILabel(frame: CGRect(x: 16, y: 45, width: 180, height: 30)) - forwardLabel.text = "Allow Forward (Coming Soon)" - forwardLabel.font = AppFont.regular(size: 14) - forwardLabel.alpha = 0.5 - containerView.addSubview(forwardLabel) - - let forwardSwitch = UISwitch(frame: CGRect(x: 190, y: 45, width: 51, height: 31)) - forwardSwitch.isOn = false - forwardSwitch.isEnabled = false - forwardSwitch.alpha = 0.5 - forwardSwitch.tag = 2 - containerView.addSubview(forwardSwitch) - - // Add container as a custom view - let containerViewController = UIViewController() - containerViewController.view = containerView - containerViewController.preferredContentSize = containerView.frame.size - alert.setValue(containerViewController, forKey: "contentViewController") - - // OK Button - alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in + bottomSheet.onComplete = { [weak self] expirySeconds, allowDownload, allowForward in guard let self = self else { return } - guard let textField = alert.textFields?.first else { return } - - let expiryTime = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - // Validate input - guard !expiryTime.isEmpty else { - self.showErrorAlert(message: "Please enter expiry time in seconds") - return - } - - guard let seconds = Int(expiryTime), seconds > 0 else { - self.showErrorAlert(message: "Please enter a valid number greater than zero") - return - } // Store the values - self.attachmentExpiryTime = expiryTime - self.attachmentAllowDownload = downloadSwitch.isOn - self.attachmentAllowForward = forwardSwitch.isOn + self.attachmentExpiryTime = String(expirySeconds) + self.attachmentAllowDownload = allowDownload + self.attachmentAllowForward = allowForward - logger.info("File encryption options set: \(seconds)s, download: \(downloadSwitch.isOn), forward: \(forwardSwitch.isOn)") + logger.info("File encryption options set: \(expirySeconds)s, download: \(allowDownload), forward: \(allowForward)") // Now open the file picker self.mediaPicker?.showFilesLibrary() - }) - - // Cancel Button - alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel) { [weak self] _ in + } + + bottomSheet.onCancel = { [weak self] in // Clear values on cancel self?.attachmentExpiryTime = "" self?.attachmentAllowDownload = false self?.attachmentAllowForward = false - }) - - present(alert, animated: true) - } - - private func showErrorAlert(message: String) { - let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in - // Re-show the encryption options dialog - self?.showFileEncryptionOptions() - }) - present(alert, animated: true) + } + + present(bottomSheet, animated: true) } private func showVoiceMessageRecorder() { @@ -2899,17 +2829,26 @@ extension ChatViewController: BaseMessageCellDelegate { if handleSelection(indexPath: indexPath) { return } let message = dcContext.getMessage(id: messageIds[indexPath.row]) - - // Get file access status if available - let fileAccessStatus = fetchFileAccessStatus(for: message, ensureProfile: true) - - // Navigate to ContentDetailsViewController - let contentDetailsVC = ContentDetailsViewController( - dcContext: dcContext, - message: message, - fileAccessStatus: fileAccessStatus + + guard let filePath = message.file else { + logger.error("Bell icon tapped but no file path available") + return + } + + let fileName = message.filename ?? "Unknown" + + // Open File Access Control screen (full page, matches Android) + logger.info("Opening File Access Control for file: \(fileName)") + let fileAccessVC = FileAccessControlViewController( + chatId: chatId, + filePath: filePath, + fileName: fileName, + msgId: message.id, + dcContext: dcContext ) - navigationController?.pushViewController(contentDetailsVC, animated: true) + + // Push onto navigation stack for full-page presentation + navigationController?.pushViewController(fileAccessVC, animated: true) } // MARK: - Privitty file handling @@ -2933,7 +2872,11 @@ extension ChatViewController: BaseMessageCellDelegate { let result = PrvContext.shared.getFileAccessStatus(chatId: String(message.chatId), filePath: filePath) if result.success { - if let data = result.data { + if var data = result.data { + // Check for pending forwarded requests (matches Android hasPendingForwardedRequests) + if message.isFromCurrentSender { + data.hasPendingForwardedRequests = hasPendingForwardedRequests(for: message) + } fileAccessStatusCache[message.id] = data return data } @@ -2943,13 +2886,102 @@ extension ChatViewController: BaseMessageCellDelegate { if let error = result.error { logger.warning("Privitty: File access status fetch failed - \(error)") } - if let data = result.data { + if var data = result.data { + // Check for pending forwarded requests even on error + if message.isFromCurrentSender { + data.hasPendingForwardedRequests = hasPendingForwardedRequests(for: message) + } fileAccessStatusCache[message.id] = data return data } return nil } } + + /// Check if there are any pending access requests in the forwarded chain + /// Matches Android's hasPendingForwardedRequests() method + private func hasPendingForwardedRequests(for message: DcMsg) -> Bool { + guard let filePath = message.file, filePath.lowercased().hasSuffix(".prv") else { + logger.debug("File Access: hasPendingForwardedRequests - not a .prv file") + return false + } + + guard message.isFromCurrentSender else { + logger.debug("File Access: hasPendingForwardedRequests - not outgoing message") + return false + } + + let chatIdString = String(message.chatId) + + logger.debug("File Access: ========================================") + logger.debug("File Access: Checking for pending forwarded requests") + logger.debug("File Access: ChatId: \(chatIdString)") + logger.debug("File Access: FilePath: \(filePath)") + logger.debug("File Access: ========================================") + + let result = PrvContext.shared.getFileAccessStatusList(chatId: chatIdString, filePath: filePath) + + guard result.success, let data = result.data else { + if let error = result.error { + logger.error("File Access: Failed to get status list - \(error)") + } + return false + } + + // Parse the file data + guard let fileData = data["file"] as? [String: Any] else { + logger.error("File Access: No file data in response") + return false + } + + logger.debug("File Access: Response received - checking for pending requests") + + var pendingCount = 0 + + // Check shared_info for pending requests (only WAITING_OWNER_ACTION) + if let sharedInfo = fileData["shared_info"] as? [String: Any] { + let status = (sharedInfo["status"] as? String ?? "").lowercased() + let contactName = sharedInfo["contact_name"] as? String ?? "unknown" + let contactId = sharedInfo["contact_id"] as? String ?? "unknown" + + logger.debug("File Access: Shared_info - User: \(contactName) (ID: \(contactId)), Status: \(status)") + + // Only check for WAITING_OWNER_ACTION (matches Android) + if status == "waiting_owner_action" { + logger.info("File Access: ✓✓✓ FOUND PENDING REQUEST in shared_info - User: \(contactName), Status: \(status)") + pendingCount += 1 + } + } else { + logger.debug("File Access: No shared_info in response") + } + + // Check forwarded_list for pending requests (only WAITING_OWNER_ACTION) + if let forwardedList = fileData["forwarded_list"] as? [[String: Any]], !forwardedList.isEmpty { + logger.debug("File Access: Checking forwarded_list with \(forwardedList.count) users") + + for (index, forwardedInfo) in forwardedList.enumerated() { + let status = (forwardedInfo["status"] as? String ?? "").lowercased() + let contactName = forwardedInfo["contact_name"] as? String ?? "unknown" + let contactId = forwardedInfo["contact_id"] as? String ?? "unknown" + + logger.debug("File Access: Forwarded[\(index)] - User: \(contactName) (ID: \(contactId)), Status: \(status)") + + // Only check for WAITING_OWNER_ACTION (matches Android) + if status == "waiting_owner_action" { + logger.info("File Access: ✓✓✓ FOUND PENDING REQUEST in forwarded_list[\(index)] - User: \(contactName), Status: \(status)") + pendingCount += 1 + } + } + } else { + logger.debug("File Access: No forwarded_list or empty forwarded_list") + } + + logger.debug("File Access: ========================================") + logger.debug("File Access: Total pending requests found: \(pendingCount)") + logger.debug("File Access: ========================================") + + return pendingCount > 0 + } private func handleFileTapped(message: DcMsg) { if message.downloadState != DC_DOWNLOAD_DONE { @@ -2982,6 +3014,119 @@ extension ChatViewController: BaseMessageCellDelegate { return } + // Check if this is a forwarded message (matches Android: !isOutgoing && messageRecord.isForwarded()) + if !message.isFromCurrentSender && message.isForwarded && encryptedFilePath.hasSuffix(".prv") { + logger.info("Privitty: Forwarded file detected - checking if forward access request is needed") + + // Get file access status first + let statusData = fetchFileAccessStatus(for: message, ensureProfile: false) + + var shouldSendForwardRequest = false + + if let statusData = statusData { + switch statusData.status { + case .notFound: + // File not registered in core yet, should send forward access request + shouldSendForwardRequest = true + logger.warning("File access status is NOT_FOUND - file may not be registered in core yet, but will attempt forward access request") + case .requested, .waitingOwnerAction: + // Access request already pending + presentAlert(title: "Request Pending", message: "Access request already pending") + return + default: + // Other statuses - don't send forward request + shouldSendForwardRequest = false + } + } else { + // No status data - should send forward access request + shouldSendForwardRequest = true + } + + // Send forward access request if needed (matches Android prvInitForwardAccessRequest) + if shouldSendForwardRequest { + guard PrvContext.shared.isInitialized() else { + logger.error("Privitty not initialized, cannot send forward access request") + presentAlert(title: "Error", message: "Privitty not initialized. Please restart the app.") + return + } + + let isProtected = PrvContext.shared.isChatProtected(chatId: chatIdString) + guard isProtected else { + logger.error("Chat is not Privitty protected, cannot send forward access request") + presentAlert(title: "Error", message: "Chat is not Privitty protected") + return + } + + // Get absolute file path + let fileURL = URL(fileURLWithPath: encryptedFilePath) + let absoluteFilePath = fileURL.path + + logger.info("Calling prvInitForwardAccessRequest - chatId: \(chatIdString)") + logger.debug("File path: \(absoluteFilePath)") + logger.debug("Message ID: \(message.id), Chat ID: \(message.chatId)") + logger.debug("Is forwarded: \(message.isForwarded)") + + // Call processInitForwardAccessRequest (matches Android prvInitForwardAccessRequest) + let result = PrvContext.shared.processInitForwardAccessRequest(chatId: chatIdString, filePath: absoluteFilePath) + + if result.success, let pdu = result.pdu { + logger.info("Forward access request created successfully, sending PDU") + + // Send the PDU as a text message (matches Android) + let requestMsg = dcContext.newMessage(viewType: DC_MSG_TEXT) + requestMsg.text = pdu + dcContext.sendMessage(chatId: message.chatId, message: requestMsg) + logger.info("Forward access request PDU sent to chat \(message.chatId)") + + // Show success message + if let messageText = result.message { + presentAlert(title: "Request Sent", message: messageText) + } else { + presentAlert(title: "Request Sent", message: "Access request has been sent.") + } + + // Refresh the file access status cache + fileAccessStatusCache.removeValue(forKey: message.id) + + // Reload the message row to update UI + if let row = messageIds.firstIndex(of: message.id) { + let indexPath = IndexPath(row: row, section: 0) + tableView.reloadRows(at: [indexPath], with: .automatic) + } + + // Don't proceed with file opening yet - wait for access to be granted + return + } else { + let errorMessage = result.error ?? result.message ?? "Failed to create forward access request" + logger.error("Forward access request failed: \(errorMessage)") + + // Try with original file path if absolute path failed (matches Android retry logic) + if absoluteFilePath != encryptedFilePath { + logger.debug("Retrying with original file path format") + let retryResult = PrvContext.shared.processInitForwardAccessRequest(chatId: chatIdString, filePath: encryptedFilePath) + + if retryResult.success, let pdu = retryResult.pdu { + let requestMsg = dcContext.newMessage(viewType: DC_MSG_TEXT) + requestMsg.text = pdu + dcContext.sendMessage(chatId: message.chatId, message: requestMsg) + logger.info("Forward access request sent with original path") + + fileAccessStatusCache.removeValue(forKey: message.id) + if let row = messageIds.firstIndex(of: message.id) { + let indexPath = IndexPath(row: row, section: 0) + tableView.reloadRows(at: [indexPath], with: .automatic) + } + return + } + } + + presentAlert(title: "Request Failed", message: errorMessage) + return + } + } + } + + // Continue with normal file access status check let statusData = fetchFileAccessStatus(for: message, ensureProfile: false) if !message.isFromCurrentSender { @@ -3026,7 +3171,19 @@ extension ChatViewController: BaseMessageCellDelegate { fileAccessStatusCache.removeValue(forKey: message.id) - let result = PrvContext.shared.requestFileDecryption(prvFile: encryptedFilePath, chatId: chatIdString) + // Check if message is forwarded and call appropriate decryption method (matches Android line 1730-1734) + let isForwarded = message.isForwarded + logger.debug("Privitty: Decrypting file - isForwarded: \(isForwarded)") + + let result: (success: Bool, data: [String: Any]?, error: String?) + if isForwarded { + // For forwarded files, use processForwardedFileDecryptRequest + result = PrvContext.shared.requestForwardedFileDecryption(fileId: encryptedFilePath, forwarderPeer: chatIdString) + } else { + // For direct files, use regular decryption + result = PrvContext.shared.requestFileDecryption(prvFile: encryptedFilePath, chatId: chatIdString) + } + guard result.success, let data = result.data, let decryptedPath = data["file_path"] as? String else { @@ -3069,7 +3226,20 @@ extension ChatViewController: BaseMessageCellDelegate { } let chatIdString = String(message.chatId) - let result = PrvContext.shared.processInitAccessGrantRequest(chatId: chatIdString, filePath: filePath) + + // Check if message is forwarded and call appropriate method (matches Android line 1475-1480) + let isForwarded = message.isForwarded + logger.debug("Privitty: Requesting file access for chatId: \(chatIdString), file: \(filePath), isForwarded: \(isForwarded)") + + let result: (success: Bool, data: [String: Any]?, message: String?, error: String?) + if isForwarded { + // For forwarded files, use prvInitForwardAccessRequest + let forwardResult = PrvContext.shared.processInitForwardAccessRequest(chatId: chatIdString, filePath: filePath) + result = (forwardResult.success, forwardResult.pdu != nil ? ["pdu": forwardResult.pdu!] : nil, forwardResult.message, forwardResult.error) + } else { + // For direct files, use prvProcessInitAccessGrantRequest + result = PrvContext.shared.processInitAccessGrantRequest(chatId: chatIdString, filePath: filePath) + } if result.success { if let data = result.data, diff --git a/deltachat-ios/Chat/Views/FileView.swift b/deltachat-ios/Chat/Views/FileView.swift index f0f806be8..bca194ccb 100644 --- a/deltachat-ios/Chat/Views/FileView.swift +++ b/deltachat-ios/Chat/Views/FileView.swift @@ -75,15 +75,28 @@ public class FileView: UIView { // Bell Icon (for notifications/alerts) lazy var bellIconView: UIImageView = { let imageView = UIImageView() - imageView.contentMode = .scaleAspectFit - imageView.clipsToBounds = true + imageView.contentMode = .center // Center the icon in the larger tap area + imageView.clipsToBounds = false imageView.tintColor = .label // Adapts to theme (dark text on light bg) imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.image = UIImage(systemName: "bell.fill") // Filled bell icon + // Keep icon at 20pt size while container is 44x44 for larger tap area + let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .regular) + imageView.image = UIImage(systemName: "bell.fill", withConfiguration: config) imageView.isHidden = true // Hidden by default return imageView }() + // Notification Badge (red dot) + private lazy var notificationBadge: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .systemRed + view.layer.cornerRadius = 5 // Half of 10 to make it circular + view.clipsToBounds = true + view.isHidden = true // Hidden by default + return view + }() + // File metadata (name + size) private lazy var fileMetadataStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [fileTitle, fileSubtitle]) @@ -151,6 +164,9 @@ public class FileView: UIView { // Add bell icon to outer box (top-right corner) outerBoxView.addSubview(bellIconView) + + // Add notification badge to bell icon (top-right corner of bell) + bellIconView.addSubview(notificationBadge) // Add tap gesture to bell icon bellIconView.isUserInteractionEnabled = true @@ -181,10 +197,18 @@ public class FileView: UIView { fileTypeLabel.trailingAnchor.constraint(equalTo: bellIconView.leadingAnchor, constant: -8), // Bell icon at top-right corner, aligned with file type label + // Increased touch area: 44x44 (Apple's recommended minimum tap target) bellIconView.centerYAnchor.constraint(equalTo: fileTypeLabel.centerYAnchor), - bellIconView.trailingAnchor.constraint(equalTo: outerBoxView.trailingAnchor, constant: -12), - bellIconView.widthAnchor.constraint(equalToConstant: 20), - bellIconView.heightAnchor.constraint(equalToConstant: 20), + bellIconView.trailingAnchor.constraint(equalTo: outerBoxView.trailingAnchor, constant: -2), + bellIconView.widthAnchor.constraint(equalToConstant: 44), + bellIconView.heightAnchor.constraint(equalToConstant: 44), + + // Notification badge at top-right corner of the visible bell icon (20pt icon centered in 44pt container) + // Icon is centered, so offset by (44-20)/2 = 12pt from edges to position at visible icon + notificationBadge.topAnchor.constraint(equalTo: bellIconView.topAnchor, constant: 10), + notificationBadge.trailingAnchor.constraint(equalTo: bellIconView.trailingAnchor, constant: -10), + notificationBadge.widthAnchor.constraint(equalToConstant: 10), + notificationBadge.heightAnchor.constraint(equalToConstant: 10), // Inner box below file type label innerBoxView.topAnchor.constraint(equalTo: fileTypeLabel.bottomAnchor, constant: 6), @@ -265,8 +289,28 @@ public class FileView: UIView { let isPrvFile = filename.hasSuffix(".prv") fileSubtitle.text = isPrvFile ? "\(sizeText) PRV" : sizeText - // Show bell icon for all file messages - bellIconView.isHidden = false + // Bell icon visibility logic (matches Android DocumentView.java) + // Bell icon: ALWAYS visible for outgoing .prv files (owner can manage access) + // Red badge: Only shows when status is WAITING_OWNER_ACTION (matches Android) + let isOutgoing = message.isFromCurrentSender + var hasPendingRequests = false + + if let status = status { + // Check for WAITING_OWNER_ACTION in direct shared_info + let isWaitingOwnerAction = (status.status == .waitingOwnerAction) + + // Check for WAITING_OWNER_ACTION in forwarded chain + let hasPendingForwarded = status.hasPendingForwardedRequests + + // Show red dot if WAITING_OWNER_ACTION in either shared or forwarded + hasPendingRequests = isWaitingOwnerAction || hasPendingForwarded + } + + // Bell icon: Always visible for outgoing .prv files (matches Android) + bellIconView.isHidden = !isOutgoing + + // 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 { @@ -481,6 +525,12 @@ public class FileView: UIView { accessUntilLabel.text = nil currentStatus = nil bellIconView.isHidden = true // Reset bell visibility + notificationBadge.isHidden = true // Reset badge visibility + } + + /// Set the notification badge visibility (red dot on bell icon) + public func setNotificationBadgeVisible(_ visible: Bool) { + notificationBadge.isHidden = !visible } // MARK: - Actions diff --git a/deltachat-ios/Controller/FileAccessControlViewController.swift b/deltachat-ios/Controller/FileAccessControlViewController.swift new file mode 100644 index 000000000..94c47dfeb --- /dev/null +++ b/deltachat-ios/Controller/FileAccessControlViewController.swift @@ -0,0 +1,473 @@ +import UIKit +import DcCore + +/// File Access Control View Controller - shows list of file access requestees +/// Equivalent to Android's FileAccessControlActivity.java +public class FileAccessControlViewController: UIViewController { + + // MARK: - Properties + + private let chatId: Int + private let filePath: String + private let fileName: String + private let msgId: Int + private let isPeer2Mode: Bool + private let dcContext: DcContext + + // Separate arrays for sections (matches Android) + private var sharedRequestees: [FileAccessRequestee] = [] + private var forwardedRequestees: [FileAccessRequestee] = [] + + 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" + } + } + } + + // MARK: - UI Components + + private lazy var titleLabel: 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 + }() + + private lazy var tableView: UITableView = { + let table = UITableView() + table.translatesAutoresizingMaskIntoConstraints = false + table.delegate = self + table.dataSource = self + table.register(FileAccessRequesteeCell.self, forCellReuseIdentifier: "FileAccessRequesteeCell") + table.rowHeight = UITableView.automaticDimension + table.estimatedRowHeight = 80 + return table + }() + + private lazy var emptyStateLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .body) + label.textColor = .secondaryLabel + label.text = "No access requests" + label.textAlignment = .center + label.isHidden = true + return label + }() + + // MARK: - Initialization + + public init(chatId: Int, filePath: String, fileName: String, msgId: Int, dcContext: DcContext, isPeer2Mode: Bool = false) { + self.chatId = chatId + self.filePath = filePath + self.fileName = fileName + self.msgId = msgId + self.dcContext = dcContext + self.isPeer2Mode = isPeer2Mode + super.init(nibName: nil, bundle: nil) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + setupUI() + loadAccessData() + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = .systemBackground + 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(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), + + tableView.topAnchor.constraint(equalTo: headerStackView.bottomAnchor, constant: 16), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + 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 + } + + // MARK: - Data Loading + + private func loadAccessData() { + logger.debug("FileAccessControl: Loading access data for chatId=\(chatId), filePath=\(filePath)") + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + // Call Privitty API to get file access status list + let result = PrvContext.shared.getFileAccessStatusList(chatId: String(self.chatId), filePath: self.filePath) + + DispatchQueue.main.async { + if result.success, let data = result.data { + logger.debug("FileAccessControl: Successfully loaded access data") + self.processFileAccessData(data) + } else { + logger.error("FileAccessControl: Failed to load access data: \(result.error ?? "Unknown error")") + self.showEmptyState() + } + } + } + } + + private func processFileAccessData(_ data: [String: Any]) { + // Parse the response similar to Android's FileAccessListResponse + guard let fileData = data["file"] as? [String: Any] else { + logger.error("FileAccessControl: No file data in response") + showEmptyState() + return + } + + var newSharedRequestees: [FileAccessRequestee] = [] + 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) + } + } + } else { + // Peer 1 mode: Show shared info in shared section + if let sharedInfo = fileData["shared_info"] as? [String: Any] { + if let requestee = FileAccessRequestee.fromSharedInfo(sharedInfo) { + newSharedRequestees.append(requestee) + } + } + } + + // Process forwarded list into forwarded section + if let forwardedList = fileData["forwarded_list"] as? [[String: Any]] { + for forwardedInfo in forwardedList { + if let requestee = FileAccessRequestee.fromForwardedInfo(forwardedInfo) { + newForwardedRequestees.append(requestee) + } + } + + // 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 + } + } + } + + self.sharedRequestees = newSharedRequestees + self.forwardedRequestees = newForwardedRequestees + + if newSharedRequestees.isEmpty && newForwardedRequestees.isEmpty { + showEmptyState() + } else { + emptyStateLabel.isHidden = true + tableView.reloadData() + } + } + + private func showEmptyState() { + emptyStateLabel.isHidden = false + tableView.isHidden = true + } + + // MARK: - Actions + + private func showFileAccessBottomSheet(for requestee: FileAccessRequestee) { + logger.debug("FileAccessControl: Showing bottom sheet for \(requestee.name)") + + let bottomSheet = FileAccessRequestBottomSheet( + requestee: requestee, + chatId: chatId, + filePath: filePath, + dcContext: dcContext + ) + + bottomSheet.onComplete = { [weak self] in + self?.loadAccessData() // Refresh the list + } + + present(bottomSheet, animated: true) + } +} + +// MARK: - UITableViewDataSource + +extension FileAccessControlViewController: UITableViewDataSource { + public func numberOfSections(in tableView: UITableView) -> Int { + // Show section only if it has data + var count = 0 + if !sharedRequestees.isEmpty { count += 1 } + if !forwardedRequestees.isEmpty { count += 1 } + return count + } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + let actualSection = getActualSection(for: section) + switch actualSection { + case .shared: + return sharedRequestees.count + case .forwarded: + return forwardedRequestees.count + } + } + + public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + let actualSection = getActualSection(for: section) + return actualSection.title + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "FileAccessRequesteeCell", for: indexPath) as! FileAccessRequesteeCell + let actualSection = getActualSection(for: indexPath.section) + let requestee: FileAccessRequestee + + switch actualSection { + case .shared: + requestee = sharedRequestees[indexPath.row] + case .forwarded: + requestee = forwardedRequestees[indexPath.row] + } + + cell.configure(with: requestee) + return cell + } + + /// Map display section index to actual section enum (handles empty sections) + private func getActualSection(for displaySection: Int) -> Section { + var currentDisplaySection = 0 + + if !sharedRequestees.isEmpty { + if currentDisplaySection == displaySection { + return .shared + } + currentDisplaySection += 1 + } + + if !forwardedRequestees.isEmpty { + if currentDisplaySection == displaySection { + return .forwarded + } + } + + return .shared // Fallback + } +} + +// MARK: - UITableViewDelegate + +extension FileAccessControlViewController: UITableViewDelegate { + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let actualSection = getActualSection(for: indexPath.section) + let requestee: FileAccessRequestee + + switch actualSection { + case .shared: + requestee = sharedRequestees[indexPath.row] + case .forwarded: + requestee = forwardedRequestees[indexPath.row] + } + + // Only show access control form if status is WAITING_OWNER_ACTION (matches Android) + let status = requestee.status.lowercased() + if status == "waiting_owner_action" { + showFileAccessBottomSheet(for: requestee) + } else { + logger.debug("FileAccessControl: Status is \(requestee.status), not showing access control form") + // Could optionally show an alert here explaining why they can't take action + } + } +} + +// MARK: - FileAccessRequestee Model + +public struct FileAccessRequestee { + public let contactId: String + public let name: String + public let email: String? + public let expiryTime: Double? + public let status: String + public let isForwarded: Bool + public let allowDownload: Bool + public let allowForward: Bool + + public static func fromOwnerInfo(_ dict: [String: Any]) -> FileAccessRequestee? { + guard let contactId = dict["contact_id"] as? String, + let contactName = dict["contact_name"] as? String else { + return nil + } + + return FileAccessRequestee( + contactId: contactId, + name: contactName, + email: dict["contact_email"] as? String, + expiryTime: dict["expiry_time"] as? Double, + status: dict["status"] as? String ?? "unknown", + isForwarded: false, + allowDownload: dict["allow_download"] as? Bool ?? false, + allowForward: dict["allow_forward"] as? Bool ?? false + ) + } + + public static func fromSharedInfo(_ dict: [String: Any]) -> FileAccessRequestee? { + guard let contactId = dict["contact_id"] as? String, + let contactName = dict["contact_name"] as? String else { + return nil + } + + return FileAccessRequestee( + contactId: contactId, + name: contactName, + email: dict["contact_email"] as? String, + expiryTime: dict["expiry_time"] as? Double, + status: dict["status"] as? String ?? "active", + isForwarded: false, + allowDownload: dict["allow_download"] as? Bool ?? true, + allowForward: dict["allow_forward"] as? Bool ?? true + ) + } + + public static func fromForwardedInfo(_ dict: [String: Any]) -> FileAccessRequestee? { + guard let contactId = dict["contact_id"] as? String, + let contactName = dict["contact_name"] as? String else { + return nil + } + + return FileAccessRequestee( + contactId: contactId, + name: contactName, + email: dict["contact_email"] as? String, + expiryTime: dict["expiry_time"] as? Double, + status: dict["status"] as? String ?? "requested", + isForwarded: true, + allowDownload: dict["allow_download"] as? Bool ?? false, + allowForward: dict["allow_forward"] as? Bool ?? false + ) + } +} + +// MARK: - FileAccessRequesteeCell + +class FileAccessRequesteeCell: UITableViewCell { + + private lazy var nameLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .body) + return label + }() + + private lazy var emailLabel: 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 + }() + + 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 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + let stackView = UIStackView(arrangedSubviews: [nameLabel, emailLabel, statusLabel]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 4 + + contentView.addSubview(stackView) + + 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) + ]) + } + + func configure(with requestee: FileAccessRequestee) { + nameLabel.text = requestee.name + emailLabel.text = requestee.email ?? "" + emailLabel.isHidden = requestee.email == nil || requestee.email!.isEmpty + + // Status text + var statusText = requestee.status.capitalized + if requestee.isForwarded { + statusText += " (Forwarded)" + } + statusLabel.text = statusText + } +} + diff --git a/deltachat-ios/Controller/FileAccessRequestBottomSheet.swift b/deltachat-ios/Controller/FileAccessRequestBottomSheet.swift new file mode 100644 index 000000000..2eb2830b6 --- /dev/null +++ b/deltachat-ios/Controller/FileAccessRequestBottomSheet.swift @@ -0,0 +1,434 @@ +import UIKit +import DcCore + +/// Bottom sheet for file access requests - allows user to accept or deny access +/// Equivalent to Android's FileAccessRequestBottomSheetFragment.java +public class FileAccessRequestBottomSheet: UIViewController { + + // MARK: - Properties + + private let requestee: FileAccessRequestee + private let chatId: Int + private let filePath: String + private let dcContext: DcContext + + var onComplete: (() -> Void)? + + // Selected values + private var selectedSeconds: Int = 86400 // Default: 24 hours + private var allowDownload: Bool = false // Default: OFF + private var allowForward: Bool = false // Default: OFF + + // MARK: - UI Components + + private lazy var containerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .systemBackground + view.layer.cornerRadius = 16 + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + return view + }() + + private lazy var handleView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .systemGray3 + view.layer.cornerRadius = 2.5 + return view + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .headline) + label.text = "File Access Request" + return label + }() + + private lazy var userNameLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .body) + label.textColor = .secondaryLabel + label.text = requestee.name + return label + }() + + private lazy var timeSelectorLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .subheadline) + label.text = "Access Until (Date & Time)" + return label + }() + + private lazy var datePicker: UIDatePicker = { + let picker = UIDatePicker() + picker.translatesAutoresizingMaskIntoConstraints = false + picker.datePickerMode = .dateAndTime + picker.preferredDatePickerStyle = .compact + picker.minimumDate = Date() // Can't select past dates + + // Set default to 24 hours from now + let defaultDate = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date() + picker.date = defaultDate + + picker.addTarget(self, action: #selector(datePickerChanged), for: .valueChanged) + return picker + }() + + private lazy var downloadToggle: UISwitch = { + let toggle = UISwitch() + toggle.translatesAutoresizingMaskIntoConstraints = false + toggle.isOn = false // Default: OFF + return toggle + }() + + private lazy var downloadLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .body) + label.text = "Allow Download" + return label + }() + + private lazy var forwardToggle: UISwitch = { + let toggle = UISwitch() + toggle.translatesAutoresizingMaskIntoConstraints = false + toggle.isOn = false // Default: OFF + return toggle + }() + + private lazy var forwardLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .body) + label.text = "Allow Forward" + return label + }() + + // Store stack views as properties for conditional layout + private var downloadStackView: UIStackView! + private var forwardStackView: UIStackView! + + private lazy var acceptButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("Accept", for: .normal) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .headline) + // #6750A4 = RGB(103, 80, 164) + button.backgroundColor = UIColor(red: 103/255.0, green: 80/255.0, blue: 164/255.0, alpha: 1.0) + button.setTitleColor(.white, for: .normal) + button.layer.cornerRadius = 8 + button.addTarget(self, action: #selector(acceptButtonTapped), for: .touchUpInside) + return button + }() + + private lazy var denyButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("Deny", for: .normal) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .headline) + button.backgroundColor = .systemGray4 // Default iOS color for secondary actions + button.setTitleColor(.label, for: .normal) + button.layer.cornerRadius = 8 + button.addTarget(self, action: #selector(denyButtonTapped), for: .touchUpInside) + return button + }() + + // MARK: - Initialization + + public init(requestee: FileAccessRequestee, chatId: Int, filePath: String, dcContext: DcContext) { + self.requestee = requestee + self.chatId = chatId + self.filePath = filePath + self.dcContext = dcContext + super.init(nibName: nil, bundle: nil) + + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = UIColor.black.withAlphaComponent(0.4) + + view.addSubview(containerView) + containerView.addSubview(handleView) + containerView.addSubview(titleLabel) + containerView.addSubview(userNameLabel) + containerView.addSubview(timeSelectorLabel) + containerView.addSubview(datePicker) + + // Download toggle row + downloadStackView = UIStackView(arrangedSubviews: [downloadLabel, downloadToggle]) + downloadStackView.translatesAutoresizingMaskIntoConstraints = false + downloadStackView.axis = .horizontal + downloadStackView.distribution = .equalSpacing + containerView.addSubview(downloadStackView) + + // Forward toggle row (hide for forwarded users - they can't forward again) + forwardStackView = UIStackView(arrangedSubviews: [forwardLabel, forwardToggle]) + forwardStackView.translatesAutoresizingMaskIntoConstraints = false + forwardStackView.axis = .horizontal + forwardStackView.distribution = .equalSpacing + forwardStackView.isHidden = requestee.isForwarded // Hide for forwarded users + containerView.addSubview(forwardStackView) + + // Button row + let buttonStackView = UIStackView(arrangedSubviews: [denyButton, acceptButton]) + buttonStackView.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.axis = .horizontal + buttonStackView.spacing = 12 + buttonStackView.distribution = .fillEqually + containerView.addSubview(buttonStackView) + + NSLayoutConstraint.activate([ + containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + handleView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 12), + handleView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + handleView.widthAnchor.constraint(equalToConstant: 40), + handleView.heightAnchor.constraint(equalToConstant: 5), + + titleLabel.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 20), + titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + userNameLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + userNameLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + userNameLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + timeSelectorLabel.topAnchor.constraint(equalTo: userNameLabel.bottomAnchor, constant: 24), + timeSelectorLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + timeSelectorLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + datePicker.topAnchor.constraint(equalTo: timeSelectorLabel.bottomAnchor, constant: 12), + datePicker.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + datePicker.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + downloadStackView.topAnchor.constraint(equalTo: datePicker.bottomAnchor, constant: 24), + downloadStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + downloadStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + forwardStackView.topAnchor.constraint(equalTo: downloadStackView.bottomAnchor, constant: 16), + forwardStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + forwardStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + // Button position depends on whether forward option is visible + buttonStackView.topAnchor.constraint( + equalTo: requestee.isForwarded ? downloadStackView.bottomAnchor : forwardStackView.bottomAnchor, + constant: 32 + ), + buttonStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + buttonStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + buttonStackView.bottomAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.bottomAnchor, constant: -20), + + acceptButton.heightAnchor.constraint(equalToConstant: 50), + denyButton.heightAnchor.constraint(equalToConstant: 50) + ]) + + // Tap to dismiss + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped)) + view.addGestureRecognizer(tapGesture) + } + + // MARK: - Actions + + @objc private func datePickerChanged() { + // Calculate seconds from now to selected date + let now = Date() + let selectedDate = datePicker.date + selectedSeconds = Int(selectedDate.timeIntervalSince(now)) + + // Ensure it's not negative (though minimum date should prevent this) + if selectedSeconds < 0 { + selectedSeconds = 3600 // Fallback to 1 hour + } + } + + @objc private func acceptButtonTapped() { + logger.info("FileAccessBottomSheet: Accept button tapped") + + // Get current values + allowDownload = downloadToggle.isOn + allowForward = forwardToggle.isOn + + // Recalculate seconds from now to selected date for accuracy + let now = Date() + let selectedDate = datePicker.date + selectedSeconds = Int(selectedDate.timeIntervalSince(now)) + + // Ensure it's positive (at least 1 minute) + if selectedSeconds < 60 { + selectedSeconds = 60 + logger.warning("FileAccessBottomSheet: Selected time was too soon, using 1 minute minimum") + } + + // Show loading indicator + acceptButton.isEnabled = false + denyButton.isEnabled = false + acceptButton.setTitle("Processing...", for: .normal) + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + let isRelayForwardRequest = self.requestee.isForwarded + let relayForwardContactId = self.requestee.contactId + + logger.debug("FileAccessBottomSheet: Calling prvInitRevertRelayForwardAccessAccept") + logger.debug(" chatId: \(self.chatId)") + logger.debug(" filePath: \(self.filePath)") + logger.debug(" contactId: \(relayForwardContactId)") + logger.debug(" seconds: \(self.selectedSeconds)") + logger.debug(" allowDownload: \(self.allowDownload)") + + // Call Privitty API + let result: (success: Bool, pdu: String?, message: String?, error: String?) + if isRelayForwardRequest { + // For forwarded requests, call prvInitRevertRelayForwardAccessAccept + result = PrvContext.shared.processInitRevertRelayForwardAccessAccept( + chatId: String(self.chatId), + filePath: self.filePath, + contactId: relayForwardContactId, + accessDuration: self.selectedSeconds, + allowDownload: self.allowDownload + ) + } else { + // For direct requests, call prvProcessInitAccessGrantAccept + result = PrvContext.shared.processInitAccessGrantAccept( + chatId: String(self.chatId), + filePath: self.filePath, + allowDownload: self.allowDownload, + allowForward: self.allowForward, + accessDuration: self.selectedSeconds + ) + } + + DispatchQueue.main.async { + if result.success, let pdu = result.pdu { + logger.info("FileAccessBottomSheet: Access granted successfully, sending PDU") + + // Send the PDU as a text message + let msg = self.dcContext.newMessage(viewType: DC_MSG_TEXT) + msg.text = pdu + self.dcContext.sendMessage(chatId: self.chatId, message: msg) + + // Show success and dismiss + self.showSuccessAndDismiss(message: result.message ?? "Access granted") + } else { + logger.error("FileAccessBottomSheet: Failed to grant access: \(result.error ?? "Unknown error")") + + // Re-enable buttons + self.acceptButton.isEnabled = true + self.denyButton.isEnabled = true + self.acceptButton.setTitle("Accept", for: .normal) + + // Show error + self.showError(message: result.error ?? "Failed to grant access") + } + } + } + } + + @objc private func denyButtonTapped() { + logger.info("FileAccessBottomSheet: Deny button tapped") + + // Show loading indicator + acceptButton.isEnabled = false + denyButton.isEnabled = false + denyButton.setTitle("Processing...", for: .normal) + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + let isRelayForwardRequest = self.requestee.isForwarded + let relayForwardContactId = self.requestee.contactId + let defaultReason = "Access denied by user" + + logger.debug("FileAccessBottomSheet: Calling prvInitRevertRelayForwardAccessDenied") + logger.debug(" chatId: \(self.chatId)") + logger.debug(" filePath: \(self.filePath)") + logger.debug(" contactId: \(relayForwardContactId)") + + // Call Privitty API + let result: (success: Bool, pdu: String?, message: String?, error: String?) + if isRelayForwardRequest { + // For forwarded requests, call prvInitRevertRelayForwardAccessDenied + result = PrvContext.shared.processInitRevertRelayForwardAccessDenied( + chatId: String(self.chatId), + filePath: self.filePath, + contactId: relayForwardContactId, + denialReason: defaultReason + ) + } else { + // For direct requests, call prvProcessInitAccessDenied + result = PrvContext.shared.processInitAccessDenied( + chatId: String(self.chatId), + filePath: self.filePath + ) + } + + DispatchQueue.main.async { + if result.success, let pdu = result.pdu { + logger.info("FileAccessBottomSheet: Access denied successfully, sending PDU") + + // Send the PDU as a text message + let msg = self.dcContext.newMessage(viewType: DC_MSG_TEXT) + msg.text = pdu + self.dcContext.sendMessage(chatId: self.chatId, message: msg) + + // Show success and dismiss + self.showSuccessAndDismiss(message: result.message ?? "Access denied") + } else { + logger.error("FileAccessBottomSheet: Failed to deny access: \(result.error ?? "Unknown error")") + + // Re-enable buttons + self.acceptButton.isEnabled = true + self.denyButton.isEnabled = true + self.denyButton.setTitle("Deny", for: .normal) + + // Show error + self.showError(message: result.error ?? "Failed to deny access") + } + } + } + } + + @objc private func backgroundTapped() { + dismiss(animated: true) + } + + // MARK: - Helpers + + private func showSuccessAndDismiss(message: String) { + let alert = UIAlertController(title: "Success", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in + self?.onComplete?() + self?.dismiss(animated: true) + }) + present(alert, animated: true) + } + + private func showError(message: String) { + let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } +} + diff --git a/deltachat-ios/Controller/FileAttachmentOptionsBottomSheet.swift b/deltachat-ios/Controller/FileAttachmentOptionsBottomSheet.swift new file mode 100644 index 000000000..7fea2483d --- /dev/null +++ b/deltachat-ios/Controller/FileAttachmentOptionsBottomSheet.swift @@ -0,0 +1,278 @@ +import UIKit +import DcCore + +/// Bottom sheet for setting file encryption options when attaching a file +public class FileAttachmentOptionsBottomSheet: UIViewController { + + // MARK: - Properties + + var onComplete: ((Int, Bool, Bool) -> Void)? // (expirySeconds, allowDownload, allowForward) + var onCancel: (() -> Void)? + + // Selected values (defaults) + private var expirySeconds: Int = 86400 // Default: 24 hours + private var allowDownload: Bool = false // Default: OFF + private var allowForward: Bool = false // Default: OFF + + // MARK: - UI Components + + private lazy var containerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .systemBackground + view.layer.cornerRadius = 16 + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + return view + }() + + private lazy var handleView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .systemGray3 + view.layer.cornerRadius = 2.5 + return view + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .headline) + label.text = "Attach File" + return label + }() + + private lazy var messageLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .subheadline) + label.textColor = .secondaryLabel + label.text = "Set access time and permissions" + label.numberOfLines = 0 + return label + }() + + private lazy var expiryLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .subheadline) + label.text = "Expires On (Date & Time)" + return label + }() + + private lazy var datePicker: UIDatePicker = { + let picker = UIDatePicker() + picker.translatesAutoresizingMaskIntoConstraints = false + picker.datePickerMode = .dateAndTime + picker.preferredDatePickerStyle = .compact + picker.minimumDate = Date() // Can't select past dates + + // Set default to 24 hours from now + let defaultDate = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date() + picker.date = defaultDate + + picker.addTarget(self, action: #selector(datePickerChanged), for: .valueChanged) + return picker + }() + + private lazy var downloadToggle: UISwitch = { + let toggle = UISwitch() + toggle.translatesAutoresizingMaskIntoConstraints = false + toggle.isOn = false // Default: OFF + return toggle + }() + + private lazy var downloadLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .body) + label.text = "Allow Download" + return label + }() + + private lazy var forwardToggle: UISwitch = { + let toggle = UISwitch() + toggle.translatesAutoresizingMaskIntoConstraints = false + toggle.isOn = false // Default: OFF + return toggle + }() + + private lazy var forwardLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .body) + label.text = "Allow Forward" + return label + }() + + private lazy var okButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("OK", for: .normal) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .headline) + // #6750A4 = RGB(103, 80, 164) + button.backgroundColor = UIColor(red: 103/255.0, green: 80/255.0, blue: 164/255.0, alpha: 1.0) + button.setTitleColor(.white, for: .normal) + button.layer.cornerRadius = 8 + button.addTarget(self, action: #selector(okButtonTapped), for: .touchUpInside) + return button + }() + + private lazy var cancelButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("Cancel", for: .normal) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .headline) + button.backgroundColor = .systemGray4 // Default iOS color for secondary actions + button.setTitleColor(.label, for: .normal) + button.layer.cornerRadius = 8 + button.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) + return button + }() + + // MARK: - Initialization + + public init() { + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = UIColor.black.withAlphaComponent(0.4) + + view.addSubview(containerView) + containerView.addSubview(handleView) + containerView.addSubview(titleLabel) + containerView.addSubview(messageLabel) + containerView.addSubview(expiryLabel) + containerView.addSubview(datePicker) + + // Download toggle row + let downloadStackView = UIStackView(arrangedSubviews: [downloadLabel, downloadToggle]) + downloadStackView.translatesAutoresizingMaskIntoConstraints = false + downloadStackView.axis = .horizontal + downloadStackView.distribution = .equalSpacing + containerView.addSubview(downloadStackView) + + // Forward toggle row + let forwardStackView = UIStackView(arrangedSubviews: [forwardLabel, forwardToggle]) + forwardStackView.translatesAutoresizingMaskIntoConstraints = false + forwardStackView.axis = .horizontal + forwardStackView.distribution = .equalSpacing + containerView.addSubview(forwardStackView) + + // Button row + let buttonStackView = UIStackView(arrangedSubviews: [cancelButton, okButton]) + buttonStackView.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.axis = .horizontal + buttonStackView.spacing = 12 + buttonStackView.distribution = .fillEqually + containerView.addSubview(buttonStackView) + + NSLayoutConstraint.activate([ + containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + handleView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 12), + handleView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + handleView.widthAnchor.constraint(equalToConstant: 40), + handleView.heightAnchor.constraint(equalToConstant: 5), + + titleLabel.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 20), + titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + messageLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + messageLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + expiryLabel.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 24), + expiryLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + expiryLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + datePicker.topAnchor.constraint(equalTo: expiryLabel.bottomAnchor, constant: 12), + datePicker.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + datePicker.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + downloadStackView.topAnchor.constraint(equalTo: datePicker.bottomAnchor, constant: 24), + downloadStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + downloadStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + forwardStackView.topAnchor.constraint(equalTo: downloadStackView.bottomAnchor, constant: 16), + forwardStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + forwardStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + + buttonStackView.topAnchor.constraint(equalTo: forwardStackView.bottomAnchor, constant: 32), + buttonStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20), + buttonStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20), + buttonStackView.bottomAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.bottomAnchor, constant: -20), + + okButton.heightAnchor.constraint(equalToConstant: 50), + cancelButton.heightAnchor.constraint(equalToConstant: 50) + ]) + + // Tap to dismiss + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped)) + view.addGestureRecognizer(tapGesture) + } + + // MARK: - Actions + + @objc private func datePickerChanged() { + // Calculate seconds from now to selected date + let now = Date() + let selectedDate = datePicker.date + expirySeconds = Int(selectedDate.timeIntervalSince(now)) + + // Ensure it's not negative + if expirySeconds < 0 { + expirySeconds = 3600 // Fallback to 1 hour + } + } + + @objc private func okButtonTapped() { + // Recalculate seconds from now to selected date for accuracy + let now = Date() + let selectedDate = datePicker.date + expirySeconds = Int(selectedDate.timeIntervalSince(now)) + + // Ensure it's positive (at least 1 minute) + if expirySeconds < 60 { + expirySeconds = 60 + } + + allowDownload = downloadToggle.isOn + allowForward = forwardToggle.isOn + + dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.onComplete?(self.expirySeconds, self.allowDownload, self.allowForward) + } + } + + @objc private func cancelButtonTapped() { + dismiss(animated: true) { [weak self] in + self?.onCancel?() + } + } + + @objc private func backgroundTapped() { + // Dismiss on background tap (same as cancel) + cancelButtonTapped() + } +} + diff --git a/deltachat-ios/Controller/PreviewController.swift b/deltachat-ios/Controller/PreviewController.swift index 7aa2b92d7..bfff888a7 100644 --- a/deltachat-ios/Controller/PreviewController.swift +++ b/deltachat-ios/Controller/PreviewController.swift @@ -31,7 +31,7 @@ class PreviewController: QLPreviewController { let watermarkImageView = UIImageView() watermarkImageView.image = UIImage(named: "privitty_logo_without_title") watermarkImageView.contentMode = .scaleAspectFit - watermarkImageView.alpha = 0.08 // 8% opacity as requested + watermarkImageView.alpha = 0.15 // Increased for better visibility watermarkImageView.translatesAutoresizingMaskIntoConstraints = false watermarkContainer.addSubview(watermarkImageView) diff --git a/deltachat-ios/DC/PrvContext.swift b/deltachat-ios/DC/PrvContext.swift index cba918878..124bc3f74 100644 --- a/deltachat-ios/DC/PrvContext.swift +++ b/deltachat-ios/DC/PrvContext.swift @@ -259,11 +259,13 @@ public class PrvContext { public let status: FileAccessStatus public let statusCode: Int? public let chatId: String? + public let contactId: String? public let fileName: String? public let expiryTime: TimeInterval? public let isDownloadAllowed: Bool? public let isForwardAllowed: Bool? public let accessDuration: TimeInterval? + public var hasPendingForwardedRequests: Bool = false init(dictionary: [String: Any]) { let statusString = (dictionary["status"] as? String ?? "").lowercased() @@ -278,6 +280,7 @@ public class PrvContext { } chatId = dictionary["chat_id"] as? String + contactId = dictionary["contact_id"] as? String fileName = dictionary["file_name"] as? String if let expiry = dictionary["expiry_time"] as? NSNumber { @@ -526,6 +529,7 @@ public class PrvContext { } /// Request file decryption + /// Request file decryption for a direct (non-forwarded) file public func requestFileDecryption(prvFile: String, chatId: String) -> (success: Bool, data: [String: Any]?, error: String?) { guard let core = getCore() else { @@ -565,6 +569,51 @@ public class PrvContext { logger.error("Unknown error decrypting file") return (false, nil, "Unknown error") } + + /// Request file decryption for a forwarded file (three-party trust model) + /// - Parameters: + /// - fileId: The encrypted file path + /// - forwarderPeer: The chat ID with the forwarder (as string) + /// - Returns: Tuple with success, data dictionary, and optional error + public func requestForwardedFileDecryption(fileId: String, + forwarderPeer: String) -> (success: Bool, data: [String: Any]?, error: String?) { + guard let core = getCore() else { + logger.error("Cannot decrypt forwarded file: Core not initialized") + return (false, nil, "Core not initialized") + } + + guard let currentUser = getCurrentUser() else { + logger.error("Cannot decrypt forwarded file: No user selected") + return (false, nil, "No user selected") + } + + logger.info("Requesting forwarded file decryption for user: \(currentUser)") + logger.debug("File ID: \(fileId)") + logger.debug("Forwarder Peer (Chat ID): \(forwarderPeer)") + + let result = core.processForwardedFileDecryptRequest(withFileId: fileId, forwarderPeer: forwarderPeer) + + guard let resultDict = result else { + logger.error("Failed to get forwarded file decryption response") + return (false, nil, "No response returned") + } + + if let success = resultDict["success"] as? Int, success == 1 { + logger.info("Forwarded file decrypted successfully") + let data = resultDict["data"] as? [String: Any] + return (true, data, nil) + } else if let successBool = resultDict["success"] as? Bool, successBool == true { + logger.info("Forwarded file decrypted successfully") + let data = resultDict["data"] as? [String: Any] + return (true, data, nil) + } else if let error = resultDict["error"] as? String { + logger.error("Failed to decrypt forwarded file: \(error)") + return (false, nil, error) + } + + logger.error("Unknown error decrypting forwarded file") + return (false, nil, "Unknown error") + } /// Retrieve file access status for a Privitty encrypted file public func getFileAccessStatus(chatId: String, @@ -703,6 +752,438 @@ public class PrvContext { logger.error("File access revoke failed: \(errorMessage)") return (false, nil, message, errorMessage) } + + /// Accept/grant access request from a recipient (owner accepts recipient's access request) + public func processInitAccessGrantAccept(chatId: String, + filePath: String, + allowDownload: Bool, + allowForward: Bool, + accessDuration: Int) -> (success: Bool, pdu: String?, message: String?, error: String?) { + guard let core = getCore() else { + logger.error("Cannot accept access request: Core not initialized") + return (false, nil, nil, "Core not initialized") + } + + guard let currentUser = getCurrentUser() else { + logger.error("Cannot accept access request: No user selected") + return (false, nil, nil, "No user selected") + } + + logger.info("Accepting access request for user: \(currentUser)") + logger.debug("File path: \(filePath)") + logger.debug("Chat ID: \(chatId)") + logger.debug("Allow download: \(allowDownload), Allow forward: \(allowForward), Duration: \(accessDuration)s") + + guard let result = core.processInitAccessGrantAccept( + withChatId: chatId, + filePath: filePath, + allowDownload: allowDownload, + allowForward: allowForward, + accessTime: accessDuration + ) else { + logger.error("Failed to get access grant accept response") + return (false, nil, nil, "No response returned") + } + + let message = result["message"] as? String + + let successValue: Bool + if let value = result["success"] as? Bool { + successValue = value + } else if let value = result["success"] as? NSNumber { + successValue = value.boolValue + } else if let value = result["success"] as? Int { + successValue = value != 0 + } else { + successValue = false + } + + if successValue { + let data = result["data"] as? [String: Any] + let pdu = data?["pdu"] as? String + return (true, pdu, message, nil) + } + + let errorMessage = (result["error"] as? String) ?? message ?? "Unknown error" + logger.error("Access grant accept failed: \(errorMessage)") + return (false, nil, message, errorMessage) + } + + /// Deny access request from a recipient (owner denies recipient's access request) + public func processInitAccessDenied(chatId: String, + filePath: String) -> (success: Bool, pdu: String?, message: String?, error: String?) { + guard let core = getCore() else { + logger.error("Cannot deny access request: Core not initialized") + return (false, nil, nil, "Core not initialized") + } + + guard let currentUser = getCurrentUser() else { + logger.error("Cannot deny access request: No user selected") + return (false, nil, nil, "No user selected") + } + + logger.info("Denying access request for user: \(currentUser)") + logger.debug("File path: \(filePath)") + logger.debug("Chat ID: \(chatId)") + + guard let result = core.processInitAccessDenied( + withChatId: chatId, + filePath: filePath + ) else { + logger.error("Failed to get access denied response") + return (false, nil, nil, "No response returned") + } + + let message = result["message"] as? String + + let successValue: Bool + if let value = result["success"] as? Bool { + successValue = value + } else if let value = result["success"] as? NSNumber { + successValue = value.boolValue + } else if let value = result["success"] as? Int { + successValue = value != 0 + } else { + successValue = false + } + + if successValue { + let data = result["data"] as? [String: Any] + let pdu = data?["pdu"] as? String + return (true, pdu, message, nil) + } + + let errorMessage = (result["error"] as? String) ?? message ?? "Unknown error" + logger.error("Access denied failed: \(errorMessage)") + return (false, nil, message, errorMessage) + } + + // MARK: - Forward Access Control (Three-party Trust Model) + + /// Initialize forward peer add request - Add a forwardee to forward file access + /// - Parameters: + /// - chatId: Chat identifier with the relay/owner + /// - forwardeeChatId: Chat identifier with the forwardee + /// - prvFile: Path to the .prv file to forward + /// - Returns: Tuple with success, PDU data, and optional error + public func processInitForwardPeerAddRequest(chatId: String, + forwardeeChatId: String, + prvFile: String) -> (success: Bool, pdu: String?, error: String?) { + guard let core = getCore() else { + logger.error("Cannot create forward peer add request: Core not initialized") + return (false, nil, "Core not initialized") + } + + guard let currentUser = getCurrentUser() else { + logger.error("Cannot create forward peer add request: No user selected") + return (false, nil, "No user selected") + } + + logger.info("Creating forward peer add request for user: \(currentUser)") + logger.debug("Source Chat ID: \(chatId), Target Chat ID: \(forwardeeChatId)") + logger.debug("File: \(prvFile)") + + guard let result = core.processInitForwardPeerAddRequest(withChatId: chatId, + forwardeeChatId: forwardeeChatId, + prvFile: prvFile) else { + logger.error("Failed to get forward peer add response") + return (false, nil, "No response returned") + } + + let successValue: Bool + if let value = result["success"] as? Bool { + successValue = value + } else if let value = result["success"] as? NSNumber { + successValue = value.boolValue + } else if let value = result["success"] as? Int { + successValue = value != 0 + } else { + successValue = false + } + + if successValue, let data = result["data"] as? [String: Any], let pdu = data["pdu"] as? String { + logger.info("Forward peer add request created successfully") + return (true, pdu, nil) + } else if let error = result["error"] as? String { + logger.error("Failed to create forward peer add request: \(error)") + return (false, nil, error) + } else if let message = result["message"] as? String { + logger.error("Forward peer add request failed: \(message)") + return (false, nil, message) + } + + logger.error("Unknown error creating forward peer add request") + return (false, nil, "Unknown error") + } + + /// Forwardee initiates forward access request - Request access to a forwarded file + /// - Parameters: + /// - chatId: Chat identifier with the relay + /// - filePath: Path to the .prv file + /// - Returns: Tuple with success, PDU data, optional message, and optional error + public func processInitForwardAccessRequest(chatId: String, + filePath: String) -> (success: Bool, pdu: String?, message: String?, error: String?) { + guard let core = getCore() else { + logger.error("Cannot create forward access request: Core not initialized") + return (false, nil, nil, "Core not initialized") + } + + guard let currentUser = getCurrentUser() else { + logger.error("Cannot create forward access request: No user selected") + return (false, nil, nil, "No user selected") + } + + logger.info("Creating forward access request for user: \(currentUser)") + logger.debug("Chat ID: \(chatId), File: \(filePath)") + + guard let result = core.processInitForwardAccessRequest(withChatId: chatId, + filePath: filePath) else { + logger.error("Failed to get forward access request response") + return (false, nil, nil, "No response returned") + } + + let message = result["message"] as? String + + let successValue: Bool + if let value = result["success"] as? Bool { + successValue = value + } else if let value = result["success"] as? NSNumber { + successValue = value.boolValue + } else if let value = result["success"] as? Int { + successValue = value != 0 + } else { + successValue = false + } + + if successValue, let data = result["data"] as? [String: Any], let pdu = data["pdu"] as? String { + logger.info("Forward access request created successfully") + return (true, pdu, message, nil) + } else if let error = result["error"] as? String { + logger.error("Failed to create forward access request: \(error)") + return (false, nil, message, error) + } + + logger.error("Unknown error creating forward access request") + return (false, nil, message, "Unknown error") + } + + /// Original owner accepts relay forward access request + /// - Parameters: + /// - chatId: Chat identifier with the relay + /// - filePath: Path to the .prv file + /// - contactId: ID of the forwarder/contact requesting access + /// - accessDuration: Duration in seconds for access validity (0 for unlimited) + /// - allowDownload: Whether to allow download of the file + /// - Returns: Tuple with success, PDU data, optional message, and optional error + public func processInitRevertRelayForwardAccessAccept(chatId: String, + filePath: String, + contactId: String, + accessDuration: Int, + allowDownload: Bool) -> (success: Bool, pdu: String?, message: String?, error: String?) { + guard let core = getCore() else { + logger.error("Cannot accept relay forward: Core not initialized") + return (false, nil, nil, "Core not initialized") + } + + guard let currentUser = getCurrentUser() else { + logger.error("Cannot accept relay forward: No user selected") + return (false, nil, nil, "No user selected") + } + + logger.info("Accepting relay forward access for user: \(currentUser)") + logger.debug("Chat ID: \(chatId), File: \(filePath)") + logger.debug("Contact ID: \(contactId), Duration: \(accessDuration)s, Allow Download: \(allowDownload)") + + guard let result = core.processInitRevertRelayForwardAccessAccept(withChatId: chatId, + filePath: filePath, + contactId: contactId, + accessDuration: accessDuration, + allowDownload: allowDownload) else { + logger.error("Failed to get relay forward accept response") + return (false, nil, nil, "No response returned") + } + + let message = result["message"] as? String + + let successValue: Bool + if let value = result["success"] as? Bool { + successValue = value + } else if let value = result["success"] as? NSNumber { + successValue = value.boolValue + } else if let value = result["success"] as? Int { + successValue = value != 0 + } else { + successValue = false + } + + if successValue, let data = result["data"] as? [String: Any], let pdu = data["pdu"] as? String { + logger.info("Relay forward access accepted successfully") + return (true, pdu, message, nil) + } else if let error = result["error"] as? String { + logger.error("Failed to accept relay forward: \(error)") + return (false, nil, message, error) + } + + logger.error("Unknown error accepting relay forward") + return (false, nil, message, "Unknown error") + } + + /// Original owner denies relay forward access request + /// - Parameters: + /// - chatId: Chat identifier with the relay + /// - filePath: Path to the .prv file + /// - contactId: ID of the forwarder/contact requesting access + /// - denialReason: Optional reason for denial + /// - Returns: Tuple with success, PDU data, optional message, and optional error + public func processInitRevertRelayForwardAccessDenied(chatId: String, + filePath: String, + contactId: String, + denialReason: String = "Access denied") -> (success: Bool, pdu: String?, message: String?, error: String?) { + guard let core = getCore() else { + logger.error("Cannot deny relay forward: Core not initialized") + return (false, nil, nil, "Core not initialized") + } + + guard let currentUser = getCurrentUser() else { + logger.error("Cannot deny relay forward: No user selected") + return (false, nil, nil, "No user selected") + } + + logger.info("Denying relay forward access for user: \(currentUser)") + logger.debug("Chat ID: \(chatId), File: \(filePath)") + logger.debug("Contact ID: \(contactId), Reason: \(denialReason)") + + guard let result = core.processInitRevertRelayForwardAccessDenied(withChatId: chatId, + filePath: filePath, + contactId: contactId, + denialReason: denialReason) else { + logger.error("Failed to get relay forward denial response") + return (false, nil, nil, "No response returned") + } + + let message = result["message"] as? String + + let successValue: Bool + if let value = result["success"] as? Bool { + successValue = value + } else if let value = result["success"] as? NSNumber { + successValue = value.boolValue + } else if let value = result["success"] as? Int { + successValue = value != 0 + } else { + successValue = false + } + + if successValue, let data = result["data"] as? [String: Any], let pdu = data["pdu"] as? String { + logger.info("Relay forward access denied successfully") + return (true, pdu, message, nil) + } else if let error = result["error"] as? String { + logger.error("Failed to deny relay forward: \(error)") + return (false, nil, message, error) + } + + logger.error("Unknown error denying relay forward") + return (false, nil, message, "Unknown error") + } + + /// Decrypt a forwarded file (file that was forwarded from another peer) + /// - Parameters: + /// - fileId: File identifier (the prv_file path) + /// - forwarderPeer: Peer who forwarded the file (the chat_id) + /// - Returns: Tuple with success, decrypted data, and optional error + public func processForwardedFileDecryptRequest(fileId: String, + forwarderPeer: String) -> (success: Bool, data: [String: Any]?, error: String?) { + guard let core = getCore() else { + logger.error("Cannot decrypt forwarded file: Core not initialized") + return (false, nil, "Core not initialized") + } + + guard let currentUser = getCurrentUser() else { + logger.error("Cannot decrypt forwarded file: No user selected") + return (false, nil, "No user selected") + } + + logger.info("Decrypting forwarded file for user: \(currentUser)") + logger.debug("File ID: \(fileId), Forwarder Peer: \(forwarderPeer)") + + guard let result = core.processForwardedFileDecryptRequest(withFileId: fileId, + forwarderPeer: forwarderPeer) else { + logger.error("Failed to get forwarded file decrypt response") + return (false, nil, "No response returned") + } + + let successValue: Bool + if let value = result["success"] as? Bool { + successValue = value + } else if let value = result["success"] as? NSNumber { + successValue = value.boolValue + } else if let value = result["success"] as? Int { + successValue = value != 0 + } else { + successValue = false + } + + if successValue { + logger.info("Forwarded file decrypted successfully") + let data = result["data"] as? [String: Any] + return (true, data, nil) + } else if let error = result["error"] as? String { + logger.error("Failed to decrypt forwarded file: \(error)") + return (false, nil, error) + } + + logger.error("Unknown error decrypting forwarded file") + return (false, nil, "Unknown error") + } + + /// Get detailed file access status list with owner, shared, and forwarded information + /// Returns comprehensive view of the file's access control chain in three-party trust model + /// - Parameters: + /// - chatId: Chat identifier + /// - filePath: Path to the .prv file + /// - Returns: Tuple with success, data dictionary (owner_info, shared_info, forwarded_list), and optional error + public func getFileAccessStatusList(chatId: String, + filePath: String) -> (success: Bool, data: [String: Any]?, error: String?) { + guard let core = getCore() else { + logger.error("Cannot get file access status list: Core not initialized") + return (false, nil, "Core not initialized") + } + + guard let currentUser = getCurrentUser() else { + logger.error("Cannot get file access status list: No user selected") + return (false, nil, "No user selected") + } + + logger.debug("Getting file access status list for user: \(currentUser)") + logger.debug("Chat ID: \(chatId), File: \(filePath)") + + guard let result = core.getFileAccessStatusList(withChatId: chatId, filePath: filePath) else { + logger.error("Failed to get file access status list response") + return (false, nil, "No response returned") + } + + let successValue: Bool + if let value = result["success"] as? Bool { + successValue = value + } else if let value = result["success"] as? NSNumber { + successValue = value.boolValue + } else if let value = result["success"] as? Int { + successValue = value != 0 + } else { + successValue = false + } + + if successValue { + let data = result["data"] as? [String: Any] + return (true, data, nil) + } else if let error = result["error"] as? String { + logger.error("Failed to get file access status list: \(error)") + return (false, nil, error) + } + + logger.error("Unknown error getting file access status list") + return (false, nil, "Unknown error") + } // MARK: - DEBUG: Database Export #if DEBUG diff --git a/deltachat-ios/Helper/RelayHelper.swift b/deltachat-ios/Helper/RelayHelper.swift index 9cb79a23f..a8fbd73b1 100644 --- a/deltachat-ios/Helper/RelayHelper.swift +++ b/deltachat-ios/Helper/RelayHelper.swift @@ -66,11 +66,203 @@ class RelayHelper { dcContext.forwardMessages(with: [curr.id], to: chatId) } } + finishRelaying() } else { - dcContext.forwardMessages(with: messageIds, to: chatId) + // Match Android flow: Check if forwarding Privitty files to unprotected chat + let targetChat = dcContext.getChat(chatId: chatId) + let targetChatIdString = String(chatId) + + // Check if we're forwarding Privitty files (.prv files) + var hasPrvFiles = false + if PrvContext.shared.isInitialized() { + for msgId in messageIds { + let msg = dcContext.getMessage(id: msgId) + if let filePath = msg.file, !filePath.isEmpty { + if filePath.hasSuffix(".prv") || (msg.filename?.hasSuffix(".prv") ?? false) { + hasPrvFiles = true + break + } + } + } + } + + // Check if target chat is Privitty protected + let isProtected = PrvContext.shared.isInitialized() && PrvContext.shared.isChatProtected(chatId: targetChatIdString) + + // If forwarding Privitty files to unprotected chat, initiate handshake first + if hasPrvFiles && !isProtected { + logger.info("Forwarding .prv files to unprotected chat - initiating handshake first") + + // Get peer information + var peerName = targetChat.name + var peerEmail = "" + + let contactIds = targetChat.getContactIds(dcContext) + if let firstContactId = contactIds.first { + let contact = dcContext.getContact(id: firstContactId) + peerEmail = contact.email + if peerName.isEmpty { + peerName = contact.authName.isEmpty ? contact.displayName : contact.authName + } + } + + if peerEmail.isEmpty { + peerEmail = peerName.isEmpty ? "" : peerName + } + + // Initiate handshake and forward (matches Android initiateHandshakeAndForward) + initiateHandshakeAndForward(targetChatId: chatId, targetChatIdString: targetChatIdString, peerName: peerName, peerEmail: peerEmail, messageIds: messageIds, dcContext: dcContext) + } else { + // Target chat is protected or no Privitty files - proceed with normal forwarding + // This matches Android's SendRelayedMessageUtil.immediatelyRelay() -> handleForwarding() + handleForwarding(messageIds: messageIds, targetChatId: chatId, dcContext: dcContext) + finishRelaying() + } } + } else { + finishRelaying() } - finishRelaying() + } + + /// Initiate handshake and forward (matches Android initiateHandshakeAndForward) + /// Flow: 1) Call prvInitPeerAddRequest(), 2) Send handshake PDU, 3) Wait for handshake, 4) Once protected call handleForwarding() + private func initiateHandshakeAndForward(targetChatId: Int, targetChatIdString: String, peerName: String, peerEmail: String, messageIds: [Int], dcContext: DcContext) { + guard PrvContext.shared.isInitialized() else { + logger.error("Privitty not initialized, cannot initiate handshake") + finishRelaying() + return + } + + logger.info("Initiating handshake for forwarding - Peer: \(peerName) <\(peerEmail)>") + + // Call prvInitPeerAddRequest (handshake) + let pdu = PrvContext.shared.createPeerAddRequest( + chatId: targetChatIdString, + peerName: peerName, + peerEmail: peerEmail.isEmpty ? nil : peerEmail, + peerId: nil + ) + + if pdu.success, let handshakePdu = pdu.pdu, !handshakePdu.isEmpty { + // Send handshake PDU + let handshakeMsg = dcContext.newMessage(viewType: DC_MSG_TEXT) + handshakeMsg.text = handshakePdu + dcContext.sendMessage(chatId: targetChatId, message: handshakeMsg) + logger.info("Handshake request sent for forwarding - chatId: \(targetChatId)") + + // Wait for handshake to complete (matches Android waitForHandshakeAndForward) + waitForHandshakeAndForward(targetChatId: targetChatId, targetChatIdString: targetChatIdString, messageIds: messageIds, dcContext: dcContext) + } else { + // Check if chat is already protected + let isProtected = PrvContext.shared.isChatProtected(chatId: targetChatIdString) + if isProtected { + logger.info("Chat is protected, proceeding with forwarding") + handleForwarding(messageIds: messageIds, targetChatId: targetChatId, dcContext: dcContext) + finishRelaying() + } else { + logger.error("Failed to initialize secure channel") + finishRelaying() + } + } + } + + /// Wait for handshake to complete (matches Android waitForHandshakeAndForward) + private func waitForHandshakeAndForward(targetChatId: Int, targetChatIdString: String, messageIds: [Int], dcContext: DcContext) { + let maxAttempts = 30 + var attemptCount = 0 + + Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in + guard let self = self else { + timer.invalidate() + return + } + + attemptCount += 1 + + let isProtected = PrvContext.shared.isChatProtected(chatId: targetChatIdString) + + if isProtected { + timer.invalidate() + logger.info("Handshake complete for forwarding - chatId: \(targetChatIdString) is now protected") + + // Once protected, call handleForwarding (matches Android SendRelayedMessageUtil.immediatelyRelay) + self.handleForwarding(messageIds: messageIds, targetChatId: targetChatId, dcContext: dcContext) + self.finishRelaying() + } else if attemptCount >= maxAttempts { + timer.invalidate() + logger.error("Handshake timeout for forwarding - chatId: \(targetChatIdString)") + self.finishRelaying() + } + } + } + + /// Handle forwarding (matches Android SendRelayedMessageUtil.handleForwarding) + /// Flow: 1) Call prvInitForwardPeerAddRequest(), 2) Send forward peer add request PDU, 3) Forward messages with forwardMsgs() + private func handleForwarding(messageIds: [Int], targetChatId: Int, dcContext: DcContext) { + guard let firstMsgId = messageIds.first else { return } + + let firstMsg = dcContext.getMessage(id: firstMsgId) + let sourceChatId = firstMsg.chatId + + // Only process if source and target are different + guard sourceChatId != targetChatId else { + // Just forward messages if same chat + dcContext.forwardMessages(with: messageIds, to: targetChatId) + return + } + + let sourceChat = dcContext.getChat(chatId: sourceChatId) + let targetChat = dcContext.getChat(chatId: targetChatId) + + // Check if both chats are protected (Delta Chat protection) + guard sourceChat.isProtected && targetChat.isProtected else { + // Just forward messages if not both protected + dcContext.forwardMessages(with: messageIds, to: targetChatId) + return + } + + guard PrvContext.shared.isInitialized() else { + logger.error("Cannot process Privitty forward: Core not initialized") + dcContext.forwardMessages(with: messageIds, to: targetChatId) + return + } + + // Get file path from first message + guard let filePath = firstMsg.file, !filePath.isEmpty else { + logger.debug("No file to forward") + dcContext.forwardMessages(with: messageIds, to: targetChatId) + return + } + + logger.info("Processing Privitty forward") + logger.debug("Source Chat: \(sourceChatId), Target Chat: \(targetChatId)") + logger.debug("File: \(filePath)") + + let sourceChatIdString = String(sourceChatId) + let targetChatIdString = String(targetChatId) + + // Call prvInitForwardPeerAddRequest (matches Android) + logger.info("Calling prvInitForwardPeerAddRequest - sourceChatId: \(sourceChatIdString), targetChatId: \(targetChatIdString), fileName: \(filePath)") + let result = PrvContext.shared.processInitForwardPeerAddRequest( + chatId: sourceChatIdString, + forwardeeChatId: targetChatIdString, + prvFile: filePath + ) + + if result.success, let pdu = result.pdu { + logger.info("Forward peer add request created successfully") + + // Send forward peer add request PDU (matches Android) + let msg = dcContext.newMessage(viewType: DC_MSG_TEXT) + msg.text = pdu + dcContext.sendMessage(chatId: targetChatId, message: msg) + logger.info("Forward peer request sent - sourceChatId: \(sourceChatId), targetChatId: \(targetChatId)") + } else { + logger.error("Forward peer add request failed: \(result.error ?? "Unknown error")") + } + + // Forward messages (matches Android messageRepository.forwardMsgs) + dcContext.forwardMessages(with: messageIds, to: targetChatId) } func finishRelaying() {