desktop, android: server operators (#5212)

* api and types

* whats new view

* new package and movements

* move network and servers to new package

* network and servers view

* wip

* api update

* build

* conditions modal in settings

* network and servers fns

* save server fixes

* more servers

* move protocol servers view

* message servers with validation

* added message servers

* use for files

* fix error by server type

* list xftp servers

* android: add server view (#5221)

* android add server wip

* test servers button

* fix save of custom servers

* remove unused code

* edit and view servers

* fix

* allow to enable untested

* show all test errors in the end

* android: custom servers view (#5224)

* cleanup

* validation footers

* operator enabled validation

* var -> val

* reuse onboarding button

* AppBarTitle without alpha

* remove non scrollable title

* change in AppBarTitle

* changes in AppBar

* bold strings + bordered text view

* ChooseServerOperators

* fix

* new server view wip

* fix

* scan

* rename

* fix roles toggle texts

* UsageConditionsView

* aligned texts

* more texts

* replace hard coded logos with object ref

* use snapshot state to recalculate errors

* align views; fix accept

* remove extra snapshots

* fix ts

* fix whatsnew

* stage

* animation on onboarding

* refactor and fix

* remember

* fix start chat alert

* show notice in chat list

* refactor

* fix validation

* open conditions

* whats new view updates

* icon for navigation improvements

* remove debug

* simplify

* fix

* handle click when have unsaved changes

* fix

* Revert "fix"

This reverts commit d49c373641.

* Revert "handle click when have unsaved changes"

This reverts commit 39ca03f9c0.

* fixed close of modals in whats new view

* grouping

* android: conditions view paddings (#5228)

* revert padding

* refresh operators on save

* fixed modals in different views for desktop

* ios: fix enabling operator model update

* fix modals

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
This commit is contained in:
Diogo
2024-11-22 18:42:07 +04:00
committed by GitHub
co-authored by spaced4ndy Avently
parent 49d1b26bba
commit 396fa7f988
38 changed files with 3032 additions and 746 deletions
@@ -395,7 +395,9 @@ func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServer
// Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers)
do {
let updatedServers = try await getUserServers()
let updatedOperators = try await getServerOperators()
await MainActor.run {
ChatModel.shared.conditions = updatedOperators
currUserServers.wrappedValue = updatedServers
userServers.wrappedValue = updatedServers
}
@@ -1,13 +1,13 @@
package chat.simplex.common.views.usersettings
package chat.simplex.common.views.usersettings.networkAndServers
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import chat.simplex.common.model.ServerCfg
import chat.simplex.common.model.UserServer
import com.google.accompanist.permissions.rememberPermissionState
@Composable
actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) {
actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
@@ -15,7 +15,6 @@ import androidx.compose.ui.draw.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
@@ -42,7 +41,6 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.math.absoluteValue
@Composable
fun AppScreen() {
@@ -194,6 +192,13 @@ fun MainScreen() {
OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
OnboardingStage.LinkAMobile -> LinkAMobile()
OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
OnboardingStage.Step3_ChooseServerOperators -> {
val modalData = remember { ModalData() }
modalData.ChooseServerOperators(true)
if (appPlatform.isDesktop) {
ModalManager.fullscreen.showInView()
}
}
// Ensure backwards compatibility with old onboarding stage for address creation, otherwise notification setup would be skipped
OnboardingStage.Step3_CreateSimpleXAddress -> SetNotificationsMode(chatModel)
OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
@@ -145,6 +145,8 @@ object ChatModel {
val clipboardHasText = mutableStateOf(false)
val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true))
val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty)
val updatingProgress = mutableStateOf(null as Float?)
var updatingRequest: Closeable? = null
@@ -2567,6 +2569,13 @@ fun localTimestamp(t: Instant): String {
return ts.toJavaLocalDateTime().format(dateFormatter)
}
fun localDate(t: Instant): String {
val tz = TimeZone.currentSystemDefault()
val ts: LocalDateTime = t.toLocalDateTime(tz)
val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
return ts.toJavaLocalDateTime().format(dateFormatter)
}
@Serializable
sealed class CIStatus {
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
@@ -26,10 +26,12 @@ import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert
import chat.simplex.common.views.migration.MigrationFileLinkData
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.serverHostname
import com.charleskorn.kaml.Yaml
import com.charleskorn.kaml.YamlConfiguration
import chat.simplex.res.MR
import com.russhwolf.settings.Settings
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
@@ -963,36 +965,6 @@ object ChatController {
return null
}
suspend fun getUserProtoServers(rh: Long?, serverProtocol: ServerProtocol): UserProtocolServers? {
val userId = kotlin.runCatching { currentUserId("getUserProtoServers") }.getOrElse { return null }
val r = sendCmd(rh, CC.APIGetUserProtoServers(userId, serverProtocol))
return if (r is CR.UserProtoServers) { if (rh == null) r.servers else r.servers.copy(protoServers = r.servers.protoServers.map { it.copy(remoteHostId = rh) }) }
else {
Log.e(TAG, "getUserProtoServers bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_loading_smp_servers else MR.strings.error_loading_xftp_servers),
"${r.responseType}: ${r.details}"
)
null
}
}
suspend fun setUserProtoServers(rh: Long?, serverProtocol: ServerProtocol, servers: List<ServerCfg>): Boolean {
val userId = kotlin.runCatching { currentUserId("setUserProtoServers") }.getOrElse { return false }
val r = sendCmd(rh, CC.APISetUserProtoServers(userId, serverProtocol, servers))
return when (r) {
is CR.CmdOk -> true
else -> {
Log.e(TAG, "setUserProtoServers bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_saving_smp_servers else MR.strings.error_saving_xftp_servers),
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.ensure_smp_server_address_are_correct_format_and_unique else MR.strings.ensure_xftp_server_address_are_correct_format_and_unique)
)
false
}
}
}
suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? {
val userId = currentUserId("testProtoServer")
val r = sendCmd(rh, CC.APITestProtoServer(userId, server))
@@ -1005,6 +977,106 @@ object ChatController {
}
}
suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? {
val r = sendCmd(rh, CC.ApiGetServerOperators())
return when (r) {
is CR.ServerOperatorConditions -> r.conditions
else -> {
Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun setServerOperators(rh: Long?, operators: List<ServerOperator>): ServerOperatorConditionsDetail? {
val r = sendCmd(rh, CC.ApiSetServerOperators(operators))
return when (r) {
is CR.ServerOperatorConditions -> r.conditions
else -> {
Log.e(TAG, "setServerOperators bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun getUserServers(rh: Long?): List<UserOperatorServers>? {
val userId = currentUserId("getUserServers")
val r = sendCmd(rh, CC.ApiGetUserServers(userId))
return when (r) {
is CR.UserServers -> r.userServers
else -> {
Log.e(TAG, "getUserServers bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun setUserServers(rh: Long?, userServers: List<UserOperatorServers>): Boolean {
val userId = currentUserId("setUserServers")
val r = sendCmd(rh, CC.ApiSetUserServers(userId, userServers))
return when (r) {
is CR.CmdOk -> true
else -> {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.failed_to_save_servers),
"${r.responseType}: ${r.details}"
)
Log.e(TAG, "setUserServers bad response: ${r.responseType} ${r.details}")
false
}
}
}
suspend fun validateServers(rh: Long?, userServers: List<UserOperatorServers>): List<UserServersError>? {
val userId = currentUserId("validateServers")
val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers))
return when (r) {
is CR.UserServersValidation -> r.serverErrors
else -> {
Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun getUsageConditions(rh: Long?): Triple<UsageConditionsDetail, String?, UsageConditionsDetail?>? {
val r = sendCmd(rh, CC.ApiGetUsageConditions())
return when (r) {
is CR.UsageConditions -> Triple(r.usageConditions, r.conditionsText, r.acceptedConditions)
else -> {
Log.e(TAG, "getUsageConditions bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun setConditionsNotified(rh: Long?, conditionsId: Long): Boolean {
val r = sendCmd(rh, CC.ApiSetConditionsNotified(conditionsId))
return when (r) {
is CR.CmdOk -> true
else -> {
Log.e(TAG, "setConditionsNotified bad response: ${r.responseType} ${r.details}")
false
}
}
}
suspend fun acceptConditions(rh: Long?, conditionsId: Long, operatorIds: List<Long>): ServerOperatorConditionsDetail? {
val r = sendCmd(rh, CC.ApiAcceptConditions(conditionsId, operatorIds))
return when (r) {
is CR.ServerOperatorConditions -> r.conditions
else -> {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.error_accepting_operator_conditions),
"${r.responseType}: ${r.details}"
)
Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun getChatItemTTL(rh: Long?): ChatItemTTL {
val userId = currentUserId("getChatItemTTL")
val r = sendCmd(rh, CC.APIGetChatItemTTL(userId))
@@ -3037,9 +3109,15 @@ sealed class CC {
class APIGetGroupLink(val groupId: Long): CC()
class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC()
class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC()
class APIGetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol): CC()
class APISetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol, val servers: List<ServerCfg>): CC()
class APITestProtoServer(val userId: Long, val server: String): CC()
class ApiGetServerOperators(): CC()
class ApiSetServerOperators(val operators: List<ServerOperator>): CC()
class ApiGetUserServers(val userId: Long): CC()
class ApiSetUserServers(val userId: Long, val userServers: List<UserOperatorServers>): CC()
class ApiValidateServers(val userId: Long, val userServers: List<UserOperatorServers>): CC()
class ApiGetUsageConditions(): CC()
class ApiSetConditionsNotified(val conditionsId: Long): CC()
class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List<Long>): CC()
class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC()
class APIGetChatItemTTL(val userId: Long): CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
@@ -3197,9 +3275,15 @@ sealed class CC {
is APIGetGroupLink -> "/_get link #$groupId"
is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId"
is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}"
is APIGetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()}"
is APISetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()} ${protoServersStr(servers)}"
is APITestProtoServer -> "/_server test $userId $server"
is ApiGetServerOperators -> "/_operators"
is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}"
is ApiGetUserServers -> "/_servers $userId"
is ApiSetUserServers -> "/_servers $userId ${json.encodeToString(userServers)}"
is ApiValidateServers -> "/_validate_servers $userId ${json.encodeToString(userServers)}"
is ApiGetUsageConditions -> "/_conditions"
is ApiSetConditionsNotified -> "/_conditions_notified ${conditionsId}"
is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}"
is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}"
is APIGetChatItemTTL -> "/_ttl $userId"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
@@ -3342,9 +3426,15 @@ sealed class CC {
is APIGetGroupLink -> "apiGetGroupLink"
is APICreateMemberContact -> "apiCreateMemberContact"
is APISendMemberContactInvitation -> "apiSendMemberContactInvitation"
is APIGetUserProtoServers -> "apiGetUserProtoServers"
is APISetUserProtoServers -> "apiSetUserProtoServers"
is APITestProtoServer -> "testProtoServer"
is ApiGetServerOperators -> "apiGetServerOperators"
is ApiSetServerOperators -> "apiSetServerOperators"
is ApiGetUserServers -> "apiGetUserServers"
is ApiSetUserServers -> "apiSetUserServers"
is ApiValidateServers -> "apiValidateServers"
is ApiGetUsageConditions -> "apiGetUsageConditions"
is ApiSetConditionsNotified -> "apiSetConditionsNotified"
is ApiAcceptConditions -> "apiAcceptConditions"
is APISetChatItemTTL -> "apiSetChatItemTTL"
is APIGetChatItemTTL -> "apiGetChatItemTTL"
is APISetNetworkConfig -> "apiSetNetworkConfig"
@@ -3459,8 +3549,6 @@ sealed class CC {
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
fun protoServersStr(servers: List<ServerCfg>) = json.encodeToString(ProtoServersConfig(servers))
}
}
@@ -3510,24 +3598,350 @@ enum class ServerProtocol {
}
@Serializable
data class ProtoServersConfig(
val servers: List<ServerCfg>
enum class OperatorTag {
@SerialName("simplex") SimpleX,
@SerialName("flux") Flux,
@SerialName("xyz") XYZ,
@SerialName("demo") Demo
}
data class ServerOperatorInfo(
val description: List<String>,
val website: String,
val logo: ImageResource,
val largeLogo: ImageResource,
val logoDarkMode: ImageResource,
val largeLogoDarkMode: ImageResource
)
val operatorsInfo: Map<OperatorTag, ServerOperatorInfo> = mapOf(
OperatorTag.SimpleX to ServerOperatorInfo(
description = listOf(
"SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.",
"SimpleX Chat Ltd develops the communication software for SimpleX network."
),
website = "https://simplex.chat",
logo = MR.images.decentralized,
largeLogo = MR.images.logo,
logoDarkMode = MR.images.decentralized_light,
largeLogoDarkMode = MR.images.logo_light
),
OperatorTag.Flux to ServerOperatorInfo(
description = listOf(
"Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.",
"Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all."
),
website = "https://runonflux.com",
logo = MR.images.flux_logo_symbol,
largeLogo = MR.images.flux_logo,
logoDarkMode = MR.images.flux_logo_symbol,
largeLogoDarkMode = MR.images.flux_logo_light
),
OperatorTag.XYZ to ServerOperatorInfo(
description = listOf("XYZ servers"),
website = "XYZ website",
logo = MR.images.shield,
largeLogo = MR.images.logo,
logoDarkMode = MR.images.shield,
largeLogoDarkMode = MR.images.logo_light
),
OperatorTag.Demo to ServerOperatorInfo(
description = listOf("Demo operator"),
website = "Demo website",
logo = MR.images.decentralized,
largeLogo = MR.images.logo,
logoDarkMode = MR.images.decentralized_light,
largeLogoDarkMode = MR.images.logo_light
)
)
@Serializable
data class UserProtocolServers(
val serverProtocol: ServerProtocol,
val protoServers: List<ServerCfg>,
val presetServers: List<ServerCfg>,
data class UsageConditionsDetail(
val conditionsId: Long,
val conditionsCommit: String,
val notifiedAt: Instant?,
val createdAt: Instant
) {
companion object {
val sampleData = UsageConditionsDetail(
conditionsId = 1,
conditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c",
notifiedAt = null,
createdAt = Clock.System.now()
)
}
}
@Serializable
sealed class UsageConditionsAction {
@Serializable @SerialName("review") data class Review(val operators: List<ServerOperator>, val deadline: Instant?, val showNotice: Boolean) : UsageConditionsAction()
@Serializable @SerialName("accepted") data class Accepted(val operators: List<ServerOperator>) : UsageConditionsAction()
val shouldShowNotice: Boolean
get() = when (this) {
is Review -> showNotice
else -> false
}
}
@Serializable
data class ServerOperatorConditionsDetail(
val serverOperators: List<ServerOperator>,
val currentConditions: UsageConditionsDetail,
val conditionsAction: UsageConditionsAction?
) {
companion object {
val empty = ServerOperatorConditionsDetail(
serverOperators = emptyList(),
currentConditions = UsageConditionsDetail(conditionsId = 0, conditionsCommit = "empty", notifiedAt = null, createdAt = Clock.System.now()),
conditionsAction = null
)
}
}
@Serializable()
sealed class ConditionsAcceptance {
@Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance()
@Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance()
val conditionsAccepted: Boolean
get() = when (this) {
is Accepted -> true
is Required -> false
}
val usageAllowed: Boolean
get() = when (this) {
is Accepted -> true
is Required -> this.deadline != null
}
}
@Serializable
data class ServerOperator(
val operatorId: Long,
val operatorTag: OperatorTag?,
val tradeName: String,
val legalName: String?,
val serverDomains: List<String>,
val conditionsAcceptance: ConditionsAcceptance,
val enabled: Boolean,
val smpRoles: ServerRoles,
val xftpRoles: ServerRoles,
) {
companion object {
val dummyOperatorInfo = ServerOperatorInfo(
description = listOf("Default"),
website = "Default",
logo = MR.images.decentralized,
largeLogo = MR.images.logo,
logoDarkMode = MR.images.decentralized_light,
largeLogoDarkMode = MR.images.logo_light
)
val sampleData1 = ServerOperator(
operatorId = 1,
operatorTag = OperatorTag.SimpleX,
tradeName = "SimpleX Chat",
legalName = "SimpleX Chat Ltd",
serverDomains = listOf("simplex.im"),
conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null),
enabled = true,
smpRoles = ServerRoles(storage = true, proxy = true),
xftpRoles = ServerRoles(storage = true, proxy = true)
)
val sampleData2 = ServerOperator(
operatorId = 2,
operatorTag = OperatorTag.XYZ,
tradeName = "XYZ",
legalName = null,
serverDomains = listOf("xyz.com"),
conditionsAcceptance = ConditionsAcceptance.Required(deadline = null),
enabled = false,
smpRoles = ServerRoles(storage = false, proxy = true),
xftpRoles = ServerRoles(storage = false, proxy = true)
)
val sampleData3 = ServerOperator(
operatorId = 3,
operatorTag = OperatorTag.Demo,
tradeName = "Demo",
legalName = null,
serverDomains = listOf("demo.com"),
conditionsAcceptance = ConditionsAcceptance.Required(deadline = null),
enabled = false,
smpRoles = ServerRoles(storage = true, proxy = false),
xftpRoles = ServerRoles(storage = true, proxy = false)
)
}
val id: Long
get() = operatorId
override fun equals(other: Any?): Boolean {
if (other !is ServerOperator) return false
return other.operatorId == this.operatorId &&
other.operatorTag == this.operatorTag &&
other.tradeName == this.tradeName &&
other.legalName == this.legalName &&
other.serverDomains == this.serverDomains &&
other.conditionsAcceptance == this.conditionsAcceptance &&
other.enabled == this.enabled &&
other.smpRoles == this.smpRoles &&
other.xftpRoles == this.xftpRoles
}
override fun hashCode(): Int {
var result = operatorId.hashCode()
result = 31 * result + (operatorTag?.hashCode() ?: 0)
result = 31 * result + tradeName.hashCode()
result = 31 * result + (legalName?.hashCode() ?: 0)
result = 31 * result + serverDomains.hashCode()
result = 31 * result + conditionsAcceptance.hashCode()
result = 31 * result + enabled.hashCode()
result = 31 * result + smpRoles.hashCode()
result = 31 * result + xftpRoles.hashCode()
return result
}
val legalName_: String
get() = legalName ?: tradeName
val info: ServerOperatorInfo get() {
return if (this.operatorTag != null) {
operatorsInfo[this.operatorTag] ?: dummyOperatorInfo
} else {
dummyOperatorInfo
}
}
val logo: ImageResource
@Composable
get() {
return if (isInDarkTheme()) info.logoDarkMode else info.logo
}
val largeLogo: ImageResource
@Composable
get() {
return if (isInDarkTheme()) info.largeLogoDarkMode else info.largeLogo
}
}
@Serializable
data class ServerRoles(
val storage: Boolean,
val proxy: Boolean
)
@Serializable
data class ServerCfg(
data class UserOperatorServers(
val operator: ServerOperator?,
val smpServers: List<UserServer>,
val xftpServers: List<UserServer>
) {
val id: String
get() = operator?.operatorId?.toString() ?: "nil operator"
val operator_: ServerOperator
get() = operator ?: ServerOperator(
operatorId = 0,
operatorTag = null,
tradeName = "",
legalName = null,
serverDomains = emptyList(),
conditionsAcceptance = ConditionsAcceptance.Accepted(null),
enabled = false,
smpRoles = ServerRoles(storage = true, proxy = true),
xftpRoles = ServerRoles(storage = true, proxy = true)
)
companion object {
val sampleData1 = UserOperatorServers(
operator = ServerOperator.sampleData1,
smpServers = listOf(UserServer.sampleData.preset),
xftpServers = listOf(UserServer.sampleData.xftpPreset)
)
val sampleDataNilOperator = UserOperatorServers(
operator = null,
smpServers = listOf(UserServer.sampleData.preset),
xftpServers = listOf(UserServer.sampleData.xftpPreset)
)
}
}
@Serializable
sealed class UserServersError {
@Serializable @SerialName("noServers") data class NoServers(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
@Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
@Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
@Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError()
val globalError: String?
get() = when (this.protocol_) {
ServerProtocol.SMP -> globalSMPError
ServerProtocol.XFTP -> globalXFTPError
}
private val protocol_: ServerProtocol
get() = when (this) {
is NoServers -> this.protocol
is StorageMissing -> this.protocol
is ProxyMissing -> this.protocol
is DuplicateServer -> this.protocol
}
val globalSMPError: String?
get() = if (this.protocol_ == ServerProtocol.SMP) {
when (this) {
is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured)}" }
?: generalGetString(MR.strings.no_message_servers_configured)
is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_receiving)}" }
?: generalGetString(MR.strings.no_message_servers_configured_for_receiving)
is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" }
?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing)
else -> null
}
} else {
null
}
val globalXFTPError: String?
get() = if (this.protocol_ == ServerProtocol.XFTP) {
when (this) {
is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured)}" }
?: generalGetString(MR.strings.no_media_servers_configured)
is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_sending)}" }
?: generalGetString(MR.strings.no_media_servers_configured_for_sending)
is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_private_routing)}" }
?: generalGetString(MR.strings.no_media_servers_configured_for_private_routing)
else -> null
}
} else {
null
}
private fun userStr(user: UserRef): String {
return String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName)
}
}
@Serializable
data class UserServer(
val remoteHostId: Long?,
val serverId: Long?,
val server: String,
val preset: Boolean,
val tested: Boolean? = null,
val enabled: Boolean
val enabled: Boolean,
val deleted: Boolean
) {
@Transient
private val createdAt: Date = Date()
@@ -3541,35 +3955,51 @@ data class ServerCfg(
get() = server.isBlank()
companion object {
val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = false)
val empty = UserServer(remoteHostId = null, serverId = null, server = "", preset = false, tested = null, enabled = false, deleted = false)
class SampleData(
val preset: ServerCfg,
val custom: ServerCfg,
val untested: ServerCfg
val preset: UserServer,
val custom: UserServer,
val untested: UserServer,
val xftpPreset: UserServer
)
val sampleData = SampleData(
preset = ServerCfg(
preset = UserServer(
remoteHostId = null,
serverId = 1,
server = "smp://abcd@smp8.simplex.im",
preset = true,
tested = true,
enabled = true
enabled = true,
deleted = false
),
custom = ServerCfg(
custom = UserServer(
remoteHostId = null,
serverId = 2,
server = "smp://abcd@smp9.simplex.im",
preset = false,
tested = false,
enabled = false
enabled = false,
deleted = false
),
untested = ServerCfg(
untested = UserServer(
remoteHostId = null,
serverId = 3,
server = "smp://abcd@smp10.simplex.im",
preset = false,
tested = null,
enabled = true
enabled = true,
deleted = false
),
xftpPreset = UserServer(
remoteHostId = null,
serverId = 4,
server = "xftp://abcd@xftp8.simplex.im",
preset = true,
tested = true,
enabled = true,
deleted = false
)
)
}
@@ -4928,8 +5358,11 @@ sealed class CR {
@Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR()
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
@Serializable @SerialName("userProtoServers") class UserProtoServers(val user: UserRef, val servers: UserProtocolServers): CR()
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
@Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR()
@Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List<UserOperatorServers>): CR()
@Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List<UserServersError>): CR()
@Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR()
@Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR()
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
@Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats_: ConnectionStats? = null, val customUserProfile: Profile? = null): CR()
@@ -5108,8 +5541,11 @@ sealed class CR {
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
is ApiChatItemInfo -> "chatItemInfo"
is UserProtoServers -> "userProtoServers"
is ServerTestResult -> "serverTestResult"
is ServerOperatorConditions -> "serverOperatorConditions"
is UserServers -> "userServers"
is UserServersValidation -> "userServersValidation"
is UsageConditions -> "usageConditions"
is ChatItemTTL -> "chatItemTTL"
is NetworkConfig -> "networkConfig"
is ContactInfo -> "contactInfo"
@@ -5278,8 +5714,11 @@ sealed class CR {
is ApiChats -> withUser(user, json.encodeToString(chats))
is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}")
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}")
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}"
is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}")
is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}")
is UsageConditions -> "usageConditions: ${json.encodeToString(usageConditions)}\nnacceptedConditions: ${json.encodeToString(acceptedConditions)}"
is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL))
is NetworkConfig -> json.encodeToString(networkConfig)
is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}")
@@ -118,6 +118,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
val user = chatController.apiGetActiveUser(null)
chatModel.currentUser.value = user
chatModel.conditions.value = chatController.getServerOperators(null) ?: ServerOperatorConditionsDetail.empty
if (user == null) {
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
chatModel.currentUser.value = null
@@ -137,13 +138,12 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
}
} else if (startChat().await()) {
val savedOnboardingStage = appPreferences.onboardingStage.get()
val next = if (appPlatform.isAndroid) {
OnboardingStage.Step4_SetNotificationsMode
} else {
OnboardingStage.OnboardingComplete
}
val newStage = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
next
if (appPlatform.isAndroid) {
OnboardingStage.Step4_SetNotificationsMode
} else {
OnboardingStage.OnboardingComplete
}
} else {
savedOnboardingStage
}
@@ -165,7 +165,7 @@ fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) {
if (!chatModel.connectedToRemote()) {
chatModel.localUserCreated.value = true
}
controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
controller.startChat(user)
controller.switchUIRemoteHost(null)
close()
@@ -204,7 +204,7 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: ()
onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) {
OnboardingStage.Step2_5_SetupDatabasePassphrase
} else {
OnboardingStage.Step4_SetNotificationsMode
OnboardingStage.Step3_ChooseServerOperators
})
} else {
// the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen,
@@ -1249,7 +1249,7 @@ fun BoxScope.ChatItemsList(
} else {
null
}
val showAvatar = if (merged is MergedItem.Grouped) shouldShowAvatar(item, listItem.nextItem) else true
val showAvatar = shouldShowAvatar(item, listItem.nextItem)
val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } }
val itemSeparation: ItemSeparation
val prevItemSeparationLargeGap: Boolean
@@ -25,19 +25,21 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.AppLock
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.setConditionsNotified
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chat.item.CIFileViewScope
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.mkValidName
import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.common.views.showInvalidNameAlert
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
@@ -115,10 +117,26 @@ fun ToggleChatListCard() {
@Composable
fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val rhId = chatModel.remoteHostId()
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
val showWhatsNew = shouldShowWhatsNew(chatModel)
val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false
if (showWhatsNew) {
delay(1000L)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) }
} else if (showUpdatedConditions) {
ModalManager.center.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
LaunchedEffect(Unit) {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
try {
setConditionsNotified(rh = rhId, conditionsId = conditionsId)
} catch (e: Exception) {
Log.d(TAG, "UsageConditionsView setConditionsNotified error: ${e.message}")
}
}
UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId)
}
}
}
@@ -48,7 +48,6 @@ import chat.simplex.common.model.localTimestamp
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.ProtocolServersView
import chat.simplex.common.views.usersettings.SettingsPreferenceItem
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@@ -540,15 +539,8 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant,
)
)
}
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 || summary.sessions != null) {
SectionDividerSpaced()
}
if (summary.stats != null || summary.sessions != null) {
SectionDividerSpaced()
}
if (summary.stats != null) {
@@ -579,12 +571,7 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r
)
)
}
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 || summary.subs != null || summary.sessions != null) {
SectionDividerSpaced()
}
@@ -17,11 +17,21 @@ import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue
@Composable
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) {
fun AppBarTitle(
title: String,
hostDevice: Pair<Long?, String>? = null,
withPadding: Boolean = true,
bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp,
enableAlphaChanges: Boolean = true
) {
val handler = LocalAppBarHandler.current
val connection = handler?.connection
val connection = if (enableAlphaChanges) handler?.connection else null
LaunchedEffect(title) {
handler?.title?.value = title
if (enableAlphaChanges) {
handler?.title?.value = title
} else {
handler?.connection?.scrollTrackingEnabled = false
}
}
val theme = CurrentColors.collectAsState()
val titleColor = MaterialTheme.appColors.title
@@ -54,7 +64,8 @@ fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPad
}
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
if (connection?.scrollTrackingEnabled == false) 1f
else if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
@Composable
@@ -84,6 +84,7 @@ class AppBarHandler(
}
class CollapsingAppBarNestedScrollConnection(): NestedScrollConnection {
var scrollTrackingEnabled = true
var appBarOffset: Float by mutableFloatStateOf(0f)
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
@@ -258,7 +258,8 @@ private fun AppBarCenterAligned(
}
private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) =
if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
if (!connection.scrollTrackingEnabled) 0f
else if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha)
val AppBarHeight = 56.dp
@@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword
import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -0,0 +1,354 @@
package chat.simplex.common.views.onboarding
import SectionBottomSpacer
import SectionTextFooter
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ServerOperator
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.networkAndServers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
fun ModalData.ChooseServerOperators(
onboarding: Boolean,
close: (() -> Unit) = { ModalManager.fullscreen.closeModals() },
modalManager: ModalManager = ModalManager.fullscreen
) {
LaunchedEffect(Unit) {
prepareChatBeforeFinishingOnboarding()
}
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({}, showClose = false, endButtons = {
IconButton({ modalManager.showModal { ChooseServerOperatorsInfoView() } }) {
Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary)
}
}) {
val serverOperators = remember { derivedStateOf { chatModel.conditions.value.serverOperators } }
val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } }
val selectedOperators = remember { derivedStateOf { serverOperators.value.filter { selectedOperatorIds.value.contains(it.operatorId) } } }
ColumnWithScrollBar(
Modifier
.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer),
maxIntrinsicSize = true
) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
AppBarTitle(stringResource(MR.strings.onboarding_choose_server_operators))
}
Column((
if (appPlatform.isDesktop) Modifier.width(600.dp).align(Alignment.CenterHorizontally) else Modifier)
.padding(horizontal = DEFAULT_PADDING)
) {
Text(stringResource(MR.strings.onboarding_select_network_operators_to_use))
Spacer(Modifier.height(DEFAULT_PADDING))
}
Spacer(Modifier.weight(1f))
Column((
if (appPlatform.isDesktop) Modifier.width(600.dp).align(Alignment.CenterHorizontally) else Modifier)
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING)
) {
serverOperators.value.forEachIndexed { index, srvOperator ->
OperatorCheckView(srvOperator, selectedOperatorIds)
if (index != serverOperators.value.lastIndex) {
Spacer(Modifier.height(DEFAULT_PADDING))
}
}
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
SectionTextFooter(annotatedStringResource(MR.strings.onboarding_network_operators_configure_via_settings), textAlign = TextAlign.Center)
}
Spacer(Modifier.weight(1f))
val reviewForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted }
val canReviewLater = reviewForOperators.all { it.conditionsAcceptance.usageAllowed }
val currEnabledOperatorIds = serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet()
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
val enabled = selectedOperatorIds.value.isNotEmpty()
when {
reviewForOperators.isNotEmpty() -> ReviewConditionsButton(enabled, onboarding, selectedOperators, selectedOperatorIds, modalManager)
selectedOperatorIds.value != currEnabledOperatorIds && enabled -> SetOperatorsButton(true, onboarding, serverOperators, selectedOperatorIds, close)
else -> ContinueButton(enabled, onboarding, close)
}
if (onboarding && reviewForOperators.isEmpty()) {
TextButtonBelowOnboardingButton(stringResource(MR.strings.operator_conditions_of_use)) {
modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
UsageConditionsView(
currUserServers = remember { mutableStateOf(emptyList()) },
userServers = remember { mutableStateOf(emptyList()) },
close = close,
rhId = null
)
}
}
} else if (onboarding || reviewForOperators.isEmpty()) {
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
if (!onboarding && reviewForOperators.isNotEmpty()) {
ReviewLaterButton(canReviewLater, close)
SectionTextFooter(
annotatedStringResource(MR.strings.onboarding_network_operators_conditions_will_be_accepted) +
AnnotatedString(" ") +
annotatedStringResource(MR.strings.onboarding_network_operators_conditions_you_can_configure),
textAlign = TextAlign.Center
)
SectionBottomSpacer()
}
}
}
}
}
}
@Composable
private fun OperatorCheckView(serverOperator: ServerOperator, selectedOperatorIds: MutableState<Set<Long>>) {
val checked = selectedOperatorIds.value.contains(serverOperator.operatorId)
TextButton({
if (checked) {
selectedOperatorIds.value -= serverOperator.operatorId
} else {
selectedOperatorIds.value += serverOperator.operatorId
}
},
border = BorderStroke(1.dp, color = if (checked) MaterialTheme.colors.primary else MaterialTheme.colors.secondary.copy(alpha = 0.5f)),
shape = RoundedCornerShape(18.dp)
) {
Row(Modifier.padding(DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically) {
Image(painterResource(serverOperator.largeLogo), null, Modifier.height(48.dp))
Spacer(Modifier.width(DEFAULT_PADDING_HALF).weight(1f))
CircleCheckbox(checked)
}
}
}
@Composable
private fun CircleCheckbox(checked: Boolean) {
if (checked) {
Box(contentAlignment = Alignment.Center) {
Icon(
painterResource(MR.images.ic_circle_filled),
null,
Modifier.size(26.dp),
tint = MaterialTheme.colors.primary
)
Icon(
painterResource(MR.images.ic_check_filled),
null,
Modifier.size(20.dp), tint = MaterialTheme.colors.background
)
}
} else {
Icon(
painterResource(MR.images.ic_circle),
null,
Modifier.size(26.dp),
tint = MaterialTheme.colors.secondary.copy(alpha = 0.5f)
)
}
}
@Composable
private fun ReviewConditionsButton(
enabled: Boolean,
onboarding: Boolean,
selectedOperators: State<List<ServerOperator>>,
selectedOperatorIds: State<Set<Long>>,
modalManager: ModalManager
) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
labelId = MR.strings.operator_review_conditions,
onboarding = null,
enabled = enabled,
onclick = {
modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
ReviewConditionsView(onboarding, selectedOperators, selectedOperatorIds, close)
}
}
)
}
@Composable
private fun SetOperatorsButton(enabled: Boolean, onboarding: Boolean, serverOperators: State<List<ServerOperator>>, selectedOperatorIds: State<Set<Long>>, close: () -> Unit) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
labelId = MR.strings.onboarding_network_operators_update,
onboarding = null,
enabled = enabled,
onclick = {
withBGApi {
val enabledOperators = enabledOperators(serverOperators.value, selectedOperatorIds.value)
if (enabledOperators != null) {
val r = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators)
if (r != null) {
chatModel.conditions.value = r
}
continueToNextStep(onboarding, close)
}
}
}
)
}
@Composable
private fun ContinueButton(enabled: Boolean, onboarding: Boolean, close: () -> Unit) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
labelId = MR.strings.onboarding_network_operators_continue,
onboarding = null,
enabled = enabled,
onclick = {
continueToNextStep(onboarding, close)
}
)
}
@Composable
private fun ReviewLaterButton(enabled: Boolean, close: () -> Unit) {
TextButtonBelowOnboardingButton(
stringResource(MR.strings.onboarding_network_operators_review_later),
onClick = if (!enabled) null else {{ continueToNextStep(false, close) }}
)
}
@Composable
private fun ReviewConditionsView(
onboarding: Boolean,
selectedOperators: State<List<ServerOperator>>,
selectedOperatorIds: State<Set<Long>>,
close: () -> Unit
) {
// remembering both since we don't want to reload the view after the user accepts conditions
val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } }
val acceptForOperators = remember { selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } }
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), withPadding = false, enableAlphaChanges = false)
if (operatorsWithConditionsAccepted.isNotEmpty()) {
ReadableText(MR.strings.operator_conditions_accepted_for_some, args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ })
ReadableText(MR.strings.operator_same_conditions_will_apply_to_operators, args = acceptForOperators.joinToString(", ") { it.legalName_ })
} else {
ReadableText(MR.strings.operator_conditions_will_be_accepted_for_some, args = acceptForOperators.joinToString(", ") { it.legalName_ })
}
Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(chatModel.remoteHostId())
}
Column(Modifier.padding(top = DEFAULT_PADDING).widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
AcceptConditionsButton(onboarding, selectedOperators, selectedOperatorIds, close)
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
}
}
@Composable
private fun AcceptConditionsButton(
onboarding: Boolean,
selectedOperators: State<List<ServerOperator>>,
selectedOperatorIds: State<Set<Long>>,
close: () -> Unit
) {
fun continueOnAccept() {
if (appPlatform.isDesktop || !onboarding) {
if (onboarding) { close() }
continueToNextStep(onboarding, close)
} else {
continueToSetNotificationsAfterAccept()
}
}
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
labelId = MR.strings.accept_conditions,
onboarding = null,
onclick = {
withBGApi {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
val acceptForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted }
val operatorIds = acceptForOperators.map { it.operatorId }
val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = operatorIds)
if (r != null) {
chatModel.conditions.value = r
val enabledOperators = enabledOperators(r.serverOperators, selectedOperatorIds.value)
if (enabledOperators != null) {
val r2 = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators)
if (r2 != null) {
chatModel.conditions.value = r2
continueOnAccept()
}
} else {
continueOnAccept()
}
}
}
}
)
}
private fun continueToNextStep(onboarding: Boolean, close: () -> Unit) {
if (onboarding) {
appPrefs.onboardingStage.set(if (appPlatform.isAndroid) OnboardingStage.Step4_SetNotificationsMode else OnboardingStage.OnboardingComplete)
} else {
close()
}
}
private fun continueToSetNotificationsAfterAccept() {
appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
ModalManager.fullscreen.showModalCloseable(showClose = false) { SetNotificationsMode(chatModel) }
}
private fun enabledOperators(operators: List<ServerOperator>, selectedOperatorIds: Set<Long>): List<ServerOperator>? {
val ops = ArrayList(operators)
if (ops.isNotEmpty()) {
for (i in ops.indices) {
val op = ops[i]
ops[i] = op.copy(enabled = selectedOperatorIds.contains(op.operatorId))
}
val haveSMPStorage = ops.any { it.enabled && it.smpRoles.storage }
val haveSMPProxy = ops.any { it.enabled && it.smpRoles.proxy }
val haveXFTPStorage = ops.any { it.enabled && it.xftpRoles.storage }
val haveXFTPProxy = ops.any { it.enabled && it.xftpRoles.proxy }
val firstEnabledIndex = ops.indexOfFirst { it.enabled }
if (haveSMPStorage && haveSMPProxy && haveXFTPStorage && haveXFTPProxy) {
return ops
} else if (firstEnabledIndex != -1) {
var op = ops[firstEnabledIndex]
if (!haveSMPStorage) op = op.copy(smpRoles = op.smpRoles.copy(storage = true))
if (!haveSMPProxy) op = op.copy(smpRoles = op.smpRoles.copy(proxy = true))
if (!haveXFTPStorage) op = op.copy(xftpRoles = op.xftpRoles.copy(storage = true))
if (!haveXFTPProxy) op = op.copy(xftpRoles = op.xftpRoles.copy(proxy = true))
ops[firstEnabledIndex] = op
return ops
} else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled
return null
}
} else {
return null
}
}
@Composable
private fun ChooseServerOperatorsInfoView() {
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.onboarding_network_operators), withPadding = false)
ReadableText(stringResource(MR.strings.onboarding_network_operators_app_will_use_different_operators))
ReadableText(stringResource(MR.strings.onboarding_network_operators_app_will_use_for_routing))
SectionBottomSpacer()
}
}
@@ -48,8 +48,8 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>?
}
@Composable
fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current) {
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style)
fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current, args: Any? = null) {
Text(annotatedStringResource(stringResId, args), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style)
}
@Composable
@@ -5,6 +5,7 @@ enum class OnboardingStage {
Step2_CreateProfile,
LinkAMobile,
Step2_5_SetupDatabasePassphrase,
Step3_ChooseServerOperators,
Step3_CreateSimpleXAddress,
Step4_SetNotificationsMode,
OnboardingComplete
@@ -16,8 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.NotificationsMode
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.changeNotificationsMode
@@ -26,7 +25,7 @@ import chat.simplex.res.MR
@Composable
fun SetNotificationsMode(m: ChatModel) {
LaunchedEffect(Unit) {
prepareChatBeforeNotificationsSetup(m)
prepareChatBeforeFinishingOnboarding()
}
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
@@ -57,6 +56,7 @@ fun SetNotificationsMode(m: ChatModel) {
onboarding = OnboardingStage.OnboardingComplete,
onclick = {
changeNotificationsMode(currentMode.value, m)
ModalManager.fullscreen.closeModals()
}
)
// Reserve space
@@ -99,7 +99,7 @@ fun <T> SelectableCard(currentValue: State<T>, newValue: T, title: String, descr
Spacer(Modifier.height(14.dp))
}
private fun prepareChatBeforeNotificationsSetup(chatModel: ChatModel) {
fun prepareChatBeforeFinishingOnboarding() {
// No visible users but may have hidden. In this case chat should be started anyway because it's stopped on this stage with hidden users
if (chatModel.users.any { u -> !u.user.hidden }) return
withBGApi {
@@ -17,7 +17,6 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.database.*
@@ -36,7 +35,7 @@ fun SetupDatabasePassphrase(m: ChatModel) {
val confirmNewKey = rememberSaveable { mutableStateOf("") }
fun nextStep() {
if (appPlatform.isAndroid || chatModel.currentUser.value != null) {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
} else {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile)
}
@@ -164,14 +164,15 @@ fun OnboardingActionButton(
@Composable
fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?) {
val state = getKeyboardState()
val enabled = onClick != null
val topPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING)
val bottomPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING * 2)
if ((appPlatform.isAndroid && state.value == KeyboardState.Closed) || topPadding > 0.dp) {
TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = onClick != null) {
TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = enabled) {
Text(
text,
Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF, bottom = 5.dp),
color = MaterialTheme.colors.primary,
color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center
)
@@ -8,7 +8,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -17,17 +16,32 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.setConditionsNotified
import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
@Composable
fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Boolean = false, close: () -> Unit) {
val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) }
val rhId = chatModel.remoteHostId()
if (updatedConditions) {
LaunchedEffect(Unit) {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
try {
setConditionsNotified(rh = rhId, conditionsId = conditionsId)
} catch (e: Exception) {
Log.d(TAG, "WhatsNewView setConditionsNotified error: ${e.message}")
}
}
}
@Composable
fun featureDescription(icon: ImageResource?, titleId: StringResource, descrId: StringResource?, link: String?, subfeatures: List<Pair<ImageResource, StringResource>>) {
@@ -124,9 +138,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
) {
AppBarTitle(String.format(generalGetString(MR.strings.new_in_version), v.version), withPadding = false, bottomPadding = DEFAULT_PADDING)
val modalManager = if (viaSettings) ModalManager.start else ModalManager.center
v.features.forEach { feature ->
if (feature.show) {
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures)
when (feature) {
is VersionFeature.FeatureDescription -> {
if (feature.show) {
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures)
}
}
is VersionFeature.FeatureView -> {
feature.view(modalManager)
}
}
}
@@ -134,6 +157,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
ReadMoreButton(v.post)
}
if (updatedConditions) {
Text(
stringResource(MR.strings.view_updated_conditions),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable {
modalManager.showModalCloseable {
close -> UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId)
}
}
)
}
if (!viaSettings) {
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(
@@ -141,7 +176,9 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
) {
Text(
generalGetString(MR.strings.ok),
modifier = Modifier.clickable(onClick = close),
modifier = Modifier.clickable(onClick = {
close()
}),
style = MaterialTheme.typography.h3,
color = MaterialTheme.colors.primary
)
@@ -166,18 +203,26 @@ fun ReadMoreButton(url: String) {
}
}
private data class FeatureDescription(
val icon: ImageResource?,
val titleId: StringResource,
val descrId: StringResource?,
var subfeatures: List<Pair<ImageResource, StringResource>> = listOf(),
val link: String? = null,
val show: Boolean = true
)
private sealed class VersionFeature {
class FeatureDescription(
val icon: ImageResource?,
val titleId: StringResource,
val descrId: StringResource?,
var subfeatures: List<Pair<ImageResource, StringResource>> = listOf(),
val link: String? = null,
val show: Boolean = true
): VersionFeature()
class FeatureView(
val icon: ImageResource?,
val titleId: StringResource,
val view: @Composable (modalManager: ModalManager) -> Unit
): VersionFeature()
}
private data class VersionDescription(
val version: String,
val features: List<FeatureDescription>,
val features: List<VersionFeature>,
val post: String? = null,
)
@@ -186,18 +231,18 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v4.2",
post = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_verified_user,
titleId = MR.strings.v4_2_security_assessment,
descrId = MR.strings.v4_2_security_assessment_desc,
link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_group,
titleId = MR.strings.v4_2_group_links,
descrId = MR.strings.v4_2_group_links_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_check,
titleId = MR.strings.v4_2_auto_accept_contact_requests,
descrId = MR.strings.v4_2_auto_accept_contact_requests_desc
@@ -208,22 +253,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v4.3",
post = "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_mic,
titleId = MR.strings.v4_3_voice_messages,
descrId = MR.strings.v4_3_voice_messages_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_delete_forever,
titleId = MR.strings.v4_3_irreversible_message_deletion,
descrId = MR.strings.v4_3_irreversible_message_deletion_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_wifi_tethering,
titleId = MR.strings.v4_3_improved_server_configuration,
descrId = MR.strings.v4_3_improved_server_configuration_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_visibility_off,
titleId = MR.strings.v4_3_improved_privacy_and_security,
descrId = MR.strings.v4_3_improved_privacy_and_security_desc
@@ -234,22 +279,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v4.4",
post = "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_timer,
titleId = MR.strings.v4_4_disappearing_messages,
descrId = MR.strings.v4_4_disappearing_messages_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_pending,
titleId = MR.strings.v4_4_live_messages,
descrId = MR.strings.v4_4_live_messages_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_verified_user,
titleId = MR.strings.v4_4_verify_connection_security,
descrId = MR.strings.v4_4_verify_connection_security_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v4_4_french_interface,
descrId = MR.strings.v4_4_french_interface_descr
@@ -260,33 +305,33 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v4.5",
post = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_manage_accounts,
titleId = MR.strings.v4_5_multiple_chat_profiles,
descrId = MR.strings.v4_5_multiple_chat_profiles_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_edit_note,
titleId = MR.strings.v4_5_message_draft,
descrId = MR.strings.v4_5_message_draft_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_safety_divider,
titleId = MR.strings.v4_5_transport_isolation,
descrId = MR.strings.v4_5_transport_isolation_descr,
link = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_task,
titleId = MR.strings.v4_5_private_filenames,
descrId = MR.strings.v4_5_private_filenames_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_battery_2_bar,
titleId = MR.strings.v4_5_reduced_battery_usage,
descrId = MR.strings.v4_5_reduced_battery_usage_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v4_5_italian_interface,
descrId = MR.strings.v4_5_italian_interface_descr,
@@ -297,32 +342,32 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v4.6",
post = "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_lock,
titleId = MR.strings.v4_6_hidden_chat_profiles,
descrId = MR.strings.v4_6_hidden_chat_profiles_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_flag,
titleId = MR.strings.v4_6_group_moderation,
descrId = MR.strings.v4_6_group_moderation_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_maps_ugc,
titleId = MR.strings.v4_6_group_welcome_message,
descrId = MR.strings.v4_6_group_welcome_message_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_call,
titleId = MR.strings.v4_6_audio_video_calls,
descrId = MR.strings.v4_6_audio_video_calls_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_battery_3_bar,
titleId = MR.strings.v4_6_reduced_battery_usage,
descrId = MR.strings.v4_6_reduced_battery_usage_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v4_6_chinese_spanish_interface,
descrId = MR.strings.v4_6_chinese_spanish_interface_descr,
@@ -333,17 +378,17 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.0",
post = "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_upload_file,
titleId = MR.strings.v5_0_large_files_support,
descrId = MR.strings.v5_0_large_files_support_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_lock,
titleId = MR.strings.v5_0_app_passcode,
descrId = MR.strings.v5_0_app_passcode_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_0_polish_interface,
descrId = MR.strings.v5_0_polish_interface_descr,
@@ -354,27 +399,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.1",
post = "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_add_reaction,
titleId = MR.strings.v5_1_message_reactions,
descrId = MR.strings.v5_1_message_reactions_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_chat,
titleId = MR.strings.v5_1_better_messages,
descrId = MR.strings.v5_1_better_messages_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_light_mode,
titleId = MR.strings.v5_1_custom_themes,
descrId = MR.strings.v5_1_custom_themes_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_lock,
titleId = MR.strings.v5_1_self_destruct_passcode,
descrId = MR.strings.v5_1_self_destruct_passcode_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_1_japanese_portuguese_interface,
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
@@ -385,27 +430,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.2",
post = "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_check,
titleId = MR.strings.v5_2_message_delivery_receipts,
descrId = MR.strings.v5_2_message_delivery_receipts_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_star,
titleId = MR.strings.v5_2_favourites_filter,
descrId = MR.strings.v5_2_favourites_filter_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_sync_problem,
titleId = MR.strings.v5_2_fix_encryption,
descrId = MR.strings.v5_2_fix_encryption_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_timer,
titleId = MR.strings.v5_2_disappear_one_message,
descrId = MR.strings.v5_2_disappear_one_message_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_redeem,
titleId = MR.strings.v5_2_more_things,
descrId = MR.strings.v5_2_more_things_descr
@@ -416,29 +461,29 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.3",
post = "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_desktop,
titleId = MR.strings.v5_3_new_desktop_app,
descrId = MR.strings.v5_3_new_desktop_app_descr,
link = "https://simplex.chat/downloads/"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_lock,
titleId = MR.strings.v5_3_encrypt_local_files,
descrId = MR.strings.v5_3_encrypt_local_files_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_search,
titleId = MR.strings.v5_3_discover_join_groups,
descrId = MR.strings.v5_3_discover_join_groups_descr,
link = "simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_theater_comedy,
titleId = MR.strings.v5_3_simpler_incognito_mode,
descrId = MR.strings.v5_3_simpler_incognito_mode_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_3_new_interface_languages,
descrId = MR.strings.v5_3_new_interface_languages_descr,
@@ -449,27 +494,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.4",
post = "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_desktop,
titleId = MR.strings.v5_4_link_mobile_desktop,
descrId = MR.strings.v5_4_link_mobile_desktop_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_group,
titleId = MR.strings.v5_4_better_groups,
descrId = MR.strings.v5_4_better_groups_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_theater_comedy,
titleId = MR.strings.v5_4_incognito_groups,
descrId = MR.strings.v5_4_incognito_groups_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_back_hand,
titleId = MR.strings.v5_4_block_group_members,
descrId = MR.strings.v5_4_block_group_members_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_redeem,
titleId = MR.strings.v5_2_more_things,
descrId = MR.strings.v5_4_more_things_descr
@@ -480,28 +525,28 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.5",
post = "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_folder_pen,
titleId = MR.strings.v5_5_private_notes,
descrId = MR.strings.v5_5_private_notes_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_link,
titleId = MR.strings.v5_5_simpler_connect_ui,
descrId = MR.strings.v5_5_simpler_connect_ui_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_forum,
titleId = MR.strings.v5_5_join_group_conversation,
descrId = MR.strings.v5_5_join_group_conversation_descr,
link = "simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_battery_3_bar,
titleId = MR.strings.v5_5_message_delivery,
descrId = MR.strings.v5_5_message_delivery_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_5_new_interface_languages,
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
@@ -512,22 +557,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.6",
post = "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_vpn_key_filled,
titleId = MR.strings.v5_6_quantum_resistant_encryption,
descrId = MR.strings.v5_6_quantum_resistant_encryption_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_ios_share,
titleId = MR.strings.v5_6_app_data_migration,
descrId = MR.strings.v5_6_app_data_migration_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_call,
titleId = MR.strings.v5_6_picture_in_picture_calls,
descrId = MR.strings.v5_6_picture_in_picture_calls_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_back_hand,
titleId = MR.strings.v5_6_safer_groups,
descrId = MR.strings.v5_6_safer_groups_descr
@@ -538,32 +583,32 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.7",
post = "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_vpn_key_filled,
titleId = MR.strings.v5_6_quantum_resistant_encryption,
descrId = MR.strings.v5_7_quantum_resistant_encryption_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_forward,
titleId = MR.strings.v5_7_forward,
descrId = MR.strings.v5_7_forward_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_music_note,
titleId = MR.strings.v5_7_call_sounds,
descrId = MR.strings.v5_7_call_sounds_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_account_box,
titleId = MR.strings.v5_7_shape_profile_images,
descrId = MR.strings.v5_7_shape_profile_images_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_wifi_tethering,
titleId = MR.strings.v5_7_network,
descrId = MR.strings.v5_7_network_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_7_new_interface_languages,
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
@@ -574,27 +619,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.8",
post = "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_settings_ethernet,
titleId = MR.strings.v5_8_private_routing,
descrId = MR.strings.v5_8_private_routing_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_palette,
titleId = MR.strings.v5_8_chat_themes,
descrId = MR.strings.v5_8_chat_themes_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_security,
titleId = MR.strings.v5_8_safe_files,
descrId = MR.strings.v5_8_safe_files_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_battery_3_bar,
titleId = MR.strings.v5_8_message_delivery,
descrId = MR.strings.v5_8_message_delivery_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_8_persian_ui,
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate
@@ -605,7 +650,7 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v6.0",
post = "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = null,
titleId = MR.strings.v6_0_new_chat_experience,
descrId = null,
@@ -616,7 +661,7 @@ private val versionDescriptions: List<VersionDescription> = listOf(
MR.images.ic_match_case to MR.strings.v6_0_increase_font_size
)
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = null,
titleId = MR.strings.v6_0_new_media_options,
descrId = null,
@@ -625,23 +670,23 @@ private val versionDescriptions: List<VersionDescription> = listOf(
MR.images.ic_blur_on to MR.strings.v6_0_privacy_blur,
)
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_toast,
titleId = MR.strings.v6_0_reachable_chat_toolbar,
descrId = MR.strings.v6_0_reachable_chat_toolbar_descr,
show = appPlatform.isAndroid
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_settings_ethernet,
titleId = MR.strings.v5_8_private_routing,
descrId = MR.strings.v6_0_private_routing_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_wifi_tethering,
titleId = MR.strings.v6_0_connection_servers_status,
descrId = MR.strings.v6_0_connection_servers_status_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_upgrade,
titleId = MR.strings.v6_0_upgrade_app,
descrId = MR.strings.v6_0_upgrade_app_descr,
@@ -653,18 +698,18 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v6.1",
post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_verified_user,
titleId = MR.strings.v6_1_better_security,
descrId = MR.strings.v6_1_better_security_descr,
link = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_videocam,
titleId = MR.strings.v6_1_better_calls,
descrId = MR.strings.v6_1_better_calls_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = null,
titleId = MR.strings.v6_1_better_user_experience,
descrId = null,
@@ -678,6 +723,39 @@ private val versionDescriptions: List<VersionDescription> = listOf(
),
),
),
VersionDescription(
version = "v6.2 (beta.1)",
post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html",
features = listOf(
VersionFeature.FeatureView(
icon = null,
titleId = MR.strings.v6_2_network_decentralization,
view = { modalManager ->
Column {
val src = (operatorsInfo[OperatorTag.Flux] ?: dummyOperatorInfo).largeLogo
Image(painterResource(src), null, modifier = Modifier.height(48.dp))
Text(stringResource(MR.strings.v6_2_network_decentralization_descr), modifier = Modifier.padding(top = 8.dp))
Row {
Text(
stringResource(MR.strings.v6_2_network_decentralization_enable_flux),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable {
modalManager.showModalCloseable { close -> ChooseServerOperators(onboarding = false, close, modalManager) }
}
)
Text(" ")
Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux_reason))
}
}
}
),
VersionFeature.FeatureDescription(
icon = MR.images.ic_chat,
titleId = MR.strings.v6_2_improved_chat_navigation,
descrId = MR.strings.v6_2_improved_chat_navigation_descr
),
),
)
)
private val lastVersion = versionDescriptions.last().version
@@ -700,7 +778,8 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
@Composable
fun PreviewWhatsNewView() {
SimpleXTheme {
WhatsNewView(
val data = remember { ModalData() }
data.WhatsNewView(
viaSettings = true,
close = {}
)
@@ -35,6 +35,7 @@ import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.validPort
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -1,383 +0,0 @@
package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
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 dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.appPlatform
import chat.simplex.res.MR
@Composable
fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) {
var presetServers by remember(rhId) { mutableStateOf(emptyList<ServerCfg>()) }
var servers by remember { stateGetOrPut("servers") { emptyList<ServerCfg>() } }
var serversAlreadyLoaded by remember { stateGetOrPut("serversAlreadyLoaded") { false } }
val currServers = remember(rhId) { mutableStateOf(servers) }
val testing = rememberSaveable(rhId) { mutableStateOf(false) }
val serversUnchanged = remember(servers) { derivedStateOf { servers == currServers.value || testing.value } }
val allServersDisabled = remember { derivedStateOf { servers.none { it.enabled } } }
val saveDisabled = remember(servers) {
derivedStateOf {
servers.isEmpty() ||
servers == currServers.value ||
testing.value ||
servers.none { srv ->
val address = parseServerAddress(srv.server)
address != null && uniqueAddress(srv, address, servers)
} ||
allServersDisabled.value
}
}
KeyChangeEffect(rhId) {
servers = emptyList()
serversAlreadyLoaded = false
}
LaunchedEffect(rhId) {
withApi {
val res = m.controller.getUserProtoServers(rhId, serverProtocol)
if (res != null) {
currServers.value = res.protoServers
presetServers = res.presetServers
if (servers.isEmpty() && !serversAlreadyLoaded) {
servers = currServers.value
serversAlreadyLoaded = true
}
}
}
}
val testServersJob = CancellableOnGoneJob()
fun showServer(server: ServerCfg) {
ModalManager.start.showModalCloseable(true) { close ->
var old by remember { mutableStateOf(server) }
val index = servers.indexOf(old)
ProtocolServerView(
m,
old,
serverProtocol,
onUpdate = { updated ->
val newServers = ArrayList(servers)
newServers.removeAt(index)
newServers.add(index, updated)
old = updated
servers = newServers
},
onDelete = {
val newServers = ArrayList(servers)
newServers.removeAt(index)
servers = newServers
close()
})
}
}
ModalView(
close = {
if (saveDisabled.value) close()
else showUnsavedChangesAlert({ saveServers(rhId, serverProtocol, currServers, servers, m, close) }, close)
},
) {
ProtocolServersLayout(
serverProtocol,
testing = testing.value,
servers = servers,
serversUnchanged = serversUnchanged.value,
saveDisabled = saveDisabled.value,
allServersDisabled = allServersDisabled.value,
m.currentUser.value,
addServer = {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.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(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
if (appPlatform.isAndroid) {
SectionItemView({
AlertManager.shared.hideAlert()
ModalManager.start.showModalCloseable { close ->
ScanProtocolServer(rhId) {
close()
servers = servers + it
}
}
}
) {
Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
val hasAllPresets = hasAllPresets(presetServers, servers, m)
if (!hasAllPresets) {
SectionItemView({
AlertManager.shared.hideAlert()
servers = (servers + addAllPresets(rhId, presetServers, servers, m)).sortedByDescending { it.preset }
}) {
Text(stringResource(MR.strings.smp_servers_preset_add), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
}
)
},
testServers = {
testServersJob.value = withLongRunningApi {
testServers(testing, servers, m) {
servers = it
}
}
},
resetServers = {
servers = currServers.value
},
saveSMPServers = {
saveServers(rhId, serverProtocol, currServers, servers, m)
},
showServer = ::showServer,
)
if (testing.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
)
}
}
}
}
@Composable
private fun ProtocolServersLayout(
serverProtocol: ServerProtocol,
testing: Boolean,
servers: List<ServerCfg>,
serversUnchanged: Boolean,
saveDisabled: Boolean,
allServersDisabled: Boolean,
currentUser: User?,
addServer: () -> Unit,
testServers: () -> Unit,
resetServers: () -> Unit,
saveSMPServers: () -> Unit,
showServer: (ServerCfg) -> Unit,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.your_SMP_servers else MR.strings.your_XFTP_servers))
val configuredServers = servers.filter { it.preset || it.enabled }
val otherServers = servers.filter { !(it.preset || it.enabled) }
if (configuredServers.isNotEmpty()) {
SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.smp_servers_configured else MR.strings.xftp_servers_configured).uppercase()) {
for (srv in configuredServers) {
SectionItemView({ showServer(srv) }, disabled = testing) {
ProtocolServerView(serverProtocol, srv, servers, testing)
}
}
}
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.smp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
}
if (otherServers.isNotEmpty()) {
SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.smp_servers_other else MR.strings.xftp_servers_other).uppercase()) {
for (srv in otherServers.filter { !(it.preset || it.enabled) }) {
SectionItemView({ showServer(srv) }, disabled = testing) {
ProtocolServerView(serverProtocol, srv, servers, testing)
}
}
}
}
SectionView {
SettingsActionItem(
painterResource(MR.images.ic_add),
stringResource(MR.strings.smp_servers_add),
addServer,
disabled = testing,
textColor = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
iconColor = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
}
SectionView {
SectionItemView(resetServers, disabled = serversUnchanged) {
Text(stringResource(MR.strings.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
val testServersDisabled = testing || allServersDisabled
SectionItemView(testServers, disabled = testServersDisabled) {
Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!testServersDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
SectionItemView(saveSMPServers, disabled = saveDisabled) {
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
}
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
HowToButton()
}
SectionBottomSpacer()
}
}
@Composable
private fun ProtocolServerView(serverProtocol: ServerProtocol, srv: ServerCfg, servers: List<ServerCfg>, disabled: Boolean) {
val address = parseServerAddress(srv.server)
when {
address == null || !address.valid || address.serverProtocol != serverProtocol || !uniqueAddress(srv, address, servers) -> InvalidServer()
!srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary)
else -> ShowTestStatus(srv)
}
Spacer(Modifier.padding(horizontal = 4.dp))
val text = address?.hostnames?.firstOrNull() ?: srv.server
if (srv.enabled) {
Text(text, color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground, maxLines = 1)
} else {
Text(text, maxLines = 1, color = MaterialTheme.colors.secondary)
}
}
@Composable
private fun HowToButton() {
val uriHandler = LocalUriHandler.current
SettingsActionItem(
painterResource(MR.images.ic_open_in_new),
stringResource(MR.strings.how_to_use_your_servers),
{ uriHandler.openUriCatching("https://simplex.chat/docs/server.html") },
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary
)
}
@Composable
fun InvalidServer() {
Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error)
}
private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List<ServerCfg>): Boolean = servers.all { srv ->
address.hostnames.all { host ->
srv.id == s.id || !srv.server.contains(host)
}
}
private fun hasAllPresets(presetServers: List<ServerCfg>, servers: List<ServerCfg>, m: ChatModel): Boolean =
presetServers.all { hasPreset(it, servers) } ?: true
private fun addAllPresets(rhId: Long?, presetServers: List<ServerCfg>, servers: List<ServerCfg>, m: ChatModel): List<ServerCfg> {
val toAdd = ArrayList<ServerCfg>()
for (srv in presetServers) {
if (!hasPreset(srv, servers)) {
toAdd.add(srv)
}
}
return toAdd
}
private fun hasPreset(srv: ServerCfg, servers: List<ServerCfg>): Boolean =
servers.any { it.server == srv.server }
private suspend fun testServers(testing: MutableState<Boolean>, servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> 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(MR.strings.smp_servers_test_failed),
text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg
)
}
}
private fun resetTestStatus(servers: List<ServerCfg>): List<ServerCfg> {
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<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit): Map<String, ProtocolTestFailure> {
val fs: MutableMap<String, ProtocolTestFailure> = mutableMapOf()
val updatedServers = ArrayList<ServerCfg>(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
interruptIfCancelled()
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.server)] = f
}
}
}
return fs
}
private fun saveServers(rhId: Long?, protocol: ServerProtocol, currServers: MutableState<List<ServerCfg>>, servers: List<ServerCfg>, m: ChatModel, afterSave: () -> Unit = {}) {
withBGApi {
if (m.controller.setUserProtoServers(rhId, protocol, servers)) {
currServers.value = servers
}
afterSave()
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.smp_save_servers_question),
confirmText = generalGetString(MR.strings.save_verb),
dismissText = generalGetString(MR.strings.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}
@@ -25,14 +25,13 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.database.DatabaseView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrateFromDeviceView
import chat.simplex.common.views.onboarding.SimpleXInfo
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView
import chat.simplex.res.MR
import kotlinx.coroutines.*
@Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) {
@@ -102,7 +101,7 @@ fun SettingsLayout(
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) })
@@ -118,7 +117,7 @@ fun SettingsLayout(
SectionView(stringResource(MR.strings.settings_section_title_help)) {
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close = close) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
if (!chatModel.desktopNoUserNoRemote) {
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
@@ -1,4 +1,4 @@
package chat.simplex.common.views.usersettings
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionDividerSpaced
@@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.usersettings.SettingsPreferenceItem
import chat.simplex.res.MR
import java.text.DecimalFormat
@@ -1,6 +1,7 @@
package chat.simplex.common.views.usersettings
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionItemWithValue
@@ -20,119 +21,245 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.input.*
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.Image
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.getServerOperators
import chat.simplex.common.model.ChatController.getUserServers
import chat.simplex.common.model.ChatController.setUserServers
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingActionButton
import chat.simplex.common.views.onboarding.ReadableText
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import kotlinx.coroutines.launch
@Composable
fun NetworkAndServersView() {
fun ModalData.NetworkAndServersView(close: () -> Unit) {
val currentRemoteHost by remember { chatModel.currentRemoteHost }
// 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 currUserServers = remember { stateGetOrPut("currUserServers") { emptyList<UserOperatorServers>() } }
val userServers = remember { stateGetOrPut("userServers") { emptyList<UserOperatorServers>() } }
val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList<UserServersError>() } }
val scope = rememberCoroutineScope()
val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } }
NetworkAndServersLayout(
currentRemoteHost = currentRemoteHost,
networkUseSocksProxy = networkUseSocksProxy,
onionHosts = remember { mutableStateOf(netCfg.onionHosts) },
toggleSocksProxy = { enable ->
val def = NetCfg.defaults
val proxyDef = NetCfg.proxyDefaults
if (enable) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.network_enable_socks),
text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value),
confirmText = generalGetString(MR.strings.confirm_verb),
onConfirm = {
withBGApi {
var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get())
if (conf.tcpConnectTimeout == def.tcpConnectTimeout) {
conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout)
}
if (conf.tcpTimeout == def.tcpTimeout) {
conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout)
}
if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) {
conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb)
}
if (conf.rcvConcurrency == def.rcvConcurrency) {
conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency)
}
chatModel.controller.apiSetNetworkConfig(conf)
chatModel.controller.setNetCfg(conf)
networkUseSocksProxy.value = true
}
}
)
ModalView(
close = {
if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) {
close()
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.network_disable_socks),
text = generalGetString(MR.strings.network_disable_socks_info),
confirmText = generalGetString(MR.strings.confirm_verb),
onConfirm = {
withBGApi {
var conf = controller.getNetCfg().copy(socksProxy = null)
if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) {
conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout)
}
if (conf.tcpTimeout == proxyDef.tcpTimeout) {
conf = conf.copy(tcpTimeout = def.tcpTimeout)
}
if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) {
conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb)
}
if (conf.rcvConcurrency == proxyDef.rcvConcurrency) {
conf = conf.copy(rcvConcurrency = def.rcvConcurrency)
}
chatModel.controller.apiSetNetworkConfig(conf)
chatModel.controller.setNetCfg(conf)
networkUseSocksProxy.value = false
}
}
showUnsavedChangesAlert(
{ scope.launch { saveServers(currentRemoteHost?.remoteHostId, currUserServers, userServers) }},
close
)
}
}
)
) {
NetworkAndServersLayout(
currentRemoteHost = currentRemoteHost,
networkUseSocksProxy = networkUseSocksProxy,
onionHosts = remember { mutableStateOf(netCfg.onionHosts) },
currUserServers = currUserServers,
userServers = userServers,
serverErrors = serverErrors,
toggleSocksProxy = { enable ->
val def = NetCfg.defaults
val proxyDef = NetCfg.proxyDefaults
if (enable) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.network_enable_socks),
text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value),
confirmText = generalGetString(MR.strings.confirm_verb),
onConfirm = {
withBGApi {
var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get())
if (conf.tcpConnectTimeout == def.tcpConnectTimeout) {
conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout)
}
if (conf.tcpTimeout == def.tcpTimeout) {
conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout)
}
if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) {
conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb)
}
if (conf.rcvConcurrency == def.rcvConcurrency) {
conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency)
}
chatModel.controller.apiSetNetworkConfig(conf)
chatModel.controller.setNetCfg(conf)
networkUseSocksProxy.value = true
}
}
)
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.network_disable_socks),
text = generalGetString(MR.strings.network_disable_socks_info),
confirmText = generalGetString(MR.strings.confirm_verb),
onConfirm = {
withBGApi {
var conf = controller.getNetCfg().copy(socksProxy = null)
if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) {
conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout)
}
if (conf.tcpTimeout == proxyDef.tcpTimeout) {
conf = conf.copy(tcpTimeout = def.tcpTimeout)
}
if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) {
conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb)
}
if (conf.rcvConcurrency == proxyDef.rcvConcurrency) {
conf = conf.copy(rcvConcurrency = def.rcvConcurrency)
}
chatModel.controller.apiSetNetworkConfig(conf)
chatModel.controller.setNetCfg(conf)
networkUseSocksProxy.value = false
}
}
)
}
}
)
}
}
@Composable fun NetworkAndServersLayout(
currentRemoteHost: RemoteHostInfo?,
networkUseSocksProxy: MutableState<Boolean>,
onionHosts: MutableState<OnionHosts>,
currUserServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
userServers: MutableState<List<UserOperatorServers>>,
toggleSocksProxy: (Boolean) -> Unit,
) {
val m = chatModel
val conditionsAction = remember { m.conditions.value.conditionsAction }
val anyOperatorEnabled = remember { derivedStateOf { userServers.value.any { it.operator?.enabled == true } } }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
if (currUserServers.value.isNotEmpty() || userServers.value.isNotEmpty()) {
return@LaunchedEffect
}
try {
val servers = getUserServers(rh = currentRemoteHost?.remoteHostId)
if (servers != null) {
currUserServers.value = servers
userServers.value = servers
}
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
}
}
@Composable
fun ConditionsButton(conditionsAction: UsageConditionsAction, rhId: Long?) {
SectionItemView(
click = { ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> UsageConditionsView(currUserServers, userServers, close, rhId) } },
) {
Text(
stringResource(if (conditionsAction is UsageConditionsAction.Review) MR.strings.operator_review_conditions else MR.strings.operator_conditions_accepted),
color = MaterialTheme.colors.primary
)
}
}
ColumnWithScrollBar {
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) }}
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) } }
AppBarTitle(stringResource(MR.strings.network_and_servers))
// TODO: Review this and socks.
if (!chatModel.desktopNoUserNoRemote) {
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.message_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } })
SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) {
userServers.value.forEachIndexed { index, srv ->
srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, currentRemoteHost?.remoteHostId) }
}
}
if (conditionsAction != null && anyOperatorEnabled.value) {
ConditionsButton(conditionsAction, rhId = currentRemoteHost?.remoteHostId)
}
val footerText = if (conditionsAction is UsageConditionsAction.Review && conditionsAction.deadline != null && anyOperatorEnabled.value) {
String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(conditionsAction.deadline))
} else null
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.media_and_file_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } })
if (footerText != null) {
SectionTextFooter(footerText)
}
SectionDividerSpaced()
}
if (currentRemoteHost == null) {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) }})
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } })
if (networkUseSocksProxy.value) {
SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
SectionDividerSpaced(maxTopPadding = true)
} else {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
val nullOperatorIndex = userServers.value.indexOfFirst { it.operator == null }
if (nullOperatorIndex != -1) {
SectionItemView({
ModalManager.start.showModal {
YourServersView(
userServers = userServers,
serverErrors = serverErrors,
operatorIndex = nullOperatorIndex,
rhId = currentRemoteHost?.remoteHostId
)
}
}) {
Icon(
painterResource(MR.images.ic_dns),
stringResource(MR.strings.your_servers),
tint = MaterialTheme.colors.secondary
)
TextIconSpaced()
Text(stringResource(MR.strings.your_servers), color = MaterialTheme.colors.onBackground)
if (currUserServers.value.getOrNull(nullOperatorIndex) != userServers.value.getOrNull(nullOperatorIndex)) {
Spacer(Modifier.weight(1f))
UnsavedChangesIndicator()
}
}
}
if (currentRemoteHost == null) {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) } })
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } })
if (networkUseSocksProxy.value) {
SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
SectionDividerSpaced(maxTopPadding = true)
} else {
SectionDividerSpaced(maxBottomPadding = false)
}
}
}
val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)
SectionItemView(
{ scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } },
disabled = saveDisabled,
) {
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
val serversErr = globalServersError(serverErrors.value)
if (serversErr != null) {
SectionCustomFooter {
ServersErrorFooter(serversErr)
}
} else if (serverErrors.value.isNotEmpty()) {
SectionCustomFooter {
ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration))
}
}
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.settings_section_title_calls)) {
SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } })
@@ -504,6 +631,165 @@ fun showWrongProxyConfigAlert() {
)
}
@Composable()
private fun ServerOperatorRow(
index: Int,
operator: ServerOperator,
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
rhId: Long?
) {
SectionItemView(
{
ModalManager.start.showModalCloseable { close ->
OperatorView(
currUserServers,
userServers,
serverErrors,
index,
rhId
)
}
}
) {
Image(
painterResource(operator.logo),
operator.tradeName,
modifier = Modifier.size(24.dp),
colorFilter = if (operator.enabled) null else ColorFilter.colorMatrix(ColorMatrix().apply {
setToSaturation(0f)
})
)
TextIconSpaced()
Text(operator.tradeName, color = if (operator.enabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
if (currUserServers.value.getOrNull(index) != userServers.value.getOrNull(index)) {
Spacer(Modifier.weight(1f))
UnsavedChangesIndicator()
}
}
}
@Composable
private fun UnsavedChangesIndicator() {
Icon(
painterResource(MR.images.ic_edit_filled),
stringResource(MR.strings.icon_descr_edited),
tint = MaterialTheme.colors.secondary,
modifier = Modifier.size(16.dp)
)
}
@Composable
fun UsageConditionsView(
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
close: () -> Unit,
rhId: Long?
) {
suspend fun acceptForOperators(rhId: Long?, operatorIds: List<Long>, close: () -> Unit) {
try {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return
chatModel.conditions.value = r
updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators)
updateOperatorsConditionsAcceptance(userServers, r.serverOperators)
close()
} catch (ex: Exception) {
Log.e(TAG, ex.stackTraceToString())
}
}
@Composable
fun AcceptConditionsButton(operatorIds: List<Long>, close: () -> Unit, bottomPadding: Dp = DEFAULT_PADDING * 2) {
val scope = rememberCoroutineScope()
Column(Modifier.fillMaxWidth().padding(bottom = bottomPadding), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingActionButton(
labelId = MR.strings.accept_conditions,
onboarding = null,
enabled = operatorIds.isNotEmpty(),
onclick = {
scope.launch {
acceptForOperators(rhId, operatorIds, close)
}
}
)
}
}
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false)
when (val conditionsAction = chatModel.conditions.value.conditionsAction) {
is UsageConditionsAction.Review -> {
if (conditionsAction.operators.isNotEmpty()) {
ReadableText(MR.strings.operators_conditions_will_be_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ })
}
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(rhId)
}
AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, if (conditionsAction.deadline != null) DEFAULT_PADDING_HALF else DEFAULT_PADDING * 2)
if (conditionsAction.deadline != null) {
SectionTextFooter(
text = AnnotatedString(String.format(generalGetString(MR.strings.operator_conditions_accepted_for_enabled_operators_on), localDate(conditionsAction.deadline))),
textAlign = TextAlign.Center
)
Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING))
}
}
is UsageConditionsAction.Accepted -> {
if (conditionsAction.operators.isNotEmpty()) {
ReadableText(MR.strings.operators_conditions_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ })
}
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(rhId)
}
}
else -> {
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(rhId)
}
}
}
}
}
@Composable
fun ServersErrorFooter(errStr: String) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painterResource(MR.images.ic_error),
contentDescription = stringResource(MR.strings.server_error),
tint = Color.Red,
modifier = Modifier
.size(19.sp.toDp())
.offset(x = 2.sp.toDp())
)
TextIconSpaced()
Text(
errStr,
color = MaterialTheme.colors.secondary,
lineHeight = 18.sp,
fontSize = 14.sp
)
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.smp_save_servers_question),
confirmText = generalGetString(MR.strings.save_verb),
dismissText = generalGetString(MR.strings.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}
fun showUpdateNetworkSettingsDialog(
title: String,
startsWith: String = "",
@@ -521,6 +807,107 @@ fun showUpdateNetworkSettingsDialog(
)
}
fun updateOperatorsConditionsAcceptance(usvs: MutableState<List<UserOperatorServers>>, updatedOperators: List<ServerOperator>) {
val modified = ArrayList(usvs.value)
for (i in modified.indices) {
val updatedOperator = updatedOperators.firstOrNull { it.operatorId == modified[i].operator?.operatorId } ?: continue
modified[i] = modified[i].copy(operator = modified[i].operator?.copy(conditionsAcceptance = updatedOperator.conditionsAcceptance))
}
usvs.value = modified
}
suspend fun validateServers_(
rhId: Long?,
userServersToValidate: List<UserOperatorServers>,
serverErrors: MutableState<List<UserServersError>>
) {
try {
val errors = chatController.validateServers(rhId, userServersToValidate) ?: return
serverErrors.value = errors
} catch (ex: Exception) {
Log.e(TAG, ex.stackTraceToString())
}
}
fun serversCanBeSaved(
currUserServers: List<UserOperatorServers>,
userServers: List<UserOperatorServers>,
serverErrors: List<UserServersError>
): Boolean {
return userServers != currUserServers && serverErrors.isEmpty()
}
fun globalServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
if (err.globalError != null) {
return err.globalError
}
}
return null
}
fun globalSMPServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
if (err.globalSMPError != null) {
return err.globalSMPError
}
}
return null
}
fun globalXFTPServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
if (err.globalXFTPError != null) {
return err.globalXFTPError
}
}
return null
}
fun findDuplicateHosts(serverErrors: List<UserServersError>): Set<String> {
val duplicateHostsList = serverErrors.mapNotNull { err ->
if (err is UserServersError.DuplicateServer) {
err.duplicateHost
} else {
null
}
}
return duplicateHostsList.toSet()
}
private suspend fun saveServers(
rhId: Long?,
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>
) {
val userServersToSave = userServers.value
try {
val set = setUserServers(rhId, userServersToSave)
if (set) {
// Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers)
val updatedServers = getUserServers(rhId)
// Get updated operators to update model
val updatedOperators = getServerOperators(rhId)
if (updatedOperators != null) {
chatModel.conditions.value = updatedOperators
}
if (updatedServers != null ) {
currUserServers.value = updatedServers
userServers.value = updatedServers
} else {
currUserServers.value = userServersToSave
}
} else {
currUserServers.value = userServersToSave
}
} catch (ex: Exception) {
Log.e(TAG, ex.stackTraceToString())
}
}
@Preview
@Composable
fun PreviewNetworkAndServersLayout() {
@@ -530,6 +917,9 @@ fun PreviewNetworkAndServersLayout() {
networkUseSocksProxy = remember { mutableStateOf(true) },
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
toggleSocksProxy = {},
currUserServers = remember { mutableStateOf(emptyList()) },
userServers = remember { mutableStateOf(emptyList()) },
serverErrors = remember { mutableStateOf(emptyList()) }
)
}
}
@@ -0,0 +1,144 @@
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
@Composable
fun ModalData.NewServerView(
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
rhId: Long?,
close: () -> Unit
) {
val testing = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val newServer = remember { mutableStateOf(UserServer.empty) }
ModalView(close = {
addServer(
scope,
newServer.value,
userServers,
serverErrors,
rhId,
close = close
)
}) {
Box {
NewServerLayout(
newServer,
testing.value,
testServer = {
testing.value = true
withLongRunningApi {
val res = testServerConnection(newServer.value, chatModel)
if (isActive) {
newServer.value = res.first
testing.value = false
}
}
},
)
if (testing.value) {
DefaultProgressView(null)
}
}
}
}
@Composable
private fun NewServerLayout(
server: MutableState<UserServer>,
testing: Boolean,
testServer: () -> Unit,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.smp_servers_new_server))
CustomServer(server, testing, testServer, onDelete = null)
SectionBottomSpacer()
}
}
fun serverProtocolAndOperator(
server: UserServer,
userServers: List<UserOperatorServers>
): Pair<ServerProtocol, ServerOperator?>? {
val serverAddress = parseServerAddress(server.server)
return if (serverAddress != null) {
val serverProtocol = serverAddress.serverProtocol
val hostnames = serverAddress.hostnames
val matchingOperator = userServers.mapNotNull { it.operator }.firstOrNull { op ->
op.serverDomains.any { domain ->
hostnames.any { hostname ->
hostname.endsWith(domain)
}
}
}
Pair(serverProtocol, matchingOperator)
} else {
null
}
}
fun addServer(
scope: CoroutineScope,
server: UserServer,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
rhId: Long?,
close: () -> Unit
) {
val result = serverProtocolAndOperator(server, userServers.value)
if (result != null) {
val (serverProtocol, matchingOperator) = result
val operatorIndex = userServers.value.indexOfFirst { it.operator?.operatorId == matchingOperator?.operatorId }
if (operatorIndex != -1) {
// Create a mutable copy of the userServers list
val updatedUserServers = userServers.value.toMutableList()
val operatorServers = updatedUserServers[operatorIndex]
// Create a mutable copy of the smpServers or xftpServers and add the server
when (serverProtocol) {
ServerProtocol.SMP -> {
val updatedSMPServers = operatorServers.smpServers.toMutableList()
updatedSMPServers.add(server)
updatedUserServers[operatorIndex] = operatorServers.copy(smpServers = updatedSMPServers)
}
ServerProtocol.XFTP -> {
val updatedXFTPServers = operatorServers.xftpServers.toMutableList()
updatedXFTPServers.add(server)
updatedUserServers[operatorIndex] = operatorServers.copy(xftpServers = updatedXFTPServers)
}
}
userServers.value = updatedUserServers
close()
matchingOperator?.let { op ->
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.operator_server_alert_title),
text = String.format(generalGetString(MR.strings.server_added_to_operator__name), op.tradeName)
)
}
} else { // Shouldn't happen
close()
AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.error_adding_server))
}
} else {
close()
if (server.server.trim().isNotEmpty()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_invalid_address),
text = generalGetString(MR.strings.smp_servers_check_address)
)
}
}
}
@@ -0,0 +1,701 @@
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.getUsageConditions
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.net.URI
@Composable
fun ModalData.OperatorView(
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
rhId: Long?
) {
val testing = remember { mutableStateOf(false) }
val operator = remember { userServers.value[operatorIndex].operator_ }
val currentUser = remember { chatModel.currentUser }.value
LaunchedEffect(userServers) {
snapshotFlow { userServers.value }
.collect { updatedServers ->
validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors)
}
}
Box {
ColumnWithScrollBar(Modifier.alpha(if (testing.value) 0.6f else 1f)) {
AppBarTitle(String.format(stringResource(MR.strings.operator_servers_title), operator.tradeName))
OperatorViewLayout(
currUserServers,
userServers,
serverErrors,
operatorIndex,
navigateToProtocolView = { serverIndex, server, protocol ->
navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol)
},
currentUser,
rhId,
testing
)
}
if (testing.value) {
DefaultProgressView(null)
}
}
}
fun navigateToProtocolView(
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
rhId: Long?,
serverIndex: Int,
server: UserServer,
protocol: ServerProtocol
) {
ModalManager.start.showCustomModal { close ->
ProtocolServerView(
m = chatModel,
server = server,
serverProtocol = protocol,
userServers = userServers,
serverErrors = serverErrors,
onDelete = {
if (protocol == ServerProtocol.SMP) {
deleteSMPServer(userServers, operatorIndex, serverIndex)
} else {
deleteXFTPServer(userServers, operatorIndex, serverIndex)
}
close()
},
onUpdate = { updatedServer ->
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
smpServers = if (protocol == ServerProtocol.SMP) {
this[operatorIndex].smpServers.toMutableList().apply {
this[serverIndex] = updatedServer
}
} else this[operatorIndex].smpServers,
xftpServers = if (protocol == ServerProtocol.XFTP) {
this[operatorIndex].xftpServers.toMutableList().apply {
this[serverIndex] = updatedServer
}
} else this[operatorIndex].xftpServers
)
}
},
close = close,
rhId = rhId
)
}
}
@Composable
fun OperatorViewLayout(
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit,
currentUser: User?,
rhId: Long?,
testing: MutableState<Boolean>
) {
val operator by remember { derivedStateOf { userServers.value[operatorIndex].operator_ } }
val scope = rememberCoroutineScope()
val duplicateHosts = findDuplicateHosts(serverErrors.value)
Column {
SectionView(generalGetString(MR.strings.operator).uppercase()) {
SectionItemView({ ModalManager.start.showModalCloseable { _ -> OperatorInfoView(operator) } }) {
Image(painterResource(operator.largeLogo), null, Modifier.height(48.dp))
}
UseOperatorToggle(
scope = scope,
currUserServers = currUserServers,
userServers = userServers,
serverErrors = serverErrors,
operatorIndex = operatorIndex,
rhId = rhId
)
}
val serversErr = globalServersError(serverErrors.value)
if (serversErr != null) {
SectionCustomFooter {
ServersErrorFooter(serversErr)
}
} else {
val footerText = when (val c = operator.conditionsAcceptance) {
is ConditionsAcceptance.Accepted -> if (c.acceptedAt != null) {
String.format(generalGetString(MR.strings.operator_conditions_accepted_on), localDate(c.acceptedAt))
} else null
is ConditionsAcceptance.Required -> if (operator.enabled && c.deadline != null) {
String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(c.deadline))
} else null
}
if (footerText != null) {
SectionTextFooter(footerText)
}
}
if (operator.enabled) {
if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
stringResource(MR.strings.operator_use_for_messages_receiving),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = userServers.value[operatorIndex].operator_.smpRoles.storage,
onCheckedChange = { enabled ->
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
)
)
}
}
)
}
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
stringResource(MR.strings.operator_use_for_messages_private_routing),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = userServers.value[operatorIndex].operator_.smpRoles.proxy,
onCheckedChange = { enabled ->
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled)
)
)
}
}
)
}
}
val smpErr = globalSMPServersError(serverErrors.value)
if (smpErr != null) {
SectionCustomFooter {
ServersErrorFooter(smpErr)
}
}
}
// Preset servers can't be deleted
if (userServers.value[operatorIndex].smpServers.any { it.preset }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.message_servers).uppercase()) {
userServers.value[operatorIndex].smpServers.forEachIndexed { i, server ->
if (!server.preset) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.SMP,
duplicateHosts = duplicateHosts
)
}
}
}
val smpErr = globalSMPServersError(serverErrors.value)
if (smpErr != null) {
SectionCustomFooter {
ServersErrorFooter(smpErr)
}
} else {
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.smp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
}
}
if (userServers.value[operatorIndex].smpServers.any { !it.preset && !it.deleted }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.operator_added_message_servers).uppercase()) {
userServers.value[operatorIndex].smpServers.forEachIndexed { i, server ->
if (server.deleted || server.preset) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.SMP,
duplicateHosts = duplicateHosts
)
}
}
}
}
if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.operator_use_for_files).uppercase()) {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
stringResource(MR.strings.operator_use_for_sending),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = userServers.value[operatorIndex].operator_.xftpRoles.storage,
onCheckedChange = { enabled ->
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
)
)
}
}
)
}
}
val xftpErr = globalXFTPServersError(serverErrors.value)
if (xftpErr != null) {
SectionCustomFooter {
ServersErrorFooter(xftpErr)
}
}
}
// Preset servers can't be deleted
if (userServers.value[operatorIndex].xftpServers.any { it.preset }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) {
userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server ->
if (!server.preset) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.XFTP,
duplicateHosts = duplicateHosts
)
}
}
}
val xftpErr = globalXFTPServersError(serverErrors.value)
if (xftpErr != null) {
SectionCustomFooter {
ServersErrorFooter(xftpErr)
}
} else {
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.xftp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
}
}
if (userServers.value[operatorIndex].xftpServers.any { !it.preset && !it.deleted}) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.operator_added_xftp_servers).uppercase()) {
userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server ->
if (server.deleted || server.preset) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.XFTP,
duplicateHosts = duplicateHosts
)
}
}
}
}
SectionDividerSpaced()
SectionView {
TestServersButton(
testing = testing,
smpServers = userServers.value[operatorIndex].smpServers,
xftpServers = userServers.value[operatorIndex].xftpServers,
) { p, l ->
when (p) {
ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
xftpServers = l
)
}
ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
smpServers = l
)
}
}
}
}
SectionBottomSpacer()
}
}
}
@Composable
private fun OperatorInfoView(serverOperator: ServerOperator) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.operator_info_title))
SectionView {
SectionItemView {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Image(painterResource(serverOperator.largeLogo), null, Modifier.height(48.dp))
if (serverOperator.legalName != null) {
Text(serverOperator.legalName)
}
}
}
}
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
SectionItemView {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
serverOperator.info.description.forEach { d ->
Text(d)
}
}
}
}
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.operator_website).uppercase()) {
SectionItemView {
val website = serverOperator.info.website
val uriHandler = LocalUriHandler.current
Text(website, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(website) })
}
}
}
}
@Composable
private fun UseOperatorToggle(
scope: CoroutineScope,
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
rhId: Long?
) {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
stringResource(MR.strings.operator_use_operator_toggle_description),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = userServers.value[operatorIndex].operator?.enabled ?: false,
onCheckedChange = { enabled ->
val operator = userServers.value[operatorIndex].operator
if (enabled) {
when (val conditionsAcceptance = operator?.conditionsAcceptance) {
is ConditionsAcceptance.Accepted -> {
changeOperatorEnabled(userServers, operatorIndex, true)
}
is ConditionsAcceptance.Required -> {
if (conditionsAcceptance.deadline == null) {
ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
SingleOperatorUsageConditionsView(
currUserServers = currUserServers,
userServers = userServers,
serverErrors = serverErrors,
operatorIndex = operatorIndex,
rhId = rhId,
close = close
)
}
} else {
changeOperatorEnabled(userServers, operatorIndex, true)
}
}
else -> {}
}
} else {
changeOperatorEnabled(userServers, operatorIndex, false)
}
},
)
}
}
@Composable
private fun SingleOperatorUsageConditionsView(
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
rhId: Long?,
close: () -> Unit
) {
val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } }
val operator = remember { userServers.value[operatorIndex].operator_ }
val scope = rememberCoroutineScope()
suspend fun acceptForOperators(rhId: Long?, operatorIds: List<Long>, operatorIndexToEnable: Int, close: () -> Unit) {
try {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return
chatModel.conditions.value = r
updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators)
updateOperatorsConditionsAcceptance(userServers, r.serverOperators)
changeOperatorEnabled(userServers, operatorIndex, true)
close()
} catch (ex: Exception) {
Log.e(TAG, ex.stackTraceToString())
}
}
@Composable
fun AcceptConditionsButton(close: () -> Unit) {
// Opened operator or Other enabled operators with conditions not accepted
val operatorIds = chatModel.conditions.value.serverOperators
.filter { it.operatorId == operator.id || (it.enabled && !it.conditionsAcceptance.conditionsAccepted) }
.map { it.operatorId }
Column(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING * 2), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingActionButton(
labelId = MR.strings.accept_conditions,
onboarding = null,
enabled = operatorIds.isNotEmpty(),
onclick = {
scope.launch {
acceptForOperators(rhId, operatorIds, operatorIndex, close)
}
}
)
}
}
@Composable
fun UsageConditionsDestinationView(close: () -> Unit) {
ColumnWithScrollBar(modifier = Modifier.fillMaxSize()) {
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false)
Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(rhId)
}
}
}
@Composable
fun UsageConditionsNavLinkButton() {
Text(
stringResource(MR.strings.view_conditions),
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF).clickable {
ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
UsageConditionsDestinationView(close)
}
}
)
}
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(String.format(stringResource(MR.strings.use_servers_of_operator_x), operator.tradeName), enableAlphaChanges = false, withPadding = false)
if (operator.conditionsAcceptance is ConditionsAcceptance.Accepted) {
// In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted
Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING)) {
ConditionsTextView(rhId)
}
} else if (operatorsWithConditionsAccepted.isNotEmpty()) {
ReadableText(
MR.strings.operator_conditions_accepted_for_some,
args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ }
)
ReadableText(
MR.strings.operator_same_conditions_will_be_applied,
args = operator.legalName_
)
ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex)
UsageConditionsNavLinkButton()
Spacer(Modifier.fillMaxWidth().weight(1f))
AcceptConditionsButton(close)
} else {
ReadableText(
MR.strings.operator_in_order_to_use_accept_conditions,
args = operator.legalName_
)
ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex)
Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING)) {
ConditionsTextView(rhId)
}
AcceptConditionsButton(close)
}
}
}
@Composable
fun ConditionsTextView(
rhId: Long?
) {
val conditionsData = remember { mutableStateOf<Triple<UsageConditionsDetail, String?, UsageConditionsDetail?>?>(null) }
val failedToLoad = remember { mutableStateOf(false) }
val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md"
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch {
try {
val conditions = getUsageConditions(rh = rhId)
if (conditions != null) {
conditionsData.value = conditions
} else {
failedToLoad.value = true
}
} catch (ex: Exception) {
failedToLoad.value = true
}
}
}
val conditions = conditionsData.value
if (conditions != null) {
val (usageConditions, conditionsText, _) = conditions
if (conditionsText != null) {
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.border(border = BorderStroke(1.dp, CurrentColors.value.colors.secondary.copy(alpha = 0.6f)), shape = RoundedCornerShape(12.dp))
.verticalScroll(scrollState)
.padding(8.dp)
) {
Text(
text = conditionsText.trimIndent(),
modifier = Modifier.padding(8.dp)
)
}
} else {
val conditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/${usageConditions.conditionsCommit}/PRIVACY.md"
ConditionsLinkView(conditionsLink)
}
} else if (failedToLoad.value) {
ConditionsLinkView(defaultConditionsLink)
} else {
DefaultProgressView(null)
}
}
@Composable
private fun ConditionsLinkView(conditionsLink: String) {
SectionItemView {
val uriHandler = LocalUriHandler.current
Text(stringResource(MR.strings.operator_conditions_failed_to_load), color = MaterialTheme.colors.onBackground)
Text(conditionsLink, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(conditionsLink) })
}
}
@Composable
private fun ConditionsAppliedToOtherOperatorsText(userServers: List<UserOperatorServers>, operatorIndex: Int) {
val otherOperatorsToApply = remember {
derivedStateOf {
chatModel.conditions.value.serverOperators.filter {
it.enabled &&
!it.conditionsAcceptance.conditionsAccepted &&
it.operatorId != userServers[operatorIndex].operator_.operatorId
}
}
}
if (otherOperatorsToApply.value.isNotEmpty()) {
ReadableText(MR.strings.operator_conditions_will_be_applied)
}
}
@Composable
fun ConditionsLinkButton() {
val showMenu = remember { mutableStateOf(false) }
val uriHandler = LocalUriHandler.current
val oneHandUI = remember { appPrefs.oneHandUI.state }
Column {
DefaultDropdownMenu(showMenu, offset = if (oneHandUI.value) DpOffset(0.dp, -AppBarHeight * fontSizeSqrtMultiplier * 3) else DpOffset.Zero) {
val commit = chatModel.conditions.value.currentConditions.conditionsCommit
ItemAction(stringResource(MR.strings.operator_open_conditions), painterResource(MR.images.ic_draft), onClick = {
val mdUrl = "https://github.com/simplex-chat/simplex-chat/blob/$commit/PRIVACY.md"
uriHandler.openUriCatching(mdUrl)
showMenu.value = false
})
ItemAction(stringResource(MR.strings.operator_open_changes), painterResource(MR.images.ic_more_horiz), onClick = {
val commitUrl = "https://github.com/simplex-chat/simplex-chat/commit/$commit"
uriHandler.openUriCatching(commitUrl)
showMenu.value = false
})
}
IconButton({ showMenu.value = true }) {
Icon(painterResource(MR.images.ic_outbound), null, tint = MaterialTheme.colors.primary)
}
}
}
private fun changeOperatorEnabled(userServers: MutableState<List<UserOperatorServers>>, operatorIndex: Int, enabled: Boolean) {
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(enabled = enabled)
)
}
}
@@ -1,16 +1,14 @@
package chat.simplex.common.views.usersettings
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionItemViewSpaceBetween
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
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 dev.icerock.moko.resources.compose.painterResource
@@ -26,62 +24,103 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.views.usersettings.PreferenceToggle
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun ProtocolServerView(m: ChatModel, server: ServerCfg, serverProtocol: ServerProtocol, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
var testing by remember { mutableStateOf(false) }
ProtocolServerLayout(
testing,
server,
serverProtocol,
testServer = {
testing = true
withLongRunningApi {
val res = testServerConnection(server, m)
if (isActive) {
onUpdate(res.first)
testing = false
fun ProtocolServerView(
m: ChatModel,
server: UserServer,
serverProtocol: ServerProtocol,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
onDelete: () -> Unit,
onUpdate: (UserServer) -> Unit,
close: () -> Unit,
rhId: Long?
) {
val testing = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val draftServer = remember { mutableStateOf(server) }
ModalView(
close = {
scope.launch {
val draftResult = serverProtocolAndOperator(draftServer.value, userServers.value)
val savedResult = serverProtocolAndOperator(server, userServers.value)
if (draftResult != null && savedResult != null) {
val (serverToEditProtocol, serverToEditOperator) = draftResult
val (svProtocol, serverOperator) = savedResult
if (serverToEditProtocol != svProtocol) {
close()
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_updating_server_title),
text = generalGetString(MR.strings.error_server_protocol_changed)
)
} else if (serverToEditOperator != serverOperator) {
close()
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_updating_server_title),
text = generalGetString(MR.strings.error_server_operator_changed)
)
} else {
onUpdate(draftServer.value)
close()
}
} else {
close()
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_invalid_address),
text = generalGetString(MR.strings.smp_servers_check_address)
)
}
}
},
onUpdate,
onDelete
)
if (testing) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
}
) {
Box {
ProtocolServerLayout(
draftServer,
serverProtocol,
testing.value,
testServer = {
testing.value = true
withLongRunningApi {
val res = testServerConnection(draftServer.value, m)
if (isActive) {
draftServer.value = res.first
testing.value = false
}
}
},
onDelete
)
if (testing.value) {
DefaultProgressView(null)
}
}
}
}
@Composable
private fun ProtocolServerLayout(
testing: Boolean,
server: ServerCfg,
server: MutableState<UserServer>,
serverProtocol: ServerProtocol,
testing: Boolean,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(if (server.preset) MR.strings.smp_servers_preset_server else MR.strings.smp_servers_your_server))
AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.XFTP) MR.strings.xftp_server else MR.strings.smp_server))
if (server.preset) {
PresetServer(testing, server, testServer, onUpdate, onDelete)
if (server.value.preset) {
PresetServer(server, testing, testServer)
} else {
CustomServer(testing, server, serverProtocol, testServer, onUpdate, onDelete)
CustomServer(server, testing, testServer, onDelete)
}
SectionBottomSpacer()
}
@@ -89,16 +128,14 @@ private fun ProtocolServerLayout(
@Composable
private fun PresetServer(
server: MutableState<UserServer>,
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
testServer: () -> Unit
) {
SectionView(stringResource(MR.strings.smp_servers_preset_address).uppercase()) {
SelectionContainer {
Text(
server.server,
server.value.server,
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
@@ -108,23 +145,21 @@ private fun PresetServer(
}
}
SectionDividerSpaced()
UseServerSection(true, testing, server, testServer, onUpdate, onDelete)
UseServerSection(server, true, testing, testServer)
}
@Composable
private fun CustomServer(
fun CustomServer(
server: MutableState<UserServer>,
testing: Boolean,
server: ServerCfg,
serverProtocol: ServerProtocol,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
onDelete: (() -> Unit)?,
) {
val serverAddress = remember { mutableStateOf(server.server) }
val serverAddress = remember { mutableStateOf(server.value.server) }
val valid = remember {
derivedStateOf {
with(parseServerAddress(serverAddress.value)) {
this?.valid == true && this.serverProtocol == serverProtocol
this?.valid == true
}
}
}
@@ -142,13 +177,14 @@ private fun CustomServer(
snapshotFlow { serverAddress.value }
.distinctUntilChanged()
.collect {
testedPreviously[server.server] = server.tested
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
testedPreviously[server.value.server] = server.value.tested
server.value = server.value.copy(server = it, tested = testedPreviously[serverAddress.value])
}
}
}
SectionDividerSpaced(maxTopPadding = true)
UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete)
UseServerSection(server, valid.value, testing, testServer, onDelete)
if (valid.value) {
SectionDividerSpaced()
@@ -160,43 +196,44 @@ private fun CustomServer(
@Composable
private fun UseServerSection(
server: MutableState<UserServer>,
valid: Boolean,
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
onDelete: (() -> Unit)? = null,
) {
SectionView(stringResource(MR.strings.smp_servers_use_server).uppercase()) {
SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) {
Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
ShowTestStatus(server)
ShowTestStatus(server.value)
}
val enabled = rememberUpdatedState(server.enabled)
val enabled = rememberUpdatedState(server.value.enabled)
PreferenceToggle(
stringResource(MR.strings.smp_servers_use_server_for_new_conn),
disabled = server.tested != true && !server.preset,
disabled = testing,
checked = enabled.value
) {
onUpdate(server.copy(enabled = it))
server.value = server.value.copy(enabled = it)
}
SectionItemView(onDelete, disabled = testing) {
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error)
if (onDelete != null) {
SectionItemView(onDelete, disabled = testing) {
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error)
}
}
}
}
@Composable
fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) =
fun ShowTestStatus(server: UserServer, modifier: Modifier = Modifier) =
when (server.tested) {
true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen)
false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error)
else -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = Color.Transparent)
}
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, ProtocolTestFailure?> =
suspend fun testServerConnection(server: UserServer, m: ChatModel): Pair<UserServer, ProtocolTestFailure?> =
try {
val r = m.controller.testProtoServer(server.remoteHostId, server.server)
server.copy(tested = r == null) to r
@@ -0,0 +1,407 @@
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
fun ModalData.YourServersView(
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
rhId: Long?
) {
val testing = remember { mutableStateOf(false) }
val currentUser = remember { chatModel.currentUser }.value
val scope = rememberCoroutineScope()
LaunchedEffect(userServers) {
snapshotFlow { userServers.value }
.collect { updatedServers ->
validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors)
}
}
Box {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.your_servers))
YourServersViewLayout(
scope,
userServers,
serverErrors,
operatorIndex,
navigateToProtocolView = { serverIndex, server, protocol ->
navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol)
},
currentUser,
rhId,
testing
)
}
if (testing.value) {
DefaultProgressView(null)
}
}
}
@Composable
fun YourServersViewLayout(
scope: CoroutineScope,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit,
currentUser: User?,
rhId: Long?,
testing: MutableState<Boolean>
) {
val duplicateHosts = findDuplicateHosts(serverErrors.value)
Column {
if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) {
SectionView(generalGetString(MR.strings.message_servers).uppercase()) {
userServers.value[operatorIndex].smpServers.forEachIndexed { i, server ->
if (server.deleted) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.SMP,
duplicateHosts = duplicateHosts
)
}
}
}
val smpErr = globalSMPServersError(serverErrors.value)
if (smpErr != null) {
SectionCustomFooter {
ServersErrorFooter(smpErr)
}
} else {
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.smp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
}
}
if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) {
userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server ->
if (server.deleted) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.XFTP,
duplicateHosts = duplicateHosts
)
}
}
}
val xftpErr = globalXFTPServersError(serverErrors.value)
if (xftpErr != null) {
SectionCustomFooter {
ServersErrorFooter(xftpErr)
}
} else {
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.xftp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
}
}
if (
userServers.value[operatorIndex].smpServers.any { !it.deleted } ||
userServers.value[operatorIndex].xftpServers.any { !it.deleted }
) {
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
}
SectionView {
SettingsActionItem(
painterResource(MR.images.ic_add),
stringResource(MR.strings.smp_servers_add),
click = { showAddServerDialog(scope, userServers, serverErrors, rhId) },
disabled = testing.value,
textColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
val serversErr = globalServersError(serverErrors.value)
if (serversErr != null) {
SectionCustomFooter {
ServersErrorFooter(serversErr)
}
}
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
SectionView {
TestServersButton(
testing = testing,
smpServers = userServers.value[operatorIndex].smpServers,
xftpServers = userServers.value[operatorIndex].xftpServers,
) { p, l ->
when (p) {
ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
xftpServers = l
)
}
ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
smpServers = l
)
}
}
}
HowToButton()
}
SectionBottomSpacer()
}
}
@Composable
fun TestServersButton(
smpServers: List<UserServer>,
xftpServers: List<UserServer>,
testing: MutableState<Boolean>,
onUpdate: (ServerProtocol, List<UserServer>) -> Unit
) {
val scope = rememberCoroutineScope()
val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled }) || testing.value }
SectionItemView(
{
scope.launch {
testServers(testing, smpServers, xftpServers, chatModel, onUpdate)
}
},
disabled = disabled.value
) {
Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!disabled.value) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
}
fun showAddServerDialog(
scope: CoroutineScope,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
rhId: Long?
) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.smp_servers_add),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
ModalManager.start.showCustomModal { close ->
NewServerView(userServers, serverErrors, rhId, close)
}
}) {
Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
if (appPlatform.isAndroid) {
SectionItemView({
AlertManager.shared.hideAlert()
ModalManager.start.showModalCloseable { close ->
ScanProtocolServer(rhId) { server ->
addServer(
scope,
server,
userServers,
serverErrors,
rhId,
close = close
)
}
}
}
) {
Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
}
)
}
@Composable
fun ProtocolServerViewLink(serverProtocol: ServerProtocol, srv: UserServer, duplicateHosts: Set<String>) {
val address = parseServerAddress(srv.server)
when {
address == null || !address.valid || address.serverProtocol != serverProtocol || address.hostnames.any { it in duplicateHosts } -> InvalidServer()
!srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary)
else -> ShowTestStatus(srv)
}
Spacer(Modifier.padding(horizontal = 4.dp))
val text = address?.hostnames?.firstOrNull() ?: srv.server
if (srv.enabled) {
Text(text, color = MaterialTheme.colors.onBackground, maxLines = 1)
} else {
Text(text, maxLines = 1, color = MaterialTheme.colors.secondary)
}
}
@Composable
private fun HowToButton() {
val uriHandler = LocalUriHandler.current
SettingsActionItem(
painterResource(MR.images.ic_open_in_new),
stringResource(MR.strings.how_to_use_your_servers),
{ uriHandler.openUriCatching("https://simplex.chat/docs/server.html") },
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary
)
}
@Composable
fun InvalidServer() {
Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error)
}
private suspend fun testServers(
testing: MutableState<Boolean>,
smpServers: List<UserServer>,
xftpServers: List<UserServer>,
m: ChatModel,
onUpdate: (ServerProtocol, List<UserServer>) -> Unit
) {
val smpResetStatus = resetTestStatus(smpServers)
onUpdate(ServerProtocol.SMP, smpResetStatus)
val xftpResetStatus = resetTestStatus(xftpServers)
onUpdate(ServerProtocol.XFTP, xftpResetStatus)
testing.value = true
val smpFailures = runServersTest(smpResetStatus, m) { onUpdate(ServerProtocol.SMP, it) }
val xftpFailures = runServersTest(xftpResetStatus, m) { onUpdate(ServerProtocol.XFTP, it) }
testing.value = false
val fs = smpFailures + xftpFailures
if (fs.isNotEmpty()) {
val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n")
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_test_failed),
text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg
)
}
}
private fun resetTestStatus(servers: List<UserServer>): List<UserServer> {
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<UserServer>, m: ChatModel, onUpdated: (List<UserServer>) -> Unit): Map<String, ProtocolTestFailure> {
val fs: MutableMap<String, ProtocolTestFailure> = mutableMapOf()
val updatedServers = ArrayList<UserServer>(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
interruptIfCancelled()
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.server)] = f
}
}
}
return fs
}
fun deleteXFTPServer(
userServers: MutableState<List<UserOperatorServers>>,
operatorServersIndex: Int,
serverIndex: Int
) {
val serverIsSaved = userServers.value[operatorServersIndex].xftpServers[serverIndex].serverId != null
if (serverIsSaved) {
userServers.value = userServers.value.toMutableList().apply {
this[operatorServersIndex] = this[operatorServersIndex].copy(
xftpServers = this[operatorServersIndex].xftpServers.toMutableList().apply {
this[serverIndex] = this[serverIndex].copy(deleted = true)
}
)
}
} else {
userServers.value = userServers.value.toMutableList().apply {
this[operatorServersIndex] = this[operatorServersIndex].copy(
xftpServers = this[operatorServersIndex].xftpServers.toMutableList().apply {
this.removeAt(serverIndex)
}
)
}
}
}
fun deleteSMPServer(
userServers: MutableState<List<UserOperatorServers>>,
operatorServersIndex: Int,
serverIndex: Int
) {
val serverIsSaved = userServers.value[operatorServersIndex].smpServers[serverIndex].serverId != null
if (serverIsSaved) {
userServers.value = userServers.value.toMutableList().apply {
this[operatorServersIndex] = this[operatorServersIndex].copy(
smpServers = this[operatorServersIndex].smpServers.toMutableList().apply {
this[serverIndex] = this[serverIndex].copy(deleted = true)
}
)
}
} else {
userServers.value = userServers.value.toMutableList().apply {
this[operatorServersIndex] = this[operatorServersIndex].copy(
smpServers = this[operatorServersIndex].smpServers.toMutableList().apply {
this.removeAt(serverIndex)
}
)
}
}
}
@@ -1,29 +1,25 @@
package chat.simplex.common.views.usersettings
package chat.simplex.common.views.usersettings.networkAndServers
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.model.ServerCfg
import chat.simplex.common.model.UserServer
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.res.MR
@Composable
expect fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit)
expect fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit)
@Composable
fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) {
fun ScanProtocolServerLayout(rhId: Long?, onNext: (UserServer) -> Unit) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr))
QRCodeScanner { text ->
val res = parseServerAddress(text)
if (res != null) {
onNext(ServerCfg(remoteHostId = rhId, text, false, null, false))
onNext(UserServer(remoteHostId = rhId, null, text, false, null, false, false))
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_invalid_address),
@@ -109,6 +109,16 @@
<string name="failed_to_create_user_invalid_title">Invalid display name!</string>
<string name="failed_to_create_user_invalid_desc">This display name is invalid. Please choose another name.</string>
<string name="failed_to_active_user_title">Error switching profile!</string>
<string name="failed_to_save_servers">Error saving servers</string>
<string name="no_message_servers_configured">No message servers.</string>
<string name="no_message_servers_configured_for_receiving">No servers to receive messages.</string>
<string name="no_message_servers_configured_for_private_routing">No servers for private message routing.</string>
<string name="no_media_servers_configured">No media &amp; file servers.</string>
<string name="no_media_servers_configured_for_sending">No servers to send files.</string>
<string name="no_media_servers_configured_for_private_routing">No servers to receive files.</string>
<string name="for_chat_profile">For chat profile %s:</string>
<string name="errors_in_servers_configuration">Errors in servers configuration.</string>
<string name="error_accepting_operator_conditions">Error accepting conditions</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Connection timeout</string>
@@ -750,6 +760,7 @@
<string name="smp_servers_test_some_failed">Some servers failed the test:</string>
<string name="smp_servers_scan_qr">Scan server QR code</string>
<string name="smp_servers_enter_manually">Enter server manually</string>
<string name="smp_servers_new_server">New server</string>
<string name="smp_servers_preset_server">Preset server</string>
<string name="smp_servers_your_server">Your server</string>
<string name="smp_servers_your_server_address">Your server address</string>
@@ -1038,6 +1049,19 @@
<string name="you_can_change_it_later">Random passphrase is stored in settings as plaintext.\nYou can change it later.</string>
<string name="use_random_passphrase">Use random passphrase</string>
<!-- ChooseServerOperators.kt -->
<string name="onboarding_choose_server_operators">Choose operators</string>
<string name="onboarding_network_operators">Network operators</string>
<string name="onboarding_network_operators_app_will_use_different_operators">When more than one network operator is enabled, the app will use the servers of different operators for each conversation.</string>
<string name="onboarding_network_operators_app_will_use_for_routing">For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing.</string>
<string name="onboarding_select_network_operators_to_use">Select network operators to use.</string>
<string name="onboarding_network_operators_configure_via_settings">You can configure servers via settings.</string>
<string name="onboarding_network_operators_conditions_will_be_accepted">Conditions will be accepted for enabled operators after 30 days.</string>
<string name="onboarding_network_operators_conditions_you_can_configure">You can configure operators in Network &amp; servers settings.</string>
<string name="onboarding_network_operators_review_later">Review later</string>
<string name="onboarding_network_operators_update">Update</string>
<string name="onboarding_network_operators_continue">Continue</string>
<!-- Call -->
<string name="incoming_video_call">Incoming video call</string>
<string name="incoming_audio_call">Incoming audio call</string>
@@ -1667,6 +1691,59 @@
<string name="save_group_profile">Save group profile</string>
<string name="error_saving_group_profile">Error saving group profile</string>
<!-- NetworkAndServers.kt -->
<string name="network_preset_servers_title">Preset servers</string>
<string name="operator_review_conditions">Review conditions</string>
<string name="operator_conditions_accepted">Accepted conditions</string>
<string name="operator_conditions_accepted_for_enabled_operators_on">Conditions will be automatically accepted for enabled operators on: %s.</string>
<string name="your_servers">Your servers</string>
<string name="operators_conditions_accepted_for"><![CDATA[Conditions are accepted for the operator(s): <b>%s</b>.]]></string>
<string name="operators_conditions_will_be_accepted_for"><![CDATA[Conditions will be accepted for the operator(s): <b>%s</b>.]]></string>
<!-- OperatorView.kt -->
<string name="operator">Operator</string>
<string name="operator_servers_title">%s servers</string>
<string name="operator_info_title">Network operator</string>
<string name="operator_website">Website</string>
<string name="operator_conditions_accepted_on">Conditions accepted on: %s.</string>
<string name="operator_conditions_will_be_accepted_on">Conditions will be accepted on: %s.</string>
<string name="operator">Operator</string>
<string name="operator_use_operator_toggle_description">Use servers</string>
<string name="use_servers_of_operator_x">Use %s</string>
<string name="operator_conditions_failed_to_load">Current conditions text couldn\'t be loaded, you can review conditions via this link:</string>
<string name="operator_conditions_accepted_for_some"><![CDATA[Conditions are already accepted for following operator(s): <b>%s</b>.]]></string>
<string name="operator_same_conditions_will_be_applied"><![CDATA[Same conditions will apply to operator <b>%s</b>.]]></string>
<string name="operator_same_conditions_will_apply_to_operators"><![CDATA[Same conditions will apply to operator(s): <b>%s</b>.]]></string>
<string name="operator_conditions_accepted_for_some"><![CDATA[Conditions are already accepted for following operator(s): <b>%s</b>.]]></string>
<string name="operator_conditions_will_be_applied"><![CDATA[These conditions will also apply for: <b>%s</b>.]]></string>
<string name="operator_same_conditions_will_be_applied"><![CDATA[Same conditions will apply to operator: <b>%s</b>.]]></string>
<string name="operator_conditions_will_be_accepted_for_some"><![CDATA[Conditions will be accepted for operator(s): <b>%s</b>.]]></string>
<string name="operators_conditions_will_also_apply"><![CDATA[These conditions will also apply for: <b>%s</b>.]]></string>
<string name="view_conditions">View conditions</string>
<string name="accept_conditions">Accept conditions</string>
<string name="operator_conditions_of_use">Conditions of use</string>
<string name="operator_in_order_to_use_accept_conditions"><![CDATA[To use the servers of <b>%s</b>, accept conditions of use.]]></string>
<string name="operator_use_for_messages">Use for messages</string>
<string name="operator_use_for_messages_receiving">To receive</string>
<string name="operator_use_for_messages_private_routing">For private routing</string>
<string name="operator_added_message_servers">Added message servers</string>
<string name="operator_use_for_files">Use for files</string>
<string name="operator_use_for_sending">To send</string>
<string name="xftp_servers_per_user">The servers for new files of your current chat profile</string>
<string name="operator_added_xftp_servers">Added media &amp; file servers</string>
<string name="operator_open_conditions">Open conditions</string>
<string name="operator_open_changes">Open changes</string>
<!-- ProtocolServerView.kt -->
<string name="error_updating_server_title">Error updating server</string>
<string name="error_server_protocol_changed">Server protocol changed.</string>
<string name="error_server_operator_changed">Server operator changed.</string>
<!-- NewServerView.kt -->
<string name="operator_server_alert_title">Operator server</string>
<string name="server_added_to_operator__name">Server added to operator %s.</string>
<string name="error_adding_server">Error adding server</string>
<!-- AdvancedNetworkSettings.kt -->
<string name="network_option_tcp_connection">TCP connection</string>
<string name="network_options_reset_to_defaults">Reset to defaults</string>
@@ -2059,6 +2136,13 @@
<string name="v6_1_message_dates_descr">Better message dates.</string>
<string name="v6_1_forward_many_messages_descr">Forward up to 20 messages at once.</string>
<string name="v6_1_delete_many_messages_descr">Delete or moderate up to 200 messages.</string>
<string name="v6_2_network_decentralization">Network decentralization</string>
<string name="v6_2_network_decentralization_descr">The second preset operator in the app!</string>
<string name="v6_2_network_decentralization_enable_flux">Enable flux</string>
<string name="v6_2_network_decentralization_enable_flux_reason">for better metadata privacy</string>
<string name="v6_2_improved_chat_navigation">Improved chat navigation</string>
<string name="v6_2_improved_chat_navigation_descr">- Open chat on the first unread message.\n- Jump to quoted messages.</string>
<string name="view_updated_conditions">View updated conditions</string>
<!-- CustomTimePicker -->
<string name="custom_time_unit_seconds">seconds</string>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m346-306 235.5-235v125.5H639V-639H415.5v57.5H540L306-347l40 41ZM480.06-85q-80.97 0-153.13-31.26-72.15-31.27-125.79-85Q147.5-255 116.25-327.02 85-399.05 85-479.94q0-81.97 31.26-154.13 31.27-72.15 85-125.54Q255-813 327.02-844q72.03-31 152.92-31 81.97 0 154.13 31.13 72.17 31.13 125.55 84.5Q813-706 844-633.98q31 72.03 31 153.92 0 80.97-31.01 153.13-31.02 72.15-84.5 125.79Q706-147.5 633.98-116.25 561.95-85 480.06-85Zm-.09-57.5q140.53 0 239.03-98.97 98.5-98.96 98.5-238.5 0-140.53-98.47-239.03-98.46-98.5-239-98.5-139.53 0-238.53 98.47-99 98.46-99 239 0 139.53 98.97 238.53 98.96 99 238.5 99ZM480-480Z"/></svg>

After

Width:  |  Height:  |  Size: 724 B

@@ -1,9 +0,0 @@
package chat.simplex.common.views.usersettings
import androidx.compose.runtime.Composable
import chat.simplex.common.model.ServerCfg
@Composable
actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) {
ScanProtocolServerLayout(rhId, onNext)
}
@@ -0,0 +1,9 @@
package chat.simplex.common.views.usersettings.networkAndServers
import androidx.compose.runtime.Composable
import chat.simplex.common.model.UserServer
@Composable
actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) {
ScanProtocolServerLayout(rhId, onNext)
}