diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 7fa5a6ac94..a27bc3e327 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -44,7 +44,10 @@ class ChatModel(val controller: ChatController) { val terminalItems = mutableStateListOf() val userAddress = mutableStateOf(null) - val userSMPServers = mutableStateOf<(List)?>(null) + val userSMPServers = mutableStateOf<(List)?>(null) + // Allows to temporary save servers that are being edited on multiple screens + val userSMPServersUnsaved = mutableStateOf<(List)?>(null) + val presetSMPServers = mutableStateOf<(List)?>(null) val chatItemTTL = mutableStateOf(ChatItemTTL.None) // set when app opened from external intent diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 9ee2656e38..3e88900ca3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -34,7 +34,7 @@ import kotlinx.datetime.Instant import kotlinx.serialization.* import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject -import kotlin.concurrent.thread +import java.util.Date typealias ChatCtrl = Long @@ -235,7 +235,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a apiSetFilesFolder(getAppFilesDirectory(appContext)) apiSetIncognito(chatModel.incognito.value) chatModel.userAddress.value = apiGetUserAddress() - chatModel.userSMPServers.value = getUserSMPServers() + val smpServers = getUserSMPServers() + chatModel.userSMPServers.value = smpServers?.first + chatModel.presetSMPServers.value = smpServers?.second chatModel.chatItemTTL.value = getChatItemTTL() val chats = apiGetChats() chatModel.updateChats(chats) @@ -425,14 +427,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return null } - private suspend fun getUserSMPServers(): List? { + private suspend fun getUserSMPServers(): Pair, List>? { val r = sendCmd(CC.GetUserSMPServers()) - if (r is CR.UserSMPServers) return r.smpServers + if (r is CR.UserSMPServers) return r.smpServers to r.presetSMPServers Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}") return null } - suspend fun setUserSMPServers(smpServers: List): Boolean { + suspend fun setUserSMPServers(smpServers: List): Boolean { val r = sendCmd(CC.SetUserSMPServers(smpServers)) return when (r) { is CR.CmdOk -> true @@ -447,6 +449,17 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun testSMPServer(smpServer: String): SMPTestFailure? { + val r = sendCmd(CC.TestSMPServer(smpServer)) + return when (r) { + is CR.SmpTestResult -> r.smpTestFailure + else -> { + Log.e(TAG, "testSMPServer bad response: ${r.responseType} ${r.details}") + throw Exception("testSMPServer bad response: ${r.responseType} ${r.details}") + } + } + } + suspend fun getChatItemTTL(): ChatItemTTL { val r = sendCmd(CC.APIGetChatItemTTL()) if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL) @@ -1465,7 +1478,8 @@ sealed class CC { class APIDeleteGroupLink(val groupId: Long): CC() class APIGetGroupLink(val groupId: Long): CC() class GetUserSMPServers: CC() - class SetUserSMPServers(val smpServers: List): CC() + class SetUserSMPServers(val smpServers: List): CC() + class TestSMPServer(val smpServer: String): CC() class APISetChatItemTTL(val seconds: Long?): CC() class APIGetChatItemTTL: CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() @@ -1530,8 +1544,9 @@ sealed class CC { is APICreateGroupLink -> "/_create link #$groupId" is APIDeleteGroupLink -> "/_delete link #$groupId" is APIGetGroupLink -> "/_get link #$groupId" - is GetUserSMPServers -> "/smp_servers" - is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}" + is GetUserSMPServers -> "/smp" + is SetUserSMPServers -> "/_smp ${smpServersStr(smpServers)}" + is TestSMPServer -> "/smp test $smpServer" is APISetChatItemTTL -> "/_ttl ${chatItemTTLStr(seconds)}" is APIGetChatItemTTL -> "/ttl" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" @@ -1599,6 +1614,7 @@ sealed class CC { is APIGetGroupLink -> "apiGetGroupLink" is GetUserSMPServers -> "getUserSMPServers" is SetUserSMPServers -> "setUserSMPServers" + is TestSMPServer -> "testSMPServer" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" is APISetNetworkConfig -> "/apiSetNetworkConfig" @@ -1656,7 +1672,7 @@ sealed class CC { companion object { fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" - fun smpServersStr(smpServers: List) = if (smpServers.isEmpty()) "default" else smpServers.joinToString(separator = ";") + fun smpServersStr(smpServers: List) = if (smpServers.isEmpty()) "default" else json.encodeToString(SMPServersConfig(smpServers)) } } @@ -1687,6 +1703,147 @@ class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = @Serializable class DBEncryptionConfig(val currentKey: String, val newKey: String) +@Serializable +data class SMPServersConfig( + val smpServers: List +) + +@Serializable +data class ServerCfg( + val server: String, + val preset: Boolean, + val tested: Boolean? = null, + val enabled: Boolean +) { + @Transient + private val createdAt: Date = Date() + // val sendEnabled: Boolean // can we potentially want to prevent sending on the servers we use to receive? + // Even if we don't see the use case, it's probably better to allow it in the model + // In any case, "trusted/known" servers are out of scope of this change + val id: String + get() = "$server $createdAt" + + val isBlank: Boolean + get() = server.isBlank() + + companion object { + val empty = ServerCfg(server = "", preset = false, tested = null, enabled = true) + + class SampleData( + val preset: ServerCfg, + val custom: ServerCfg, + val untested: ServerCfg + ) + + val sampleData = SampleData( + preset = ServerCfg( + server = "smp://abcd@smp8.simplex.im", + preset = true, + tested = true, + enabled = true + ), + custom = ServerCfg( + server = "smp://abcd@smp9.simplex.im", + preset = false, + tested = false, + enabled = false + ), + untested = ServerCfg( + server = "smp://abcd@smp10.simplex.im", + preset = false, + tested = null, + enabled = true + ) + ) + } +} + +@Serializable +enum class SMPTestStep { + @SerialName("connect") Connect, + @SerialName("createQueue") CreateQueue, + @SerialName("secureQueue") SecureQueue, + @SerialName("deleteQueue") DeleteQueue, + @SerialName("disconnect") Disconnect; + + val text: String get() = when (this) { + Connect -> generalGetString(R.string.smp_server_test_connect) + CreateQueue -> generalGetString(R.string.smp_server_test_create_queue) + SecureQueue -> generalGetString(R.string.smp_server_test_secure_queue) + DeleteQueue -> generalGetString(R.string.smp_server_test_delete_queue) + Disconnect -> generalGetString(R.string.smp_server_test_disconnect) + } +} + +@Serializable +data class SMPTestFailure( + val testStep: SMPTestStep, + val testError: AgentErrorType +) { + override fun equals(other: Any?): Boolean { + if (other !is SMPTestFailure) return false + return other.testStep == this.testStep + } + + override fun hashCode(): Int { + return testStep.hashCode() + } + + val localizedDescription: String get() { + val err = String.format(generalGetString(R.string.error_smp_test_failed_at_step), testStep.text) + return when { + testError is AgentErrorType.SMP && testError.smpErr is SMPErrorType.AUTH -> + err + " " + generalGetString(R.string.error_smp_test_server_auth) + testError is AgentErrorType.BROKER && testError.brokerErr is BrokerErrorType.NETWORK -> + err + " " + generalGetString(R.string.error_smp_test_certificate) + else -> err + } + } +} + +@Serializable +data class ServerAddress( + val hostnames: List, + val port: String, + val keyHash: String, + val basicAuth: String = "" +) { + val uri: String + get() = + "smp://${keyHash}${if (basicAuth.isEmpty()) "" else ":$basicAuth"}@${hostnames.joinToString(",")}" + + val valid: Boolean + get() = hostnames.isNotEmpty() && hostnames.toSet().size == hostnames.size + + companion object { + val empty = ServerAddress( + hostnames = emptyList(), + port = "", + keyHash = "", + basicAuth = "" + ) + val sampleData = ServerAddress( + hostnames = listOf("smp.simplex.im", "1234.onion"), + port = "", + keyHash = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=", + basicAuth = "server_password" + ) + + fun parseServerAddress(s: String): ServerAddress? { + val parsed = chatParseServer(s) + return runCatching { json.decodeFromString(ParsedServerAddress.serializer(), parsed) } + .onFailure { Log.d(TAG, "parseServerAddress decode error: $it") } + .getOrNull()?.serverAddress + } + } +} + +@Serializable +data class ParsedServerAddress ( + var serverAddress: ServerAddress?, + var parseError: String +) + @Serializable data class NetCfg( val socksProxy: String? = null, @@ -1828,7 +1985,8 @@ sealed class CR { @Serializable @SerialName("chatStopped") class ChatStopped: CR() @Serializable @SerialName("apiChats") class ApiChats(val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR() - @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List): CR() + @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List, val presetSMPServers: List): CR() + @Serializable @SerialName("smpTestResult") class SmpTestResult(val smpTestFailure: SMPTestFailure? = null): CR() @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR() @@ -1926,6 +2084,7 @@ sealed class CR { is ApiChats -> "apiChats" is ApiChat -> "apiChat" is UserSMPServers -> "userSMPServers" + is SmpTestResult -> "smpTestResult" is ChatItemTTL -> "chatItemTTL" is NetworkConfig -> "networkConfig" is ContactInfo -> "contactInfo" @@ -2020,7 +2179,8 @@ sealed class CR { is ChatStopped -> noDetails() is ApiChats -> json.encodeToString(chats) is ApiChat -> json.encodeToString(chat) - is UserSMPServers -> json.encodeToString(smpServers) + is UserSMPServers -> "$smpServers: ${json.encodeToString(smpServers)}\n$presetSMPServers: ${json.encodeToString(presetSMPServers)}" + is SmpTestResult -> json.encodeToString(smpTestFailure) is ChatItemTTL -> json.encodeToString(chatItemTTL) is NetworkConfig -> json.encodeToString(networkConfig) is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt index 79d27f0f47..cc1f06274b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt @@ -1,11 +1,17 @@ package chat.simplex.app.views.helpers import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog import chat.simplex.app.R import chat.simplex.app.TAG +import chat.simplex.app.ui.theme.DEFAULT_PADDING class AlertManager { var alertViews = mutableStateListOf<(@Composable () -> Unit)>() @@ -35,6 +41,26 @@ class AlertManager { } } + fun showAlertDialogButtonsColumn( + title: String, + text: String? = null, + buttons: @Composable () -> Unit, + ) { + showAlert { + Dialog(onDismissRequest = this::hideAlert) { + Column(Modifier.background(MaterialTheme.colors.background)) { + Text(title, Modifier.padding(DEFAULT_PADDING), fontSize = 18.sp) + if (text != null) { + Text(text) + } + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { + buttons() + } + } + } + } + } + fun showAlertDialog( title: String, text: String? = null, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt index 18941e2c76..056d6b5872 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.app.ui.theme.* @@ -31,6 +32,28 @@ fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(), } } +@Composable +fun SectionView( + title: String, + icon: ImageVector, + iconTint: Color = HighOrLowlight, + leadingIcon: Boolean = false, + padding: PaddingValues = PaddingValues(), + content: (@Composable ColumnScope.() -> Unit) +) { + Column { + val iconSize = with(LocalDensity.current) { 15.sp.toDp() } + Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { + if (leadingIcon) Icon(icon, null, Modifier.padding(end = 4.dp).size(iconSize), tint = iconTint) + Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, fontSize = 12.sp) + if (!leadingIcon) Icon(icon, null, Modifier.padding(start = 4.dp).size(iconSize), tint = iconTint) + } + Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) { + Column(Modifier.padding(padding).fillMaxWidth()) { content() } + } + } +} + @Composable fun SectionViewSelectable( title: String?, @@ -56,7 +79,12 @@ fun SectionViewSelectable( } @Composable -fun SectionItemView(click: (() -> Unit)? = null, minHeight: Dp = 46.dp, disabled: Boolean = false, content: (@Composable RowScope.() -> Unit)) { +fun SectionItemView( + click: (() -> Unit)? = null, + minHeight: Dp = 46.dp, + disabled: Boolean = false, + content: (@Composable RowScope.() -> Unit) +) { val modifier = Modifier .fillMaxWidth() .sizeIn(minHeight = minHeight) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt index c02bdfb6a4..71f9874eed 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt @@ -12,21 +12,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.* import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.* +import chat.simplex.app.ui.theme.DEFAULT_PADDING import chat.simplex.app.ui.theme.HighOrLowlight @Composable -fun TextEditor(modifier: Modifier, text: MutableState) { +fun TextEditor( + modifier: Modifier, + text: MutableState, + border: Boolean = true, + fontSize: TextUnit = 14.sp, + background: Color = MaterialTheme.colors.background, + onChange: ((String) -> Unit)? = null +) { BasicTextField( value = text.value, - onValueChange = { text.value = it }, + onValueChange = { text.value = it; onChange?.invoke(it) }, textStyle = TextStyle( - fontFamily = FontFamily.Monospace, fontSize = 14.sp, + fontFamily = FontFamily.Monospace, fontSize = fontSize, color = MaterialTheme.colors.onBackground ), keyboardOptions = KeyboardOptions.Default.copy( @@ -37,17 +44,17 @@ fun TextEditor(modifier: Modifier, text: MutableState) { cursorBrush = SolidColor(HighOrLowlight), decorationBox = { innerTextField -> Surface( - shape = RoundedCornerShape(10.dp), - border = BorderStroke(1.dp, MaterialTheme.colors.secondary) + shape = if (border) RoundedCornerShape(10.dp) else RectangleShape, + border = if (border) BorderStroke(1.dp, MaterialTheme.colors.secondary) else null ) { Row( - Modifier.background(MaterialTheme.colors.background), + Modifier.background(background), verticalAlignment = Alignment.Top ) { Box( Modifier .weight(1f) - .padding(vertical = 5.dp, horizontal = 7.dp) + .padding(vertical = 5.dp, horizontal = if (border) 7.dp else DEFAULT_PADDING) ) { innerTextField() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt index d5c45ca432..d950981682 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt @@ -32,6 +32,10 @@ fun NetworkAndServersView( val developerTools = chatModel.controller.appPrefs.developerTools.get() val onionHosts = remember { mutableStateOf(netCfg.onionHosts) } + LaunchedEffect(Unit) { + chatModel.userSMPServersUnsaved.value = null + } + NetworkAndServersLayout( developerTools = developerTools, networkUseSocksProxy = networkUseSocksProxy, @@ -112,7 +116,7 @@ fun NetworkAndServersView( ) { AppBarTitle(stringResource(R.string.network_and_servers)) SectionView(generalGetString(R.string.settings_section_title_messages)) { - SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }) + SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showSettingsModal { SMPServersView(it) }) SectionDivider() SectionItemView { UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServerView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServerView.kt new file mode 100644 index 0000000000..170cbb9c76 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServerView.kt @@ -0,0 +1,200 @@ +package chat.simplex.app.views.usersettings + +import SectionDivider +import SectionItemView +import SectionItemViewSpaceBetween +import SectionSpacer +import SectionView +import android.util.Log +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +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.app.R +import chat.simplex.app.TAG +import chat.simplex.app.model.* +import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.QRCode +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +@Composable +fun SMPServerView(m: ChatModel, server: ServerCfg, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) { + var testing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + SMPServerLayout( + testing, + server, + testServer = { + testing = true + scope.launch { + val res = testServerConnection(server, m) + if (isActive) { + onUpdate(res.first) + testing = false + } + } + }, + onUpdate, + onDelete + ) + if (testing) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = HighOrLowlight, + strokeWidth = 2.5.dp + ) + } + } +} + +@Composable +private fun SMPServerLayout( + testing: Boolean, + server: ServerCfg, + testServer: () -> Unit, + onUpdate: (ServerCfg) -> Unit, + onDelete: () -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(bottom = DEFAULT_PADDING) + ) { + AppBarTitle(stringResource(if (server.preset) R.string.smp_servers_preset_server else R.string.smp_servers_your_server)) + + if (server.preset) { + PresetServer(testing, server, testServer, onUpdate, onDelete) + } else { + CustomServer(testing, server, testServer, onUpdate, onDelete) + } + } +} + +@Composable +private fun PresetServer( + testing: Boolean, + server: ServerCfg, + testServer: () -> Unit, + onUpdate: (ServerCfg) -> Unit, + onDelete: () -> Unit, +) { + SectionView(stringResource(R.string.smp_servers_preset_address).uppercase()) { + SelectionContainer { + Text( + server.server, + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + style = TextStyle( + fontFamily = FontFamily.Monospace, fontSize = 16.sp, + color = HighOrLowlight + ) + ) + } + } + SectionSpacer() + UseServerSection(true, testing, server, testServer, onUpdate, onDelete) +} + +@Composable +private fun CustomServer( + testing: Boolean, + server: ServerCfg, + testServer: () -> Unit, + onUpdate: (ServerCfg) -> Unit, + onDelete: () -> Unit, +) { + val serverAddress = remember { mutableStateOf(server.server) } + val valid = remember { derivedStateOf { parseServerAddress(serverAddress.value)?.valid == true } } + SectionView( + stringResource(R.string.smp_servers_your_server_address).uppercase(), + icon = Icons.Outlined.ErrorOutline, + iconTint = if (!valid.value) MaterialTheme.colors.error else Color.Transparent, + ) { + val testedPreviously = remember { mutableMapOf() } + TextEditor( + Modifier.height(144.dp), + text = serverAddress, + border = false, + fontSize = 16.sp, + background = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background + ) { + testedPreviously[server.server] = server.tested + onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value])) + } + } + SectionSpacer() + UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete) + SectionSpacer() + + if (valid.value) { + SectionView(stringResource(R.string.smp_servers_add_to_another_device).uppercase()) { + QRCode(serverAddress.value, Modifier.aspectRatio(1f)) + } + } +} + +@Composable +private fun UseServerSection( + valid: Boolean, + testing: Boolean, + server: ServerCfg, + testServer: () -> Unit, + onUpdate: (ServerCfg) -> Unit, + onDelete: () -> Unit, +) { + SectionView(stringResource(R.string.smp_servers_use_server).uppercase()) { + SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) { + Text(stringResource(R.string.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else HighOrLowlight) + ShowTestStatus(server) + } + SectionDivider() + SectionItemView { + val enabled = rememberUpdatedState(server.enabled) + PreferenceToggle(stringResource(R.string.smp_servers_use_server_for_new_conn), enabled.value) { onUpdate(server.copy(enabled = it)) } + } + SectionDivider() + SectionItemView(onDelete, disabled = testing) { + Text(stringResource(R.string.smp_servers_delete_server), color = if (testing) HighOrLowlight else MaterialTheme.colors.error) + } + } +} + +@Composable +fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) = + when (server.tested) { + true -> Icon(Icons.Outlined.Check, null, modifier, tint = Color.Green) + false -> Icon(Icons.Outlined.Clear, null, modifier, tint = MaterialTheme.colors.error) + else -> Icon(Icons.Outlined.Check, null, modifier, tint = Color.Transparent) + } + +suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair = + try { + val r = m.controller.testSMPServer(server.server) + server.copy(tested = r == null) to r + } catch (e: Exception) { + Log.e(TAG, "testServerConnection ${e.stackTraceToString()}") + server.copy(tested = false) to null + } + +fun serverHostname(srv: ServerCfg): String = + parseServerAddress(srv.server)?.hostnames?.firstOrNull() ?: srv.server diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt deleted file mode 100644 index 7fda17c677..0000000000 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt +++ /dev/null @@ -1,258 +0,0 @@ -package chat.simplex.app.views.usersettings - -import SectionItemViewSpaceBetween -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.OpenInNew -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* - -@Composable -fun SMPServersView(chatModel: ChatModel) { - val userSMPServers = chatModel.userSMPServers.value - if (userSMPServers != null) { - var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) } - var editSMPServers by remember { mutableStateOf(!isUserSMPServers) } - val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") } - fun saveSMPServers(smpServers: List) { - withApi { - val r = chatModel.controller.setUserSMPServers(smpServers = smpServers) - if (r) { - chatModel.userSMPServers.value = smpServers - if (smpServers.isEmpty()) { - isUserSMPServers = false - editSMPServers = true - } else { - editSMPServers = false - } - } - } - } - - SMPServersLayout( - isUserSMPServers = isUserSMPServers, - editSMPServers = editSMPServers, - userSMPServersStr = userSMPServersStr, - isUserSMPServersOnOff = { switch -> - if (switch) { - isUserSMPServers = true - } else { - val userSMPServers = chatModel.userSMPServers.value - if (userSMPServers != null) { - if (userSMPServers.isNotEmpty()) { - AlertManager.shared.showAlertMsg( - title = generalGetString(R.string.use_simplex_chat_servers__question), - text = generalGetString(R.string.saved_SMP_servers_will_be_removed), - confirmText = generalGetString(R.string.confirm_verb), - onConfirm = { - saveSMPServers(listOf()) - isUserSMPServers = false - userSMPServersStr.value = "" - } - ) - } else { - isUserSMPServers = false - userSMPServersStr.value = "" - } - } - } - }, - cancelEdit = { - val userSMPServers = chatModel.userSMPServers.value - if (userSMPServers != null) { - isUserSMPServers = userSMPServers.isNotEmpty() - editSMPServers = !isUserSMPServers - userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "" - } - }, - saveSMPServers = { saveSMPServers(it) }, - editOn = { editSMPServers = true }, - ) - } -} - -@Composable -fun SMPServersLayout( - isUserSMPServers: Boolean, - editSMPServers: Boolean, - userSMPServersStr: MutableState, - isUserSMPServersOnOff: (Boolean) -> Unit, - cancelEdit: () -> Unit, - saveSMPServers: (List) -> Unit, - editOn: () -> Unit, -) { - Column { - AppBarTitle(stringResource(R.string.your_SMP_servers)) - Column( - Modifier - .fillMaxWidth() - .padding(horizontal = DEFAULT_PADDING), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - SectionItemViewSpaceBetween(padding = PaddingValues()) { - Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp)) - Switch( - checked = isUserSMPServers, - onCheckedChange = isUserSMPServersOnOff, - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colors.primary, - uncheckedThumbColor = HighOrLowlight - ), - ) - } - - if (!isUserSMPServers) { - Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp) - } else { - Text(stringResource(R.string.enter_one_SMP_server_per_line)) - if (editSMPServers) { - TextEditor(Modifier.height(160.dp), text = userSMPServersStr) - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(horizontalAlignment = Alignment.Start) { - Row { - Text( - stringResource(R.string.cancel_verb), - color = MaterialTheme.colors.primary, - modifier = Modifier - .clickable(onClick = cancelEdit) - ) - Spacer(Modifier.padding(horizontal = 8.dp)) - Text( - stringResource(R.string.save_servers_button), - color = MaterialTheme.colors.primary, - modifier = Modifier.clickable(onClick = { - val servers = userSMPServersStr.value.split("\n") - saveSMPServers(servers) - }) - ) - } - } - Column(horizontalAlignment = Alignment.End) { - howToButton() - } - } - } else { - Surface( - modifier = Modifier - .height(160.dp) - .fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - border = BorderStroke(1.dp, MaterialTheme.colors.secondary) - ) { - SelectionContainer( - Modifier.verticalScroll(rememberScrollState()) - ) { - Text( - userSMPServersStr.value, - Modifier - .padding(vertical = 5.dp, horizontal = 7.dp), - style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp), - ) - } - } - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(horizontalAlignment = Alignment.Start) { - Text( - stringResource(R.string.edit_verb), - color = MaterialTheme.colors.primary, - modifier = Modifier - .clickable(onClick = editOn) - ) - } - Column(horizontalAlignment = Alignment.End) { - howToButton() - } - } - } - } - } - } -} - -@Composable -private fun howToButton() { - val uriHandler = LocalUriHandler.current - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") } - ) { - Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary) - Icon( - Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(horizontal = 5.dp) - ) - } -} - -@Preview(showBackground = true) -@Composable -fun PreviewSMPServersLayoutDefaultServers() { - SimpleXTheme { - SMPServersLayout( - isUserSMPServers = false, - editSMPServers = true, - userSMPServersStr = remember { mutableStateOf("") }, - isUserSMPServersOnOff = {}, - cancelEdit = {}, - saveSMPServers = {}, - editOn = {}, - ) - } -} - -@Preview(showBackground = true) -@Composable -fun PreviewSMPServersLayoutUserServersEditOn() { - SimpleXTheme { - SMPServersLayout( - isUserSMPServers = true, - editSMPServers = true, - userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") }, - isUserSMPServersOnOff = {}, - cancelEdit = {}, - saveSMPServers = {}, - editOn = {}, - ) - } -} - -@Preview(showBackground = true) -@Composable -fun PreviewSMPServersLayoutUserServersEditOff() { - SimpleXTheme { - SMPServersLayout( - isUserSMPServers = true, - editSMPServers = false, - userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") }, - isUserSMPServersOnOff = {}, - cancelEdit = {}, - saveSMPServers = {}, - editOn = {}, - ) - } -} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServersView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServersView.kt new file mode 100644 index 0000000000..9ec28d0bde --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServersView.kt @@ -0,0 +1,311 @@ +package chat.simplex.app.views.usersettings + +import SectionDivider +import SectionItemView +import SectionSpacer +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.launch + +@Composable +fun SMPServersView(m: ChatModel) { + var servers by remember { + mutableStateOf(m.userSMPServersUnsaved.value ?: m.userSMPServers.value ?: emptyList()) + } + val testing = rememberSaveable { mutableStateOf(false) } + val serversUnchanged = remember { derivedStateOf { servers == m.userSMPServers.value || testing.value } } + val saveDisabled = remember { + derivedStateOf { + servers.isEmpty() || servers == m.userSMPServers.value || testing.value || !servers.all { srv -> + val address = parseServerAddress(srv.server) + address != null && uniqueAddress(srv, address, servers) + } + } + } + + fun showServer(server: ServerCfg) { + ModalManager.shared.showModalCloseable(true) { close -> + var old by remember { mutableStateOf(server) } + val index = servers.indexOf(old) + SMPServerView( + m, + old, + onUpdate = { updated -> + val newServers = ArrayList(servers) + newServers.removeAt(index) + newServers.add(index, updated) + old = updated + servers = newServers + m.userSMPServersUnsaved.value = servers + }, + onDelete = { + val newServers = ArrayList(servers) + newServers.removeAt(index) + servers = newServers + m.userSMPServersUnsaved.value = servers + close() + }) + } + } + val scope = rememberCoroutineScope() + + SMPServersLayout( + testing.value, + servers, + serversUnchanged.value, + saveDisabled.value, + addServer = { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(R.string.smp_servers_add), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + servers = servers + ServerCfg.empty + // No saving until something will be changed on the next screen to prevent blank servers on the list + showServer(servers.last()) + }) { + Text(stringResource(R.string.smp_servers_enter_manually)) + } + SectionItemView({ + AlertManager.shared.hideAlert() + ModalManager.shared.showModalCloseable { close -> + ScanSMPServer { + close() + servers = servers + it + m.userSMPServersUnsaved.value = servers + } + } + } + ) { + Text(stringResource(R.string.smp_servers_scan_qr)) + } + val hasAllPresets = hasAllPresets(servers, m) + if (!hasAllPresets) { + SectionItemView({ + AlertManager.shared.hideAlert() + servers = (servers + addAllPresets(servers, m)).sortedByDescending { it.preset } + }) { + Text(stringResource(R.string.smp_servers_preset_add), color = MaterialTheme.colors.onBackground) + } + } + } + } + ) + }, + testServers = { + scope.launch { + testServers(testing, servers, m) { + servers = it + m.userSMPServersUnsaved.value = servers + } + } + }, + resetServers = { + servers = m.userSMPServers.value ?: emptyList() + m.userSMPServersUnsaved.value = null + }, + saveSMPServers = { + saveSMPServers(servers, m) + }, + showServer = ::showServer, + ) + + if (testing.value) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = HighOrLowlight, + strokeWidth = 2.5.dp + ) + } + } +} + +@Composable +private fun SMPServersLayout( + testing: Boolean, + servers: List, + serversUnchanged: Boolean, + saveDisabled: Boolean, + addServer: () -> Unit, + testServers: () -> Unit, + resetServers: () -> Unit, + saveSMPServers: () -> Unit, + showServer: (ServerCfg) -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(bottom = DEFAULT_PADDING), + ) { + AppBarTitle(stringResource(R.string.your_SMP_servers)) + + SectionView(stringResource(R.string.smp_servers).uppercase()) { + for (srv in servers) { + SectionItemView({ showServer(srv) }, disabled = testing) { + SmpServerView(srv, servers, testing) + } + SectionDivider() + } + SettingsActionItem( + Icons.Outlined.Add, + stringResource(R.string.smp_servers_add), + addServer, + disabled = testing, + textColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary, + iconColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary + ) + } + SectionSpacer() + SectionView { + SectionItemView(resetServers, disabled = serversUnchanged) { + Text(stringResource(R.string.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else HighOrLowlight) + } + SectionDivider() + SectionItemView(testServers, disabled = testing) { + Text(stringResource(R.string.smp_servers_test_servers), color = if (!testing) MaterialTheme.colors.onBackground else HighOrLowlight) + } + SectionDivider() + SectionItemView(saveSMPServers, disabled = saveDisabled) { + Text(stringResource(R.string.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else HighOrLowlight) + } + } + SectionSpacer() + SectionView { + HowToButton() + } + } +} + +@Composable +private fun SmpServerView(srv: ServerCfg, servers: List, disabled: Boolean) { + val address = parseServerAddress(srv.server) + when { + address == null || !address.valid || !uniqueAddress(srv, address, servers) -> InvalidServer() + !srv.enabled -> Icon(Icons.Outlined.DoNotDisturb, null, tint = HighOrLowlight) + else -> ShowTestStatus(srv) + } + Spacer(Modifier.padding(horizontal = 4.dp)) + val text = address?.hostnames?.firstOrNull() ?: srv.server + if (srv.enabled) { + Text(text, color = if (disabled) HighOrLowlight else MaterialTheme.colors.onBackground, maxLines = 1) + } else { + Text(text, maxLines = 1, color = HighOrLowlight) + } +} + +@Composable +private fun HowToButton() { + val uriHandler = LocalUriHandler.current + SettingsActionItem( + Icons.Outlined.OpenInNew, + stringResource(R.string.how_to_use_your_servers), + { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary + ) +} + +@Composable +fun InvalidServer() { + Icon(Icons.Outlined.ErrorOutline, null, tint = MaterialTheme.colors.error) +} + +private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List): Boolean = servers.all { srv -> + address.hostnames.all { host -> + srv.id == s.id || !srv.server.contains(host) + } +} + +private fun hasAllPresets(servers: List, m: ChatModel): Boolean = + m.presetSMPServers.value?.all { hasPreset(it, servers) } ?: true + +private fun addAllPresets(servers: List, m: ChatModel): List { + val toAdd = ArrayList() + for (srv in m.presetSMPServers.value ?: emptyList()) { + if (!hasPreset(srv, servers)) { + toAdd.add(ServerCfg(srv, preset = true, tested = null, enabled = true)) + } + } + return toAdd +} + +private fun hasPreset(srv: String, servers: List): Boolean = + servers.any { it.server == srv } + +private suspend fun testServers(testing: MutableState, servers: List, m: ChatModel, onUpdated: (List) -> Unit) { + val resetStatus = resetTestStatus(servers) + onUpdated(resetStatus) + testing.value = true + val fs = runServersTest(resetStatus, m) { onUpdated(it) } + testing.value = false + if (fs.isNotEmpty()) { + val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n") + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.smp_servers_test_failed), + text = generalGetString(R.string.smp_servers_test_some_failed) + "\n" + msg + ) + } +} + +private fun resetTestStatus(servers: List): List { + val copy = ArrayList(servers) + for ((index, server) in servers.withIndex()) { + if (server.enabled) { + copy.removeAt(index) + copy.add(index, server.copy(tested = null)) + } + } + return copy +} + +private suspend fun runServersTest(servers: List, m: ChatModel, onUpdated: (List) -> Unit): Map { + val fs: MutableMap = mutableMapOf() + val updatedServers = ArrayList(servers) + for ((index, server) in servers.withIndex()) { + if (server.enabled) { + val (updatedServer, f) = testServerConnection(server, m) + updatedServers.removeAt(index) + updatedServers.add(index, updatedServer) + // toList() is important. Otherwise, Compose will not redraw the screen after first update + onUpdated(updatedServers.toList()) + if (f != null) { + fs[serverHostname(updatedServer)] = f + } + } + } + return fs +} + +private fun saveSMPServers(servers: List, m: ChatModel) { + withApi { + if (m.controller.setUserSMPServers(servers)) { + m.userSMPServers.value = servers + m.userSMPServersUnsaved.value = null + } + } +} + diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ScanSMPServer.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ScanSMPServer.kt new file mode 100644 index 0000000000..3844a712d7 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ScanSMPServer.kt @@ -0,0 +1,55 @@ +package chat.simplex.app.views.usersettings + +import android.Manifest +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.app.model.ServerCfg +import chat.simplex.app.ui.theme.DEFAULT_PADDING +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.QRCodeScanner +import com.google.accompanist.permissions.rememberPermissionState + +@Composable +fun ScanSMPServer(onNext: (ServerCfg) -> Unit) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } + ScanSMPServerLayout(onNext) +} + +@Composable +private fun ScanSMPServerLayout(onNext: (ServerCfg) -> Unit) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = DEFAULT_PADDING) + ) { + AppBarTitle(stringResource(R.string.smp_servers_scan_qr), false) + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + .padding(bottom = 12.dp) + ) { + QRCodeScanner { text -> + val res = parseServerAddress(text) + if (res != null) { + onNext(ServerCfg(text, false, null, true)) + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.smp_servers_invalid_address), + text = generalGetString(R.string.smp_servers_check_address) + ) + } + } + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index f3da3e444c..06b5bf3eb9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -394,6 +394,26 @@ fun SettingsPreferenceItemWithInfo( } } +@Composable +fun PreferenceToggle( + text: String, + checked: Boolean, + onChange: (Boolean) -> Unit = {}, +) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(text) + Spacer(Modifier.fillMaxWidth().weight(1f)) + Switch( + checked = checked, + onCheckedChange = onChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ) + ) + } +} + @Composable fun PreferenceToggleWithIcon( text: String, diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 89646d8c1b..64377cfc19 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -70,6 +70,14 @@ Fehler beim Löschen der Kontaktanfrage Fehler beim Löschen der anstehenden Kontaktaufnahme Fehler beim Wechseln der Adresse + ***Test failed at step %s. + ***Server requires authorization to create queues, check password + ***Possibly, certificate fingerprint in server address is incorrect + ***Connect + ***Create queue + ***Secure queue + ***Delete queue + ***Disconnect Sofortige Benachrichtigungen @@ -235,6 +243,7 @@ Zurück Abbrechen Bestätigen + ***Reset OK Keine Details Kontakt hinzufügen @@ -368,17 +377,34 @@ SimpleX Sperre Chat Konsole SMP-Server + ***Preset server address + ***Add preset servers + ***Add server… + ***Test server + ***Test servers + ***Save servers + ***Server test failed! + ***Some servers failed the test: + ***Scan server QR code + ***Enter server manually + ***Preset server + ***Your server + ***Your server address + ***Use server + ***Use for new connections + ***Add to another device + ***Invalid server address! + ***Check server address and try again. + ***Delete server Installieren Sie SimpleX Chat als Terminalanwendung Stern auf GitHub vergeben Unterstützen Sie uns Bewerten Sie die App Verwenden Sie SimpleX Chat Server? - Gespeicherte SMP-Server werden entfernt. Ihre SMP-Server - SMP-Server konfigurieren Verwendung von SimpleX Chat Servern. - SMP-Server (einer pro Zeile) Anleitung + ***How to use your servers Gespeicherte WebRTC ICE-Server werden entfernt. Ihre ICE-Server Konfigurieren Sie ICE-Server diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index ae96bf780a..94611ed484 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -70,6 +70,14 @@ Ошибка удаления запроса Ошибка удаления ожидаемого соединения Ошибка при изменении адреса + Ошибка теста на шаге %s. + Сервер требует авторизации для создания очередей, проверьте пароль + Возможно, хэш сертификата в адресе сервера неверный + Соединение + Создание очереди + Защита очереди + Удаление очереди + Разрыв соединения Мгновенные уведомления @@ -235,6 +243,7 @@ Назад Отменить Подтвердить + Сбросить OK нет описания Одноразовая ссылка @@ -365,17 +374,34 @@ Блокировка SimpleX Консоль SMP серверы + Адрес сервера по умолчанию + Добавить серверы по умолчанию + Добавить сервер… + Тестировать сервер + Тестировать серверы + Сохранить серверы + Ошибка теста сервера! + Серверы не прошли тест: + Сканировать QR код сервера + Ввести сервер вручную + Сервер по умолчанию + Ваш сервер + Адрес вашего сервера + Использовать сервер + Использовать для новых соединений + Добавить на другое устройство + Ошибка в адресе сервера! + Проверьте адрес сервера и попробуйте снова. + Удалить сервер SimpleX Chat для терминала Поставить звездочку в GitHub Внести свой вклад Оценить приложение Использовать серверы предосталенные SimpleX Chat? - Сохраненные SMP серверы будут удалены. Ваши SMP серверы - Настройка SMP серверов Используются серверы предоставленные SimpleX Chat. - Введите SMP серверы, каждый сервер в отдельной строке: Инфо + Как использовать серверы Сохраненные WebRTC ICE серверы будут удалены. Ваши ICE серверы Настройка ICE серверов diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 06dbb3555b..7a2704a3c5 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -70,6 +70,14 @@ Error deleting contact request Error deleting pending contact connection Error changing address + Test failed at step %s. + Server requires authorization to create queues, check password + Possibly, certificate fingerprint in server address is incorrect + Connect + Create queue + Secure queue + Delete queue + Disconnect Instant notifications @@ -235,6 +243,7 @@ Back Cancel Confirm + Reset OK no details One-time invitation link @@ -368,17 +377,34 @@ SimpleX Lock Chat console SMP servers + Preset server address + Add preset servers + Add server… + Test server + Test servers + Save servers + Server test failed! + Some servers failed the test: + Scan server QR code + Enter server manually + Preset server + Your server + Your server address + Use server + Use for new connections + Add to another device + Invalid server address! + Check server address and try again. + Delete server Install SimpleX Chat for terminal Star on GitHub Contribute Rate the app Use SimpleX Chat servers? - Saved SMP servers will be removed. Your SMP servers - Configure SMP servers Using SimpleX Chat servers. - SMP servers (one per line) How to + How to use your servers Saved WebRTC ICE servers will be removed. Your ICE servers Configure ICE servers