ui: show XFTP servers used for a file in message info

Surface the XFTP servers that hosted a file's chunks in the message info
screen (Android, desktop, iOS), so a user can see which servers they are
downloading from (or, for sent files, uploading to) whenever they want to know.

APIGetChatItemInfo now returns fileXftpServers, derived from the stored file
description (private snd descr for sent items, rcv descr for received items);
extraction is best-effort and never fails the call.
This commit is contained in:
Narasimha-sc
2026-06-17 08:45:43 +00:00
parent b9d1f0c0a3
commit 5eca9ec8ee
10 changed files with 205 additions and 8 deletions
@@ -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
+1
View File
@@ -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 {
@@ -5207,7 +5207,8 @@ data class ChatTag(
class ChatItemInfo(
val itemVersions: List<ChatItemVersion>,
val memberDeliveryStatuses: List<MemberDeliveryStatus>?,
val forwardedFromChatItem: AChatItem?
val forwardedFromChatItem: AChatItem?,
val fileXftpServers: List<String> = emptyList()
)
@Serializable
@@ -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("")
@@ -1961,6 +1961,7 @@
<string name="info_row_updated_at">Record updated at</string>
<string name="info_row_message_status">Message status</string>
<string name="info_row_file_status">File status</string>
<string name="info_row_file_servers">File servers</string>
<string name="info_row_sent_at">Sent at</string>
<string name="info_row_created_at">Created at</string>
<string name="info_row_received_at">Received at</string>
@@ -1971,6 +1972,7 @@
<string name="share_text_updated_at">Record updated at: %s</string>
<string name="share_text_message_status">Message status: %s</string>
<string name="share_text_file_status">File status: %s</string>
<string name="share_text_file_servers">File servers: %s</string>
<string name="share_text_sent_at">Sent at: %s</string>
<string name="share_text_created_at">Created at: %s</string>
<string name="share_text_received_at">Received at: %s</string>
@@ -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<String>`.
---
## 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<ChatItemVersion>,
val memberDeliveryStatuses: List<MemberDeliveryStatus>?,
val forwardedFromChatItem: AChatItem?,
val fileXftpServers: List<String> = 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
<string name="info_row_file_servers">File servers</string>
```
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()`.
+2 -1
View File
@@ -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
+25 -4
View File
@@ -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
+3 -2
View File
@@ -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)
+10
View File
@@ -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