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() {