1
0
Fork 0

For #4125: Migrate Sign in to Sync to Libstate

master
Yeon Taek Jeong 2019-07-26 11:33:02 -07:00 committed by Jeff Boek
parent 65de521ccf
commit faf0ecbcc0
5 changed files with 300 additions and 86 deletions

View File

@ -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<Preference>(signOut)
@ -91,7 +119,6 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
val preferenceSyncNow = findPreference<Preference>(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<Preference>(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<Preference>(deviceNameKey)
preferenceDeviceName?.summary = constellation.currentDevice?.displayName
}
}
private fun updateSyncingItemsPreference() {
val syncCategory = context!!.getPreferenceKey(R.string.preferences_sync_category)
val preferencesSyncCategory = findPreference<Preference>(syncCategory) as PreferenceCategory
val stringSet = mutableSetOf<String>()
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<Preference>(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<Preference>(syncNow)?.summary = value
}
companion object {

View File

@ -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
)
}
}

View File

@ -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<AccountSettingsState, AccountSettingsAction>(
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)
}
}

View File

@ -329,7 +329,7 @@
android:label="@string/preferences_accessibility" />
<fragment
android:id="@+id/accountSettingsFragment"
android:name="org.mozilla.fenix.settings.AccountSettingsFragment"
android:name="org.mozilla.fenix.settings.account.AccountSettingsFragment"
android:label="@string/preferences_account_settings">
<action
android:id="@+id/action_accountSettingsFragment_to_signOutFragment"

View File

@ -0,0 +1,75 @@
/* 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
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Test
import org.mozilla.fenix.R
import org.mozilla.fenix.settings.account.AccountSettingsAction
import org.mozilla.fenix.settings.account.AccountSettingsFragmentDirections
import org.mozilla.fenix.settings.account.AccountSettingsInteractor
import org.mozilla.fenix.settings.account.AccountSettingsStore
class AccountSettingsInteractorTest {
@Test
fun onSyncNow() {
var ranSyncNow = false
val interactor = AccountSettingsInteractor(
mockk(),
{ ranSyncNow = true },
mockk(),
mockk(),
mockk()
)
interactor.onSyncNow()
assertEquals(ranSyncNow, true)
}
@Test
fun onChangeDeviceName() {
val store: AccountSettingsStore = mockk(relaxed = true)
val interactor = AccountSettingsInteractor(
mockk(),
mockk(),
{ true },
{},
store
)
interactor.onChangeDeviceName("New Name")
verify { store.dispatch(AccountSettingsAction.UpdateDeviceName("New Name")) }
}
@Test
fun onSignOut() {
val navController: NavController = mockk(relaxed = true)
every { navController.currentDestination } returns NavDestination("").apply { id = R.id.accountSettingsFragment }
val interactor = AccountSettingsInteractor(
navController,
mockk(),
mockk(),
mockk(),
mockk()
)
interactor.onSignOut()
verify {
navController.navigate(AccountSettingsFragmentDirections.actionAccountSettingsFragmentToSignOutFragment())
}
}
}