From 4bd6517d19aff7f5edd9caf4216691f8cb20ece6 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 3 Jul 2024 15:58:37 +0400 Subject: [PATCH 1/2] core: servers stats improvements, fixes (#4358) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 2 + src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Stats.hs | 174 +++++++++++++++++++-------------- 5 files changed, 105 insertions(+), 76 deletions(-) diff --git a/cabal.project b/cabal.project index 3f05fb8561..e5220e2156 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f392ce0a9355cd3883400906ae6c361b77ca46ea + tag: ae8e1c5e9aa3155907f1bd075e9c69af5fce2bee source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d046469ea8..712a0dbff4 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f392ce0a9355cd3883400906ae6c361b77ca46ea" = "0id9mg30kmhlfcpnn2np3f0a4bb4smdzvhrbw6km8vv26si1js60"; + "https://github.com/simplex-chat/simplexmq.git"."ae8e1c5e9aa3155907f1bd075e9c69af5fce2bee" = "1k6phsn0xslqwd30g6l5bsg3ilghwjh2csav2g4bk6hb5a5ga2yk"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index cc829eaa16..131fbb7089 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2266,6 +2266,7 @@ processChatCommand' vr = \case servers <- map (\ServerCfg {server} -> server) <$> withStore' (`getProtocolServers` users) let srvs = if null servers then L.toList defServers else servers pure $ map protoServer srvs + ResetAgentServersStats -> withAgent resetAgentServersStats >> ok_ GetAgentWorkers -> lift $ CRAgentWorkersSummary <$> withAgent' getAgentWorkersSummary GetAgentWorkersDetails -> lift $ CRAgentWorkersDetails <$> withAgent' getAgentWorkersDetails GetAgentSubs -> lift $ summary <$> withAgent' getAgentSubscriptions @@ -7616,6 +7617,7 @@ chatCommandP = "/debug locks" $> DebugLocks, "/debug event " *> (DebugEvent <$> jsonP), "/get servers summary " *> (GetAgentServersSummary <$> A.decimal), + "/reset servers stats" $> ResetAgentServersStats, "/get subs" $> GetAgentSubs, "/get subs details" $> GetAgentSubsDetails, "/get workers" $> GetAgentWorkers, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 366e767154..14f8d6ff5c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -507,6 +507,7 @@ data ChatCommand | DebugLocks | DebugEvent ChatResponse | GetAgentServersSummary UserId + | ResetAgentServersStats | GetAgentSubs | GetAgentSubsDetails | GetAgentWorkers diff --git a/src/Simplex/Chat/Stats.hs b/src/Simplex/Chat/Stats.hs index f6ea195e1c..f14353f0e0 100644 --- a/src/Simplex/Chat/Stats.hs +++ b/src/Simplex/Chat/Stats.hs @@ -5,7 +5,6 @@ module Simplex.Chat.Stats where -import Control.Applicative ((<|>)) import qualified Data.Aeson.TH as J import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -20,8 +19,10 @@ import Simplex.Messaging.Protocol data PresentedServersSummary = PresentedServersSummary { statsStartedAt :: UTCTime, - currentUserServers :: ServersSummary, - allUsersServers :: ServersSummary + allUsersSMP :: SMPServersSummary, + allUsersXFTP :: XFTPServersSummary, + currentUserSMP :: SMPServersSummary, + currentUserXFTP :: XFTPServersSummary } deriving (Show) @@ -29,8 +30,10 @@ data PresentedServersSummary = PresentedServersSummary -- so users can differentiate currently used (connected) servers, -- previously connected servers that were in use in previous sessions, -- and servers that are only proxied (not connected directly). -data ServersSummary = ServersSummary - { -- currently used SMP servers are those with Just in sessions and/or subs in SMPServerSummary; +data SMPServersSummary = SMPServersSummary + { -- SMP totals are calculated from all accounted SMP server summaries + smpTotals :: SMPTotals, + -- currently used SMP servers are those with Just in sessions and/or subs in SMPServerSummary; -- all other servers would fall either into previously used or only proxied servers category currentlyUsedSMPServers :: [SMPServerSummary], -- previously used SMP servers are those with Nothing in sessions and subs, @@ -40,13 +43,14 @@ data ServersSummary = ServersSummary -- only proxied SMP servers are those that aren't (according to current state - sessions and subs) -- and weren't (according to stats) connected directly; they would have Nothing in sessions and subs, -- and have all of sentDirect, sentProxied, recvMsgs, etc. = 0 in server stats - onlyProxiedSMPServers :: [SMPServerSummary], - -- currently used XFTP servers are those with Just in sessions in XFTPServerSummary, - -- and/or have upload/download/deletion in progress; - -- all other servers would fall into previously used servers category - currentlyUsedXFTPServers :: [XFTPServerSummary], - -- previously used XFTP servers are those with Nothing in sessions and don't have any process in progress - previouslyUsedXFTPServers :: [XFTPServerSummary] + onlyProxiedSMPServers :: [SMPServerSummary] + } + deriving (Show) + +data SMPTotals = SMPTotals + { sessions :: ServerSessions, + subs :: SMPServerSubs, + stats :: AgentSMPServerStatsData } deriving (Show) @@ -68,6 +72,24 @@ data SMPServerSummary = SMPServerSummary } deriving (Show) +data XFTPServersSummary = XFTPServersSummary + { -- XFTP totals are calculated from all accounted XFTP server summaries + xftpTotals :: XFTPTotals, + -- currently used XFTP servers are those with Just in sessions in XFTPServerSummary, + -- and/or have upload/download/deletion in progress; + -- all other servers would fall into previously used servers category + currentlyUsedXFTPServers :: [XFTPServerSummary], + -- previously used XFTP servers are those with Nothing in sessions and don't have any process in progress + previouslyUsedXFTPServers :: [XFTPServerSummary] + } + deriving (Show) + +data XFTPTotals = XFTPTotals + { sessions :: ServerSessions, + stats :: AgentXFTPServerStatsData + } + deriving (Show) + data XFTPServerSummary = XFTPServerSummary { xftpServer :: XFTPServer, known :: Maybe Bool, -- same as for SMPServerSummary @@ -87,34 +109,64 @@ data XFTPServerSummary = XFTPServerSummary toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> [SMPServer] -> [XFTPServer] -> PresentedServersSummary toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrvs = do let (userSMPSrvsSumms, allSMPSrvsSumms) = accSMPSrvsSummaries + (userSMPTotals, allSMPTotals) = (accSMPTotals userSMPSrvsSumms, accSMPTotals allSMPSrvsSumms) (userSMPCurr, userSMPPrev, userSMPProx) = smpSummsIntoCategories userSMPSrvsSumms (allSMPCurr, allSMPPrev, allSMPProx) = smpSummsIntoCategories allSMPSrvsSumms (userXFTPSrvsSumms, allXFTPSrvsSumms) = accXFTPSrvsSummaries + (userXFTPTotals, allXFTPTotals) = (accXFTPTotals userXFTPSrvsSumms, accXFTPTotals allXFTPSrvsSumms) (userXFTPCurr, userXFTPPrev) = xftpSummsIntoCategories userXFTPSrvsSumms (allXFTPCurr, allXFTPPrev) = xftpSummsIntoCategories allXFTPSrvsSumms PresentedServersSummary { statsStartedAt, - currentUserServers = - ServersSummary - { currentlyUsedSMPServers = userSMPCurr, - previouslyUsedSMPServers = userSMPPrev, - onlyProxiedSMPServers = userSMPProx, - currentlyUsedXFTPServers = userXFTPCurr, - previouslyUsedXFTPServers = userXFTPPrev - }, - allUsersServers = - ServersSummary - { currentlyUsedSMPServers = allSMPCurr, + allUsersSMP = + SMPServersSummary + { smpTotals = allSMPTotals, + currentlyUsedSMPServers = allSMPCurr, previouslyUsedSMPServers = allSMPPrev, - onlyProxiedSMPServers = allSMPProx, + onlyProxiedSMPServers = allSMPProx + }, + allUsersXFTP = + XFTPServersSummary + { xftpTotals = allXFTPTotals, currentlyUsedXFTPServers = allXFTPCurr, previouslyUsedXFTPServers = allXFTPPrev + }, + currentUserSMP = + SMPServersSummary + { smpTotals = userSMPTotals, + currentlyUsedSMPServers = userSMPCurr, + previouslyUsedSMPServers = userSMPPrev, + onlyProxiedSMPServers = userSMPProx + }, + currentUserXFTP = + XFTPServersSummary + { xftpTotals = userXFTPTotals, + currentlyUsedXFTPServers = userXFTPCurr, + previouslyUsedXFTPServers = userXFTPPrev } } where AgentServersSummary {statsStartedAt, smpServersSessions, smpServersSubs, smpServersStats, xftpServersSessions, xftpServersStats, xftpRcvInProgress, xftpSndInProgress, xftpDelInProgress} = agentSummary - countUserInAll auId = auId == aUserId currentUser || auId `notElem` hiddenUserIds - hiddenUserIds = map aUserId $ filter (isJust . viewPwdHash) users + countUserInAll auId = countUserInAllStats (AgentUserId auId) currentUser users + accSMPTotals :: Map SMPServer SMPServerSummary -> SMPTotals + accSMPTotals = M.foldr addTotals initialTotals + where + initialTotals = SMPTotals {sessions = ServerSessions 0 0 0, subs = SMPServerSubs 0 0, stats = newAgentSMPServerStatsData} + addTotals SMPServerSummary {sessions, subs, stats} SMPTotals {sessions = accSess, subs = accSubs, stats = accStats} = + SMPTotals + { sessions = maybe accSess (accSess `addServerSessions`) sessions, + subs = maybe accSubs (accSubs `addSMPSubs`) subs, + stats = maybe accStats (accStats `addSMPStatsData`) stats + } + accXFTPTotals :: Map XFTPServer XFTPServerSummary -> XFTPTotals + accXFTPTotals = M.foldr addTotals initialTotals + where + initialTotals = XFTPTotals {sessions = ServerSessions 0 0 0, stats = newAgentXFTPServerStatsData} + addTotals XFTPServerSummary {sessions, stats} XFTPTotals {sessions = accSess, stats = accStats} = + XFTPTotals + { sessions = maybe accSess (accSess `addServerSessions`) sessions, + stats = maybe accStats (accStats `addXFTPStatsData`) stats + } smpSummsIntoCategories :: Map SMPServer SMPServerSummary -> ([SMPServerSummary], [SMPServerSummary], [SMPServerSummary]) smpSummsIntoCategories = foldr partitionSummary ([], [], []) where @@ -171,7 +223,7 @@ toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrv addSubs :: SMPServerSubs -> SMPServerSummary -> SMPServerSummary addSubs s summ@SMPServerSummary {subs} = summ {subs = Just $ maybe s (s `addSMPSubs`) subs} addStats :: AgentSMPServerStatsData -> SMPServerSummary -> SMPServerSummary - addStats s summ@SMPServerSummary {stats} = summ {stats = Just $ maybe s (s `addSMPStats`) stats} + addStats s summ@SMPServerSummary {stats} = summ {stats = Just $ maybe s (s `addSMPStatsData`) stats} accXFTPSrvsSummaries :: (Map XFTPServer XFTPServerSummary, Map XFTPServer XFTPServerSummary) accXFTPSrvsSummaries = M.foldrWithKey' (addServerData addStats) summs1 xftpServersStats where @@ -205,7 +257,7 @@ toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrv addSessions :: ServerSessions -> XFTPServerSummary -> XFTPServerSummary addSessions s summ@XFTPServerSummary {sessions} = summ {sessions = Just $ maybe s (s `addServerSessions`) sessions} addStats :: AgentXFTPServerStatsData -> XFTPServerSummary -> XFTPServerSummary - addStats s summ@XFTPServerSummary {stats} = summ {stats = Just $ maybe s (s `addXFTPStats`) stats} + addStats s summ@XFTPServerSummary {stats} = summ {stats = Just $ maybe s (s `addXFTPStatsData`) stats} addServerSessions :: ServerSessions -> ServerSessions -> ServerSessions addServerSessions ss1 ss2 = ServerSessions @@ -213,56 +265,30 @@ toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrv ssErrors = ssErrors ss1 + ssErrors ss2, ssConnecting = ssConnecting ss1 + ssConnecting ss2 } - addSMPSubs :: SMPServerSubs -> SMPServerSubs -> SMPServerSubs - addSMPSubs ss1 ss2 = - SMPServerSubs - { ssActive = ssActive ss1 + ssActive ss2, - ssPending = ssPending ss1 + ssPending ss2 - } - addSMPStats :: AgentSMPServerStatsData -> AgentSMPServerStatsData -> AgentSMPServerStatsData - addSMPStats sd1 sd2 = - AgentSMPServerStatsData - { _sentDirect = _sentDirect sd1 + _sentDirect sd2, - _sentViaProxy = _sentViaProxy sd1 + _sentViaProxy sd2, - _sentProxied = _sentProxied sd1 + _sentProxied sd2, - _sentDirectAttempts = _sentDirectAttempts sd1 + _sentDirectAttempts sd2, - _sentViaProxyAttempts = _sentViaProxyAttempts sd1 + _sentViaProxyAttempts sd2, - _sentProxiedAttempts = _sentProxiedAttempts sd1 + _sentProxiedAttempts sd2, - _sentAuthErrs = _sentAuthErrs sd1 + _sentAuthErrs sd2, - _sentQuotaErrs = _sentQuotaErrs sd1 + _sentQuotaErrs sd2, - _sentExpiredErrs = _sentExpiredErrs sd1 + _sentExpiredErrs sd2, - _sentOtherErrs = _sentOtherErrs sd1 + _sentOtherErrs sd2, - _recvMsgs = _recvMsgs sd1 + _recvMsgs sd2, - _recvDuplicates = _recvDuplicates sd1 + _recvDuplicates sd2, - _recvCryptoErrs = _recvCryptoErrs sd1 + _recvCryptoErrs sd2, - _recvErrs = _recvErrs sd1 + _recvErrs sd2, - _connCreated = _connCreated sd1 + _connCreated sd2, - _connSecured = _connSecured sd1 + _connSecured sd2, - _connCompleted = _connCompleted sd1 + _connCompleted sd2, - _connDeleted = _connDeleted sd1 + _connDeleted sd2, - _connSubscribed = _connSubscribed sd1 + _connSubscribed sd2, - _connSubAttempts = _connSubAttempts sd1 + _connSubAttempts sd2, - _connSubErrs = _connSubErrs sd1 + _connSubErrs sd2 - } - addXFTPStats :: AgentXFTPServerStatsData -> AgentXFTPServerStatsData -> AgentXFTPServerStatsData - addXFTPStats sd1 sd2 = - AgentXFTPServerStatsData - { _uploads = _uploads sd1 + _uploads sd2, - _uploadAttempts = _uploadAttempts sd1 + _uploadAttempts sd2, - _uploadErrs = _uploadErrs sd1 + _uploadErrs sd2, - _downloads = _downloads sd1 + _downloads sd2, - _downloadAttempts = _downloadAttempts sd1 + _downloadAttempts sd2, - _downloadAuthErrs = _downloadAuthErrs sd1 + _downloadAuthErrs sd2, - _downloadErrs = _downloadErrs sd1 + _downloadErrs sd2, - _deletions = _deletions sd1 + _deletions sd2, - _deleteAttempts = _deleteAttempts sd1 + _deleteAttempts sd2, - _deleteErrs = _deleteErrs sd1 + _deleteErrs sd2 - } + +countUserInAllStats :: AgentUserId -> User -> [User] -> Bool +countUserInAllStats (AgentUserId auId) currentUser users = + auId == aUserId currentUser || auId `notElem` hiddenUserIds + where + hiddenUserIds = map aUserId $ filter (isJust . viewPwdHash) users + +addSMPSubs :: SMPServerSubs -> SMPServerSubs -> SMPServerSubs +addSMPSubs ss1 ss2 = + SMPServerSubs + { ssActive = ssActive ss1 + ssActive ss2, + ssPending = ssPending ss1 + ssPending ss2 + } + +$(J.deriveJSON defaultJSON ''SMPTotals) $(J.deriveJSON defaultJSON ''SMPServerSummary) +$(J.deriveJSON defaultJSON ''SMPServersSummary) + +$(J.deriveJSON defaultJSON ''XFTPTotals) + $(J.deriveJSON defaultJSON ''XFTPServerSummary) -$(J.deriveJSON defaultJSON ''ServersSummary) +$(J.deriveJSON defaultJSON ''XFTPServersSummary) $(J.deriveJSON defaultJSON ''PresentedServersSummary) From 1180edf80e0441d0f41fc6113117382fccf27477 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 3 Jul 2024 16:59:46 +0400 Subject: [PATCH 2/2] ios: servers summary (#4321) * types * chat list buttons * update * wip * xftp summary * sub status view * reconnect server button * reset stats button * refactor * subscription icon in server view * getAgentSubsSummary api * view * subs indicator with timer * rename * variableValueAsPercentage * encodePrettyPrinted * totals, timer * reduce interval * show more/less stats * refactor * sort * rework indicator wip * change offline icon * rework indicator * show percent * file progress * fix * onion * user servers, icons * dashes * Revert "user servers, icons" This reverts commit 805e7e9bd6ad838b14901771f60f6442a021f7d2. * remove icon, remove total, remove session % * exclude sessions * move starting from * file stats * fix * open server settings * file in progress * update * wpi * wip * rework stats * arrow for sessions * texts * reconnect all * single user * rework file stats * update * dont log terminal items * center * update * _connSubIgnored * rename * update * large titles * refactor * update * text * upd * single dash --- apps/ios/Shared/Model/SimpleXAPI.swift | 37 +- .../Shared/Views/ChatList/ChatListView.swift | 125 ++- .../Views/ChatList/ServersSummaryView.swift | 741 ++++++++++++++++++ .../Views/NewChat/NewChatMenuButton.swift | 7 + .../UserSettings/NetworkAndServers.swift | 5 +- .../Views/UserSettings/SettingsView.swift | 2 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/APITypes.swift | 158 +++- apps/ios/SimpleXChat/ChatTypes.swift | 2 + 9 files changed, 1034 insertions(+), 47 deletions(-) create mode 100644 apps/ios/Shared/Views/ChatList/ServersSummaryView.swift diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index eb66071ef8..4751968496 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -92,18 +92,22 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T { return r } -func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse { - logger.debug("chatSendCmd \(cmd.cmdType)") +func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse { + if log { + logger.debug("chatSendCmd \(cmd.cmdType)") + } let start = Date.now let resp = bgTask ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) } : sendSimpleXCmd(cmd, ctrl) - logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") - if case let .response(_, json) = resp { - logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") - } - Task { - await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp) + if log { + logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") + if case let .response(_, json) = resp { + logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") + } + Task { + await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp) + } } return resp } @@ -543,6 +547,11 @@ func reconnectAllServers() async throws { try await sendCommandOkResp(.reconnectAllServers) } +func reconnectServer(smpServer: String) async throws { + let userId = try currentUserId("reconnectServer") + try await sendCommandOkResp(.reconnectServer(userId: userId, smpServer: smpServer)) +} + func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws { try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings)) } @@ -1336,6 +1345,18 @@ func apiGetVersion() throws -> CoreVersionInfo { throw r } +func getAgentServersSummary() throws -> PresentedServersSummary { + let userId = try currentUserId("getAgentServersSummary") + let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false) + if case let .agentServersSummary(_, serversSummary) = r { return serversSummary } + logger.error("getAgentServersSummary error: \(String(describing: r))") + throw r +} + +func resetAgentServersStats() async throws { + try await sendCommandOkResp(.resetAgentServersStats) +} + private func currentUserId(_ funcName: String) throws -> Int64 { if let userId = ChatModel.shared.currentUser?.userId { return userId diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index e39d39293a..9471e09d5e 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -115,9 +115,7 @@ struct ChatListView: View { HStack(spacing: 4) { Text("Chats") .font(.headline) - if chatModel.chats.count > 0 { - toggleFilterButton() - } + SubsStatusIndicator() } .frame(maxWidth: .infinity, alignment: .center) } @@ -131,15 +129,6 @@ struct ChatListView: View { } } - private func toggleFilterButton() -> some View { - Button { - showUnreadAndFavorites = !showUnreadAndFavorites - } label: { - Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : "")) - .foregroundColor(.accentColor) - } - } - @ViewBuilder private var chatList: some View { let cs = filteredChats() ZStack { @@ -272,6 +261,75 @@ struct ChatListView: View { } } +struct SubsStatusIndicator: View { + @State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs + @State private var sess: ServerSessions = ServerSessions.newServerSessions + @State private var timer: Timer? = nil + @State private var timerCounter = 0 + @State private var showServersSummary = false + + @AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false + + // Constants for the intervals + let initialInterval: TimeInterval = 1.0 + let regularInterval: TimeInterval = 3.0 + let initialPhaseDuration: TimeInterval = 10.0 // Duration for initial phase in seconds + + var body: some View { + Button { + showServersSummary = true + } label: { + HStack(spacing: 4) { + SubscriptionStatusIndicatorView(subs: subs, sess: sess) + if showSubscriptionPercentage { + SubscriptionStatusPercentageView(subs: subs, sess: sess) + } + } + } + .onAppear { + startInitialTimer() + } + .onDisappear { + stopTimer() + } + .sheet(isPresented: $showServersSummary) { + ServersSummaryView() + } + } + + private func startInitialTimer() { + timer = Timer.scheduledTimer(withTimeInterval: initialInterval, repeats: true) { _ in + getServersSummary() + timerCounter += 1 + // Switch to the regular timer after the initial phase + if timerCounter * Int(initialInterval) >= Int(initialPhaseDuration) { + switchToRegularTimer() + } + } + } + + func switchToRegularTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: regularInterval, repeats: true) { _ in + getServersSummary() + } + } + + func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func getServersSummary() { + do { + let summ = try getAgentServersSummary() + (subs, sess) = (summ.allUsersSMP.smpTotals.subs, summ.allUsersSMP.smpTotals.sessions) + } catch let error { + logger.error("getAgentServersSummary error: \(responseError(error))") + } + } +} + struct ChatListSearchBar: View { @EnvironmentObject var m: ChatModel @Binding var searchMode: Bool @@ -280,9 +338,9 @@ struct ChatListSearchBar: View { @Binding var searchShowingSimplexLink: Bool @Binding var searchChatFilteredBySimplexLink: String? @State private var ignoreSearchTextChange = false - @State private var showScanCodeSheet = false @State private var alert: PlanAndConnectAlert? @State private var sheet: PlanAndConnectActionSheet? + @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false var body: some View { VStack(spacing: 12) { @@ -299,26 +357,6 @@ struct ChatListSearchBar: View { .onTapGesture { searchText = "" } - } else if !searchFocussed { - HStack(spacing: 24) { - if m.pasteboardHasStrings { - Image(systemName: "doc") - .onTapGesture { - if let str = UIPasteboard.general.string { - searchText = str - } - } - } - - Image(systemName: "qrcode") - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .onTapGesture { - showScanCodeSheet = true - } - } - .padding(.trailing, 2) } } .padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7)) @@ -333,14 +371,12 @@ struct ChatListSearchBar: View { searchText = "" searchFocussed = false } + } else if m.chats.count > 0 { + toggleFilterButton() } } Divider() } - .sheet(isPresented: $showScanCodeSheet) { - NewChatView(selection: .connect, showQRCodeScanner: true) - .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) // fixes .refreshable in ChatListView affecting nested view - } .onChange(of: searchFocussed) { sf in withAnimation { searchMode = sf } } @@ -374,6 +410,21 @@ struct ChatListSearchBar: View { } } + private func toggleFilterButton() -> some View { + ZStack { + Color.clear + .frame(width: 22, height: 22) + Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease") + .resizable() + .scaledToFit() + .foregroundColor(showUnreadAndFavorites ? .accentColor : .secondary) + .frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16) + .onTapGesture { + showUnreadAndFavorites = !showUnreadAndFavorites + } + } + } + private func connect(_ link: String) { planAndConnect( link, diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift new file mode 100644 index 0000000000..ca55d9493b --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift @@ -0,0 +1,741 @@ +// +// ServersSummaryView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 25.06.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ServersSummaryView: View { + @EnvironmentObject var m: ChatModel + @State private var serversSummary: PresentedServersSummary? = nil + @State private var selectedUserCategory: PresentedUserCategory = .allUsers + @State private var selectedServerType: PresentedServerType = .smp + @State private var selectedSMPServer: String? = nil + @State private var selectedXFTPServer: String? = nil + @State private var timer: Timer? = nil + @State private var alert: SomeAlert? + + @AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false + + enum PresentedUserCategory { + case currentUser + case allUsers + } + + enum PresentedServerType { + case smp + case xftp + } + + var body: some View { + NavigationView { + viewBody() + .navigationTitle("Servers info") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + shareButton() + } + } + } + .onAppear { + if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count == 1 { + selectedUserCategory = .currentUser + } + getServersSummary() + startTimer() + } + .onDisappear { + stopTimer() + } + .alert(item: $alert) { $0.alert } + } + + private func startTimer() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + getServersSummary() + } + } + + func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func shareButton() -> some View { + Button { + if let serversSummary = serversSummary { + showShareSheet(items: [encodePrettyPrinted(serversSummary)]) + } + } label: { + Image(systemName: "square.and.arrow.up") + } + .disabled(serversSummary == nil) + } + + public func encodePrettyPrinted(_ value: T) -> String { + let encoder = jsonEncoder + encoder.outputFormatting = .prettyPrinted + let data = try! encoder.encode(value) + return String(decoding: data, as: UTF8.self) + } + + @ViewBuilder private func viewBody() -> some View { + if let summ = serversSummary { + List { + Group { + if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 { + Picker("User selection", selection: $selectedUserCategory) { + Text("All users").tag(PresentedUserCategory.allUsers) + Text("Current user").tag(PresentedUserCategory.currentUser) + } + .pickerStyle(.segmented) + } + + Picker("Server type", selection: $selectedServerType) { + Text("Messages").tag(PresentedServerType.smp) + Text("Files").tag(PresentedServerType.xftp) + } + .pickerStyle(.segmented) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + + switch (selectedUserCategory, selectedServerType) { + case (.allUsers, .smp): + let smpSumm = summ.allUsersSMP + let (totals, curr, prev, prox) = (smpSumm.smpTotals, smpSumm.currentlyUsedSMPServers, smpSumm.previouslyUsedSMPServers, smpSumm.onlyProxiedSMPServers) + + SMPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt) + + smpSubsSection(totals) + + if curr.count > 0 { + smpServersListView(curr, summ.statsStartedAt, "Connected servers") + } + if prev.count > 0 { + smpServersListView(prev, summ.statsStartedAt, "Previously connected servers") + } + if prox.count > 0 { + smpServersListView(prox, summ.statsStartedAt, "Proxied servers", "You are not connected to these servers. Private routing is used to deliver messages to them.") + } + + ServerSessionsView(sess: totals.sessions) + case (.currentUser, .smp): + let smpSumm = summ.currentUserSMP + let (totals, curr, prev, prox) = (smpSumm.smpTotals, smpSumm.currentlyUsedSMPServers, smpSumm.previouslyUsedSMPServers, smpSumm.onlyProxiedSMPServers) + + SMPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt) + + smpSubsSection(totals) + + if curr.count > 0 { + smpServersListView(curr, summ.statsStartedAt, "Connected servers") + } + if prev.count > 0 { + smpServersListView(prev, summ.statsStartedAt, "Previously connected servers") + } + if prox.count > 0 { + smpServersListView(prox, summ.statsStartedAt, "Proxied servers", "You are not connected to these servers. Private routing is used to deliver messages to them.") + } + + ServerSessionsView(sess: totals.sessions) + case (.allUsers, .xftp): + let xftpSumm = summ.allUsersXFTP + let (totals, curr, prev) = (xftpSumm.xftpTotals, xftpSumm.currentlyUsedXFTPServers, xftpSumm.previouslyUsedXFTPServers) + + XFTPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt) + + if curr.count > 0 { + xftpServersListView(curr, summ.statsStartedAt, "Connected servers") + } + if prev.count > 0 { + xftpServersListView(prev, summ.statsStartedAt, "Previously connected servers") + } + + ServerSessionsView(sess: totals.sessions) + case (.currentUser, .xftp): + let xftpSumm = summ.currentUserXFTP + let (totals, curr, prev) = (xftpSumm.xftpTotals, xftpSumm.currentlyUsedXFTPServers, xftpSumm.previouslyUsedXFTPServers) + + XFTPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt) + + if curr.count > 0 { + xftpServersListView(curr, summ.statsStartedAt, "Connected servers") + } + if prev.count > 0 { + xftpServersListView(prev, summ.statsStartedAt, "Previously connected servers") + } + + ServerSessionsView(sess: totals.sessions) + } + + Section { + reconnectAllButton() + resetStatsButton() + } + } + } else { + Text("No info, try to reload") + } + } + + private func smpSubsSection(_ totals: SMPTotals) -> some View { + Section { + infoRow("Connections subscribed", numOrDash(totals.subs.ssActive)) + infoRow("Total", numOrDash(totals.subs.total)) + } header: { + HStack { + Text("Message subscriptions") + SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions) + if showSubscriptionPercentage { + SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions) + } + } + } + } + + private func reconnectAllButton() -> some View { + Button { + alert = SomeAlert( + alert: Alert( + title: Text("Reconnect all servers?"), + message: Text("Reconnect all connected servers to force message delivery. It uses additional traffic."), + primaryButton: .default(Text("Ok")) { + Task { + do { + try await reconnectAllServers() + } catch let error { + alert = SomeAlert( + alert: mkAlert( + title: "Error reconnecting servers", + message: "\(responseError(error))" + ), + id: "error reconnecting servers" + ) + } + } + }, + secondaryButton: .cancel() + ), + id: "reconnect servers question" + ) + } label: { + Text("Reconnect all servers") + } + } + + @ViewBuilder private func smpServersListView( + _ servers: [SMPServerSummary], + _ statsStartedAt: Date, + _ header: LocalizedStringKey? = nil, + _ footer: LocalizedStringKey? = nil + ) -> some View { + let sortedServers = servers.sorted { + $0.hasSubs == $1.hasSubs + ? serverAddress($0.smpServer) < serverAddress($1.smpServer) + : $0.hasSubs && !$1.hasSubs + } + Section { + ForEach(sortedServers) { server in + smpServerView(server, statsStartedAt) + } + } header: { + if let header = header { + Text(header) + } + } footer: { + if let footer = footer { + Text(footer) + } + } + } + + private func smpServerView(_ srvSumm: SMPServerSummary, _ statsStartedAt: Date) -> some View { + NavigationLink(tag: srvSumm.id, selection: $selectedSMPServer) { + SMPServerSummaryView( + summary: srvSumm, + statsStartedAt: statsStartedAt + ) + .navigationBarTitle("SMP server") + .navigationBarTitleDisplayMode(.large) + } label: { + HStack { + Text(serverAddress(srvSumm.smpServer)) + .lineLimit(1) + if let subs = srvSumm.subs { + Spacer() + if showSubscriptionPercentage { + SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew) + } + SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew) + } else if let sess = srvSumm.sessions { + Spacer() + Image(systemName: "arrow.up.circle") + .symbolRenderingMode(.palette) + .foregroundStyle(sessIconColor(sess), Color.clear) + } + } + } + } + + private func serverAddress(_ server: String) -> String { + parseServerAddress(server)?.hostnames.first ?? server + } + + private func sessIconColor(_ sess: ServerSessions) -> Color { + let online = m.networkInfo.online + return ( + online && sess.ssConnected > 0 + ? sessionActiveColor + : Color(uiColor: .tertiaryLabel) + ) + } + + private var sessionActiveColor: Color { + let onionHosts = networkUseOnionHostsGroupDefault.get() + return onionHosts == .require ? .indigo : .accentColor + } + + @ViewBuilder private func xftpServersListView( + _ servers: [XFTPServerSummary], + _ statsStartedAt: Date, + _ header: LocalizedStringKey? = nil, + _ footer: LocalizedStringKey? = nil + ) -> some View { + let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) } + Section { + ForEach(sortedServers) { server in + xftpServerView(server, statsStartedAt) + } + } header: { + if let header = header { + Text(header) + } + } footer: { + if let footer = footer { + Text(footer) + } + } + } + + private func xftpServerView(_ srvSumm: XFTPServerSummary, _ statsStartedAt: Date) -> some View { + NavigationLink(tag: srvSumm.id, selection: $selectedXFTPServer) { + XFTPServerSummaryView( + summary: srvSumm, + statsStartedAt: statsStartedAt + ) + .navigationBarTitle("XFTP server") + .navigationBarTitleDisplayMode(.large) + } label: { + HStack { + Text(serverAddress(srvSumm.xftpServer)) + .lineLimit(1) + if let inProgressIcon = inProgressIcon(srvSumm) { + Spacer() + Image(systemName: inProgressIcon) + .symbolRenderingMode(.palette) + .foregroundStyle(sessionActiveColor, Color.clear) + } + } + } + } + + private func inProgressIcon(_ srvSumm: XFTPServerSummary) -> String? { + switch (srvSumm.rcvInProgress, srvSumm.sndInProgress, srvSumm.delInProgress) { + case (false, false, false): nil + case (true, false, false): "arrow.down.circle" + case (false, true, false): "arrow.up.circle" + case (false, false, true): "trash.circle" + default: "arrow.up.arrow.down.circle" + } + } + + private func resetStatsButton() -> some View { + Button { + alert = SomeAlert( + alert: Alert( + title: Text("Reset all servers statistics?"), + message: Text("Servers statistics will be reset - this cannot be undone!"), + primaryButton: .destructive(Text("Reset")) { + Task { + do { + try await resetAgentServersStats() + getServersSummary() + } catch let error { + alert = SomeAlert( + alert: mkAlert( + title: "Error resetting statistics", + message: "\(responseError(error))" + ), + id: "error resetting statistics" + ) + } + } + }, + secondaryButton: .cancel() + ), + id: "reset statistics question" + ) + } label: { + Text("Reset all statistics") + } + } + + private func getServersSummary() { + do { + serversSummary = try getAgentServersSummary() + } catch let error { + logger.error("getAgentServersSummary error: \(responseError(error))") + } + } +} + +struct SubscriptionStatusIndicatorView: View { + @EnvironmentObject var m: ChatModel + var subs: SMPServerSubs + var sess: ServerSessions + + var body: some View { + let onionHosts = networkUseOnionHostsGroupDefault.get() + let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess) + if #available(iOS 16.0, *) { + Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue) + .foregroundColor(color) + } else { + Image(systemName: "dot.radiowaves.up.forward") + .foregroundColor(color.opacity(opacity)) + } + } +} + +struct SubscriptionStatusPercentageView: View { + @EnvironmentObject var m: ChatModel + var subs: SMPServerSubs + var sess: ServerSessions + + var body: some View { + let onionHosts = networkUseOnionHostsGroupDefault.get() + let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess) + Text("\(Int(floor(statusPercent * 100)))%") + .foregroundColor(.secondary) + .font(.caption) + } +} + +func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) { + func roundedToQuarter(_ n: Double) -> Double { + n >= 1 ? 1 + : n <= 0 ? 0 + : (n * 4).rounded() / 4 + } + + let activeColor: Color = onionHosts == .require ? .indigo : .accentColor + let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0) + let activeSubsRounded = roundedToQuarter(subs.shareOfActive) + + return online && subs.total > 0 + ? ( + subs.ssActive == 0 + ? ( + sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) + ) + : ( // ssActive > 0 + sess.ssConnected == 0 + ? (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error + : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) + ) + ) + : noConnColorAndPercent +} + +struct SMPServerSummaryView: View { + var summary: SMPServerSummary + var statsStartedAt: Date + @State private var alert: SomeAlert? + + @AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false + + var body: some View { + List { + Section("Server address") { + Text(summary.smpServer) + .textSelection(.enabled) + if summary.known == true { + NavigationLink { + ProtocolServersView(serverProtocol: .smp) + .navigationTitle("Your SMP servers") + } label: { + Text("Open server settings") + } + } + } + + if let stats = summary.stats { + SMPStatsView(stats: stats, statsStartedAt: statsStartedAt) + } + + if let subs = summary.subs { + smpSubsSection(subs) + } + + if let sess = summary.sessions { + ServerSessionsView(sess: sess) + } + } + .alert(item: $alert) { $0.alert } + } + + private func smpSubsSection(_ subs: SMPServerSubs) -> some View { + Section { + infoRow("Connections subscribed", numOrDash(subs.ssActive)) + infoRow("Pending", numOrDash(subs.ssPending)) + infoRow("Total", numOrDash(subs.total)) + reconnectButton() + } header: { + HStack { + Text("Message subscriptions") + SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew) + if showSubscriptionPercentage { + SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew) + } + } + } + } + + private func reconnectButton() -> some View { + Button { + alert = SomeAlert( + alert: Alert( + title: Text("Reconnect server?"), + message: Text("Reconnect server to force message delivery. It uses additional traffic."), + primaryButton: .default(Text("Ok")) { + Task { + do { + try await reconnectServer(smpServer: summary.smpServer) + } catch let error { + alert = SomeAlert( + alert: mkAlert( + title: "Error reconnecting server", + message: "\(responseError(error))" + ), + id: "error reconnecting server" + ) + } + } + }, + secondaryButton: .cancel() + ), + id: "reconnect server question" + ) + } label: { + Text("Reconnect") + } + } +} + +struct ServerSessionsView: View { + var sess: ServerSessions + + var body: some View { + Section("Transport sessions") { + infoRow("Connected", numOrDash(sess.ssConnected)) + infoRow("Errors", numOrDash(sess.ssErrors)) + infoRow("Connecting", numOrDash(sess.ssConnecting)) + } + } +} + +struct SMPStatsView: View { + var stats: AgentSMPServerStatsData + var statsStartedAt: Date + + var body: some View { + Section { + infoRow("Messages sent", numOrDash(stats._sentDirect + stats._sentViaProxy)) + infoRow("Messages received", numOrDash(stats._recvMsgs)) + NavigationLink { + DetailedSMPStatsView(stats: stats, statsStartedAt: statsStartedAt) + .navigationTitle("Detailed statistics") + .navigationBarTitleDisplayMode(.large) + } label: { + Text("Details") + } + } header: { + Text("Statistics") + } footer: { + Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.") + } + } +} + +private func numOrDash(_ n: Int) -> String { + n == 0 ? "-" : "\(n)" +} + +struct DetailedSMPStatsView: View { + var stats: AgentSMPServerStatsData + var statsStartedAt: Date + + var body: some View { + List { + Section("Sent messages") { + infoRow("Sent total", numOrDash(stats._sentDirect + stats._sentViaProxy)) + infoRowTwoValues("Sent directly", "attempts", stats._sentDirect, stats._sentDirectAttempts) + infoRowTwoValues("Sent via proxy", "attempts", stats._sentViaProxy, stats._sentViaProxyAttempts) + infoRowTwoValues("Proxied", "attempts", stats._sentProxied, stats._sentProxiedAttempts) + Text("Send errors") + indentedInfoRow("AUTH", numOrDash(stats._sentAuthErrs)) + indentedInfoRow("QUOTA", numOrDash(stats._sentQuotaErrs)) + indentedInfoRow("expired", numOrDash(stats._sentExpiredErrs)) + indentedInfoRow("other", numOrDash(stats._sentOtherErrs)) + } + Section("Received messages") { + infoRow("Received total", numOrDash(stats._recvMsgs)) + Text("Receive errors") + indentedInfoRow("duplicates", numOrDash(stats._recvDuplicates)) + indentedInfoRow("decryption errors", numOrDash(stats._recvCryptoErrs)) + indentedInfoRow("other errors", numOrDash(stats._recvErrs)) + infoRowTwoValues("Acknowledged", "attempts", stats._ackMsgs, stats._ackAttempts) + Text("Acknowledgement errors") + indentedInfoRow("NO_MSG errors", numOrDash(stats._ackNoMsgErrs)) + indentedInfoRow("other errors", numOrDash(stats._ackOtherErrs)) + } + Section { + infoRow("Created", numOrDash(stats._connCreated)) + infoRow("Secured", numOrDash(stats._connCreated)) + infoRow("Completed", numOrDash(stats._connCompleted)) + infoRowTwoValues("Deleted", "attempts", stats._connDeleted, stats._connDelAttempts) + infoRow("Deletion errors", numOrDash(stats._connDelErrs)) + infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts) + infoRow("Subscription results ignored", numOrDash(stats._connSubIgnored)) + infoRow("Subscription errors", numOrDash(stats._connSubErrs)) + } header: { + Text("Connections") + } footer: { + Text("Starting from \(localTimestamp(statsStartedAt)).") + } + } + } +} + +private func infoRowTwoValues(_ title: LocalizedStringKey, _ title2: LocalizedStringKey, _ value: Int, _ value2: Int) -> some View { + HStack { + Text(title) + Text(" / ").font(.caption2) + Text(title2).font(.caption2) + Spacer() + Group { + if value == 0 && value2 == 0 { + Text("-") + } else { + Text(numOrDash(value)) + Text(" / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2) + } + } + .foregroundStyle(.secondary) + } +} + +private func indentedInfoRow(_ title: LocalizedStringKey, _ value: String) -> some View { + HStack { + Text(title) + .padding(.leading, 24) + Spacer() + Text(value) + .foregroundStyle(.secondary) + } +} + +struct XFTPServerSummaryView: View { + var summary: XFTPServerSummary + var statsStartedAt: Date + + var body: some View { + List { + Section("Server address") { + Text(summary.xftpServer) + .textSelection(.enabled) + if summary.known == true { + NavigationLink { + ProtocolServersView(serverProtocol: .xftp) + .navigationTitle("Your XFTP servers") + } label: { + Text("Open server settings") + } + } + } + + if let stats = summary.stats { + XFTPStatsView(stats: stats, statsStartedAt: statsStartedAt) + } + + if let sess = summary.sessions { + ServerSessionsView(sess: sess) + } + } + } +} + +struct XFTPStatsView: View { + var stats: AgentXFTPServerStatsData + var statsStartedAt: Date + @State private var expanded = false + + var body: some View { + Section { + infoRow("Uploaded", prettySize(stats._uploadsSize)) + infoRow("Downloaded", prettySize(stats._downloadsSize)) + NavigationLink { + DetailedXFTPStatsView(stats: stats, statsStartedAt: statsStartedAt) + .navigationTitle("Detailed statistics") + .navigationBarTitleDisplayMode(.large) + } label: { + Text("Details") + } + } header: { + Text("Statistics") + } footer: { + Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.") + } + } +} + +private func prettySize(_ sizeInKB: Int64) -> String { + let kb: Int64 = 1024 + return sizeInKB == 0 ? "-" : ByteCountFormatter.string(fromByteCount: sizeInKB * kb, countStyle: .binary) +} + +struct DetailedXFTPStatsView: View { + var stats: AgentXFTPServerStatsData + var statsStartedAt: Date + + var body: some View { + List { + Section("Uploaded files") { + infoRow("Size", prettySize(stats._uploadsSize)) + infoRowTwoValues("Chunks uploaded", "attempts", stats._uploads, stats._uploadAttempts) + infoRow("Upload errors", numOrDash(stats._uploadErrs)) + infoRowTwoValues("Chunks deleted", "attempts", stats._deletions, stats._deleteAttempts) + infoRow("Deletion errors", numOrDash(stats._deleteErrs)) + } + Section { + infoRow("Size", prettySize(stats._downloadsSize)) + infoRowTwoValues("Chunks downloaded", "attempts", stats._downloads, stats._downloadAttempts) + Text("Download errors") + indentedInfoRow("AUTH", numOrDash(stats._downloadAuthErrs)) + indentedInfoRow("other", numOrDash(stats._downloadErrs)) + } header: { + Text("Downloaded files") + } footer: { + Text("Starting from \(localTimestamp(statsStartedAt)).") + } + } + } +} + +#Preview { + ServersSummaryView() +} diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index c3452ce18d..3be1095bfd 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -10,6 +10,7 @@ import SwiftUI enum NewChatMenuOption: Identifiable { case newContact + case scanPaste case newGroup var id: Self { self } @@ -25,6 +26,11 @@ struct NewChatMenuButton: View { } label: { Text("Add contact") } + Button { + newChatMenuOption = .scanPaste + } label: { + Text("Scan / Paste link") + } Button { newChatMenuOption = .newGroup } label: { @@ -39,6 +45,7 @@ struct NewChatMenuButton: View { .sheet(item: $newChatMenuOption) { opt in switch opt { case .newContact: NewChatView(selection: .invite) + case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true) case .newGroup: AddGroupView() } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift index 6d849479e5..19ae3e61ab 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift @@ -30,7 +30,8 @@ private enum NetworkAlert: Identifiable { struct NetworkAndServers: View { @EnvironmentObject var m: ChatModel @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false - @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = true + @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false + @AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false @State private var cfgLoaded = false @State private var currentNetCfg = NetCfg.defaults @State private var netCfg = NetCfg.defaults @@ -58,6 +59,8 @@ struct NetworkAndServers: View { Text("XFTP servers") } + Toggle("Subscription percentage", isOn: $showSubscriptionPercentage) + Picker("Use .onion hosts", selection: $onionHosts) { ForEach(OnionHosts.values, id: \.self) { Text($0.text) } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 89582ab810..a2df236738 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -61,6 +61,7 @@ let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto" let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" +let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage" let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen" @@ -101,6 +102,7 @@ let appDefaults: [String: Any] = [ DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true, DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true, DEFAULT_SHOW_SENT_VIA_RPOXY: false, + DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false, ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue ] diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 057d6cccbe..4a0dc23bff 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -177,6 +177,7 @@ 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; }; 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; + 64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; 8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; }; 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; }; @@ -474,6 +475,7 @@ 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = ""; }; 64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; + 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = ""; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; 8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = ""; }; 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; @@ -802,6 +804,7 @@ 5C13730A28156D2700F43030 /* ContactConnectionView.swift */, 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */, 18415835CBD939A9ABDC108A /* UserPicker.swift */, + 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */, ); path = ChatList; sourceTree = ""; @@ -1256,6 +1259,7 @@ 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */, 8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */, + 64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */, 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 1ab2e2b575..a54effbf91 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -10,7 +10,7 @@ import Foundation import SwiftUI public let jsonDecoder = getJSONDecoder() -let jsonEncoder = getJSONEncoder() +public let jsonEncoder = getJSONEncoder() public enum ChatCommand { case showActiveUser @@ -78,6 +78,7 @@ public enum ChatCommand { case apiGetNetworkConfig case apiSetNetworkInfo(networkInfo: UserNetworkInfo) case reconnectAllServers + case reconnectServer(userId: Int64, smpServer: String) case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) case apiContactInfo(contactId: Int64) @@ -122,6 +123,7 @@ public enum ChatCommand { case apiEndCall(contact: Contact) case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) + // WebRTC calls / case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) @@ -142,6 +144,8 @@ public enum ChatCommand { case apiStandaloneFileInfo(url: String) // misc case showVersion + case getAgentServersSummary(userId: Int64) + case resetAgentServersStats case string(String) public var cmdString: String { @@ -226,6 +230,7 @@ public enum ChatCommand { case .apiGetNetworkConfig: return "/network" case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))" case .reconnectAllServers: return "/reconnect" + case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" case let .apiContactInfo(contactId): return "/_info @\(contactId)" @@ -301,6 +306,8 @@ public enum ChatCommand { case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" case let .apiStandaloneFileInfo(link): return "/_download info \(link)" case .showVersion: return "/version" + case let .getAgentServersSummary(userId): return "/get servers summary \(userId)" + case .resetAgentServersStats: return "/reset servers stats" case let .string(str): return str } } @@ -375,6 +382,7 @@ public enum ChatCommand { case .apiGetNetworkConfig: return "apiGetNetworkConfig" case .apiSetNetworkInfo: return "apiSetNetworkInfo" case .reconnectAllServers: return "reconnectAllServers" + case .reconnectServer: return "reconnectServer" case .apiSetChatSettings: return "apiSetChatSettings" case .apiSetMemberSettings: return "apiSetMemberSettings" case .apiContactInfo: return "apiContactInfo" @@ -435,6 +443,8 @@ public enum ChatCommand { case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" case .showVersion: return "showVersion" + case .getAgentServersSummary: return "getAgentServersSummary" + case .resetAgentServersStats: return "resetAgentServersStats" case .string: return "console command" } } @@ -663,6 +673,8 @@ public enum ChatResponse: Decodable, Error { // misc case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user: UserRef?) + case agentServersSummary(user: UserRef, serversSummary: PresentedServersSummary) + case agentSubsSummary(user: UserRef, subsSummary: SMPServerSubs) case chatCmdError(user_: UserRef?, chatError: ChatError) case chatError(user_: UserRef?, chatError: ChatError) case archiveImported(archiveErrors: [ArchiveError]) @@ -821,6 +833,8 @@ public enum ChatResponse: Decodable, Error { case .contactPQEnabled: return "contactPQEnabled" case .versionInfo: return "versionInfo" case .cmdOk: return "cmdOk" + case .agentServersSummary: return "agentServersSummary" + case .agentSubsSummary: return "agentSubsSummary" case .chatCmdError: return "chatCmdError" case .chatError: return "chatError" case .archiveImported: return "archiveImported" @@ -984,6 +998,8 @@ public enum ChatResponse: Decodable, Error { case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" case .cmdOk: return noDetails + case let .agentServersSummary(u, serversSummary): return withUser(u, String(describing: serversSummary)) + case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary)) case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) case let .archiveImported(archiveErrors): return String(describing: archiveErrors) @@ -2230,3 +2246,143 @@ public enum MsgType: String, Codable, Hashable { case message case quota } + +public struct PresentedServersSummary: Codable { + public var statsStartedAt: Date + public var allUsersSMP: SMPServersSummary + public var allUsersXFTP: XFTPServersSummary + public var currentUserSMP: SMPServersSummary + public var currentUserXFTP: XFTPServersSummary +} + +public struct SMPServersSummary: Codable { + public var smpTotals: SMPTotals + public var currentlyUsedSMPServers: [SMPServerSummary] + public var previouslyUsedSMPServers: [SMPServerSummary] + public var onlyProxiedSMPServers: [SMPServerSummary] +} + +public struct SMPTotals: Codable { + public var sessions: ServerSessions + public var subs: SMPServerSubs + public var stats: AgentSMPServerStatsData +} + +public struct SMPServerSummary: Codable, Identifiable { + public var smpServer: String + public var known: Bool? + public var sessions: ServerSessions? + public var subs: SMPServerSubs? + public var stats: AgentSMPServerStatsData? + + public var id: String { smpServer } + + public var hasSubs: Bool { subs != nil } + + public var sessionsOrNew: ServerSessions { sessions ?? ServerSessions.newServerSessions } + + public var subsOrNew: SMPServerSubs { subs ?? SMPServerSubs.newSMPServerSubs } +} + +public struct ServerSessions: Codable { + public var ssConnected: Int + public var ssErrors: Int + public var ssConnecting: Int + + static public var newServerSessions = ServerSessions( + ssConnected: 0, + ssErrors: 0, + ssConnecting: 0 + ) +} + +public struct SMPServerSubs: Codable { + public var ssActive: Int + public var ssPending: Int + + public init(ssActive: Int, ssPending: Int) { + self.ssActive = ssActive + self.ssPending = ssPending + } + + static public var newSMPServerSubs = SMPServerSubs( + ssActive: 0, + ssPending: 0 + ) + + public var total: Int { ssActive + ssPending } + + public var shareOfActive: Double { + guard total != 0 else { return 0.0 } + return Double(ssActive) / Double(total) + } +} + +public struct AgentSMPServerStatsData: Codable { + public var _sentDirect: Int + public var _sentViaProxy: Int + public var _sentProxied: Int + public var _sentDirectAttempts: Int + public var _sentViaProxyAttempts: Int + public var _sentProxiedAttempts: Int + public var _sentAuthErrs: Int + public var _sentQuotaErrs: Int + public var _sentExpiredErrs: Int + public var _sentOtherErrs: Int + public var _recvMsgs: Int + public var _recvDuplicates: Int + public var _recvCryptoErrs: Int + public var _recvErrs: Int + public var _ackMsgs: Int + public var _ackAttempts: Int + public var _ackNoMsgErrs: Int + public var _ackOtherErrs: Int + public var _connCreated: Int + public var _connSecured: Int + public var _connCompleted: Int + public var _connDeleted: Int + public var _connDelAttempts: Int + public var _connDelErrs: Int + public var _connSubscribed: Int + public var _connSubAttempts: Int + public var _connSubIgnored: Int + public var _connSubErrs: Int +} + +public struct XFTPServersSummary: Codable { + public var xftpTotals: XFTPTotals + public var currentlyUsedXFTPServers: [XFTPServerSummary] + public var previouslyUsedXFTPServers: [XFTPServerSummary] +} + +public struct XFTPTotals: Codable { + public var sessions: ServerSessions + public var stats: AgentXFTPServerStatsData +} + +public struct XFTPServerSummary: Codable, Identifiable { + public var xftpServer: String + public var known: Bool? + public var sessions: ServerSessions? + public var stats: AgentXFTPServerStatsData? + public var rcvInProgress: Bool + public var sndInProgress: Bool + public var delInProgress: Bool + + public var id: String { xftpServer } +} + +public struct AgentXFTPServerStatsData: Codable { + public var _uploads: Int + public var _uploadsSize: Int64 + public var _uploadAttempts: Int + public var _uploadErrs: Int + public var _downloads: Int + public var _downloadsSize: Int64 + public var _downloadAttempts: Int + public var _downloadAuthErrs: Int + public var _downloadErrs: Int + public var _deletions: Int + public var _deleteAttempts: Int + public var _deleteErrs: Int +} diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index d2a7e704ad..46cb384a58 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -11,6 +11,7 @@ import SwiftUI public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 + public var agentUserId: String var userContactId: Int64 var localDisplayName: ContactName public var profile: LocalProfile @@ -41,6 +42,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public static let sampleData = User( userId: 1, + agentUserId: "abc", userContactId: 1, localDisplayName: "alice", profile: LocalProfile.sampleData,