diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt index 0f354de1b3..67e93b81b1 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt @@ -31,6 +31,8 @@ import co.electriccoin.zcash.ui.common.provider.RestoreTimestampStorageProvider import co.electriccoin.zcash.ui.common.provider.RestoreTimestampStorageProviderImpl import co.electriccoin.zcash.ui.common.provider.SelectedAccountUUIDProvider import co.electriccoin.zcash.ui.common.provider.SelectedAccountUUIDProviderImpl +import co.electriccoin.zcash.ui.common.provider.ServerSelectionProvider +import co.electriccoin.zcash.ui.common.provider.ServerSelectionProviderImpl import co.electriccoin.zcash.ui.common.provider.ShieldFundsInfoProvider import co.electriccoin.zcash.ui.common.provider.ShieldFundsInfoProviderImpl import co.electriccoin.zcash.ui.common.provider.SimpleSwapAssetProvider @@ -65,6 +67,7 @@ val providerModule = singleOf(::GetZcashCurrencyProvider) singleOf(::SelectedAccountUUIDProviderImpl) bind SelectedAccountUUIDProvider::class singleOf(::PersistableWalletProviderImpl) bind PersistableWalletProvider::class + singleOf(::ServerSelectionProviderImpl) bind ServerSelectionProvider::class singleOf(::SynchronizerProviderImpl) bind SynchronizerProvider::class singleOf(::ApplicationStateProviderImpl) bind ApplicationStateProvider::class singleOf(::RestoreTimestampStorageProviderImpl) bind RestoreTimestampStorageProvider::class diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt index 270336350e..bd62b06ac9 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt @@ -43,6 +43,7 @@ import co.electriccoin.zcash.ui.common.usecase.GetResyncDataFromHeightUseCase import co.electriccoin.zcash.ui.common.usecase.GetSelectedEndpointUseCase import co.electriccoin.zcash.ui.common.usecase.GetSelectedSwapAssetUseCase import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase +import co.electriccoin.zcash.ui.common.usecase.GetServerSelectionUseCase import co.electriccoin.zcash.ui.common.usecase.GetSlippageUseCase import co.electriccoin.zcash.ui.common.usecase.GetSupportUseCase import co.electriccoin.zcash.ui.common.usecase.GetSwapAssetsUseCase @@ -92,7 +93,7 @@ import co.electriccoin.zcash.ui.common.usecase.OptInExchangeRateUseCase import co.electriccoin.zcash.ui.common.usecase.ParseKeystonePCZTUseCase import co.electriccoin.zcash.ui.common.usecase.ParseKeystoneSignInRequestUseCase import co.electriccoin.zcash.ui.common.usecase.ParseKeystoneUrToZashiAccountsUseCase -import co.electriccoin.zcash.ui.common.usecase.PersistEndpointUseCase +import co.electriccoin.zcash.ui.common.usecase.PersistServerSelectionUseCase import co.electriccoin.zcash.ui.common.usecase.PrefillSendUseCase import co.electriccoin.zcash.ui.common.usecase.PreselectSwapAssetUseCase import co.electriccoin.zcash.ui.common.usecase.ProcessSwapTransactionUseCase @@ -147,8 +148,9 @@ val useCaseModule = module { factoryOf(::ObserveFastestServersUseCase) factoryOf(::GetSelectedEndpointUseCase) + factoryOf(::GetServerSelectionUseCase) factoryOf(::RefreshFastestServersUseCase) - factoryOf(::PersistEndpointUseCase) + factoryOf(::PersistServerSelectionUseCase) factoryOf(::ValidateEndpointUseCase) factoryOf(::GetConfigurationUseCase) factoryOf(::RescanBlockchainUseCase) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/ServerSelection.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/ServerSelection.kt new file mode 100644 index 0000000000..a3f8db1325 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/ServerSelection.kt @@ -0,0 +1,71 @@ +package co.electriccoin.zcash.ui.common.model + +import co.electriccoin.lightwallet.client.model.LightWalletEndpoint +import org.json.JSONObject + +data class ServerSelection( + val mode: ConnectionMode, + val endpoint: LightWalletEndpoint? = null +) { + init { + require(mode == ConnectionMode.AUTOMATIC || endpoint != null) { + "Manual server selection requires an endpoint" + } + } + + fun toJson() = + JSONObject().apply { + put(KEY_MODE, mode.persistedValue) + endpoint?.let { + put(KEY_ENDPOINT_HOST, it.host) + put(KEY_ENDPOINT_PORT, it.port) + put(KEY_ENDPOINT_IS_SECURE, it.isSecure) + } + } + + companion object { + private const val KEY_MODE = "mode" + private const val KEY_ENDPOINT_HOST = "endpoint_host" + private const val KEY_ENDPOINT_PORT = "endpoint_port" + private const val KEY_ENDPOINT_IS_SECURE = "endpoint_is_secure" + + fun automatic() = ServerSelection(ConnectionMode.AUTOMATIC) + + fun manual(endpoint: LightWalletEndpoint) = + ServerSelection( + mode = ConnectionMode.MANUAL, + endpoint = endpoint + ) + + fun from(jsonObject: JSONObject): ServerSelection { + val mode = + ConnectionMode.fromPersistedValue( + jsonObject.getString(KEY_MODE) + ) + + return when (mode) { + ConnectionMode.AUTOMATIC -> automatic() + ConnectionMode.MANUAL -> manual(jsonObject.getEndpoint()) + } + } + + private fun JSONObject.getEndpoint() = + LightWalletEndpoint( + host = getString(KEY_ENDPOINT_HOST), + port = getInt(KEY_ENDPOINT_PORT), + isSecure = getBoolean(KEY_ENDPOINT_IS_SECURE) + ) + } +} + +enum class ConnectionMode( + val persistedValue: String +) { + AUTOMATIC("automatic"), + MANUAL("manual"); + + companion object { + fun fromPersistedValue(value: String) = + entries.firstOrNull { it.persistedValue == value } ?: AUTOMATIC + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/ServerSelectionProvider.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/ServerSelectionProvider.kt new file mode 100644 index 0000000000..14f5a1d6cd --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/ServerSelectionProvider.kt @@ -0,0 +1,52 @@ +package co.electriccoin.zcash.ui.common.provider + +import co.electriccoin.zcash.preference.EncryptedPreferenceProvider +import co.electriccoin.zcash.preference.api.PreferenceProvider +import co.electriccoin.zcash.preference.model.entry.PreferenceDefault +import co.electriccoin.zcash.preference.model.entry.PreferenceKey +import co.electriccoin.zcash.ui.common.model.ServerSelection +import kotlinx.coroutines.flow.Flow +import org.json.JSONObject + +interface ServerSelectionProvider { + val serverSelection: Flow + + suspend fun store(serverSelection: ServerSelection) + + suspend fun getServerSelection(): ServerSelection? +} + +class ServerSelectionProviderImpl( + preferenceHolder: EncryptedPreferenceProvider +) : ServerSelectionProvider { + private val storageProvider = ServerSelectionStorageProviderImpl(preferenceHolder) + + override val serverSelection = storageProvider.observe() + + override suspend fun store(serverSelection: ServerSelection) { + storageProvider.store(serverSelection) + } + + override suspend fun getServerSelection() = storageProvider.get() +} + +private interface ServerSelectionStorageProvider : NullableStorageProvider + +private class ServerSelectionStorageProviderImpl( + override val preferenceHolder: EncryptedPreferenceProvider, +) : BaseNullableStorageProvider(), + ServerSelectionStorageProvider { + override val default = ServerSelectionPreferenceDefault(PreferenceKey("server_selection")) +} + +private class ServerSelectionPreferenceDefault( + override val key: PreferenceKey +) : PreferenceDefault { + override suspend fun getValue(preferenceProvider: PreferenceProvider) = + preferenceProvider.getString(key)?.let { ServerSelection.from(JSONObject(it)) } + + override suspend fun putValue( + preferenceProvider: PreferenceProvider, + newValue: ServerSelection? + ) = preferenceProvider.putString(key, newValue?.toJson()?.toString()) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt index 79c902fa73..59e5f1e5ff 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt @@ -12,17 +12,22 @@ import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT import cash.z.ecc.sdk.type.fromResources import co.electriccoin.lightwallet.client.model.LightWalletEndpoint import co.electriccoin.zcash.preference.StandardPreferenceProvider +import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.ui.common.datasource.RestoreTimestampDataSource +import co.electriccoin.zcash.ui.common.model.ConnectionMode import co.electriccoin.zcash.ui.common.model.FastestServersState import co.electriccoin.zcash.ui.common.model.OnboardingState +import co.electriccoin.zcash.ui.common.model.ServerSelection import co.electriccoin.zcash.ui.common.model.WalletRestoringState import co.electriccoin.zcash.ui.common.provider.LightWalletEndpointProvider import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider +import co.electriccoin.zcash.ui.common.provider.ServerSelectionProvider import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider import co.electriccoin.zcash.ui.common.provider.WalletBackupFlagStorageProvider import co.electriccoin.zcash.ui.common.provider.WalletRestoringStateProvider import co.electriccoin.zcash.ui.common.viewmodel.SecretState import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,13 +37,17 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -72,6 +81,7 @@ class WalletRepositoryImpl( private val application: Application, private val lightWalletEndpointProvider: LightWalletEndpointProvider, private val persistableWalletProvider: PersistableWalletProvider, + private val serverSelectionProvider: ServerSelectionProvider, private val synchronizerProvider: SynchronizerProvider, private val standardPreferenceProvider: StandardPreferenceProvider, private val restoreTimestampDataSource: RestoreTimestampDataSource, @@ -149,6 +159,13 @@ class WalletRepositoryImpl( initialValue = FastestServersState(servers = emptyList(), isLoading = true) ) + init { + scope.launch { + migrateServerSelectionIfNeeded() + keepSelectedEndpointUpdated() + } + } + override val walletRestoringState: StateFlow = walletRestoringStateProvider .observe() @@ -160,13 +177,17 @@ class WalletRepositoryImpl( override fun updateWalletEndpoint(endpoint: LightWalletEndpoint) { scope.launch { - val selectedWallet = persistableWalletProvider.getPersistableWallet() ?: return@launch - val selectedEndpoint = selectedWallet.endpoint - if (selectedEndpoint == endpoint) return@launch - persistWalletInternal(selectedWallet.copy(endpoint = endpoint)) + updateWalletEndpointInternal(endpoint) } } + private suspend fun updateWalletEndpointInternal(endpoint: LightWalletEndpoint) { + val selectedWallet = persistableWalletProvider.getPersistableWallet() ?: return + val selectedEndpoint = selectedWallet.endpoint + if (selectedEndpoint == endpoint) return + persistWalletInternal(selectedWallet.copy(endpoint = endpoint)) + } + private suspend fun persistWalletInternal(persistableWallet: PersistableWallet) { synchronizerProvider.synchronizer.firstOrNull()?.let { (it as? SdkSynchronizer)?.close() } persistableWalletProvider.store(persistableWallet) @@ -175,6 +196,7 @@ class WalletRepositoryImpl( override fun createNewWallet() { scope.launch { persistOnboardingStateInternal(OnboardingState.READY) + serverSelectionProvider.store(ServerSelection.automatic()) val zcashNetwork = ZcashNetwork.fromResources(application) val newWallet = PersistableWallet.new( @@ -209,6 +231,7 @@ class WalletRepositoryImpl( birthday: BlockHeight ) { scope.launch { + serverSelectionProvider.store(ServerSelection.automatic()) val restoredWallet = PersistableWallet( network = network, @@ -224,4 +247,60 @@ class WalletRepositoryImpl( persistOnboardingStateInternal(OnboardingState.READY) } } + + private suspend fun migrateServerSelectionIfNeeded() { + if (serverSelectionProvider.getServerSelection() != null) return + + val existingWallet = persistableWalletProvider.getPersistableWallet() + val selection = + existingWallet + ?.endpoint + ?.let { endpoint -> + if (lightWalletEndpointProvider.getEndpoints().contains(endpoint)) { + ServerSelection.automatic() + } else { + ServerSelection.manual(endpoint) + } + } ?: ServerSelection.automatic() + + if (serverSelectionProvider.getServerSelection() == null) { + serverSelectionProvider.store(selection) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun keepSelectedEndpointUpdated() { + serverSelectionProvider + .serverSelection + .filterNotNull() + .distinctUntilChanged() + .flatMapLatest { selection -> + when (selection.mode) { + ConnectionMode.AUTOMATIC -> { + fastestEndpoints.map { fastestServers -> + if (fastestServers.isLoading) { + null + } else { + fastestServers.servers?.firstOrNull() + } + } + } + + ConnectionMode.MANUAL -> { + flowOf(selection.endpoint) + } + } + }.distinctUntilChanged() + .collect { endpoint -> + endpoint ?: return@collect + + try { + updateWalletEndpointInternal(endpoint) + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + Twig.error(t) { "Unable to update selected server endpoint" } + } + } + } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ConfirmResyncUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ConfirmResyncUseCase.kt index 699b237e00..eb15f5bba9 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ConfirmResyncUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ConfirmResyncUseCase.kt @@ -17,7 +17,7 @@ class ConfirmResyncUseCase( ) { suspend operator fun invoke(blockHeight: BlockHeight) { val synchronizer = synchronizerProvider.getSynchronizer() - synchronizer.rewindToHeight(blockHeight) + synchronizer.rewindToNearestHeight(blockHeight) walletRestoringStateProvider.store(WalletRestoringState.RESYNCING) synchronizerProvider.resetSynchronizer() isKeepScreenOnDuringRestoreProvider.clear() diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetSelectedEndpointUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetSelectedEndpointUseCase.kt index 08a7a4357f..f8d09454eb 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetSelectedEndpointUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetSelectedEndpointUseCase.kt @@ -11,4 +11,6 @@ class GetSelectedEndpointUseCase( persistableWalletProvider.persistableWallet .map { it?.endpoint } .distinctUntilChanged() + + suspend operator fun invoke() = persistableWalletProvider.getPersistableWallet()?.endpoint } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetServerSelectionUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetServerSelectionUseCase.kt new file mode 100644 index 0000000000..e7dcc350aa --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetServerSelectionUseCase.kt @@ -0,0 +1,17 @@ +package co.electriccoin.zcash.ui.common.usecase + +import co.electriccoin.zcash.ui.common.model.ServerSelection +import co.electriccoin.zcash.ui.common.provider.ServerSelectionProvider +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +class GetServerSelectionUseCase( + private val serverSelectionProvider: ServerSelectionProvider +) { + fun observe() = + serverSelectionProvider.serverSelection + .map { it ?: ServerSelection.automatic() } + .distinctUntilChanged() + + suspend operator fun invoke() = serverSelectionProvider.getServerSelection() ?: ServerSelection.automatic() +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/PersistEndpointException.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/PersistEndpointException.kt new file mode 100644 index 0000000000..79d32678b4 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/PersistEndpointException.kt @@ -0,0 +1,5 @@ +package co.electriccoin.zcash.ui.common.usecase + +class PersistEndpointException( + message: String? +) : Exception(message) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/PersistEndpointUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/PersistEndpointUseCase.kt deleted file mode 100644 index 4773339047..0000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/PersistEndpointUseCase.kt +++ /dev/null @@ -1,31 +0,0 @@ -package co.electriccoin.zcash.ui.common.usecase - -import android.app.Application -import cash.z.ecc.android.sdk.type.ServerValidation -import co.electriccoin.lightwallet.client.model.LightWalletEndpoint -import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider -import co.electriccoin.zcash.ui.common.repository.WalletRepository - -class PersistEndpointUseCase( - private val application: Application, - private val walletRepository: WalletRepository, - private val synchronizerProvider: SynchronizerProvider, -) { - @Throws(PersistEndpointException::class) - suspend operator fun invoke(endpoint: LightWalletEndpoint) { - when (val result = validateServerEndpoint(endpoint)) { - ServerValidation.Valid -> walletRepository.updateWalletEndpoint(endpoint) - is ServerValidation.InValid -> throw PersistEndpointException(result.reason.message) - ServerValidation.Running -> throw PersistEndpointException(null) - } - } - - private suspend fun validateServerEndpoint(endpoint: LightWalletEndpoint) = - synchronizerProvider - .getSynchronizer() - .validateServerEndpoint(application, endpoint) -} - -class PersistEndpointException( - message: String? -) : Exception(message) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/PersistServerSelectionUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/PersistServerSelectionUseCase.kt new file mode 100644 index 0000000000..74016da807 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/PersistServerSelectionUseCase.kt @@ -0,0 +1,72 @@ +package co.electriccoin.zcash.ui.common.usecase + +import android.app.Application +import cash.z.ecc.android.sdk.type.ServerValidation +import co.electriccoin.lightwallet.client.model.LightWalletEndpoint +import co.electriccoin.zcash.ui.common.model.ConnectionMode +import co.electriccoin.zcash.ui.common.model.ServerSelection +import co.electriccoin.zcash.ui.common.provider.LightWalletEndpointProvider +import co.electriccoin.zcash.ui.common.provider.ServerSelectionProvider +import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider +import co.electriccoin.zcash.ui.common.repository.WalletRepository + +class PersistServerSelectionUseCase( + private val application: Application, + private val walletRepository: WalletRepository, + private val synchronizerProvider: SynchronizerProvider, + private val lightWalletEndpointProvider: LightWalletEndpointProvider, + private val serverSelectionProvider: ServerSelectionProvider, + private val getSelectedEndpoint: GetSelectedEndpointUseCase, +) { + @Throws(PersistEndpointException::class) + suspend operator fun invoke(selection: ServerSelection) { + when (selection.mode) { + ConnectionMode.AUTOMATIC -> persistAutomatic() + ConnectionMode.MANUAL -> persistManual(checkNotNull(selection.endpoint)) + } + } + + private suspend fun persistAutomatic() { + val endpoint = getAutomaticEndpoint() + serverSelectionProvider.store(ServerSelection.automatic()) + walletRepository.updateWalletEndpoint(endpoint) + } + + @Throws(PersistEndpointException::class) + private suspend fun persistManual(endpoint: LightWalletEndpoint) { + when (val result = validateServerEndpoint(endpoint)) { + ServerValidation.Valid -> { + serverSelectionProvider.store(ServerSelection.manual(endpoint)) + walletRepository.updateWalletEndpoint(endpoint) + } + + is ServerValidation.InValid -> { + throw PersistEndpointException(result.reason.message) + } + + ServerValidation.Running -> { + throw PersistEndpointException(null) + } + } + } + + private suspend fun getAutomaticEndpoint(): LightWalletEndpoint { + val fastestEndpoint = + walletRepository.fastestEndpoints.value.let { fastestServers -> + if (fastestServers.isLoading) { + null + } else { + fastestServers.servers?.firstOrNull() + } + } + + return fastestEndpoint + ?: getSelectedEndpoint()?.takeIf { lightWalletEndpointProvider.getEndpoints().contains(it) } + ?: lightWalletEndpointProvider.getDefaultEndpoint() + } + + private suspend fun validateServerEndpoint(endpoint: LightWalletEndpoint) = + synchronizerProvider + .getSynchronizer() + .validateServerEndpoint(application, endpoint) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerState.kt index 80094333a6..3218bd4db5 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerState.kt @@ -8,6 +8,7 @@ import co.electriccoin.zcash.ui.design.util.Itemizable import co.electriccoin.zcash.ui.design.util.StringResource data class ChooseServerState( + val connectionMode: ServerConnectionModeState, val fastest: ServerListState.Fastest, val other: ServerListState.Other, val saveButton: ButtonState, @@ -15,6 +16,14 @@ data class ChooseServerState( val onBack: () -> Unit ) +data class ServerConnectionModeState( + val automatic: RadioButtonState, + val manual: RadioButtonState, + val automaticBadge: StringResource? = null +) { + val isManualSelected = manual.isChecked +} + sealed interface ServerListState { val title: StringResource val servers: List diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerVM.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerVM.kt index 56cdeff227..ee75e8d907 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerVM.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerVM.kt @@ -8,11 +8,14 @@ import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT import co.electriccoin.lightwallet.client.model.LightWalletEndpoint import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.common.model.ConnectionMode +import co.electriccoin.zcash.ui.common.model.ServerSelection import co.electriccoin.zcash.ui.common.provider.LightWalletEndpointProvider import co.electriccoin.zcash.ui.common.usecase.GetSelectedEndpointUseCase +import co.electriccoin.zcash.ui.common.usecase.GetServerSelectionUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveFastestServersUseCase import co.electriccoin.zcash.ui.common.usecase.PersistEndpointException -import co.electriccoin.zcash.ui.common.usecase.PersistEndpointUseCase +import co.electriccoin.zcash.ui.common.usecase.PersistServerSelectionUseCase import co.electriccoin.zcash.ui.common.usecase.RefreshFastestServersUseCase import co.electriccoin.zcash.ui.common.usecase.ValidateEndpointUseCase import co.electriccoin.zcash.ui.design.component.AlertDialogState @@ -25,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -33,10 +37,11 @@ import kotlinx.coroutines.launch class ChooseServerVM( application: Application, observeFastestServers: ObserveFastestServersUseCase, - getSelectedEndpoint: GetSelectedEndpointUseCase, + private val getSelectedEndpoint: GetSelectedEndpointUseCase, + private val getServerSelection: GetServerSelectionUseCase, private val lightWalletEndpointProvider: LightWalletEndpointProvider, private val refreshFastestServersUseCase: RefreshFastestServersUseCase, - private val persistEndpoint: PersistEndpointUseCase, + private val persistServerSelection: PersistServerSelectionUseCase, private val validateEndpoint: ValidateEndpointUseCase, private val navigationRouter: NavigationRouter, ) : AndroidViewModel(application) { @@ -44,6 +49,8 @@ class ChooseServerVM( private val userEndpointSelection = MutableStateFlow(null) + private val userModeSelection = MutableStateFlow(null) + private val isSaveInProgress = MutableStateFlow(false) private val dialogState = MutableStateFlow(null) @@ -52,18 +59,111 @@ class ChooseServerVM( private val availableServers by lazy(LazyThreadSafetyMode.NONE) { lightWalletEndpointProvider.getEndpoints() } + private val selectedMode = + combine( + getServerSelection.observe(), + userModeSelection + ) { persistedServerSelection, userModeSelection -> + userModeSelection ?: persistedServerSelection.mode + } + + private val endpointUiSelection = + combine( + userCustomEndpointText, + userEndpointSelection, + isCustomEndpointExpanded + ) { customEndpointText, endpointSelection, isCustomEndpointExpanded -> + EndpointUiSelection( + customEndpointText = customEndpointText, + endpointSelection = endpointSelection, + isCustomEndpointExpanded = isCustomEndpointExpanded + ) + } + + private val saveButtonInput = + combine( + userEndpointSelection, + userModeSelection, + isSaveInProgress, + userCustomEndpointText + ) { endpointSelection, modeSelection, isSaveInProgress, customEndpointText -> + SaveButtonInput( + endpointSelection = endpointSelection, + modeSelection = modeSelection, + isSaveInProgress = isSaveInProgress, + customEndpointText = customEndpointText + ) + } + + private val connectionMode = + combine( + selectedMode, + getSelectedEndpoint.observe(), + observeFastestServers() + ) { selectedMode, selectedEndpoint, fastestServers -> + val isAutomatic = selectedMode == ConnectionMode.AUTOMATIC + ServerConnectionModeState( + automatic = + RadioButtonState( + text = stringRes(R.string.choose_server_automatic), + subtitle = + if (isAutomatic && selectedEndpoint != null) { + stringRes( + R.string.choose_server_full_server_name, + selectedEndpoint.host, + selectedEndpoint.port + ) + } else { + null + }, + isChecked = isAutomatic, + onClick = ::onAutomaticModeClicked, + hapticFeedbackType = + if (isAutomatic) { + null + } else { + HapticFeedbackType.SegmentTick + } + ), + manual = + RadioButtonState( + text = stringRes(R.string.choose_server_manual), + isChecked = selectedMode == ConnectionMode.MANUAL, + onClick = ::onManualModeClicked, + hapticFeedbackType = + if (selectedMode == ConnectionMode.MANUAL) { + null + } else { + HapticFeedbackType.SegmentTick + } + ), + automaticBadge = + if (isAutomatic && fastestServers.isLoading) { + stringRes(R.string.choose_server_testing) + } else { + null + } + ) + } + private val fastest = combine( getSelectedEndpoint.observe(), observeFastestServers(), userEndpointSelection, - ) { selectedEndpoint, fastestServers, userEndpointSelection -> + selectedMode, + ) { selectedEndpoint, fastestServers, userEndpointSelection, selectedMode -> ServerListState.Fastest( title = stringRes(R.string.choose_server_fastest_servers), servers = fastestServers.servers ?.map { endpoint -> - createDefaultServerState(endpoint, userEndpointSelection, selectedEndpoint) + createDefaultServerState( + endpoint = endpoint, + userEndpointSelection = userEndpointSelection, + selectedEndpoint = selectedEndpoint, + selectedMode = selectedMode + ) }.orEmpty(), isLoading = fastestServers.isLoading, retryButton = @@ -78,21 +178,23 @@ class ChooseServerVM( combine( getSelectedEndpoint.observe(), observeFastestServers(), - userCustomEndpointText, - userEndpointSelection, - isCustomEndpointExpanded - ) { selectedEndpoint, fastest, userCustomEndpointText, userEndpointSelection, isCustomEndpointExpanded -> + endpointUiSelection, + selectedMode + ) { selectedEndpoint, fastest, endpointUiSelection, selectedMode -> if (selectedEndpoint == null) return@combine null val isSelectedEndpointCustom = !availableServers.contains(selectedEndpoint) val customEndpointState = createCustomServerState( - userEndpointSelection = userEndpointSelection, + userEndpointSelection = endpointUiSelection.endpointSelection, isSelectedEndpointCustom = isSelectedEndpointCustom, - userCustomEndpointText = userCustomEndpointText, + userCustomEndpointText = endpointUiSelection.customEndpointText, selectedEndpoint = selectedEndpoint, - isCustomEndpointExpanded = isCustomEndpointExpanded + isCustomEndpointExpanded = + endpointUiSelection.isCustomEndpointExpanded || + (selectedMode == ConnectionMode.MANUAL && isSelectedEndpointCustom), + selectedMode = selectedMode ) ServerListState.Other( @@ -102,7 +204,12 @@ class ChooseServerVM( .filter { !fastest.servers.orEmpty().contains(it) }.map { endpoint -> - createDefaultServerState(endpoint, userEndpointSelection, selectedEndpoint) + createDefaultServerState( + endpoint = endpoint, + userEndpointSelection = endpointUiSelection.endpointSelection, + selectedEndpoint = selectedEndpoint, + selectedMode = selectedMode + ) }.toMutableList() .apply { val index = 1.coerceIn(0, size.coerceAtLeast(0)) @@ -114,36 +221,40 @@ class ChooseServerVM( private val buttonState = combine( getSelectedEndpoint.observe(), - userEndpointSelection, - isSaveInProgress, - userCustomEndpointText, - ) { selectedEndpoint, userEndpointSelection, isSaveInProgress, userCustomEndpointText -> + getServerSelection.observe(), + saveButtonInput + ) { selectedEndpoint, persistedServerSelection, saveButtonInput -> + val selectedMode = saveButtonInput.modeSelection ?: persistedServerSelection.mode val userSelectedEndpoint = - when (userEndpointSelection) { + when (saveButtonInput.endpointSelection) { Selection.Custom -> { val isSelectedEndpointCustom = !availableServers.contains(selectedEndpoint) if (isSelectedEndpointCustom) selectedEndpoint else null } is Selection.Endpoint -> { - userEndpointSelection.endpoint + saveButtonInput.endpointSelection.endpoint } null -> { - null + if (selectedMode == ConnectionMode.MANUAL) { + selectedEndpoint + } else { + null + } } } val isCustomEndpointSelectedAndUpdated = - when (userEndpointSelection) { + when (saveButtonInput.endpointSelection) { Selection.Custom -> { val isSelectedEndpointCustom = !availableServers.contains(selectedEndpoint) when { - isSelectedEndpointCustom && userCustomEndpointText == null -> false + isSelectedEndpointCustom && saveButtonInput.customEndpointText == null -> false isSelectedEndpointCustom && selectedEndpoint?.generateUserString() != - userCustomEndpointText -> true + saveButtonInput.customEndpointText -> true else -> false } @@ -158,19 +269,34 @@ class ChooseServerVM( } } + val hasUnsavedSelection = + selectedMode != persistedServerSelection.mode || + ( + selectedMode == ConnectionMode.MANUAL && + saveButtonInput.endpointSelection != null && + selectedEndpoint != userSelectedEndpoint + ) || + isCustomEndpointSelectedAndUpdated + ButtonState( - text = stringRes(R.string.choose_server_save), + text = + if (saveButtonInput.isSaveInProgress) { + stringRes(R.string.choose_server_saving) + } else { + stringRes(R.string.choose_server_save) + }, isEnabled = - (userEndpointSelection != null && selectedEndpoint != userSelectedEndpoint) || - isCustomEndpointSelectedAndUpdated, - isLoading = isSaveInProgress, + !saveButtonInput.isSaveInProgress && + hasUnsavedSelection, + isLoading = saveButtonInput.isSaveInProgress, onClick = ::onSaveButtonClicked, hapticFeedbackType = HapticFeedbackType.Confirm ) } val state = - combine(fastest, other, buttonState, dialogState) { + combine(connectionMode, fastest, other, buttonState, dialogState) { + connectionMode, fastest, other, buttonState, @@ -181,6 +307,7 @@ class ChooseServerVM( } ChooseServerState( + connectionMode = connectionMode, fastest = fastest, other = other, saveButton = buttonState, @@ -202,10 +329,14 @@ class ChooseServerVM( userCustomEndpointText: String?, selectedEndpoint: LightWalletEndpoint, isCustomEndpointExpanded: Boolean, + selectedMode: ConnectionMode, ): ServerState.Custom { - var isChecked = - userEndpointSelection is Selection.Custom || - (userEndpointSelection == null && isSelectedEndpointCustom) + val isChecked = + selectedMode == ConnectionMode.MANUAL && + ( + userEndpointSelection is Selection.Custom || + (userEndpointSelection == null && isSelectedEndpointCustom) + ) return ServerState.Custom( radioButtonState = RadioButtonState( @@ -247,11 +378,15 @@ class ChooseServerVM( endpoint: LightWalletEndpoint, userEndpointSelection: Selection?, selectedEndpoint: LightWalletEndpoint?, + selectedMode: ConnectionMode, ): ServerState.Default { val defaultEndpoint = lightWalletEndpointProvider.getDefaultEndpoint() val isEndpointChecked = - (userEndpointSelection is Selection.Endpoint && userEndpointSelection.endpoint == endpoint) || - (userEndpointSelection == null && selectedEndpoint == endpoint) + selectedMode == ConnectionMode.MANUAL && + ( + (userEndpointSelection is Selection.Endpoint && userEndpointSelection.endpoint == endpoint) || + (userEndpointSelection == null && selectedEndpoint == endpoint) + ) return ServerState.Default( key = "default_${endpoint.host}_${endpoint.port}", @@ -280,13 +415,25 @@ class ChooseServerVM( this.userCustomEndpointText.update { new } } + private fun onAutomaticModeClicked() { + isCustomEndpointExpanded.update { false } + userEndpointSelection.update { null } + userModeSelection.update { ConnectionMode.AUTOMATIC } + } + + private fun onManualModeClicked() { + userModeSelection.update { ConnectionMode.MANUAL } + } + private fun onEndpointClicked(endpoint: LightWalletEndpoint) { isCustomEndpointExpanded.update { false } + userModeSelection.update { ConnectionMode.MANUAL } userEndpointSelection.update { Selection.Endpoint(endpoint) } } private fun onCustomEndpointClicked() { isCustomEndpointExpanded.update { true } + userModeSelection.update { ConnectionMode.MANUAL } userEndpointSelection.update { Selection.Custom } } @@ -295,10 +442,11 @@ class ChooseServerVM( try { if (isSaveInProgress.value) return@launch isSaveInProgress.update { true } - val selection = getUserEndpointSelectionOrShowError() ?: return@launch - persistEndpoint(selection) + val selection = getUserServerSelectionOrShowError() ?: return@launch + persistServerSelection(selection) isCustomEndpointExpanded.update { false } userEndpointSelection.update { null } + userModeSelection.update { null } } catch (e: PersistEndpointException) { showValidationErrorDialog(e.message) } finally { @@ -311,9 +459,29 @@ class ChooseServerVM( } /** - * @return an endpoint selected by user or null if user didn't select any new endpoint explicitly or if selected - * custom endpoint is invalid + * @return the server selection requested by the user, or null if the selected custom endpoint is invalid. */ + private suspend fun getUserServerSelectionOrShowError(): ServerSelection? = + when (userModeSelection.value ?: getServerSelection().mode) { + ConnectionMode.AUTOMATIC -> { + ServerSelection.automatic() + } + + ConnectionMode.MANUAL -> { + val endpoint = + when (userEndpointSelection.value) { + Selection.Custom -> getUserEndpointSelectionOrShowError() ?: return null + is Selection.Endpoint -> getUserEndpointSelectionOrShowError() + null -> getSelectedEndpoint() + } ?: run { + showValidationErrorDialog(null) + return null + } + + ServerSelection.manual(endpoint) + } + } + private fun getUserEndpointSelectionOrShowError(): LightWalletEndpoint? = when (val selection = userEndpointSelection.value) { is Selection.Custom -> { @@ -363,3 +531,16 @@ private sealed interface Selection { val endpoint: LightWalletEndpoint ) : Selection } + +private data class EndpointUiSelection( + val customEndpointText: String?, + val endpointSelection: Selection?, + val isCustomEndpointExpanded: Boolean, +) + +private data class SaveButtonInput( + val endpointSelection: Selection?, + val modeSelection: ConnectionMode?, + val isSaveInProgress: Boolean, + val customEndpointText: String?, +) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerView.kt index 1c7e5c7316..ef603122ec 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerView.kt @@ -11,11 +11,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed @@ -53,11 +56,11 @@ import co.electriccoin.zcash.ui.design.component.BlankBgScaffold import co.electriccoin.zcash.ui.design.component.ButtonState import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator import co.electriccoin.zcash.ui.design.component.LottieProgress -import co.electriccoin.zcash.ui.design.component.OldZashiBottomBar import co.electriccoin.zcash.ui.design.component.RadioButtonCheckedContent import co.electriccoin.zcash.ui.design.component.RadioButtonState import co.electriccoin.zcash.ui.design.component.TextFieldState import co.electriccoin.zcash.ui.design.component.ZashiBadge +import co.electriccoin.zcash.ui.design.component.ZashiBadgeDefaults import co.electriccoin.zcash.ui.design.component.ZashiButton import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider import co.electriccoin.zcash.ui.design.component.ZashiRadioButton @@ -103,18 +106,27 @@ fun ChooseServerView(state: ChooseServerState?) { bottom = ZcashTheme.dimens.spacingDefault, ) ) { - if (state.fastest.servers.isEmpty() && state.fastest.isLoading) { - item( - key = "fastest_loading", - contentType = "fastest_loading" - ) { - ServerLoading() - } - } else if (state.fastest.servers.isNotEmpty()) { - serverListItems(state.fastest) + item( + key = "connection_mode", + contentType = "connection_mode" + ) { + ConnectionModeSection(state.connectionMode) } - serverListItems(state.other) + if (state.connectionMode.isManualSelected) { + if (state.fastest.servers.isEmpty() && state.fastest.isLoading) { + item( + key = "fastest_loading", + contentType = "fastest_loading" + ) { + ServerLoading() + } + } else if (state.fastest.servers.isNotEmpty()) { + serverListItems(state.fastest) + } + + serverListItems(state.other) + } } if (state.dialogState != null) { @@ -123,6 +135,65 @@ fun ChooseServerView(state: ChooseServerState?) { } } +@Composable +private fun ConnectionModeSection(state: ServerConnectionModeState) { + Column { + Column( + modifier = Modifier.padding(horizontal = 24.dp) + ) { + ServerHeader(text = stringRes(R.string.choose_server_connection_mode)) + Spacer(modifier = Modifier.height(8.dp)) + } + + ZashiRadioButton( + state = state.automatic, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .then( + if (state.automatic.isChecked) { + Modifier.background(ZashiColors.Surfaces.bgSecondary, RoundedCornerShape(12.dp)) + } else { + Modifier + } + ), + checkedContent = { + if (state.automaticBadge != null) { + LottieProgress(size = 20.dp) + } else { + RadioButtonCheckedContent(state.automatic) + } + }, + trailingContent = { + if (state.automaticBadge != null) { + ZashiBadge( + text = state.automaticBadge, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 2.dp), + colors = ZashiBadgeDefaults.warningColors() + ) + } + } + ) + Spacer(modifier = Modifier.height(4.dp)) + ZashiRadioButton( + state = state.manual, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .then( + if (state.manual.isChecked) { + Modifier.background(ZashiColors.Surfaces.bgSecondary, RoundedCornerShape(12.dp)) + } else { + Modifier + } + ) + ) + Spacer(modifier = Modifier.height(32.dp)) + } +} + @Composable private fun ServerLoading() { Column( @@ -190,14 +261,31 @@ private fun ErrorDialog(dialogState: ServerDialogState) { @Composable fun ChooseServerBottomBar(saveButtonState: ButtonState) { - OldZashiBottomBar { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(ZashiColors.Surfaces.bgPrimary) + ) { + ZashiHorizontalDivider() + Spacer(modifier = Modifier.height(20.dp)) ZashiButton( state = saveButtonState, modifier = Modifier .padding(horizontal = 24.dp) .fillMaxWidth() + .height(48.dp), + content = { scope -> + if (saveButtonState.isLoading) { + scope.Loading() + Spacer(modifier = Modifier.width(6.dp)) + } + scope.Text() + } ) + Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) } } @@ -292,7 +380,10 @@ private fun LazyListScope.serverListItems(state: ServerListState) { }, trailingContent = { if (item.badge != null) { - ZashiBadge(text = item.badge) + ZashiBadge( + text = item.badge, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 2.dp) + ) } } ) @@ -320,14 +411,6 @@ private fun FastestServersHeader(state: ServerListState.Fastest) { ServerHeader(text = state.title) Spacer(modifier = Modifier.weight(1f)) TextButton(onClick = state.retryButton.onClick) { - Text( - text = state.retryButton.text.getValue(), - style = ZashiTypography.textSm, - fontWeight = FontWeight.SemiBold, - color = ZashiColors.Text.textPrimary - ) - Spacer(modifier = Modifier.width(6.dp)) - if (state.isLoading) { LottieProgress() } else { @@ -337,6 +420,13 @@ private fun FastestServersHeader(state: ServerListState.Fastest) { colorFilter = ColorFilter.tint(ZashiColors.Text.textPrimary) ) } + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = state.retryButton.text.getValue(), + style = ZashiTypography.textSm, + fontWeight = FontWeight.SemiBold, + color = ZashiColors.Text.textPrimary + ) } } @@ -397,6 +487,7 @@ private fun CustomServerRadioButton( if (state.badge != null) { ZashiBadge( text = state.badge, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 2.dp), ) Spacer( modifier = Modifier.width(8.dp), @@ -493,6 +584,21 @@ private fun ChooseServerPreview( ChooseServerView( state = ChooseServerState( + connectionMode = + ServerConnectionModeState( + automatic = + RadioButtonState( + text = stringRes("Automatic"), + isChecked = true, + onClick = {} + ), + manual = + RadioButtonState( + text = stringRes("Manual"), + isChecked = false, + onClick = {} + ) + ), fastest = fastestServers, other = ServerListState.Other( diff --git a/ui-lib/src/main/res/ui/choose_server/values-es/strings.xml b/ui-lib/src/main/res/ui/choose_server/values-es/strings.xml index b2afe56504..5791a7108d 100644 --- a/ui-lib/src/main/res/ui/choose_server/values-es/strings.xml +++ b/ui-lib/src/main/res/ui/choose_server/values-es/strings.xml @@ -13,6 +13,7 @@ <nombre del host>:<puerto> Guardar selección + Guardando selección Punto final del servidor no válido Error: El intento de cambiar el punto final falló. @@ -28,9 +29,15 @@ Realizando prueba de servidor Esto puede tardar un momento… + Modo de conexión + Automático + Zodl elige un servidor activo. + Manual + Usar exactamente un servidor seleccionado. Servidores más rápidos Otros servidores Actualizar Activo + Probando Predeterminado diff --git a/ui-lib/src/main/res/ui/choose_server/values/strings.xml b/ui-lib/src/main/res/ui/choose_server/values/strings.xml index 9a042b80ef..111f62652c 100644 --- a/ui-lib/src/main/res/ui/choose_server/values/strings.xml +++ b/ui-lib/src/main/res/ui/choose_server/values/strings.xml @@ -13,6 +13,7 @@ <hostname>:<port> Save selection + Saving selection Invalid server endpoint Error: The attempt to switch endpoints failed. @@ -28,9 +29,15 @@ Performing Server Test This may take a moment… + Connection mode + Automatic + Zodl chooses one active server. + Manual + Use exactly one selected server. Fastest servers Other servers Refresh Active + Testing Default