diff --git a/app/src/main/java/cash/z/ecc/android/ext/Const.kt b/app/src/main/java/cash/z/ecc/android/ext/Const.kt index 5ab2a86..ac5fc35 100644 --- a/app/src/main/java/cash/z/ecc/android/ext/Const.kt +++ b/app/src/main/java/cash/z/ecc/android/ext/Const.kt @@ -17,7 +17,18 @@ object Const { object Pref { const val FIRST_USE_VIEW_TX = "const.pref.first_use_view_tx" const val FEEDBACK_ENABLED = "const.pref.feedback_enabled" - const val SERVER_NAME = "const.pref.server_name" + const val SERVER_HOST = "const.pref.server_host" const val SERVER_PORT = "const.pref.server_port" } + + /** + * Default values to use application-wide. Ideally, this set of values should remain very short. + */ + object Default { + object Server { + // If you've forked the ECC repo, change this to your hosted lightwalletd instance + const val HOST = "lightwalletd.electriccoin.co"//"your.hosted.lightwalletd.org" + const val PORT = 9067 + } + } } diff --git a/app/src/main/java/cash/z/ecc/android/ext/Dialogs.kt b/app/src/main/java/cash/z/ecc/android/ext/Dialogs.kt index 3d8f0ef..495d2c9 100644 --- a/app/src/main/java/cash/z/ecc/android/ext/Dialogs.kt +++ b/app/src/main/java/cash/z/ecc/android/ext/Dialogs.kt @@ -5,7 +5,10 @@ import android.app.Dialog import android.content.Context import android.content.Intent import android.provider.Settings +import android.view.View import androidx.core.content.getSystemService +import cash.z.ecc.android.sdk.exception.LightWalletException +import cash.z.ecc.android.ui.MainActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -94,4 +97,32 @@ fun Context.showCriticalProcessorError(error: Throwable?, onRetry: () -> Unit = throw error ?: RuntimeException("Critical error while processing blocks and the user chose to exit.") } .show() +} + +fun Context.showUpdateServerCriticalError(userFacingMessage: String, onConfirm: () -> Unit = {}): Dialog { + return MaterialAlertDialogBuilder(this) + .setTitle("Failed to Change Server") + .setMessage(userFacingMessage) + .setCancelable(false) + .setPositiveButton("Ok") { d, _ -> + d.dismiss() + onConfirm() + } + .show() +} + +fun Context.showUpdateServerDialog(positiveText: String = "Update", onCancel: () -> Unit = {}, onUpdate: () -> Unit = {}): Dialog { + return MaterialAlertDialogBuilder(this) + .setTitle("Modify Lightwalletd Server?") + .setMessage("WARNING: Entering an invalid or untrusted server might result in misconfiguration or loss of funds!") + .setCancelable(false) + .setPositiveButton(positiveText) { dialog, _ -> + dialog.dismiss() + onUpdate() + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + onCancel + } + .show() } \ No newline at end of file diff --git a/app/src/main/java/cash/z/ecc/android/ui/settings/SettingsFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/settings/SettingsFragment.kt index fd9b62f..ffdba10 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/settings/SettingsFragment.kt @@ -1,13 +1,21 @@ package cash.z.ecc.android.ui.settings +import android.content.res.ColorStateList import android.os.Bundle import android.view.LayoutInflater import android.view.View -import cash.z.ecc.android.di.viewmodel.viewModel +import android.widget.Toast +import androidx.core.widget.doAfterTextChanged +import cash.z.ecc.android.R +import cash.z.ecc.android.ZcashWalletApp import cash.z.ecc.android.databinding.FragmentSettingsBinding -import cash.z.ecc.android.ext.onClickNavBack +import cash.z.ecc.android.di.viewmodel.viewModel +import cash.z.ecc.android.ext.* +import cash.z.ecc.android.sdk.exception.LightWalletException +import cash.z.ecc.android.sdk.ext.collectWith +import cash.z.ecc.android.sdk.ext.twig import cash.z.ecc.android.ui.base.BaseFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch class SettingsFragment : BaseFragment() { @@ -16,61 +24,131 @@ class SettingsFragment : BaseFragment() { override fun inflate(inflater: LayoutInflater): FragmentSettingsBinding = FragmentSettingsBinding.inflate(inflater) + // + // Lifecycle + // + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - getCurrentServer() - binding.hitAreaClose.onClickNavBack() - binding.buttonUpdate.setOnClickListener(View.OnClickListener { - validateServerHost(view) - }) - binding.buttonReset.setOnClickListener(View.OnClickListener { - resetServer() - showUpdateServerDialog(view) - }) - } - - private fun getCurrentServer() { - binding.inputTextLightwalletdServer.setText(viewModel.getServerHost()) - binding.inputTextLightwalletdPort.setText(viewModel.getServerPort().toString()) - } - - private fun resetServer() { - } - - private fun validateServerHost(view: View) { - var isError = false - if (binding.inputTextLightwalletdServer.text.toString().contains("http")) { - binding.lightwalletdServer.error = "Please remove http:// or https://" - isError = true - } else { - binding.lightwalletdServer.error = null - } - if (Integer.valueOf(binding.inputTextLightwalletdPort.text.toString()) > 65535) { - binding.lightwalletdPort.error = "Please enter port number below 65535" - isError = true - } else { - binding.lightwalletdPort.error = null - } - if (!isError) { - showUpdateServerDialog(view) - } - } - - private fun showUpdateServerDialog(view: View) { - MaterialAlertDialogBuilder(view.context) - .setTitle("Modify lightwalletd Server?") - .setMessage("WARNING: Entering an invalid or compromised lighthttpd server might result in misconfiguration or loss of funds.") - .setCancelable(false) - .setPositiveButton("Update") { dialog, _ -> - dialog.dismiss() - updateServer() + mainActivity?.preventBackPress(this) + viewModel.init() + binding.apply { + groupLoading.gone() + hitAreaExit.onClickNavBack() + buttonReset.setOnClickListener(::onResetClicked) + buttonUpdate.setOnClickListener(::onUpdateClicked) + buttonUpdate.isActivated = true + buttonReset.isActivated = true + inputHost.doAfterTextChanged { + viewModel.pendingHost = it.toString() } - .setNegativeButton("Cancel") { dialog, _ -> - dialog.dismiss() + inputPort.doAfterTextChanged { + viewModel.pendingPortText = it.toString() } - .show() + } } - private fun updateServer() { + override fun onResume() { + super.onResume() + viewModel.uiModels.collectWith(resumedScope, ::onUiModelUpdated) + } + + + // + // Event handlers + // + + private fun onResetClicked(unused: View?) { + mainActivity?.hideKeyboard() + context?.showUpdateServerDialog("Restore Defaults") { + resumedScope.launch { + binding.groupLoading.visible() + binding.loadingView.requestFocus() + viewModel.resetServer() + } + } + } + + private fun onUpdateClicked(unused: View?) { + mainActivity?.hideKeyboard() + context?.showUpdateServerDialog { + resumedScope.launch { + binding.groupLoading.visible() + binding.loadingView.requestFocus() + viewModel.submit() + } + } + } + + private fun onUiModelUpdated(uiModel: SettingsViewModel.UiModel) { + twig("onUiModelUpdated:::::$uiModel") + binding.apply { + if (handleCompletion(uiModel)) return@onUiModelUpdated + + // avoid moving the cursor on instances where the change originated from the UI + if (inputHost.text.toString() != uiModel.host) inputHost.setText(uiModel.host) + if (inputPort.text.toString() != uiModel.portText) inputPort.setText(uiModel.portText) + + buttonReset.isEnabled = uiModel.submitEnabled + buttonUpdate.isEnabled = uiModel.submitEnabled && !uiModel.hasError + + uiModel.hostErrorMessage.let { it -> + textInputLayoutHost.helperText = it + ?: R.string.settings_host_helper_text.toAppString() + textInputLayoutHost.setHelperTextColor(it.toHelperTextColor()) + } + uiModel.portErrorMessage.let { it -> + textInputLayoutPort.helperText = it + ?: R.string.settings_port_helper_text.toAppString() + textInputLayoutPort.setHelperTextColor(it.toHelperTextColor()) + } + } + } + + /** + * Handle the exit conditions and return true if we're done here. + */ + private fun handleCompletion(uiModel: SettingsViewModel.UiModel): Boolean { + return if (uiModel.changeError != null) { + binding.groupLoading.gone() + onCriticalError(uiModel.changeError) + true + } else { + if (uiModel.complete) { + binding.groupLoading.gone() + mainActivity?.safeNavigate(R.id.nav_home) + Toast.makeText(ZcashWalletApp.instance, "Successfully changed server!", Toast.LENGTH_SHORT).show() + true + } + false + } + } + + private fun onCriticalError(error: Throwable) { + val details = if (error is LightWalletException.ChangeServerException.StatusException) { + error.status.description + } else { + error.javaClass.simpleName + } + val message = "An error occured while changing servers. Please verify the info" + + " and try again.\n\nError: $details" + twig(message) + Toast.makeText(ZcashWalletApp.instance, "Failed to change server!", Toast.LENGTH_SHORT).show() + context?.showUpdateServerCriticalError(message) + } + + + // + // Utilities + // + + private fun String?.toHelperTextColor(): ColorStateList { + val color = if (this == null) { + R.color.text_light_dimmed + } else { + R.color.zcashRed + } + return ColorStateList.valueOf(color.toAppColor()) } } + diff --git a/app/src/main/java/cash/z/ecc/android/ui/settings/SettingsViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/settings/SettingsViewModel.kt index 5806497..180c64e 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/settings/SettingsViewModel.kt @@ -1,30 +1,99 @@ package cash.z.ecc.android.ui.settings import androidx.lifecycle.ViewModel -import cash.z.ecc.android.di.module.InitializerModule +import cash.z.ecc.android.ext.Const +import cash.z.ecc.android.lockbox.LockBox import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.ext.twig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.cancellable import javax.inject.Inject +import javax.inject.Named +import kotlin.properties.Delegates.observable +import kotlin.reflect.KProperty class SettingsViewModel @Inject constructor() : ViewModel() { @Inject lateinit var synchronizer: Synchronizer - fun updateServer(host: String, port: Int) { - // TODO: Update the SecurePrefs here + @Inject + @Named(Const.Name.APP_PREFS) + lateinit var prefs: LockBox + + lateinit var uiModels: MutableStateFlow + + private lateinit var initialServer: UiModel + + var pendingHost by observable("", ::onUpdateModel) + var pendingPortText by observable("", ::onUpdateModel) + + + private fun getHost(): String { + return prefs[Const.Pref.SERVER_HOST] ?: Const.Default.Server.HOST } - fun getServerHost(): String { - return InitializerModule.defaultHost + private fun getPort(): Int { + return prefs[Const.Pref.SERVER_PORT] ?: Const.Default.Server.PORT } - fun getServerPort(): Int { - return InitializerModule.defaultPort + fun init() { + initialServer = UiModel(getHost(), getPort().toString()) + uiModels = MutableStateFlow(initialServer) } - override fun onCleared() { - super.onCleared() - twig("SettingsViewModel cleared!") + suspend fun resetServer() { + UiModel( + Const.Default.Server.HOST, + Const.Default.Server.PORT.toString() + ).let { default -> + uiModels.value = default + submit() + } } -} \ No newline at end of file + + suspend fun submit() { + var error: Throwable? = null + val host = uiModels.value.host + val port = uiModels.value.portInt + synchronizer.changeServer(uiModels.value.host, uiModels.value.portInt) { + error = it + } + if (error == null) { + prefs[Const.Pref.SERVER_HOST] = host + prefs[Const.Pref.SERVER_PORT] = port + } + uiModels.value = uiModels.value.copy(changeError = error, complete = true) + } + + private fun onUpdateModel(kProperty: KProperty<*>, old: String, new: String) { + val pendingPort = pendingPortText.toIntOrNull() ?: -1 + uiModels.value = UiModel( + pendingHost, + pendingPortText, + pendingHost != initialServer.host || pendingPortText != initialServer.portText, + if (!pendingHost.isValidHost()) "Please enter a valid host name or IP" else null, + if (pendingPort >= 65535) "Please enter a valid port number below 65535" else null + ).also { + twig("updated model with $it") + } + } + + data class UiModel( + val host: String = "", + val portText: String = "", + val submitEnabled: Boolean = false, + val hostErrorMessage: String? = null, + val portErrorMessage: String? = null, + val changeError: Throwable? = null, + val complete: Boolean = false + ) { + val portInt get() = portText.toIntOrNull() ?: -1 + val hasError get() = hostErrorMessage != null || portErrorMessage != null + } + + // we can beef this up later if we want to but this is enough for now + private fun String.isValidHost(): Boolean { + return !contains("://") + } +} diff --git a/app/src/main/res/color/selector_secondary_button_activatable.xml b/app/src/main/res/color/selector_secondary_button_activatable.xml new file mode 100644 index 0000000..ff34655 --- /dev/null +++ b/app/src/main/res/color/selector_secondary_button_activatable.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index f77a35e..ee46f63 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -1,13 +1,32 @@ - + + + + - + app:layout_constraintBottom_toBottomOf="@id/icon_exit" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@id/hit_area_exit" + app:layout_constraintTop_toTopOf="@id/icon_exit" /> + app:layout_constraintTop_toBottomOf="@+id/text_title" + app:layout_constraintWidth_percent="0.84"> + android:background="@android:color/transparent" + android:imeOptions="actionNext" + android:maxLength="253" + android:singleLine="true" + android:textColor="@color/text_light" + android:textColorHint="@color/text_light_dimmed" /> + app:layout_constraintTop_toBottomOf="@+id/text_input_layout_host" + app:layout_constraintWidth_percent="0.84"> + android:maxLength="5" + android:textColor="@color/text_light" + android:textColorHint="@color/text_light_dimmed" /> - - + android:layout_marginEnd="16dp" + style="@style/Zcash.Button.OutlinedButton" + android:text="@string/settings_reset" + android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" + android:textColor="@color/selector_secondary_button_activatable" + app:layout_constraintEnd_toStartOf="@id/button_update" + app:layout_constraintTop_toTopOf="@id/button_update" + app:strokeColor="@color/selector_secondary_button_activatable" /> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0536081..2b52a1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,11 +49,13 @@ v1.0.0-alpha05 - Change lightwalletd server: - Server Address - Server Port + Change Lightwalletd Server + Host + Port Update - Reset to Default Host + Reset + Enter a valid port number + Enter a valid host name or IP address Don\'t show me again