diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 3858d15252..8224bf3bb9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -195,6 +195,9 @@ struct ChatItemInfoView: View { } } } + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + infoRow("File servers", servers.map(serverHostname).joined(separator: "\n")) + } } } @@ -509,6 +512,9 @@ struct ChatItemInfoView: View { shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)] } } + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + shareText += [String.localizedStringWithFormat(NSLocalizedString("File servers: %@", comment: "copied message info"), servers.map(serverHostname).joined(separator: ", "))] + } if let qi = ci.quotedItem { shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")] let t = qi.text diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 3302850b64..ff7cee67f6 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -5774,6 +5774,7 @@ public struct ChatItemInfo: Decodable, Hashable { public var itemVersions: [ChatItemVersion] public var memberDeliveryStatuses: [MemberDeliveryStatus]? public var forwardedFromChatItem: AChatItem? + public var fileXftpServers: [String]? } public struct ChatItemVersion: Decodable, Hashable { 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 f3024d7ca1..4abaec484c 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 @@ -5207,7 +5207,8 @@ data class ChatTag( class ChatItemInfo( val itemVersions: List, val memberDeliveryStatuses: List?, - val forwardedFromChatItem: AChatItem? + val forwardedFromChatItem: AChatItem?, + val fileXftpServers: List = emptyList() ) @Serializable 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 9c36f4896b..5065ee4732 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 @@ -30,6 +30,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.MemberProfileImage import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlinx.serialization.encodeToString @@ -280,6 +281,16 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools } } } + if (ci.file != null && ciInfo.fileXftpServers.isNotEmpty()) { + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionView(stringResource(MR.strings.info_row_file_servers)) { + ciInfo.fileXftpServers.forEach { server -> + SectionItemView { + Text(serverHostname(server), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth()) + } + } + } + } } @Composable @@ -565,6 +576,9 @@ fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItem shareText.add(String.format(generalGetString(MR.strings.share_text_file_status), jsonShort.encodeToString(ci.file.fileStatus))) } } + if (ci.file != null && chatItemInfo.fileXftpServers.isNotEmpty()) { + shareText.add(String.format(generalGetString(MR.strings.share_text_file_servers), chatItemInfo.fileXftpServers.joinToString(", ") { serverHostname(it) })) + } val qi = ci.quotedItem if (qi != null) { shareText.add("") 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 5bca9402a8..17f42ee875 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1961,6 +1961,7 @@ Record updated at Message status File status + File servers Sent at Created at Received at @@ -1971,6 +1972,7 @@ Record updated at: %s Message status: %s File status: %s + File servers: %s Sent at: %s Created at: %s Received at: %s diff --git a/plans/2026-06-17-message-info-file-xftp-servers.md b/plans/2026-06-17-message-info-file-xftp-servers.md new file mode 100644 index 0000000000..f7803984cf --- /dev/null +++ b/plans/2026-06-17-message-info-file-xftp-servers.md @@ -0,0 +1,140 @@ +# Plan: Show XFTP servers used for a file in Message Info + +## Goal +In the message-info screen, when the message contains a file, show the list of XFTP servers that hosted the file's chunks ("servers used to upload the file"). + +## Why this is needed +For transparency: a recipient downloads a file's chunks from XFTP relays without any visible indication of which servers those are. Surfacing them in message info lets a user see exactly what servers they are downloading from (and, for sent files, uploading to) whenever they want to know — useful for trust, debugging, and deciding whether to download. + +## Decisions (confirmed) +- **Direction:** Both sent and received files. +- **Visibility:** Always shown when the item has a file (not gated behind Developer tools). +- **Platform:** Android, desktop, and iOS. + +## Why this needs a core change (not Kotlin-only) +The server list is **already known to the app** — the file description (which lists each chunk's XFTP server replicas) is what the agent uses to upload/download. But that data lives in the **core (Haskell) layer**: it's stored in the `files` table (`private_snd_file_descr` for sent, the rcv file descr row for received) and parsed inside the core. It is **never surfaced across the core→client API boundary**: the client-facing `CIFile` and `ChatItemInfo` types carry no server info. So this is a **plumbing task** (expose existing data through the API response), not an algorithmic one. The core already has the extraction helper. + +## Existing building blocks (no new logic needed) +- `Internal.hs:764-766` — `fileServers` extracts the unique `[XFTPServer]` from a parsed description's chunk replicas. Currently a local `where` helper; will be lifted to a reusable top-level function. +- `Internal.hs:747` — `parseFileDescription :: Text -> CM (ValidFileDescription 'FRecipient)`. +- `Store/Files.hs:42` — `getRcvFileDescrByRcvFileId` returns `RcvFileDescr {fileDescrText, fileDescrComplete}` (received files). +- `Store/Files.hs:197` — `setSndFTPrivateSndDescr` stores `private_snd_file_descr` (sent files). A matching getter must be added (none exists today). +- `XFTPServer` already JSON-encodes to a string on the wire (see `XFTPServerSummary.xftpServer :: XFTPServer` → Kotlin `xftpServer: String`), so the Kotlin field can be `List`. + +--- + +## Implementation + +### 1. Core — extraction helper (`src/Simplex/Chat/Library/Internal.hs`) +- Lift `fileServers` out of `receiveViaCompleteFD`'s `where` block to a top-level function, generalized to work on any party's description chunks: + ```haskell + fileDescrServers :: FD.FileDescription p -> [XFTPServer] + fileDescrServers FD.FileDescription {chunks} = + S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks + ``` + (Keep `receiveViaCompleteFD` working by calling the lifted helper.) +- Add a convenience that resolves a file's servers by direction, returning `[]` when no XFTP description exists (inline/legacy files): + ```haskell + getChatItemFileServers :: User -> ChatItem c d -> CM [XFTPServer] + ``` + - For **received** (`SMDRcv`) with an XFTP file: `getRcvFileDescrByRcvFileId` → `parseFileDescription` → `fileDescrServers`. + - For **sent** (`SMDSnd`) with an XFTP file: read `private_snd_file_descr` (new getter) → parse the sender description → `fileDescrServers`. + - Guard on `fileProtocol == FPXFTP`; wrap parse in tolerant error handling so a missing/partial descr yields `[]` (no crash, no section). + +### 2. Core — store getter (`src/Simplex/Chat/Store/Files.hs`) +- Add `getSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> IO (Maybe Text)` reading `private_snd_file_descr` from `files` (mirror of `setSndFTPrivateSndDescr`; column already selected at `Files.hs:799`). Export it. + +### 3. Core — extend `ChatItemInfo` (`src/Simplex/Chat/Messages.hs`) +- Add field: + ```haskell + data ChatItemInfo = ChatItemInfo + { itemVersions :: [ChatItemVersion], + memberDeliveryStatuses :: Maybe (NonEmpty MemberDeliveryStatus), + forwardedFromChatItem :: Maybe AChatItem, + fileXftpServers :: [XFTPServer] -- NEW (empty when no file / not XFTP) + } + ``` +- `$(JQ.deriveJSON defaultJSON ''ChatItemInfo)` at `Messages.hs:1531` regenerates the instance automatically. Import `XFTPServer` if not already in scope. +- Note: using `[XFTPServer]` (not `Maybe`) keeps the JSON additive and the Kotlin side simple; empty list = nothing to show. + +### 4. Core — populate in handler (`src/Simplex/Chat/Library/Commands.hs:619`) +- In `APIGetChatItemInfo`, after computing `forwardedFromChatItem`: + ```haskell + fileXftpServers <- getChatItemFileServers user ci + pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem, fileXftpServers} + ``` + +### 5. Kotlin model (`apps/multiplatform/.../model/ChatModel.kt:5207`) +```kotlin +class ChatItemInfo( + val itemVersions: List, + val memberDeliveryStatuses: List?, + val forwardedFromChatItem: AChatItem?, + val fileXftpServers: List = emptyList() // NEW; server hosts as strings +) +``` +(`XFTPServer` serializes to a string, matching the existing `XFTPServerSummary.xftpServer: String`.) + +### 6. Desktop/shared UI (`apps/multiplatform/.../views/chat/ChatItemInfoView.kt`) +- In `Details()` (`:250-283`), after the existing file-status block, add (always-visible, when present): + ```kotlin + if (ci.file != null && ciInfo.fileXftpServers.isNotEmpty()) { + // section header + one InfoRow/host per server + } + ``` + - Render as its own `SectionView` with a title (e.g. "File servers") and one row per server host, styled like the existing info rows. For long host strings, single-line with ellipsis (consistent with the long-name handling already on this branch). +- Add the servers to the shareable text in `itemInfoShareText` (`:534-580`), near the existing file-status share line (`:564-565`), so "Share" / copy includes them. + +### 7. Translations (additive only — never rename/remove keys) +- Add one new key to `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml`, e.g.: + ```xml + File servers + ``` + Base locale only; Weblate fans out to other locales. Do not touch existing keys. + +### 8. iOS model (`apps/ios/SimpleXChat/ChatTypes.swift:5773`) +Same core change (shared `libsimplex`), so iOS only needs the Swift model + view. Add the optional field (optional so an absent key never breaks decoding): +```swift +public struct ChatItemInfo: Decodable, Hashable { + public var itemVersions: [ChatItemVersion] + public var memberDeliveryStatuses: [MemberDeliveryStatus]? + public var forwardedFromChatItem: AChatItem? + public var fileXftpServers: [String]? // NEW +} +``` + +### 9. iOS UI (`apps/ios/Shared/Views/Chat/ChatItemInfoView.swift`) +- In `details()`, after the `developerTools` block (always-visible when present): + ```swift + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + infoRow("File servers", servers.map(serverHostname).joined(separator: "\n")) + } + ``` + `serverHostname(_:)` is public in `SimpleXChat` (`ErrorAlert.swift:142`). +- Add the same to `itemInfoShareText()` (joined with ", "). Strings are inline `NSLocalizedString` (no separate resource file to edit). + +--- + +## Edge cases / behavior +- **Inline / legacy files** (no XFTP description): `fileXftpServers == []` → section hidden. No crash. +- **Sent file before upload completes** (`private_snd_file_descr` not yet set): `[]` → hidden until available. +- **Received file not yet accepted/downloaded:** the rcv description exists as soon as the offer is received (it's what enables download), so servers are available even before download. ✓ +- **Multiple chunks across different servers:** deduped via `S.fromList`; all distinct servers listed. +- **Large files (any size up to the limits):** servers are accurate regardless of size. Chat-message files **never** use XFTP redirect — the description is split across multiple `XMsgFileDescr` messages (`splitFileDescr`) and reassembled in full by the recipient (`appendRcvFD`), so a received chat file always carries the real data-chunk servers. (`maxFileSize` = 1 GB soft, `maxFileSizeHard` = 5 GB; XFTP chunk sizes 64 KB / 256 KB / 1 MB / 4 MB.) +- **Redirect descriptions:** XFTP redirect (a small `redirect = Just` description whose chunks point to the relay hosting the real description) is used **only** for *standalone file links* (`SFDONE` `Nothing` branch → `xftpSndFileRedirect`), which have no chat item and never appear in message info. The chat-message branch never redirects, so no redirect description can reach `getChatItemFileServers` — no guard is needed. + +## Build / verification +- **Heaviest step:** rebuild the native core lib (`libsimplex`) consumed by the multiplatform app, since core types changed. Without the rebuild, the new JSON field is simply ignored by the old lib (the Kotlin default `emptyList()` keeps it backward-compatible — section just won't appear). +- Tests: a core unit/integration check that `APIGetChatItemInfo` returns a non-empty `fileXftpServers` for an XFTP file (sent and received), empty for a text message and for an inline file. +- Manual: send a file → open message info → confirm the server list; repeat on the receiving device for the received item. + +## Touch list +- `src/Simplex/Chat/Library/Internal.hs` — lift `fileDescrServers`, add `getChatItemFileServers`. +- `src/Simplex/Chat/Store/Files.hs` — add `getSndFTPrivateSndDescr` (+ export). +- `src/Simplex/Chat/Messages.hs` — add `fileXftpServers` to `ChatItemInfo`. +- `src/Simplex/Chat/Library/Commands.hs` — populate it in `APIGetChatItemInfo`. +- `apps/multiplatform/.../model/ChatModel.kt` — add Kotlin field. +- `apps/multiplatform/.../views/chat/ChatItemInfoView.kt` — UI section + share text. +- `apps/multiplatform/.../resources/MR/base/strings.xml` — new label key. +- `apps/ios/SimpleXChat/ChatTypes.swift` — add `fileXftpServers` to the Swift `ChatItemInfo`. +- `apps/ios/Shared/Views/Chat/ChatItemInfoView.swift` — UI row in `details()` + `itemInfoShareText()`. diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b72eae7c72..7b612ec055 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -624,7 +624,8 @@ processChatCommand cxt nm = \case (SCTGroup, SMDSnd) -> L.nonEmpty <$> withFastStore' (`getGroupSndStatuses` itemId) _ -> pure Nothing forwardedFromChatItem <- getForwardedFromItem user ci - pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem} + fileXftpServers <- getChatItemFileServers user dir ci + pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem, fileXftpServers} where getForwardedFromItem :: User -> ChatItem c d -> CM (Maybe AChatItem) getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 052c09b7cd..d33a76d863 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -748,7 +748,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} if userApprovedRelays then receive' rd True else do - let srvs = fileServers rd + let srvs = fileDescrServers rd unknownSrvs <- getUnknownSrvs srvs let approved = null unknownSrvs ifM @@ -761,9 +761,6 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd cfArgs approved startReceivingFile user fileId withStore' $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) - fileServers :: ValidFileDescription 'FRecipient -> [XFTPServer] - fileServers (FD.ValidFileDescription FD.FileDescription {chunks}) = - S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer] getUnknownSrvs srvs = do knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user @@ -1591,6 +1588,30 @@ parseFileDescription :: FilePartyI p => Text -> CM (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) +-- | Unique XFTP servers hosting the file's chunks, parsed from a stored file description. +fileDescrServers :: ValidFileDescription p -> [XFTPServer] +fileDescrServers (FD.ValidFileDescription FD.FileDescription {chunks}) = + S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks + +-- | XFTP servers the file's data chunks were uploaded to (sender's servers for sent items, +-- the same servers via the recipient description for received items). +-- Returns [] for non-XFTP/inline files or when no description is available; never fails the caller. +getChatItemFileServers :: User -> SMsgDirection d -> ChatItem c d -> CM [XFTPServer] +getChatItemFileServers user dir ci = case ci of + ChatItem {file = Just CIFile {fileId, fileProtocol = FPXFTP}} -> + itemFileServers fileId `catchAllErrors` \_ -> pure [] + _ -> pure [] + where + itemFileServers fileId = case dir of + SMDSnd -> do + sfd_ <- withStore' $ \db -> getSndFTPrivateSndDescr db user fileId + case sfd_ of + Just sfdText -> fileDescrServers <$> (parseFileDescription sfdText :: CM (ValidFileDescription 'FSender)) + Nothing -> pure [] + SMDRcv -> do + RcvFileDescr {fileDescrText} <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId + fileDescrServers <$> (parseFileDescription fileDescrText :: CM (ValidFileDescription 'FRecipient)) + sendDirectFileInline :: User -> Contact -> FileTransferMeta -> SharedMsgId -> CM () sendDirectFileInline user ct ft sharedMsgId = do msgDeliveryId <- sendFileInline_ ft sharedMsgId $ sendDirectContactMessage user ct diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 5800ab5bdd..167c253f84 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -55,7 +55,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, sumTypeJSON) -import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) +import Simplex.Messaging.Protocol (BlockingInfo, MsgBody, XFTPServer) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection @@ -1336,7 +1336,8 @@ instance TextEncoding CIForwardedFromTag where data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], memberDeliveryStatuses :: Maybe (NonEmpty MemberDeliveryStatus), - forwardedFromChatItem :: Maybe AChatItem + forwardedFromChatItem :: Maybe AChatItem, + fileXftpServers :: [XFTPServer] } deriving (Show) diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 6cb8e39bbc..b456dae277 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -20,6 +20,7 @@ module Simplex.Chat.Store.Files createSndFileTransferXFTP, createSndFTDescrXFTP, setSndFTPrivateSndDescr, + getSndFTPrivateSndDescr, updateSndFTDescrXFTP, createExtraSndFTDescrs, updateSndFTDeliveryXFTP, @@ -202,6 +203,15 @@ setSndFTPrivateSndDescr db User {userId} fileId sfdText = do "UPDATE files SET private_snd_file_descr = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (sfdText, currentTs, userId, fileId) +getSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> IO (Maybe Text) +getSndFTPrivateSndDescr db User {userId} fileId = + fmap (maybe Nothing fromOnly) $ + maybeFirstRow id $ + DB.query + db + "SELECT private_snd_file_descr FROM files WHERE user_id = ? AND file_id = ?" + (userId, fileId) + updateSndFTDescrXFTP :: DB.Connection -> User -> SndFileTransfer -> Text -> IO () updateSndFTDescrXFTP db user@User {userId} sft@SndFileTransfer {fileId, fileDescrId} rfdText = do currentTs <- getCurrentTime