From f99c474ca67adf9e849f61bbfed5ac80e99abbec Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 1 Sep 2026 23:16:15 +0100 Subject: [PATCH] core: send badge ZK-proofs to XFTP servers, add file expiry time (#7430) * core: send badge ZK-proofs to XFTP servers * update simplexmq, use keys and header from simplexmq * core: add file expiry time * refactor * remove posix module * rename migrations * bot api * update UI * import * show item from loaded information * ios: update core library * text * send entitlements in handshake * simplexmq * query plans * update simplexmq --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Views/Chat/ChatItem/CIFileView.swift | 19 +++-- .../Views/Chat/ChatItem/CIImageView.swift | 4 +- .../Views/Chat/ChatItem/CIVideoView.swift | 4 +- .../Views/Chat/ChatItem/CIVoiceView.swift | 4 +- .../Shared/Views/Chat/ChatItemInfoView.swift | 3 + apps/ios/Shared/Views/Chat/ChatView.swift | 7 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++-- apps/ios/SimpleXChat/ChatTypes.swift | 7 +- .../chat/simplex/common/model/ChatModel.kt | 7 +- .../chat/simplex/common/model/SimpleXAPI.kt | 4 +- .../common/views/chat/ChatItemInfoView.kt | 5 ++ .../simplex/common/views/chat/ChatView.kt | 7 +- .../common/views/chat/item/CIFileView.kt | 20 +++-- .../common/views/chat/item/CIImageView.kt | 8 +- .../common/views/chat/item/CIVideoView.kt | 14 ++-- .../common/views/chat/item/CIVoiceView.kt | 4 +- .../commonMain/resources/MR/base/strings.xml | 4 + bots/api/TYPES.md | 1 + cabal.project | 2 +- .../types/typescript/src/types.ts | 1 + .../src/simplex_chat/types/_types.py | 1 + plans/2026-08-28-file-expiry-display.md | 74 +++++++++++++++++++ scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 18 ++--- src/Simplex/Chat/Badges.hs | 10 ++- src/Simplex/Chat/Library/Commands.hs | 8 +- src/Simplex/Chat/Library/Internal.hs | 24 +++--- src/Simplex/Chat/Library/Subscriber.hs | 47 ++++++------ src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Protocol.hs | 10 +-- src/Simplex/Chat/Store/Files.hs | 17 ++++- src/Simplex/Chat/Store/Messages.hs | 20 ++--- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260828_file_expiry.hs | 19 +++++ .../Store/Postgres/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260828_file_expiry.hs | 18 +++++ .../SQLite/Migrations/agent_query_plans.txt | 12 +-- .../SQLite/Migrations/chat_query_plans.txt | 12 ++- .../Store/SQLite/Migrations/chat_schema.sql | 3 +- tests/ChatClient.hs | 5 +- 43 files changed, 328 insertions(+), 133 deletions(-) create mode 100644 plans/2026-08-28-file-expiry-display.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260828_file_expiry.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260828_file_expiry.hs diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 74f97134d7..d7f29087d8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -495,9 +495,9 @@ func loadChat(chatId: ChatId, im: ItemsModel, contentTag: MsgContentTag? = nil, ) } -func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> ChatItemInfo { +func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> (AChatItem, ChatItemInfo) { let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId)) - if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo } + if case let .chatItemInfo(_, aci, chatItemInfo) = r { return (aci, chatItemInfo) } throw r.unexpected } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index fc46669cee..f08f4fba6a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -126,7 +126,7 @@ struct CIFileView: View { } case let .rcvError(rcvFileError): logger.debug("CIFileView fileAction - in .rcvError") - showFileErrorAlert(rcvFileError) + showFileErrorAlert(rcvFileError, file) case let .rcvWarning(rcvFileError): logger.debug("CIFileView fileAction - in .rcvWarning") showFileErrorAlert(rcvFileError, temporary: true) @@ -171,10 +171,12 @@ struct CIFileView: View { case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10) case .rcvInvitation: - if fileSizeValid(file, senderProfile) { - fileIcon("arrow.down.doc.fill", color: theme.colors.primary) - } else { + if !fileSizeValid(file, senderProfile) { fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) + } else if file.expired { + fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) + } else { + fileIcon("arrow.down.doc.fill", color: theme.colors.primary) } case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12) case let .rcvTransfer(rcvProgress, rcvTotal): @@ -264,7 +266,14 @@ func saveCryptoFile(_ fileSource: CryptoFile) { } } -func showFileErrorAlert(_ err: FileError, temporary: Bool = false) { +func showFileErrorAlert(_ err: FileError, _ file: CIFile? = nil, temporary: Bool = false) { + if let file, file.expired, let fileExpires = file.fileExpires, err == .auth || err == .noFile { + showAlert( + NSLocalizedString("File expired", comment: "file error alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("File was available until %@.", comment: "file error text"), localTimestamp(fileExpires)) + ) + return + } let title: String = if temporary { NSLocalizedString("Temporary file error", comment: "file error alert title") } else { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 972e9c4ec6..93966543c6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -83,7 +83,7 @@ struct CIImageView: View { case .rcvComplete: () // ? case .rcvCancelled: () // TODO case let .rcvError(rcvFileError): - showFileErrorAlert(rcvFileError) + showFileErrorAlert(rcvFileError, file) case let .rcvWarning(rcvFileError): showFileErrorAlert(rcvFileError, temporary: true) case let .sndError(sndFileError): @@ -152,7 +152,7 @@ struct CIImageView: View { case .sndCancelled: fileIcon("xmark", 10, 13) case .sndError: fileIcon("xmark", 10, 13) case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13) - case .rcvInvitation: fileIcon("arrow.down", 10, 13) + case .rcvInvitation: fileIcon(file.expired && fileSizeValid(file, senderProfile) ? "xmark" : "arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) case .rcvTransfer: progressView() case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 912fde4043..d5340010ab 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -368,7 +368,7 @@ struct CIVideoView: View { .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError, temporary: true) }) - case .rcvInvitation: fileIcon("arrow.down", 10, 13) + case .rcvInvitation: fileIcon(file.expired && fileSizeValid(file, senderProfile) ? "xmark" : "arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) case let .rcvTransfer(rcvProgress, rcvTotal): if file.fileProtocol == .xftp && rcvProgress < rcvTotal { @@ -382,7 +382,7 @@ struct CIVideoView: View { case let .rcvError(rcvFileError): fileIcon("xmark", 10, 13) .simultaneousGesture(TapGesture().onEnded { - showFileErrorAlert(rcvFileError) + showFileErrorAlert(rcvFileError, file) }) case let .rcvWarning(rcvFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 820074542f..64fa2a4dd3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -178,7 +178,7 @@ struct VoiceMessagePlayer: View { .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError, temporary: true) }) - case .rcvInvitation: downloadButton(recordingFile, "play.fill") + case .rcvInvitation: downloadButton(recordingFile, recordingFile.expired ? "multiply" : "play.fill") case .rcvAccepted: loadingIcon() case .rcvTransfer: loadingIcon() case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath") @@ -187,7 +187,7 @@ struct VoiceMessagePlayer: View { case let .rcvError(rcvFileError): fileStatusIcon("multiply", 14) .simultaneousGesture(TapGesture().onEnded { - showFileErrorAlert(rcvFileError) + showFileErrorAlert(rcvFileError, recordingFile) }) case let .rcvWarning(rcvFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 2213b34586..15effaeea6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -162,6 +162,9 @@ struct ChatItemInfoView: View { if let deleteAt = meta.itemTimed?.deleteAt { infoRow("Disappears at", localTimestamp(deleteAt)) } + if let file = ci.file, let fileExpires = file.fileExpires { + infoRow(file.expired ? "File was available until" : "File available until", localTimestamp(fileExpires)) + } if meta.msgVerified?.verified == true { let signedText: LocalizedStringKey = ci.chatDir.sent ? "Signed" : "Signed & verified" HStack { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index bc55fc6174..08941e9ccc 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1719,6 +1719,7 @@ struct ChatView: View { @State private var showArchivingReports = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? + @State private var chatItemInfoItem: ChatItem? @State private var msgWidth: CGFloat = 0 @State private var touchInProgress: Bool = false @@ -2230,8 +2231,9 @@ struct ChatView: View { .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) .sheet(isPresented: $showChatItemInfoSheet, onDismiss: { chatItemInfo = nil + chatItemInfoItem = nil }) { - ChatItemInfoView(ci: ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo) + ChatItemInfoView(ci: chatItemInfoItem ?? ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo) } } @@ -2574,8 +2576,9 @@ struct ChatView: View { Task { do { let cInfo = chat.chatInfo - let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemId: ci.id) + let (aci, ciInfo) = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemId: ci.id) await MainActor.run { + chatItemInfoItem = aci.chatItem chatItemInfo = ciInfo } if case let .group(gInfo, _) = chat.chatInfo { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 890a85fac1..3f566c107a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,8 +183,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -563,8 +563,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -735,8 +735,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -822,8 +822,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e3212ee87a..8f93ed8033 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -4685,6 +4685,7 @@ public struct CIFile: Decodable, Hashable { public var fileSource: CryptoFile? public var fileStatus: CIFileStatus public var fileProtocol: FileProtocol + public var fileExpires: Date? = nil public static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile { let f: CryptoFile? @@ -4718,6 +4719,10 @@ public struct CIFile: Decodable, Hashable { } } + public var expired: Bool { + if let fileExpires { fileExpires < Date.now } else { false } + } + public var cancelAction: CancelAction? { get { switch self.fileStatus { @@ -4754,7 +4759,7 @@ public struct CIFile: Decodable, Hashable { case .sndCancelled: true case .sndError: true case .sndWarning: true - case .rcvInvitation: false + case .rcvInvitation: expired case .rcvAccepted: true case .rcvTransfer: true case .rcvAborted: true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index d8702d3109..64f74b6ba7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -4252,8 +4252,11 @@ data class CIFile( val fileSize: Long, val fileSource: CryptoFile? = null, val fileStatus: CIFileStatus, - val fileProtocol: FileProtocol + val fileProtocol: FileProtocol, + val fileExpires: Instant? = null ) { + val expired: Boolean = fileExpires != null && fileExpires < Clock.System.now() + val loaded: Boolean = when (fileStatus) { is CIFileStatus.SndStored -> true is CIFileStatus.SndTransfer -> true @@ -4303,7 +4306,7 @@ data class CIFile( is CIFileStatus.SndCancelled -> true is CIFileStatus.SndError -> true is CIFileStatus.SndWarning -> true - is CIFileStatus.RcvInvitation -> false + is CIFileStatus.RcvInvitation -> expired is CIFileStatus.RcvAccepted -> true is CIFileStatus.RcvTransfer -> true is CIFileStatus.RcvAborted -> true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index c351ee3281..63d0f4f82a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1161,9 +1161,9 @@ object ChatController { return null } - suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long): ChatItemInfo? { + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long): Pair? { val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, scope, itemId)) - if (r is API.Result && r.res is CR.ApiChatItemInfo) return r.res.chatItemInfo + if (r is API.Result && r.res is CR.ApiChatItemInfo) return r.res.chatItem.chatItem to r.res.chatItemInfo apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) return null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 64c6160665..554d79ebae 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -273,6 +273,11 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools if (deleteAt != null) { InfoRow(stringResource(MR.strings.info_row_disappears_at), localTimestamp(deleteAt)) } + val file = ci.file + if (file?.fileExpires != null) { + val expiresRes = if (file.expired) MR.strings.info_row_file_expired else MR.strings.info_row_file_expires + InfoRow(stringResource(expiresRes), localTimestamp(file.fileExpires)) + } if (ci.meta.msgVerified?.verified == true) { val signedRes = if (sent) MR.strings.info_row_signed else MR.strings.info_row_signed_verified InfoRow(stringResource(signedRes), "", icon = painterResource(MR.images.ic_verified)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 9bbfda558f..cb36d86230 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -699,7 +699,7 @@ fun ChatView( } }, showItemDetails = { cInfo, cItem -> - suspend fun loadChatItemInfo(): ChatItemInfo? = coroutineScope { + suspend fun loadChatItemInfo(): Pair? = coroutineScope { val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cInfo.groupChatScope(), cItem.id) if (ciInfo != null) { if (chatInfo is ChatInfo.Group) { @@ -717,11 +717,12 @@ fun ChatView( } ModalManager.end.showModalCloseable(endButtons = { ShareButton { - clipboard.shareText(itemInfoShareText(chatModel, cItem, initialCiInfo, chatModel.controller.appPrefs.developerTools.get())) + clipboard.shareText(itemInfoShareText(chatModel, initialCiInfo.first, initialCiInfo.second, chatModel.controller.appPrefs.developerTools.get())) } }) { close -> var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } - ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) + val (item, info) = ciInfo + ChatItemInfoView(chatRh, item, info, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) LaunchedEffect(cItem.id) { withContext(Dispatchers.Default) { for (msg in controller.messagesChannel) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 28afc0132f..8d107ae32a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -101,7 +101,7 @@ fun CIFileView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvError -> - showFileErrorAlert(file.fileStatus.rcvFileError) + showFileErrorAlert(file.fileStatus.rcvFileError, file) file.fileStatus is CIFileStatus.RcvWarning -> showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) file.fileStatus is CIFileStatus.SndError -> @@ -157,10 +157,12 @@ fun CIFileView( is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled)) is CIFileStatus.RcvInvitation -> - if (fileSizeValid(file, senderProfile)) - fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp()) - else + if (!fileSizeValid(file, senderProfile)) fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange) + else if (file.expired) + fileIcon(innerIcon = painterResource(MR.images.ic_close)) + else + fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp()) is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = painterResource(MR.images.ic_more_horiz)) is CIFileStatus.RcvTransfer -> if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) { @@ -241,7 +243,15 @@ fun CIFileView( fun fileSizeValid(file: CIFile, senderProfile: LocalProfile?): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) -fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { +fun showFileErrorAlert(err: FileError, file: CIFile? = null, temporary: Boolean = false) { + val fileExpires = file?.fileExpires + if (file != null && fileExpires != null && file.expired && (err is FileError.Auth || err is FileError.NoFile)) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.file_expired), + String.format(generalGetString(MR.strings.file_error_expired), localTimestamp(fileExpires)) + ) + return + } val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error) val btn = err.moreInfoButton if (btn != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 67fc0a038c..fe6a1bf4b7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -83,7 +83,11 @@ fun CIImageView( is CIFileStatus.SndCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) is CIFileStatus.SndWarning -> fileIcon(painterResource(MR.images.ic_warning_filled), MR.strings.icon_descr_file) - is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_asked_to_receive) + is CIFileStatus.RcvInvitation -> + if (file.expired && fileSizeValid(file, senderProfile)) + fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) + else + fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_asked_to_receive) is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_image) is CIFileStatus.RcvTransfer -> progressIndicator() is CIFileStatus.RcvComplete -> {} @@ -239,7 +243,7 @@ fun CIImageView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvError -> - showFileErrorAlert(file.fileStatus.rcvFileError) + showFileErrorAlert(file.fileStatus.rcvFileError, file) file.fileStatus is CIFileStatus.RcvWarning -> showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) file.fileStatus is CIFileStatus.SndError -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 8ca0add460..20d9d4417a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -135,10 +135,10 @@ fun CIVideoView( } // Do not show download icon when the view is blurred if (!smallView && (!showDownloadButton(file?.fileStatus) || !blurred.value)) { - fileStatusIcon(file, false) + fileStatusIcon(file, false, senderProfile) } else if (smallView && file?.showStatusIconInSmallView == true) { Box(Modifier.align(Alignment.Center)) { - fileStatusIcon(file, true) + fileStatusIcon(file, true, senderProfile) } } } @@ -486,7 +486,7 @@ private fun progressCircle(progress: Long, total: Long) { } @Composable -private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { +private fun fileStatusIcon(file: CIFile?, smallView: Boolean, senderProfile: LocalProfile?) { if (file != null) { Box( Modifier @@ -525,7 +525,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) } ) - is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive) + is CIFileStatus.RcvInvitation -> + if (file.expired && fileSizeValid(file, senderProfile)) + fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) + else + fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive) is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_video) is CIFileStatus.RcvTransfer -> if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) { @@ -541,7 +545,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_close), MR.strings.icon_descr_file, onClick = { - showFileErrorAlert(file.fileStatus.rcvFileError) + showFileErrorAlert(file.fileStatus.rcvFileError, file) } ) is CIFileStatus.RcvWarning -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 136300e4ed..c3e1c92732 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -412,7 +412,7 @@ private fun VoiceMsgIndicator( } ) file?.fileStatus is CIFileStatus.RcvInvitation -> - PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, sizeMultiplier, { receiveFile(file.fileId) }, {}, longClick = longClick) + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, sizeMultiplier, { receiveFile(file.fileId) }, {}, longClick = longClick, icon = if (file.expired) MR.images.ic_close else MR.images.ic_play_arrow_filled) file?.fileStatus is CIFileStatus.RcvTransfer || file?.fileStatus is CIFileStatus.RcvAccepted -> VoiceMsgLoadingProgressIndicator(sizeMultiplier) file?.fileStatus is CIFileStatus.RcvAborted -> @@ -424,7 +424,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - showFileErrorAlert(file.fileStatus.rcvFileError) + showFileErrorAlert(file.fileStatus.rcvFileError, file) } ) file != null && file.fileStatus is CIFileStatus.RcvWarning -> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b1661d195f..622ff9d9dc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -697,6 +697,8 @@ Loading the file Please, wait while the file is being loaded from the linked mobile File error + File expired + File was available until %1$s. Temporary file error Open with %s @@ -2044,6 +2046,8 @@ Deleted at Moderated at Disappears at + File available until + File was available until Database ID: %d Record updated at: %s Message status: %s diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 665dd26889..41c77b3bd2 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -747,6 +747,7 @@ LocalRcv: - fileSource: [CryptoFile](#cryptofile)? - fileStatus: [CIFileStatus](#cifilestatus) - fileProtocol: [FileProtocol](#fileprotocol) +- fileExpires: UTCTime? --- diff --git a/cabal.project b/cabal.project index fa144d6780..2aa5d15026 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 0d3cf39c27aec1c51c88d7bf3d6762bc22278967 + tag: 1876357c7f64fc00d9d031084c5e78e844b27c34 source-repository-package type: git diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index cae52090e7..73fa758ca3 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -692,6 +692,7 @@ export interface CIFile { fileSource?: CryptoFile fileStatus: CIFileStatus fileProtocol: FileProtocol + fileExpires?: string // ISO-8601 timestamp } export type CIFileStatus = diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 512a7464db..22ac2cc200 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -481,6 +481,7 @@ class CIFile(TypedDict): fileSource: NotRequired["CryptoFile"] fileStatus: "CIFileStatus" fileProtocol: "FileProtocol" + fileExpires: NotRequired[str] # ISO-8601 timestamp class CIFileStatus_sndStored(TypedDict): type: Literal["sndStored"] diff --git a/plans/2026-08-28-file-expiry-display.md b/plans/2026-08-28-file-expiry-display.md new file mode 100644 index 0000000000..3b6742c430 --- /dev/null +++ b/plans/2026-08-28-file-expiry-display.md @@ -0,0 +1,74 @@ +# File expiry display + +Show the sender and recipients when an XFTP file stops being downloadable. + +The value is the storage expiry the server grants. The agent already reports it in `SFDONE` as `Maybe GrantedStorageTime` (`GSTExpires { epochSeconds }`, absolute UTC). No simplexmq change. `Nothing` means unknown (a server or chunk below the storage-time version). + +## Protocol + +In `Simplex.Chat.Protocol`: + +- add optional `fileExpires :: Maybe UTCTime` to `XMsgFileDescr` +- encode with `"fileExpires" .=? fileExpires`, decode with `opt "fileExpires"` +- no chat version bump; an older app skips the field, an older sender omits it + +## Chat item file + +In `Simplex.Chat.Messages`: + +- add `fileExpires :: Maybe UTCTime` to `CIFile` +- add no `CIFileStatus`; the app derives the expired state from `fileExpires < now` + +## Store + +In the SQLite and PostgreSQL stores: + +- add a nullable `file_expires_at` column to `files`, typed as the other `files` timestamps; migration in both stores +- add `setFileExpires :: DB.Connection -> User -> FileTransferId -> UTCTime -> IO ()` +- read `file_expires_at` into `CIFile` in the file-row queries + +## Sender + +In `Simplex.Chat.Library.Subscriber`, `SFDONE` handler: + +- bind the granted time (currently dropped) +- convert `GSTExpires epochSeconds` to `UTCTime` and store it with `setFileExpires` +- thread the expiry through `sendFileDescriptions` into each `XMsgFileDescr` it builds + +## Recipient + +In `Simplex.Chat.Library.Subscriber`, `XMsgFileDescr` handling (direct and group): + +- take the received `fileExpires` and store it with `setFileExpires` + +## Apps + +Received files show the expiry; sent files are unchanged, they keep the checkmark. A tap still attempts the download: the receive actions, the download overlays, and the "Download file" menu item stay as they are. + +Model: + +- `CIFile` gets `fileExpires` — `Date?` in `SimpleXChat/ChatTypes.swift`, `Instant?` in `model/ChatModel.kt` +- `CIFile` gets `expired`, beside `loaded`: `fileExpires` is set and has passed + +Message information, in `ChatItemInfoView`, a row after "Disappears at": + +- "File can be received until