From faf0ecbcc0d2d2b5e106deafa3c5679510359f76 Mon Sep 17 00:00:00 2001 From: Yeon Taek Jeong Date: Fri, 26 Jul 2019 11:33:02 -0700 Subject: [PATCH] For #4125: Migrate Sign in to Sync to Libstate --- .../{ => account}/AccountSettingsFragment.kt | 194 ++++++++++-------- .../account/AccountSettingsInteractor.kt | 62 ++++++ .../fenix/settings/account/AccountStore.kt | 53 +++++ app/src/main/res/navigation/nav_graph.xml | 2 +- .../settings/AccountSettingsInteractorTest.kt | 75 +++++++ 5 files changed, 300 insertions(+), 86 deletions(-) rename app/src/main/java/org/mozilla/fenix/settings/{ => account}/AccountSettingsFragment.kt (62%) create mode 100644 app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsInteractor.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/account/AccountStore.kt create mode 100644 app/src/test/java/org/mozilla/fenix/settings/AccountSettingsInteractorTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/settings/AccountSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt similarity index 62% rename from app/src/main/java/org/mozilla/fenix/settings/AccountSettingsFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt index 3975c2b0c..cceb06ea7 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/AccountSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt @@ -2,26 +2,23 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -package org.mozilla.fenix.settings +package org.mozilla.fenix.settings.account -import android.content.Context import android.os.Bundle import android.text.InputFilter import android.text.format.DateUtils import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import androidx.preference.CheckBoxPreference import androidx.preference.EditTextPreference import androidx.preference.Preference -import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat -import androidx.preference.forEach import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.ConstellationState import mozilla.components.concept.sync.DeviceConstellationObserver +import mozilla.components.lib.state.ext.observe import mozilla.components.service.fxa.FxaException import mozilla.components.service.fxa.FxaPanicException import mozilla.components.service.fxa.manager.FxaAccountManager @@ -30,13 +27,16 @@ import mozilla.components.service.fxa.sync.getLastSynced import mozilla.components.support.base.log.logger.Logger import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.getPreferenceKey -import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.requireComponents +@SuppressWarnings("TooManyFunctions") class AccountSettingsFragment : PreferenceFragmentCompat() { private lateinit var accountManager: FxaAccountManager + private lateinit var accountSettingsStore: AccountSettingsStore + private lateinit var accountSettingsInteractor: AccountSettingsInteractor // Navigate away from this fragment when we encounter auth problems or logout events. private val accountStateObserver = object : AccountObserver { @@ -78,9 +78,37 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.account_settings_preferences, rootKey) + accountSettingsStore = StoreProvider.get(this) { + AccountSettingsStore( + AccountSettingsState( + lastSyncedDate = + if (getLastSynced(requireContext()) == 0L) + LastSyncTime.Never + else + LastSyncTime.Success(getLastSynced(requireContext())), + deviceName = "" + ) + ) + } + + accountSettingsStore.observe(this) { + viewLifecycleOwner.lifecycleScope.launch { + updateLastSyncTimePref(it) + updateDeviceName(it) + } + } + accountManager = requireComponents.backgroundServices.accountManager accountManager.register(accountStateObserver, this, true) + accountSettingsInteractor = AccountSettingsInteractor( + findNavController(), + ::onSyncNow, + ::makeSnackbar, + ::syncDeviceName, + accountSettingsStore + ) + // Sign out val signOut = context!!.getPreferenceKey(R.string.pref_key_sign_out) val preferenceSignOut = findPreference(signOut) @@ -91,7 +119,6 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { val preferenceSyncNow = findPreference(syncNow) preferenceSyncNow?.let { it.onPreferenceClickListener = getClickListenerForSyncNow() - updateLastSyncedTimePref(context!!, it) // Current sync state if (requireComponents.backgroundServices.accountManager.isSyncActive()) { @@ -110,6 +137,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { deviceConstellation?.state()?.currentDevice?.let { device -> summary = device.displayName text = device.displayName + accountSettingsStore.dispatch(AccountSettingsAction.UpdateDeviceName(device.displayName)) } setOnBindEditTextListener { editText -> editText.filters = arrayOf(InputFilter.LengthFilter(DEVICE_NAME_MAX_LENGTH)) @@ -125,60 +153,63 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { ) } + private fun onSyncNow() { + lifecycleScope.launch { + requireComponents.analytics.metrics.track(Event.SyncAccountSyncNow) + // Trigger a sync. + requireComponents.backgroundServices.accountManager.syncNowAsync().await() + // Poll for device events. + accountManager.authenticatedAccount() + ?.deviceConstellation() + ?.refreshDeviceStateAsync() + ?.await() + } + } + + private fun makeSnackbar(newValue: String): Boolean { + // The network request requires a nonempty string, so don't persist any changes if the user inputs one. + if (newValue.trim().isEmpty()) { + FenixSnackbar.make(view!!, FenixSnackbar.LENGTH_LONG) + .setText(getString(R.string.empty_device_name_error)) + .show() + return false + } + return true + } + + private fun syncDeviceName(newValue: String) { + // This may fail, and we'll have a disparity in the UI until `updateDeviceName` is called. + lifecycleScope.launch(IO) { + try { + accountManager.authenticatedAccount() + ?.deviceConstellation() + ?.setDeviceNameAsync(newValue) + ?.await() + } catch (e: FxaPanicException) { + throw e + } catch (e: FxaException) { + Logger.error("Setting device name failed.", e) + } + } + } + private fun getClickListenerForSignOut(): Preference.OnPreferenceClickListener { return Preference.OnPreferenceClickListener { - nav( - R.id.accountSettingsFragment, - AccountSettingsFragmentDirections.actionAccountSettingsFragmentToSignOutFragment() - ) + accountSettingsInteractor.onSignOut() true } } private fun getClickListenerForSyncNow(): Preference.OnPreferenceClickListener { return Preference.OnPreferenceClickListener { - lifecycleScope.launch { - requireComponents.analytics.metrics.track(Event.SyncAccountSyncNow) - // Trigger a sync. - requireComponents.backgroundServices.accountManager.syncNowAsync().await() - // Poll for device events. - accountManager.authenticatedAccount() - ?.deviceConstellation() - ?.refreshDeviceStateAsync() - ?.await() - } + accountSettingsInteractor.onSyncNow() true } } private fun getChangeListenerForDeviceName(): Preference.OnPreferenceChangeListener { return Preference.OnPreferenceChangeListener { _, newValue -> - // The network request requires a nonempty string, so don't persist any changes if the user inputs one. - if (newValue.toString().trim().isEmpty()) { - FenixSnackbar.make(view!!, FenixSnackbar.LENGTH_LONG) - .setText(getString(R.string.empty_device_name_error)) - .show() - return@OnPreferenceChangeListener false - } - // Optimistically set the device name to what user requested. - val deviceNameKey = context!!.getPreferenceKey(R.string.pref_key_sync_device_name) - val preferenceDeviceName = findPreference(deviceNameKey) - preferenceDeviceName?.summary = newValue as String - - // This may fail, and we'll have a disparity in the UI until `updateDeviceName` is called. - lifecycleScope.launch(IO) { - try { - accountManager.authenticatedAccount() - ?.deviceConstellation() - ?.setDeviceNameAsync(newValue) - ?.await() - } catch (e: FxaPanicException) { - throw e - } catch (e: FxaException) { - Logger.error("Setting device name failed.", e) - } - } - true + accountSettingsInteractor.onChangeDeviceName(newValue as String) } } @@ -189,8 +220,6 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { view?.announceForAccessibility(getString(R.string.sync_syncing_in_progress)) pref?.title = getString(R.string.sync_syncing_in_progress) pref?.isEnabled = false - - updateSyncingItemsPreference() } } @@ -201,7 +230,9 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { pref?.let { pref.title = getString(R.string.preferences_sync_now) pref.isEnabled = true - updateLastSyncedTimePref(context!!, pref, failed = false) + + val time = getLastSynced(requireContext()) + accountSettingsStore.dispatch(AccountSettingsAction.SyncEnded(time)) } } } @@ -213,7 +244,9 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { pref?.let { pref.title = getString(R.string.preferences_sync_now) pref.isEnabled = true - updateLastSyncedTimePref(context!!, pref, failed = true) + + val failedTime = getLastSynced(requireContext()) + accountSettingsStore.dispatch(AccountSettingsAction.SyncFailed(failedTime)) } } } @@ -221,48 +254,39 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { private val deviceConstellationObserver = object : DeviceConstellationObserver { override fun onDevicesUpdate(constellation: ConstellationState) { - val deviceNameKey = context!!.getPreferenceKey(R.string.pref_key_sync_device_name) - val preferenceDeviceName = findPreference(deviceNameKey) - preferenceDeviceName?.summary = constellation.currentDevice?.displayName - } - } - - private fun updateSyncingItemsPreference() { - val syncCategory = context!!.getPreferenceKey(R.string.preferences_sync_category) - val preferencesSyncCategory = findPreference(syncCategory) as PreferenceCategory - val stringSet = mutableSetOf() - - preferencesSyncCategory.forEach { - (it as? CheckBoxPreference)?.let { checkboxPreference -> - if (checkboxPreference.isChecked) { - stringSet.add(checkboxPreference.key) - } + constellation.currentDevice?.displayName?.also { + accountSettingsStore.dispatch(AccountSettingsAction.UpdateDeviceName(it)) } } } - fun updateLastSyncedTimePref(context: Context, pref: Preference, failed: Boolean = false) { - val lastSyncTime = getLastSynced(context) + private fun updateDeviceName(state: AccountSettingsState) { + val deviceNameKey = context!!.getPreferenceKey(R.string.pref_key_sync_device_name) + val preferenceDeviceName = findPreference(deviceNameKey) + preferenceDeviceName?.summary = state.deviceName + } - pref.summary = if (!failed && lastSyncTime == 0L) { - // Never tried to sync. - getString(R.string.sync_never_synced_summary) - } else if (failed && lastSyncTime == 0L) { - // Failed to sync, never succeeded before. - getString(R.string.sync_failed_never_synced_summary) - } else if (!failed && lastSyncTime != 0L) { - // Successfully synced. - getString( + private fun updateLastSyncTimePref(state: AccountSettingsState) { + val value = when (state.lastSyncedDate) { + LastSyncTime.Never -> getString(R.string.sync_never_synced_summary) + is LastSyncTime.Failed -> { + if (state.lastSyncedDate.lastSync == 0L) { + getString(R.string.sync_failed_never_synced_summary) + } else { + getString( + R.string.sync_failed_summary, + DateUtils.getRelativeTimeSpanString(state.lastSyncedDate.lastSync) + ) + } + } + is LastSyncTime.Success -> getString( R.string.sync_last_synced_summary, - DateUtils.getRelativeTimeSpanString(lastSyncTime) - ) - } else { - // Failed to sync, succeeded before. - getString( - R.string.sync_failed_summary, - DateUtils.getRelativeTimeSpanString(lastSyncTime) + DateUtils.getRelativeTimeSpanString(state.lastSyncedDate.lastSync) ) } + + val syncNow = context!!.getPreferenceKey(R.string.pref_key_sync_now) + findPreference(syncNow)?.summary = value } companion object { diff --git a/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsInteractor.kt new file mode 100644 index 000000000..977c91440 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsInteractor.kt @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.account + +import androidx.navigation.NavController +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.nav + +interface AccountSettingsUserActions { + + /** + * Called whenever the "Sync now" button is tapped + */ + fun onSyncNow() + + /** + * Called whenever user sets a new device name + * @param newDeviceName the device name to change to + * @return Boolean indicating whether the new device name has been accepted or not + */ + fun onChangeDeviceName(newDeviceName: String): Boolean + + /** + * Called whenever the "Sign out" button is tapped + */ + fun onSignOut() +} + +class AccountSettingsInteractor( + private val navController: NavController, + private val syncNow: () -> Unit, + private val checkValidName: (String) -> Boolean, + private val setDeviceName: (String) -> Unit, + private val store: AccountSettingsStore +) : AccountSettingsUserActions { + + override fun onSyncNow() { + syncNow.invoke() + } + + override fun onChangeDeviceName(newDeviceName: String): Boolean { + val isValidName = checkValidName.invoke(newDeviceName) + if (!isValidName) { + return false + } + // Optimistically set the device name to what user requested. + store.dispatch(AccountSettingsAction.UpdateDeviceName(newDeviceName)) + + setDeviceName.invoke(newDeviceName) + return true + } + + override fun onSignOut() { + val directions = AccountSettingsFragmentDirections.actionAccountSettingsFragmentToSignOutFragment() + navController.nav( + R.id.accountSettingsFragment, + directions + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/account/AccountStore.kt b/app/src/main/java/org/mozilla/fenix/settings/account/AccountStore.kt new file mode 100644 index 000000000..e0bd246d0 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/account/AccountStore.kt @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.account + +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * The [Store] for holding the [AccountSettingsState] and applying [AccountAction]s. + */ +class AccountSettingsStore( + initialState: AccountSettingsState +) : Store( + initialState, + ::accountStateReducer +) + +sealed class LastSyncTime { + object Never : LastSyncTime() + data class Failed(val lastSync: Long) : LastSyncTime() + data class Success(val lastSync: Long) : LastSyncTime() +} + +/** + * The state for the Account Settings Screen + */ +data class AccountSettingsState( + val lastSyncedDate: LastSyncTime, + val deviceName: String +) : State + +/** + * Actions to dispatch through the `SearchStore` to modify `SearchState` through the reducer. + */ +sealed class AccountSettingsAction : Action { + data class SyncFailed(val time: Long) : AccountSettingsAction() + data class SyncEnded(val time: Long) : AccountSettingsAction() + data class UpdateDeviceName(val name: String) : AccountSettingsAction() +} + +/** + * The SearchState Reducer. + */ +fun accountStateReducer(state: AccountSettingsState, action: AccountSettingsAction): AccountSettingsState { + return when (action) { + is AccountSettingsAction.SyncFailed -> state.copy(lastSyncedDate = LastSyncTime.Failed(action.time)) + is AccountSettingsAction.SyncEnded -> state.copy(lastSyncedDate = LastSyncTime.Success(action.time)) + is AccountSettingsAction.UpdateDeviceName -> state.copy(deviceName = action.name) + } +} diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index e3dfeeb6b..e5f125578 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -329,7 +329,7 @@ android:label="@string/preferences_accessibility" />