android, desktop: servers summary (#4398)

* multiplatform: subscription icon (#4397)

* multiplatform: added network setting to control display of subscription percentage

* multiplatform: moved filter to search bar and scan to button

* multiplatform: added types and calls for new apis

* Update apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* chore: minor changes in API connections and usage

* fix: removed show sub setting from net cfg

* multiplatform: added subscription status to chats screen

* multiplatform: added reconnect all servers api

* multiplatform: added basic view for single user messages

* multiplatform: added basic xftp server info view

* multiplatform: added detailed stats view for SMP server

* multiplatform: added detailed stats view for XFTP server

* multiplatform: added individual server view for XFTP servers

* multiplatform: added individual server view for SMP servers

* multiplatform: added custom coloring for connections

* multiplatform: added all translation strings

* multiplatform: added support for multi user in serve summary

* multiplatform: added missing translations

* multiplatform: added share button to servers info

* better type safety for server summaries

* multiplatform: fixed action arrow paddings in server summary views

* multiplatform: serverSummaryView padding and icon fixes

* multiplatform: reused shared section divider

* move and rename

* remove tab icons, text

* colors

* filter button

* paddings

* fix translation keys

* text

* fix buttons clickable area, alerts

* stats view

* remove chevrons

* colors

* remove id, fix open server button

* don't log terminal items

* desktop left modal

* single timer, pass state variable

* remove unused

* fix no summary view

* net cfg

* ability to hide servers screen by clicking outside servers screen

* addressed review feedback

* move user/all users selector under tabs

* disable horizontal scroll on desktop

---------

Co-authored-by: Diogo Cunha <diogofncunha@gmail.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
spaced4ndy
2024-07-09 12:45:09 +01:00
committed by GitHub
co-authored by Diogo Cunha Avently Evgeny Poberezkin
parent fd90b47194
commit 3e623684bc
15 changed files with 1486 additions and 68 deletions
@@ -353,7 +353,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
}
val (userPickerState, scaffoldState ) = settingsState
val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen) {
if (scaffoldState.drawerState.isOpen || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
Box(
Modifier
.fillMaxSize()
@@ -127,6 +127,7 @@ class AppPreferences {
val showSlowApiCalls = mkBoolPreference(SHARED_PREFS_SHOW_SLOW_API_CALLS, false)
val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false)
val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
val networkShowSubscriptionPercentage = mkBoolPreference(SHARED_PREFS_NETWORK_SHOW_SUBSCRIPTION_PERCENTAGE, false)
val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050")
private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name)
val networkSessionMode: SharedPreference<TransportSessionMode> = SharedPreference(
@@ -338,6 +339,7 @@ class AppPreferences {
private const val SHARED_PREFS_SHOW_SLOW_API_CALLS = "ShowSlowApiCalls"
private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible"
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
private const val SHARED_PREFS_NETWORK_SHOW_SUBSCRIPTION_PERCENTAGE = "ShowSubscriptionPercentage"
private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort"
private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode"
private const val SHARED_PREFS_NETWORK_SMP_PROXY_MODE = "NetworkSMPProxyMode"
@@ -411,6 +413,18 @@ object ChatController {
fun hasChatCtrl() = ctrl != -1L && ctrl != null
suspend fun getAgentServersSummary(rh: Long?): PresentedServersSummary? {
val userId = currentUserId("getAgentServersSummary")
val r = sendCmd(rh, CC.GetAgentServersSummary(userId), log = false)
if (r is CR.AgentServersSummary) return r.serversSummary
Log.e(TAG, "getAgentServersSummary bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun resetAgentServersStats(rh: Long?): Boolean = sendCommandOkResp(rh, CC.ResetAgentServersStats())
private suspend fun currentUserId(funcName: String): Long = changingActiveUserMutex.withLock {
val userId = chatModel.currentUser.value?.userId
if (userId == null) {
@@ -569,20 +583,24 @@ object ChatController {
}
}
suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null): CR {
suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, log: Boolean = true): CR {
val ctrl = otherCtrl ?: ctrl ?: throw Exception("Controller is not initialized")
return withContext(Dispatchers.IO) {
val c = cmd.cmdString
chatModel.addTerminalItem(TerminalItem.cmd(rhId, cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
if (log) {
chatModel.addTerminalItem(TerminalItem.cmd(rhId, cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
}
val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c)
val r = APIResponse.decodeStr(json)
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
if (r.resp is CR.Response || r.resp is CR.Invalid) {
Log.d(TAG, "sendCmd response json $json")
if (log) {
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
if (r.resp is CR.Response || r.resp is CR.Invalid) {
Log.d(TAG, "sendCmd response json $json")
}
chatModel.addTerminalItem(TerminalItem.resp(rhId, r.resp))
}
chatModel.addTerminalItem(TerminalItem.resp(rhId, r.resp))
r.resp
}
}
@@ -920,6 +938,14 @@ object ChatController {
}
}
suspend fun reconnectServer(rh: Long?, server: String): Boolean {
val userId = currentUserId("reconnectServer")
return sendCommandOkResp(rh, CC.ReconnectServer(userId, server))
}
suspend fun reconnectAllServers(rh: Long?): Boolean = sendCommandOkResp(rh, CC.ReconnectAllServers())
suspend fun apiSetSettings(rh: Long?, type: ChatType, id: Long, settings: ChatSettings): Boolean {
val r = sendCmd(rh, CC.APISetChatSettings(type, id, settings))
return when (r) {
@@ -2577,6 +2603,8 @@ sealed class CC {
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
class APIGetNetworkConfig: CC()
class APISetNetworkInfo(val networkInfo: UserNetworkInfo): CC()
class ReconnectServer(val userId: Long, val server: String): CC()
class ReconnectAllServers: CC()
class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC()
class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC()
class APIContactInfo(val contactId: Long): CC()
@@ -2648,6 +2676,8 @@ sealed class CC {
class ApiStandaloneFileInfo(val url: String): CC()
// misc
class ShowVersion(): CC()
class ResetAgentServersStats(): CC()
class GetAgentServersSummary(val userId: Long): CC()
val cmdString: String get() = when (this) {
is Console -> cmd
@@ -2724,6 +2754,8 @@ sealed class CC {
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
is APIGetNetworkConfig -> "/network"
is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}"
is ReconnectServer -> "/reconnect $userId $server"
is ReconnectAllServers -> "/reconnect"
is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}"
is APIContactInfo -> "/_info @$contactId"
@@ -2804,6 +2836,8 @@ sealed class CC {
is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}"
is ApiStandaloneFileInfo -> "/_download info $url"
is ShowVersion -> "/version"
is ResetAgentServersStats -> "/reset servers stats"
is GetAgentServersSummary -> "/get servers summary $userId"
}
val cmdType: String get() = when (this) {
@@ -2864,6 +2898,8 @@ sealed class CC {
is APISetNetworkConfig -> "apiSetNetworkConfig"
is APIGetNetworkConfig -> "apiGetNetworkConfig"
is APISetNetworkInfo -> "apiSetNetworkInfo"
is ReconnectServer -> "reconnectServer"
is ReconnectAllServers -> "reconnectAllServers"
is APISetChatSettings -> "apiSetChatSettings"
is ApiSetMemberSettings -> "apiSetMemberSettings"
is APIContactInfo -> "apiContactInfo"
@@ -2933,6 +2969,8 @@ sealed class CC {
is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile"
is ApiStandaloneFileInfo -> "apiStandaloneFileInfo"
is ShowVersion -> "showVersion"
is ResetAgentServersStats -> "resetAgentServersStats"
is GetAgentServersSummary -> "getAgentServersSummary"
}
class ItemRange(val from: Long, val to: Long)
@@ -3203,7 +3241,7 @@ data class NetCfg(
val tcpKeepAlive: KeepAliveOpts?,
val smpPingInterval: Long, // microseconds
val smpPingCount: Int,
val logTLSErrors: Boolean = false
val logTLSErrors: Boolean = false,
) {
val useSocksProxy: Boolean get() = socksProxy != null
val enableKeepAlive: Boolean get() = tcpKeepAlive != null
@@ -3428,6 +3466,154 @@ data class TimedMessagesPreference(
}
}
@Serializable
data class PresentedServersSummary(
val statsStartedAt: Instant,
val allUsersSMP: SMPServersSummary,
val allUsersXFTP: XFTPServersSummary,
val currentUserSMP: SMPServersSummary,
val currentUserXFTP: XFTPServersSummary
)
@Serializable
data class SMPServersSummary(
val smpTotals: SMPTotals,
val currentlyUsedSMPServers: List<SMPServerSummary>,
val previouslyUsedSMPServers: List<SMPServerSummary>,
val onlyProxiedSMPServers: List<SMPServerSummary>
)
@Serializable
data class SMPTotals(
val sessions: ServerSessions,
val subs: SMPServerSubs,
val stats: AgentSMPServerStatsData
)
@Serializable
data class SMPServerSummary(
val smpServer: String,
val known: Boolean? = null,
val sessions: ServerSessions? = null,
val subs: SMPServerSubs? = null,
val stats: AgentSMPServerStatsData? = null
) {
val hasSubs: Boolean
get() = subs != null
val sessionsOrNew: ServerSessions
get() = sessions ?: ServerSessions.newServerSessions
val subsOrNew: SMPServerSubs
get() = subs ?: SMPServerSubs.newSMPServerSubs
}
@Serializable
data class ServerSessions(
val ssConnected: Int,
val ssErrors: Int,
val ssConnecting: Int
) {
companion object {
val newServerSessions = ServerSessions(
ssConnected = 0,
ssErrors = 0,
ssConnecting = 0
)
}
}
@Serializable
data class SMPServerSubs(
val ssActive: Int,
val ssPending: Int
) {
companion object {
val newSMPServerSubs = SMPServerSubs(
ssActive = 0,
ssPending = 0
)
}
val total: Int
get() = ssActive + ssPending
val shareOfActive: Float
get() = if (total != 0) ssActive.toFloat() / total else 0f
}
@Serializable
data class AgentSMPServerStatsData(
val _sentDirect: Int,
val _sentViaProxy: Int,
val _sentProxied: Int,
val _sentDirectAttempts: Int,
val _sentViaProxyAttempts: Int,
val _sentProxiedAttempts: Int,
val _sentAuthErrs: Int,
val _sentQuotaErrs: Int,
val _sentExpiredErrs: Int,
val _sentOtherErrs: Int,
val _recvMsgs: Int,
val _recvDuplicates: Int,
val _recvCryptoErrs: Int,
val _recvErrs: Int,
val _ackMsgs: Int,
val _ackAttempts: Int,
val _ackNoMsgErrs: Int,
val _ackOtherErrs: Int,
val _connCreated: Int,
val _connSecured: Int,
val _connCompleted: Int,
val _connDeleted: Int,
val _connDelAttempts: Int,
val _connDelErrs: Int,
val _connSubscribed: Int,
val _connSubAttempts: Int,
val _connSubIgnored: Int,
val _connSubErrs: Int
)
@Serializable
data class XFTPServersSummary(
val xftpTotals: XFTPTotals,
val currentlyUsedXFTPServers: List<XFTPServerSummary>,
val previouslyUsedXFTPServers: List<XFTPServerSummary>
)
@Serializable
data class XFTPTotals(
val sessions: ServerSessions,
val stats: AgentXFTPServerStatsData
)
@Serializable
data class XFTPServerSummary(
val xftpServer: String,
val known: Boolean? = null,
val sessions: ServerSessions? = null,
val stats: AgentXFTPServerStatsData? = null,
val rcvInProgress: Boolean,
val sndInProgress: Boolean,
val delInProgress: Boolean
) {}
@Serializable
data class AgentXFTPServerStatsData(
val _uploads: Int,
val _uploadsSize: Long,
val _uploadAttempts: Int,
val _uploadErrs: Int,
val _downloads: Int,
val _downloadsSize: Long,
val _downloadAttempts: Int,
val _downloadAuthErrs: Int,
val _downloadErrs: Int,
val _deletions: Int,
val _deleteAttempts: Int,
val _deleteErrs: Int
)
sealed class CustomTimeUnit {
object Second: CustomTimeUnit()
object Minute: CustomTimeUnit()
@@ -4439,6 +4625,7 @@ sealed class CR {
@Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR()
@Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List<ArchiveError>): CR()
@Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR()
@Serializable @SerialName("agentServersSummary") class AgentServersSummary(val user: UserRef, val serversSummary: PresentedServersSummary): CR()
// general
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
@@ -4598,6 +4785,7 @@ sealed class CR {
is ContactPQAllowed -> "contactPQAllowed"
is ContactPQEnabled -> "contactPQEnabled"
is VersionInfo -> "versionInfo"
is AgentServersSummary -> "agentServersSummary"
is CmdOk -> "cmdOk"
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
@@ -4776,6 +4964,7 @@ sealed class CR {
is RemoteCtrlStopped -> "rcsState: $rcsState\nrcsStopReason: $rcStopReason"
is ContactPQAllowed -> withUser(user, "contact: ${contact.id}\npqEncryption: $pqEncryption")
is ContactPQEnabled -> withUser(user, "contact: ${contact.id}\npqEnabled: $pqEnabled")
is AgentServersSummary -> withUser(user, json.encodeToString(serversSummary))
is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" +
"chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" +
"agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}"
@@ -35,7 +35,10 @@ import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import java.net.URI
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
@@ -69,7 +72,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(searchText, scaffoldState.drawerState, userPickerState, stopped)} },
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(scaffoldState.drawerState, userPickerState, stopped)} },
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
@@ -181,8 +184,10 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
}
@Composable
private fun ChatListToolbar(searchInList: State<TextFieldValue>, drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (stopped) {
barButtons.add {
IconButton(onClick = {
@@ -200,6 +205,7 @@ private fun ChatListToolbar(searchInList: State<TextFieldValue>, drawerState: Dr
}
}
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
DefaultTopAppBar(
navigationButton = {
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
@@ -219,20 +225,33 @@ private fun ChatListToolbar(searchInList: State<TextFieldValue>, drawerState: Dr
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON)) {
Text(
stringResource(MR.strings.your_chats),
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
if (chatModel.chats.size > 0) {
val enabled = remember { derivedStateOf { searchInList.value.text.isEmpty() } }
if (enabled.value) {
ToggleFilterEnabledButton()
} else {
ToggleFilterDisabledButton()
SubscriptionStatusIndicator(
serversSummary = serversSummary,
click = {
ModalManager.start.closeModals()
ModalManager.start.showModalCloseable(
endButtons = {
val summary = serversSummary.value
if (summary != null) {
ShareButton {
val json = Json {
prettyPrint = true
}
val text = json.encodeToString(PresentedServersSummary.serializer(), summary)
clipboard.shareText(text)
}
}
}
) { ServersSummaryView(chatModel.currentRemoteHost.value, serversSummary) }
}
}
)
}
},
onTitleClick = null,
@@ -243,6 +262,53 @@ private fun ChatListToolbar(searchInList: State<TextFieldValue>, drawerState: Dr
Divider(Modifier.padding(top = AppBarHeight))
}
@Composable
fun SubscriptionStatusIndicator(serversSummary: MutableState<PresentedServersSummary?>, click: (() -> Unit)) {
var subs by remember { mutableStateOf(SMPServerSubs.newSMPServerSubs) }
var sess by remember { mutableStateOf(ServerSessions.newServerSessions) }
var timer: Job? by remember { mutableStateOf(null) }
val fetchInterval: Duration = 1.seconds
val scope = rememberCoroutineScope()
fun setServersSummary() {
withBGApi {
serversSummary.value = chatModel.controller.getAgentServersSummary(chatModel.remoteHostId())
serversSummary.value?.let {
subs = it.allUsersSMP.smpTotals.subs
sess = it.allUsersSMP.smpTotals.sessions
}
}
}
LaunchedEffect(Unit) {
setServersSummary()
timer = timer ?: scope.launch {
while (true) {
delay(fetchInterval.inWholeMilliseconds)
setServersSummary()
}
}
}
fun stopTimer() {
timer?.cancel()
timer = null
}
DisposableEffect(Unit) {
onDispose {
stopTimer()
}
}
SimpleButtonFrame(click = click) {
SubscriptionStatusIndicatorView(subs = subs, sess = sess)
}
}
@Composable
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -292,27 +358,11 @@ private fun ToggleFilterEnabledButton() {
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.primary,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = MaterialTheme.colors.primary, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(16.dp)
)
}
}
@Composable
private fun ToggleFilterDisabledButton() {
IconButton({}, enabled = false) {
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.border(width = 1.dp, color = MaterialTheme.colors.secondary, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(16.dp)
)
@@ -357,33 +407,11 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
hideSearchOnBack()
}
} else {
Row {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
val clipboard = LocalClipboardManager.current
val clipboardHasText = remember(focused) { chatModel.clipboardHasText }.value
if (clipboardHasText) {
IconButton(
onClick = { searchText.value = searchText.value.copy(clipboard.getText()?.text ?: return@IconButton) },
Modifier.size(30.dp).desktopPointerHoverIconHand()
) {
Icon(painterResource(MR.images.ic_article), null, tint = MaterialTheme.colors.secondary)
}
}
Spacer(Modifier.width(padding))
IconButton(
onClick = {
val fixedRhId = chatModel.currentRemoteHost.value
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close ->
NewChatView(fixedRhId, selection = NewChatOption.CONNECT, showQRCodeScanner = true, close = close)
}
},
Modifier.size(30.dp).desktopPointerHoverIconHand()
) {
Icon(painterResource(MR.images.ic_qr_code), null, tint = MaterialTheme.colors.secondary)
}
Spacer(Modifier.width(padding))
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.size > 0) {
ToggleFilterEnabledButton()
}
Spacer(Modifier.width(padding))
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
@@ -0,0 +1,977 @@
package chat.simplex.common.views.chatlist
import InfoRow
import InfoRowTwoValues
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.AgentSMPServerStatsData
import chat.simplex.common.model.AgentXFTPServerStatsData
import chat.simplex.common.model.ChatController.chatModel
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.PresentedServersSummary
import chat.simplex.common.model.RemoteHostInfo
import chat.simplex.common.model.SMPServerSubs
import chat.simplex.common.model.SMPServerSummary
import chat.simplex.common.model.SMPTotals
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.model.ServerProtocol
import chat.simplex.common.model.ServerSessions
import chat.simplex.common.model.XFTPServerSummary
import chat.simplex.common.model.localTimestamp
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.ProtocolServersView
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import numOrDash
import java.text.DecimalFormat
import kotlin.math.floor
import kotlin.math.roundToInt
enum class SubscriptionColorType {
ACTIVE, ACTIVE_SOCKS_PROXY, DISCONNECTED, ACTIVE_DISCONNECTED
}
data class SubscriptionStatus(
val color: SubscriptionColorType,
val variableValue: Float,
val opacity: Float,
val statusPercent: Float
)
fun subscriptionStatusColorAndPercentage(
online: Boolean,
socksProxy: String?,
subs: SMPServerSubs,
sess: ServerSessions
): SubscriptionStatus {
fun roundedToQuarter(n: Float): Float = when {
n >= 1 -> 1f
n <= 0 -> 0f
else -> (n * 4).roundToInt() / 4f
}
val activeColor: SubscriptionColorType = if (socksProxy != null) SubscriptionColorType.ACTIVE_SOCKS_PROXY else SubscriptionColorType.ACTIVE
val noConnColorAndPercent = SubscriptionStatus(SubscriptionColorType.DISCONNECTED, 1f, 1f, 0f)
val activeSubsRounded = roundedToQuarter(subs.shareOfActive)
return if (online && subs.total > 0) {
if (subs.ssActive == 0) {
if (sess.ssConnected == 0)
noConnColorAndPercent
else
SubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
} else { // ssActive > 0
if (sess.ssConnected == 0)
// This would mean implementation error
SubscriptionStatus(SubscriptionColorType.ACTIVE_DISCONNECTED, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
else
SubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
}
} else noConnColorAndPercent
}
@Composable
private fun SubscriptionStatusIndicatorPercentage(percentageText: String) {
Text(
percentageText,
color = MaterialTheme.colors.secondary,
fontSize = 12.sp,
style = MaterialTheme.typography.caption
)
}
@Composable
fun SubscriptionStatusIndicatorView(subs: SMPServerSubs, sess: ServerSessions, leadingPercentage: Boolean = false) {
val netCfg = rememberUpdatedState(chatModel.controller.getNetCfg())
val statusColorAndPercentage = subscriptionStatusColorAndPercentage(chatModel.networkInfo.value.online, netCfg.value.socksProxy, subs, sess)
val pref = remember { chatModel.controller.appPrefs.networkShowSubscriptionPercentage }
val percentageText = "${(floor(statusColorAndPercentage.statusPercent * 100)).toInt()}%"
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON)
) {
if (pref.state.value && leadingPercentage) SubscriptionStatusIndicatorPercentage(percentageText)
SubscriptionStatusIcon(
color = when(statusColorAndPercentage.color) {
SubscriptionColorType.ACTIVE -> MaterialTheme.colors.primary
SubscriptionColorType.ACTIVE_SOCKS_PROXY -> Indigo
SubscriptionColorType.ACTIVE_DISCONNECTED -> WarningOrange
SubscriptionColorType.DISCONNECTED -> MaterialTheme.colors.secondary
},
modifier = Modifier.size(16.dp),
variableValue = statusColorAndPercentage.variableValue)
if (pref.state.value && !leadingPercentage) SubscriptionStatusIndicatorPercentage(percentageText)
}
}
enum class PresentedUserCategory {
CURRENT_USER, ALL_USERS
}
enum class PresentedServerType {
SMP, XFTP
}
@Composable
private fun ServerSessionsView(sess: ServerSessions) {
SectionView(generalGetString(MR.strings.servers_info_transport_sessions_section_header).uppercase()) {
InfoRow(
generalGetString(MR.strings.servers_info_sessions_connected),
numOrDash(sess.ssConnected)
)
InfoRow(
generalGetString(MR.strings.servers_info_sessions_errors),
numOrDash(sess.ssErrors)
)
InfoRow(
generalGetString(MR.strings.servers_info_sessions_connecting),
numOrDash(sess.ssConnecting)
)
}
}
private fun serverAddress(server: String): String {
val address = parseServerAddress(server)
return address?.hostnames?.first() ?: server
}
@Composable
private fun SMPServerView(srvSumm: SMPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) {
SectionItemView(
click = {
ModalManager.start.showCustomModal { close ->
SMPServerSummaryView(
rh = rh,
close = close,
summary = srvSumm,
statsStartedAt = statsStartedAt
)
}
}
) {
Text(
serverAddress(srvSumm.smpServer),
modifier = Modifier.weight(10f, fill = true)
)
if (srvSumm.subs != null) {
Spacer(Modifier.fillMaxWidth().weight(1f))
SubscriptionStatusIndicatorView(subs = srvSumm.subs, sess = srvSumm.sessionsOrNew, leadingPercentage = true)
} else if (srvSumm.sessions != null) {
Spacer(Modifier.fillMaxWidth().weight(1f))
Icon(painterResource(MR.images.ic_arrow_upward), contentDescription = null, tint = SessIconColor(srvSumm.sessions))
}
}
}
@Composable
private fun SessIconColor(sess: ServerSessions): Color {
val online = chatModel.networkInfo.value.online
return if (online && sess.ssConnected > 0) SessionActiveColor() else MaterialTheme.colors.secondary
}
@Composable
private fun SessionActiveColor(): Color {
val netCfg = rememberUpdatedState(chatModel.controller.getNetCfg())
return if (netCfg.value.socksProxy != null) Indigo else MaterialTheme.colors.primary
}
@Composable
private fun SMPServersListView(servers: List<SMPServerSummary>, statsStartedAt: Instant, header: String? = null, footer: String? = null, rh: RemoteHostInfo?) {
val sortedServers = servers.sortedWith(compareBy<SMPServerSummary> { !it.hasSubs }
.thenBy { serverAddress(it.smpServer) })
SectionView(header) {
sortedServers.map { svr -> SMPServerView(srvSumm = svr, statsStartedAt = statsStartedAt, rh = rh) }
}
if (footer != null) {
SectionTextFooter(
footer
)
}
}
fun prettySize(sizeInKB: Long): String {
if (sizeInKB == 0L) {
return "-"
}
val sizeInBytes = sizeInKB * 1024
val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
var size = sizeInBytes.toDouble()
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024
unitIndex++
}
val formatter = DecimalFormat("#,##0.#")
return "${formatter.format(size)} ${units[unitIndex]}"
}
@Composable
private fun XFTPServerView(srvSumm: XFTPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) {
SectionItemView(
click = {
ModalManager.start.showCustomModal { close ->
XFTPServerSummaryView(
rh = rh,
close = close,
summary = srvSumm,
statsStartedAt = statsStartedAt
)
}
}
) {
Text(
serverAddress(srvSumm.xftpServer),
modifier = Modifier.weight(10f, fill = true)
)
if (srvSumm.rcvInProgress || srvSumm.sndInProgress || srvSumm.delInProgress) {
Spacer(Modifier.fillMaxWidth().weight(1f))
XFTPServerInProgressIcon(srvSumm)
}
}
}
@Composable
private fun XFTPServerInProgressIcon(srvSumm: XFTPServerSummary) {
return when {
srvSumm.rcvInProgress && !srvSumm.sndInProgress && !srvSumm.delInProgress -> Icon(painterResource(MR.images.ic_arrow_downward),"download", tint = SessionActiveColor())
!srvSumm.rcvInProgress && srvSumm.sndInProgress && !srvSumm.delInProgress -> Icon(painterResource(MR.images.ic_arrow_upward), "upload", tint = SessionActiveColor())
!srvSumm.rcvInProgress && !srvSumm.sndInProgress && srvSumm.delInProgress -> Icon(painterResource(MR.images.ic_delete), "deletion", tint = SessionActiveColor())
else -> Icon(painterResource(MR.images.ic_expand_all), "upload and download", tint = SessionActiveColor())
}
}
@Composable
private fun XFTPServersListView(servers: List<XFTPServerSummary>, statsStartedAt: Instant, header: String? = null, rh: RemoteHostInfo?) {
val sortedServers = servers.sortedBy { serverAddress(it.xftpServer) }
SectionView(header) {
sortedServers.map { svr -> XFTPServerView(svr, statsStartedAt, rh) }
}
}
@Composable
private fun SMPStatsView(stats: AgentSMPServerStatsData, statsStartedAt: Instant, remoteHostInfo: RemoteHostInfo?) {
SectionView(generalGetString(MR.strings.servers_info_statistics_section_header).uppercase()) {
InfoRow(
generalGetString(MR.strings.servers_info_messages_sent),
numOrDash(stats._sentDirect + stats._sentViaProxy)
)
InfoRow(
generalGetString(MR.strings.servers_info_messages_received),
numOrDash(stats._recvMsgs)
)
SectionItemView(
click = {
ModalManager.start.showCustomModal { close -> DetailedSMPStatsView(
rh = remoteHostInfo,
close = close,
stats = stats,
statsStartedAt = statsStartedAt)
}
}
) {
Text(text = generalGetString(MR.strings.servers_info_details), color = MaterialTheme.colors.onBackground)
}
}
SectionTextFooter(
String.format(stringResource(MR.strings.servers_info_private_data_disclaimer), localTimestamp(statsStartedAt))
)
}
@Composable
private fun SMPSubscriptionsSection(totals: SMPTotals) {
Column {
Row(
Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON * 2)
) {
Text(
generalGetString(MR.strings.servers_info_subscriptions_section_header).uppercase(),
color = MaterialTheme.colors.secondary,
style = MaterialTheme.typography.body2,
fontSize = 12.sp
)
SubscriptionStatusIndicatorView(totals.subs, totals.sessions)
}
Column(Modifier.padding(PaddingValues()).fillMaxWidth()) {
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_connections_subscribed),
numOrDash(totals.subs.ssActive)
)
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_total),
numOrDash(totals.subs.total)
)
}
}
}
@Composable
private fun SMPSubscriptionsSection(subs: SMPServerSubs, summary: SMPServerSummary, rh: RemoteHostInfo?) {
Column {
Row(
Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON * 2)
) {
Text(
generalGetString(MR.strings.servers_info_subscriptions_section_header).uppercase(),
color = MaterialTheme.colors.secondary,
style = MaterialTheme.typography.body2,
fontSize = 12.sp
)
SubscriptionStatusIndicatorView(subs, summary.sessionsOrNew)
}
Column(Modifier.padding(PaddingValues()).fillMaxWidth()) {
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_connections_subscribed),
numOrDash(subs.ssActive)
)
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_connections_pending),
numOrDash(subs.ssPending)
)
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_total),
numOrDash(subs.total)
)
ReconnectServerButton(rh, summary.smpServer)
}
}
}
@Composable
private fun ReconnectServerButton(rh: RemoteHostInfo?, server: String) {
SectionItemView(click = { reconnectServerAlert(rh, server) }) {
Text(
stringResource(MR.strings.reconnect),
color = MaterialTheme.colors.primary
)
}
}
private fun reconnectServerAlert(rh: RemoteHostInfo?, server: String) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.servers_info_reconnect_server_title),
text = generalGetString(MR.strings.servers_info_reconnect_server_message),
onConfirm = {
withBGApi {
val success = controller.reconnectServer(rh?.remoteHostId, server)
if (!success) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.servers_info_modal_error_title),
text = generalGetString(MR.strings.servers_info_reconnect_server_error)
)
}
}
}
)
}
@Composable
fun XFTPStatsView(stats: AgentXFTPServerStatsData, statsStartedAt: Instant, rh: RemoteHostInfo?) {
SectionView(generalGetString(MR.strings.servers_info_statistics_section_header).uppercase()) {
InfoRow(
generalGetString(MR.strings.servers_info_uploaded),
prettySize(stats._uploadsSize)
)
InfoRow(
generalGetString(MR.strings.servers_info_downloaded),
prettySize(stats._downloadsSize)
)
SectionItemView (
click = {
ModalManager.start.showCustomModal { close -> DetailedXFTPStatsView(
rh = rh,
close = close,
stats = stats,
statsStartedAt = statsStartedAt)
}
}
) {
Text(text = generalGetString(MR.strings.servers_info_details), color = MaterialTheme.colors.onBackground)
}
}
SectionTextFooter(
String.format(stringResource(MR.strings.servers_info_private_data_disclaimer), localTimestamp(statsStartedAt))
)
}
@Composable
private fun IndentedInfoRow(title: String, desc: String) {
InfoRow(title, desc, padding = PaddingValues(start = 24.dp + DEFAULT_PADDING, end = DEFAULT_PADDING))
}
@Composable
fun DetailedSMPStatsLayout(stats: AgentSMPServerStatsData, statsStartedAt: Instant) {
SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_sent_messages_header).uppercase()) {
InfoRow(generalGetString(MR.strings.servers_info_detailed_statistics_sent_messages_total), numOrDash(stats._sentDirect + stats._sentViaProxy))
InfoRowTwoValues(generalGetString(MR.strings.sent_directly), generalGetString(MR.strings.attempts_label), stats._sentDirect, stats._sentDirectAttempts)
InfoRowTwoValues(generalGetString(MR.strings.sent_via_proxy), generalGetString(MR.strings.attempts_label), stats._sentViaProxy, stats._sentViaProxyAttempts)
InfoRowTwoValues(generalGetString(MR.strings.proxied), generalGetString(MR.strings.attempts_label), stats._sentProxied, stats._sentProxiedAttempts)
SectionItemView {
Text(generalGetString(MR.strings.send_errors), color = MaterialTheme.colors.onBackground)
}
IndentedInfoRow("AUTH", numOrDash(stats._sentAuthErrs))
IndentedInfoRow("QUOTA", numOrDash(stats._sentQuotaErrs))
IndentedInfoRow(generalGetString(MR.strings.expired_label), numOrDash(stats._sentExpiredErrs))
IndentedInfoRow(generalGetString(MR.strings.other_label), numOrDash(stats._sentOtherErrs))
}
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_received_messages_header).uppercase()) {
InfoRow(generalGetString(MR.strings.servers_info_detailed_statistics_received_total), numOrDash(stats._recvMsgs))
SectionItemView {
Text(generalGetString(MR.strings.servers_info_detailed_statistics_receive_errors), color = MaterialTheme.colors.onBackground)
}
IndentedInfoRow(generalGetString(MR.strings.duplicates_label), numOrDash(stats._recvDuplicates))
IndentedInfoRow(generalGetString(MR.strings.decryption_errors), numOrDash(stats._recvCryptoErrs))
IndentedInfoRow(generalGetString(MR.strings.other_errors), numOrDash(stats._recvErrs))
InfoRowTwoValues(generalGetString(MR.strings.acknowledged), generalGetString(MR.strings.attempts_label), stats._ackMsgs, stats._ackAttempts)
SectionItemView {
Text(generalGetString(MR.strings.acknowledgement_errors), color = MaterialTheme.colors.onBackground)
}
IndentedInfoRow("NO_MSG errors", numOrDash(stats._ackNoMsgErrs))
IndentedInfoRow(generalGetString(MR.strings.other_errors), numOrDash(stats._ackOtherErrs))
}
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.connections).uppercase()) {
InfoRow(generalGetString(MR.strings.created), numOrDash(stats._connCreated))
InfoRow(generalGetString(MR.strings.secured), numOrDash(stats._connSecured))
InfoRow(generalGetString(MR.strings.completed), numOrDash(stats._connCompleted))
InfoRowTwoValues(generalGetString(MR.strings.deleted), generalGetString(MR.strings.attempts_label), stats._connDeleted, stats._connDelAttempts)
InfoRow(generalGetString(MR.strings.deletion_errors), numOrDash(stats._connDelErrs))
InfoRowTwoValues(generalGetString(MR.strings.subscribed), generalGetString(MR.strings.attempts_label), stats._connSubscribed, stats._connSubAttempts)
InfoRow(generalGetString(MR.strings.subscription_results_ignored), numOrDash(stats._connSubIgnored))
InfoRow(generalGetString(MR.strings.subscription_errors), numOrDash(stats._connSubErrs))
}
SectionTextFooter(
String.format(stringResource(MR.strings.servers_info_starting_from), localTimestamp(statsStartedAt))
)
SectionBottomSpacer()
}
@Composable
fun DetailedXFTPStatsLayout(stats: AgentXFTPServerStatsData, statsStartedAt: Instant) {
SectionView(generalGetString(MR.strings.uploaded_files).uppercase()) {
InfoRow(generalGetString(MR.strings.size), prettySize(stats._uploadsSize))
InfoRowTwoValues(generalGetString(MR.strings.chunks_uploaded), generalGetString(MR.strings.attempts_label), stats._uploads, stats._uploadAttempts)
InfoRow(generalGetString(MR.strings.upload_errors), numOrDash(stats._uploadErrs))
InfoRowTwoValues(generalGetString(MR.strings.chunks_deleted), generalGetString(MR.strings.attempts_label), stats._deletions, stats._deleteAttempts)
InfoRow(generalGetString(MR.strings.deletion_errors), numOrDash(stats._deleteErrs))
}
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.downloaded_files).uppercase()) {
InfoRow(generalGetString(MR.strings.size), prettySize(stats._downloadsSize))
InfoRowTwoValues(generalGetString(MR.strings.chunks_downloaded), generalGetString(MR.strings.attempts_label), stats._downloads, stats._downloadAttempts)
SectionItemView {
Text(generalGetString(MR.strings.download_errors), color = MaterialTheme.colors.onBackground)
}
IndentedInfoRow("AUTH", numOrDash(stats._downloadAuthErrs))
IndentedInfoRow(generalGetString(MR.strings.other_label), numOrDash(stats._downloadErrs))
}
SectionTextFooter(
String.format(stringResource(MR.strings.servers_info_starting_from), localTimestamp(statsStartedAt))
)
SectionBottomSpacer()
}
@Composable
fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) {
SectionView(generalGetString(MR.strings.server_address).uppercase()) {
SelectionContainer {
Text(
summary.xftpServer,
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
color = MaterialTheme.colors.secondary
)
)
}
if (summary.known == true) {
SectionItemView(click = {
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.XFTP, close) }
}) {
Text(generalGetString(MR.strings.open_server_settings_button))
}
}
if (summary.stats != null) {
SectionDividerSpaced()
XFTPStatsView(stats = summary.stats, rh = rh, statsStartedAt = statsStartedAt)
}
if (summary.sessions != null) {
SectionDividerSpaced()
ServerSessionsView(summary.sessions)
}
}
SectionBottomSpacer()
}
@Composable
fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) {
SectionView(generalGetString(MR.strings.server_address).uppercase()) {
SelectionContainer {
Text(
summary.smpServer,
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
color = MaterialTheme.colors.secondary
)
)
}
if (summary.known == true) {
SectionItemView(click = {
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.SMP, close) }
}) {
Text(generalGetString(MR.strings.open_server_settings_button))
}
}
if (summary.stats != null) {
SectionDividerSpaced()
SMPStatsView(stats = summary.stats, remoteHostInfo = rh, statsStartedAt = statsStartedAt)
}
if (summary.subs != null) {
SectionDividerSpaced()
SMPSubscriptionsSection(subs = summary.subs, summary = summary, rh = rh)
}
if (summary.sessions != null) {
SectionDividerSpaced()
ServerSessionsView(summary.sessions)
}
}
SectionBottomSpacer()
}
@Composable
fun ModalData.SMPServerSummaryView(
rh: RemoteHostInfo?,
close: () -> Unit,
summary: SMPServerSummary,
statsStartedAt: Instant
) {
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.smp_server),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
SMPServerSummaryLayout(summary, statsStartedAt, rh)
}
}
}
@Composable
fun ModalData.DetailedXFTPStatsView(
rh: RemoteHostInfo?,
close: () -> Unit,
stats: AgentXFTPServerStatsData,
statsStartedAt: Instant
) {
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.servers_info_detailed_statistics),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
DetailedXFTPStatsLayout(stats, statsStartedAt)
}
}
}
@Composable
fun ModalData.DetailedSMPStatsView(
rh: RemoteHostInfo?,
close: () -> Unit,
stats: AgentSMPServerStatsData,
statsStartedAt: Instant
) {
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.servers_info_detailed_statistics),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
DetailedSMPStatsLayout(stats, statsStartedAt)
}
}
}
@Composable
fun ModalData.XFTPServerSummaryView(
rh: RemoteHostInfo?,
close: () -> Unit,
summary: XFTPServerSummary,
statsStartedAt: Instant
) {
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.xftp_server),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
XFTPServerSummaryLayout(summary, statsStartedAt, rh)
}
}
}
@Composable
fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableState<PresentedServersSummary?>) {
Column(
Modifier.fillMaxSize(),
) {
var showUserSelection by remember { mutableStateOf(false) }
val selectedUserCategory =
remember { stateGetOrPut("selectedUserCategory") { PresentedUserCategory.ALL_USERS } }
val selectedServerType =
remember { stateGetOrPut("serverTypeSelection") { PresentedServerType.SMP } }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
if (chatModel.users.count { u -> u.user.activeUser || !u.user.hidden } == 1
) {
selectedUserCategory.value = PresentedUserCategory.CURRENT_USER
} else {
showUserSelection = true
}
}
Column(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.servers_info),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
if (serversSummary.value == null) {
Box(
modifier = Modifier
.fillMaxSize()
) {
Text(generalGetString(MR.strings.servers_info_missing), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
}
} else {
val userOptions by remember {
mutableStateOf(
listOf(
PresentedUserCategory.ALL_USERS to generalGetString(MR.strings.all_users),
PresentedUserCategory.CURRENT_USER to generalGetString(MR.strings.current_user),
)
)
}
val serverTypeTabTitles = PresentedServerType.entries.map {
when (it) {
PresentedServerType.SMP ->
stringResource(MR.strings.messages_section_title)
PresentedServerType.XFTP ->
stringResource(MR.strings.servers_info_files_tab)
}
}
val serverTypePagerState = rememberPagerState(
initialPage = selectedServerType.value.ordinal,
initialPageOffsetFraction = 0f
) { PresentedServerType.entries.size }
KeyChangeEffect(serverTypePagerState.currentPage) {
selectedServerType.value = PresentedServerType.values()[serverTypePagerState.currentPage]
}
TabRow(
selectedTabIndex = serverTypePagerState.currentPage,
backgroundColor = Color.Transparent,
contentColor = MaterialTheme.colors.primary,
) {
serverTypeTabTitles.forEachIndexed { index, it ->
Tab(
selected = serverTypePagerState.currentPage == index,
onClick = {
scope.launch {
serverTypePagerState.animateScrollToPage(index)
}
},
text = { Text(it, fontSize = 13.sp) },
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = MaterialTheme.colors.secondary,
)
}
}
HorizontalPager(
state = serverTypePagerState,
Modifier.fillMaxSize(),
verticalAlignment = Alignment.Top,
userScrollEnabled = appPlatform.isAndroid
) { index ->
ColumnWithScrollBar(
Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Top
) {
Spacer(Modifier.height(DEFAULT_PADDING))
if (showUserSelection) {
ExposedDropDownSettingRow(
generalGetString(MR.strings.servers_info_target),
userOptions,
selectedUserCategory,
icon = null,
enabled = remember { mutableStateOf(true) },
onSelected = {
selectedUserCategory.value = it
}
)
SectionDividerSpaced()
}
when (index) {
PresentedServerType.SMP.ordinal -> {
serversSummary.value?.let {
val smpSummary =
if (selectedUserCategory.value == PresentedUserCategory.CURRENT_USER) it.currentUserSMP else it.allUsersSMP;
val totals = smpSummary.smpTotals
val currentlyUsedSMPServers = smpSummary.currentlyUsedSMPServers
val previouslyUsedSMPServers = smpSummary.previouslyUsedSMPServers
val proxySMPServers = smpSummary.onlyProxiedSMPServers
val statsStartedAt = it.statsStartedAt
SMPStatsView(totals.stats, statsStartedAt, rh)
SectionDividerSpaced()
SMPSubscriptionsSection(totals)
SectionDividerSpaced()
if (currentlyUsedSMPServers.isNotEmpty()) {
SMPServersListView(
servers = currentlyUsedSMPServers,
statsStartedAt = statsStartedAt,
header = generalGetString(MR.strings.servers_info_connected_servers_section_header).uppercase(),
rh = rh
)
SectionDividerSpaced()
}
if (previouslyUsedSMPServers.isNotEmpty()) {
SMPServersListView(
servers = previouslyUsedSMPServers,
statsStartedAt = statsStartedAt,
header = generalGetString(MR.strings.servers_info_previously_connected_servers_section_header).uppercase(),
rh = rh
)
SectionDividerSpaced()
}
if (proxySMPServers.isNotEmpty()) {
SMPServersListView(
servers = proxySMPServers,
statsStartedAt = statsStartedAt,
header = generalGetString(MR.strings.servers_info_proxied_servers_section_header).uppercase(),
footer = generalGetString(MR.strings.servers_info_proxied_servers_section_footer),
rh = rh
)
SectionDividerSpaced()
}
ServerSessionsView(totals.sessions)
}
}
PresentedServerType.XFTP.ordinal -> {
serversSummary.value?.let {
val xftpSummary =
if (selectedUserCategory.value == PresentedUserCategory.CURRENT_USER) it.currentUserXFTP else it.allUsersXFTP
val totals = xftpSummary.xftpTotals
val statsStartedAt = it.statsStartedAt
val currentlyUsedXFTPServers = xftpSummary.currentlyUsedXFTPServers
val previouslyUsedXFTPServers = xftpSummary.previouslyUsedXFTPServers
XFTPStatsView(totals.stats, statsStartedAt, rh)
SectionDividerSpaced()
if (currentlyUsedXFTPServers.isNotEmpty()) {
XFTPServersListView(
currentlyUsedXFTPServers,
statsStartedAt,
generalGetString(MR.strings.servers_info_connected_servers_section_header).uppercase(),
rh
)
SectionDividerSpaced()
}
if (previouslyUsedXFTPServers.isNotEmpty()) {
XFTPServersListView(
previouslyUsedXFTPServers,
statsStartedAt,
generalGetString(MR.strings.servers_info_previously_connected_servers_section_header).uppercase(),
rh
)
SectionDividerSpaced()
}
ServerSessionsView(totals.sessions)
}
}
}
SectionDividerSpaced()
SectionView {
ReconnectAllServersButton(rh)
ResetStatisticsButton(rh)
}
SectionBottomSpacer()
}
}
}
}
}
}
@Composable
private fun ReconnectAllServersButton(rh: RemoteHostInfo?) {
SectionItemView(click = { reconnectAllServersAlert(rh) }) {
Text(
stringResource(MR.strings.servers_info_reconnect_all_servers_button),
color = MaterialTheme.colors.primary
)
}
}
private fun reconnectAllServersAlert(rh: RemoteHostInfo?) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.servers_info_reconnect_servers_title),
text = generalGetString(MR.strings.servers_info_reconnect_servers_message),
onConfirm = {
withBGApi {
val success = controller.reconnectAllServers(rh?.remoteHostId)
if (!success) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.servers_info_modal_error_title),
text = generalGetString(MR.strings.servers_info_reconnect_servers_error)
)
}
}
}
)
}
@Composable
private fun ResetStatisticsButton(rh: RemoteHostInfo?) {
SectionItemView(click = { resetStatisticsAlert(rh) }) {
Text(
stringResource(MR.strings.servers_info_reset_stats),
color = MaterialTheme.colors.primary
)
}
}
private fun resetStatisticsAlert(rh: RemoteHostInfo?) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.servers_info_reset_stats_alert_title),
text = generalGetString(MR.strings.servers_info_reset_stats_alert_message),
confirmText = generalGetString(MR.strings.servers_info_reset_stats_alert_confirm),
destructive = true,
onConfirm = {
withBGApi {
val success = controller.resetAgentServersStats(rh?.remoteHostId)
if (!success) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.servers_info_modal_error_title),
text = generalGetString(MR.strings.servers_info_reset_stats_alert_error_title)
)
}
}
}
)
}
@@ -100,6 +100,9 @@ class ModalManager(private val placement: ModalPlacement? = null) {
fun hasModalsOpen() = modalCount.value > 0
val hasModalsOpen: Boolean
@Composable get () = remember { modalCount }.value > 0
fun closeModal() {
if (modalViews.isNotEmpty()) {
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
@@ -276,8 +276,8 @@ fun TextIconSpaced(extraPadding: Boolean = false) {
}
@Composable
fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground) {
SectionItemViewSpaceBetween {
fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionItemViewSpaceBetween(padding = padding) {
Row {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: MaterialTheme.colors.secondary)
@@ -287,6 +287,61 @@ fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color
}
}
fun numOrDash(n: Number): String = if (n.toLong() == 0L) "-" else n.toString()
@Composable
fun InfoRowTwoValues(
title: String,
title2: String,
value: Int,
value2: Int,
textColor: Color = MaterialTheme.colors.onBackground
) {
SectionItemViewSpaceBetween {
Row(
verticalAlignment = Alignment.Bottom
) {
Text(
text = title,
color = textColor,
)
Text(
text = " / ",
fontSize = 12.sp,
)
Text(
text = title2,
color = textColor,
fontSize = 12.sp,
)
}
Row(verticalAlignment = Alignment.Bottom) {
if (value == 0 && value2 == 0) {
Text(
text = "-",
color = MaterialTheme.colors.secondary
)
} else {
Text(
text = numOrDash(value),
color = MaterialTheme.colors.secondary,
)
Text(
text = " / ",
color = MaterialTheme.colors.secondary,
fontSize = 12.sp,
)
Text(
text = numOrDash(value2),
color = MaterialTheme.colors.secondary,
fontSize = 12.sp,
)
}
}
}
}
@Composable
fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) {
SectionItemViewSpaceBetween(onClick) {
@@ -0,0 +1,41 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.Box
import androidx.compose.material.Icon
import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun SubscriptionStatusIcon(
color: Color,
variableValue: Float,
modifier: Modifier = Modifier
) {
@Composable
fun ZeroIcon() {
Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color.copy(alpha = 0.33f), modifier = modifier)
}
when {
variableValue <= 0f -> ZeroIcon()
variableValue > 0f && variableValue <= 0.25f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_1_bar), null, tint = color, modifier = modifier)
}
variableValue > 0.25f && variableValue <= 0.5f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_2_bar), null, tint = color, modifier = modifier)
}
variableValue > 0.5f && variableValue <= 0.75f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_3_bar), null, tint = color, modifier = modifier)
}
else -> Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color, modifier = modifier)
}
}
@@ -44,6 +44,11 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) }
},
scanPaste = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) }
},
createGroup = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
@@ -55,15 +60,17 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
private val titles = listOf(
MR.strings.add_contact_tab,
MR.strings.scan_paste_link,
MR.strings.create_group_button
)
private val icons = listOf(MR.images.ic_add_link, MR.images.ic_group)
private val icons = listOf(MR.images.ic_add_link, MR.images.ic_qr_code, MR.images.ic_group)
@Composable
private fun NewChatSheetLayout(
newChatSheetState: StateFlow<AnimatedViewState>,
stopped: Boolean,
addContact: () -> Unit,
scanPaste: () -> Unit,
createGroup: () -> Unit,
closeNewChatSheet: (animated: Boolean) -> Unit,
) {
@@ -102,7 +109,7 @@ private fun NewChatSheetLayout(
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End
) {
val actions = remember { listOf(addContact, createGroup) }
val actions = remember { listOf(addContact, scanPaste, createGroup) }
val backgroundColor = if (isInDarkTheme())
blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F)
else
@@ -264,6 +271,7 @@ private fun PreviewNewChatSheet() {
MutableStateFlow(AnimatedViewState.VISIBLE),
stopped = false,
addContact = {},
scanPaste = {},
createGroup = {},
closeNewChatSheet = {},
)
@@ -140,7 +140,7 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC
}
}
HorizontalPager(state = pagerState, Modifier.fillMaxSize(), verticalAlignment = Alignment.Top) { index ->
HorizontalPager(state = pagerState, Modifier.fillMaxSize(), verticalAlignment = Alignment.Top, userScrollEnabled = appPlatform.isAndroid) { index ->
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(
Modifier
@@ -42,6 +42,7 @@ fun NetworkAndServersView() {
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
val netCfg = remember { chatModel.controller.getNetCfg() }
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
val networkShowSubscriptionPercentage: MutableState<Boolean> = remember { mutableStateOf(chatModel.controller.appPrefs.networkShowSubscriptionPercentage.get()) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
val sessionMode = remember { mutableStateOf(netCfg.sessionMode) }
@@ -53,6 +54,7 @@ fun NetworkAndServersView() {
currentRemoteHost = currentRemoteHost,
developerTools = developerTools,
networkUseSocksProxy = networkUseSocksProxy,
networkShowSubscriptionPercentage = networkShowSubscriptionPercentage,
onionHosts = onionHosts,
sessionMode = sessionMode,
smpProxyMode = smpProxyMode,
@@ -117,6 +119,9 @@ fun NetworkAndServersView() {
)
}
},
toggleNetworkShowSubscriptionPercentage = { enable ->
networkShowSubscriptionPercentage.value = enable
},
useOnion = {
if (onionHosts.value == it) return@NetworkAndServersLayout
val prevValue = onionHosts.value
@@ -230,12 +235,14 @@ fun NetworkAndServersView() {
currentRemoteHost: RemoteHostInfo?,
developerTools: Boolean,
networkUseSocksProxy: MutableState<Boolean>,
networkShowSubscriptionPercentage: MutableState<Boolean>,
onionHosts: MutableState<OnionHosts>,
sessionMode: MutableState<TransportSessionMode>,
smpProxyMode: MutableState<SMPProxyMode>,
smpProxyFallback: MutableState<SMPProxyFallback>,
proxyPort: State<Int>,
toggleSocksProxy: (Boolean) -> Unit,
toggleNetworkShowSubscriptionPercentage: (Boolean) -> Unit,
useOnion: (OnionHosts) -> Unit,
updateSessionMode: (TransportSessionMode) -> Unit,
updateSMPProxyMode: (SMPProxyMode) -> Unit,
@@ -256,6 +263,7 @@ fun NetworkAndServersView() {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } })
if (currentRemoteHost == null) {
SettingsPreferenceItem(painterResource(MR.images.ic_radiowaves_up_forward_4_bar),stringResource(MR.strings.subscription_percentage), chatModel.controller.appPrefs.networkShowSubscriptionPercentage)
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, chatModel.controller.appPrefs.networkProxyHostPort, false)
UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion)
if (developerTools) {
@@ -677,8 +685,10 @@ fun PreviewNetworkAndServersLayout() {
currentRemoteHost = null,
developerTools = true,
networkUseSocksProxy = remember { mutableStateOf(true) },
networkShowSubscriptionPercentage = remember { mutableStateOf(false) },
proxyPort = remember { mutableStateOf(9050) },
toggleSocksProxy = {},
toggleNetworkShowSubscriptionPercentage = {},
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
sessionMode = remember { mutableStateOf(TransportSessionMode.User) },
smpProxyMode = remember { mutableStateOf(SMPProxyMode.Never) },
@@ -617,6 +617,7 @@
<!-- NewChatView.kt -->
<string name="new_chat">New chat</string>
<string name="add_contact_tab">Add contact</string>
<string name="scan_paste_link">Scan / Paste link</string>
<string name="one_time_link">One-time invitation link</string>
<string name="one_time_link_short">1-time link</string>
<string name="simplex_address">SimpleX address</string>
@@ -685,6 +686,7 @@
<string name="smp_servers_per_user">The servers for new connections of your current chat profile</string>
<string name="smp_save_servers_question">Save servers?</string>
<string name="xftp_servers">XFTP servers</string>
<string name="subscription_percentage">Subscription percentage</string>
<string name="install_simplex_chat_for_terminal">Install SimpleX Chat for terminal</string>
<string name="star_on_github">Star on GitHub</string>
<string name="contribute">Contribute</string>
@@ -2075,4 +2077,85 @@
<string name="network_type_network_wifi">WiFi</string>
<string name="network_type_ethernet">Wired ethernet</string>
<string name="network_type_other">Other</string>
<!-- ServersSummaryView.kt -->
<string name="servers_info">Servers info</string>
<string name="servers_info_files_tab">Files</string>
<string name="servers_info_missing">No info, try to reload</string>
<string name="servers_info_target">Showing info for</string>
<string name="all_users">All users</string>
<string name="current_user">Current user</string>
<string name="servers_info_transport_sessions_section_header">Transport sessions</string>
<string name="servers_info_sessions_connected">Connected</string>
<string name="servers_info_sessions_connecting">Connecting</string>
<string name="servers_info_sessions_errors">Errors</string>
<string name="servers_info_statistics_section_header">Statistics</string>
<string name="servers_info_messages_sent">Messages sent</string>
<string name="servers_info_messages_received">Messages received</string>
<string name="servers_info_details">Details</string>
<string name="servers_info_private_data_disclaimer">Starting from %s.\nAll data is private to your device.</string>
<string name="servers_info_subscriptions_section_header">Messages subscriptions</string>
<string name="servers_info_subscriptions_connections_subscribed">Connections subscribed</string>
<string name="servers_info_subscriptions_connections_pending">Pending</string>
<string name="servers_info_subscriptions_total">Total</string>
<string name="servers_info_connected_servers_section_header">Connected servers</string>
<string name="servers_info_previously_connected_servers_section_header">Previously connected servers</string>
<string name="servers_info_proxied_servers_section_header">Proxied servers</string>
<string name="servers_info_proxied_servers_section_footer">You are not connected to these servers. Private routing is used to deliver messages to them.</string>
<string name="servers_info_reconnect_servers_title">Reconnect servers?</string>
<string name="servers_info_reconnect_servers_message">Reconnect all connected servers to force message delivery. It uses additional traffic.</string>
<string name="servers_info_reconnect_server_title">Reconnect server?</string>
<string name="servers_info_reconnect_server_message">Reconnect server to force message delivery. It uses additional traffic.</string>
<string name="servers_info_reconnect_servers_error">Error reconnecting servers</string>
<string name="servers_info_reconnect_server_error">Error reconnecting server</string>
<string name="servers_info_modal_error_title">Error</string>
<string name="servers_info_reconnect_all_servers_button">Reconnect all servers</string>
<string name="servers_info_reset_stats">Reset all statistics</string>
<string name="servers_info_reset_stats_alert_title">Reset all statistics?</string>
<string name="servers_info_reset_stats_alert_message">Servers statistics will be reset - this cannot be undone!</string>
<string name="servers_info_reset_stats_alert_confirm">Reset</string>
<string name="servers_info_reset_stats_alert_error_title">Error resetting statistics</string>
<string name="servers_info_uploaded">Uploaded</string>
<string name="servers_info_downloaded">Downloaded</string>
<string name="servers_info_detailed_statistics">Detailed statistics</string>
<string name="servers_info_detailed_statistics_sent_messages_header">Sent messages</string>
<string name="servers_info_detailed_statistics_sent_messages_total">Sent total</string>
<string name="servers_info_detailed_statistics_received_messages_header">Received messages</string>
<string name="servers_info_detailed_statistics_received_total">Received total</string>
<string name="servers_info_detailed_statistics_receive_errors">Receive errors</string>
<string name="servers_info_starting_from">Starting from %s.</string>
<string name="smp_server">SMP server</string>
<string name="xftp_server">XFTP server</string>
<string name="reconnect">Reconnect</string>
<string name="attempts_label">attempts</string>
<string name="sent_directly">Sent directly</string>
<string name="sent_via_proxy">Sent via proxy</string>
<string name="proxied">Proxied</string>
<string name="send_errors">Send errors</string>
<string name="expired_label">expired</string>
<string name="other_label">other</string>
<string name="duplicates_label">duplicates</string>
<string name="decryption_errors">decryption errors</string>
<string name="other_errors">other errors</string>
<string name="acknowledged">Acknowledged</string>
<string name="acknowledgement_errors">Acknowledgement errors</string>
<string name="connections">Connections</string>
<string name="created">Created</string>
<string name="secured">Secured</string>
<string name="completed">Completed</string>
<string name="deleted">Deleted</string>
<string name="deletion_errors">Deletion errors</string>
<string name="subscribed">Subscribed</string>
<string name="subscription_results_ignored">Subscriptions ignored</string>
<string name="subscription_errors">Subscription errors</string>
<string name="uploaded_files">Uploaded files</string>
<string name="size">Size</string>
<string name="chunks_uploaded">Chunks uploaded</string>
<string name="upload_errors">Upload errors</string>
<string name="chunks_deleted">Chunks deleted</string>
<string name="chunks_downloaded">Chunks downloaded</string>
<string name="downloaded_files">Downloaded files</string>
<string name="download_errors">Download errors</string>
<string name="server_address">Server address</string>
<string name="open_server_settings_button">Open server settings</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg height="24" viewBox="-44.134 -43.207 588.823 585.294" width="24" xmlns="http://www.w3.org/2000/svg">
<g id="Light-S" transform="matrix(8.710668563842773, 0, 0, 8.710668563842773, -85.06543731689453, 558.4567260742189)">
<path d="M 15.124 -6.711 C 18.033 -6.711 20.399 -9.129 20.399 -11.986 C 20.399 -14.96 18.082 -17.295 15.124 -17.295 C 12.232 -17.295 9.766 -14.862 9.766 -11.889 C 9.766 -9.129 12.281 -6.711 15.124 -6.711 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 492 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg height="24" viewBox="-44.134 -43.207 588.823 585.294" width="24" xmlns="http://www.w3.org/2000/svg">
<g id="Light-S" transform="matrix(8.710668563842773, 0, 0, 8.710668563842773, -85.06543731689453, 558.4567260742188)">
<path d="M 15.124 -6.711 C 18.033 -6.711 20.399 -9.129 20.399 -11.986 C 20.399 -14.96 18.082 -17.295 15.124 -17.295 C 12.232 -17.295 9.766 -14.862 9.766 -11.889 C 9.766 -9.129 12.281 -6.711 15.124 -6.711 Z M 10.714 -30.066 C 10.714 -28.585 11.962 -27.352 13.428 -27.352 C 22.787 -27.352 30.392 -19.732 30.392 -10.388 C 30.392 -8.908 31.64 -7.675 33.106 -7.675 C 34.571 -7.675 35.819 -8.908 35.819 -10.388 C 35.819 -22.746 25.718 -32.764 13.428 -32.764 C 11.962 -32.764 10.714 -31.565 10.714 -30.066 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 787 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg height="24" viewBox="-44.134 -43.207 588.823 585.294" width="24" xmlns="http://www.w3.org/2000/svg">
<g id="Light-S" transform="matrix(8.710668563842773, 0, 0, 8.710668563842773, -85.06543731689453, 558.4567260742188)">
<path d="M 15.124 -6.711 C 18.033 -6.711 20.399 -9.129 20.399 -11.986 C 20.399 -14.96 18.082 -17.295 15.124 -17.295 C 12.232 -17.295 9.766 -14.862 9.766 -11.889 C 9.766 -9.129 12.281 -6.711 15.124 -6.711 Z M 10.714 -30.066 C 10.714 -28.585 11.962 -27.352 13.428 -27.352 C 22.787 -27.352 30.392 -19.732 30.392 -10.388 C 30.392 -8.908 31.64 -7.675 33.106 -7.675 C 34.571 -7.675 35.819 -8.908 35.819 -10.388 C 35.819 -22.746 25.718 -32.764 13.428 -32.764 C 11.962 -32.764 10.714 -31.565 10.714 -30.066 Z M 10.714 -45.251 C 10.714 -43.771 11.962 -42.538 13.428 -42.538 C 31.216 -42.538 45.578 -28.161 45.578 -10.388 C 45.578 -8.908 46.826 -7.675 48.291 -7.675 C 49.756 -7.675 51.004 -8.908 51.004 -10.388 C 51.004 -31.141 34.147 -47.95 13.428 -47.95 C 11.962 -47.95 10.714 -46.75 10.714 -45.251 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg height="24" viewBox="-44.134 -43.207 588.823 585.294" width="24" xmlns="http://www.w3.org/2000/svg">
<g id="Light-S" transform="matrix(8.710668563842773, 0, 0, 8.710668563842773, -85.06543731689453, 558.4567260742188)">
<path d="M15.1235-6.71093C18.0332-6.71093 20.3989-9.1289 20.3989-11.9863C20.3989-14.96 18.082-17.2954 15.1235-17.2954C12.2324-17.2954 9.76562-14.8623 9.76562-11.8887C9.76562-9.1289 12.2813-6.71093 15.1235-6.71093ZM10.7143-30.0659C10.7143-28.5854 11.9624-27.3525 13.4277-27.3525C22.7871-27.3525 30.3921-19.7324 30.3921-10.3882C30.3921-8.90771 31.6401-7.6748 33.1055-7.6748C34.5708-7.6748 35.8189-8.90771 35.8189-10.3882C35.8189-22.7456 25.7178-32.7642 13.4277-32.7642C11.9624-32.7642 10.7143-31.5649 10.7143-30.0659ZM10.7143-45.2515C10.7143-43.771 11.9624-42.5381 13.4277-42.5381C31.2158-42.5381 45.5776-28.1611 45.5776-10.3882C45.5776-8.90771 46.8257-7.6748 48.291-7.6748C49.7564-7.6748 51.0044-8.90771 51.0044-10.3882C51.0044-31.1406 34.1465-47.9497 13.4277-47.9497C11.9624-47.9497 10.7143-46.7505 10.7143-45.2515ZM10.7143-61.4136C10.7143-59.9331 11.9624-58.7002 13.4277-58.7002C40.0874-58.7002 61.7397-37.0327 61.7397-10.3882C61.7397-8.90771 62.9878-7.6748 64.4531-7.6748C65.9185-7.6748 67.1665-8.90771 67.1665-10.3882C67.1665-40.0273 43.0181-64.1118 13.4277-64.1118C11.9624-64.1118 10.7143-62.9126 10.7143-61.4136Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB