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 * 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/. */ * 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.os.Bundle
import android.text.InputFilter import android.text.InputFilter
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.forEach
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.ConstellationState import mozilla.components.concept.sync.ConstellationState
import mozilla.components.concept.sync.DeviceConstellationObserver import mozilla.components.concept.sync.DeviceConstellationObserver
import mozilla.components.lib.state.ext.observe
import mozilla.components.service.fxa.FxaException import mozilla.components.service.fxa.FxaException
import mozilla.components.service.fxa.FxaPanicException import mozilla.components.service.fxa.FxaPanicException
import mozilla.components.service.fxa.manager.FxaAccountManager 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 mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.getPreferenceKey import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
@SuppressWarnings("TooManyFunctions")
class AccountSettingsFragment : PreferenceFragmentCompat() { class AccountSettingsFragment : PreferenceFragmentCompat() {
private lateinit var accountManager: FxaAccountManager 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. // Navigate away from this fragment when we encounter auth problems or logout events.
private val accountStateObserver = object : AccountObserver { private val accountStateObserver = object : AccountObserver {
@ -78,9 +78,37 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.account_settings_preferences, rootKey) 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 = requireComponents.backgroundServices.accountManager
accountManager.register(accountStateObserver, this, true) accountManager.register(accountStateObserver, this, true)
accountSettingsInteractor = AccountSettingsInteractor(
findNavController(),
::onSyncNow,
::makeSnackbar,
::syncDeviceName,
accountSettingsStore
)
// Sign out // Sign out
val signOut = context!!.getPreferenceKey(R.string.pref_key_sign_out) val signOut = context!!.getPreferenceKey(R.string.pref_key_sign_out)
val preferenceSignOut = findPreference<Preference>(signOut) val preferenceSignOut = findPreference<Preference>(signOut)
@ -91,7 +119,6 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
val preferenceSyncNow = findPreference<Preference>(syncNow) val preferenceSyncNow = findPreference<Preference>(syncNow)
preferenceSyncNow?.let { preferenceSyncNow?.let {
it.onPreferenceClickListener = getClickListenerForSyncNow() it.onPreferenceClickListener = getClickListenerForSyncNow()
updateLastSyncedTimePref(context!!, it)
// Current sync state // Current sync state
if (requireComponents.backgroundServices.accountManager.isSyncActive()) { if (requireComponents.backgroundServices.accountManager.isSyncActive()) {
@ -110,6 +137,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
deviceConstellation?.state()?.currentDevice?.let { device -> deviceConstellation?.state()?.currentDevice?.let { device ->
summary = device.displayName summary = device.displayName
text = device.displayName text = device.displayName
accountSettingsStore.dispatch(AccountSettingsAction.UpdateDeviceName(device.displayName))
} }
setOnBindEditTextListener { editText -> setOnBindEditTextListener { editText ->
editText.filters = arrayOf(InputFilter.LengthFilter(DEVICE_NAME_MAX_LENGTH)) 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 { private fun getClickListenerForSignOut(): Preference.OnPreferenceClickListener {
return Preference.OnPreferenceClickListener { return Preference.OnPreferenceClickListener {
nav( accountSettingsInteractor.onSignOut()
R.id.accountSettingsFragment,
AccountSettingsFragmentDirections.actionAccountSettingsFragmentToSignOutFragment()
)
true true
} }
} }
private fun getClickListenerForSyncNow(): Preference.OnPreferenceClickListener { private fun getClickListenerForSyncNow(): Preference.OnPreferenceClickListener {
return Preference.OnPreferenceClickListener { return Preference.OnPreferenceClickListener {
lifecycleScope.launch { accountSettingsInteractor.onSyncNow()
requireComponents.analytics.metrics.track(Event.SyncAccountSyncNow)
// Trigger a sync.
requireComponents.backgroundServices.accountManager.syncNowAsync().await()
// Poll for device events.
accountManager.authenticatedAccount()
?.deviceConstellation()
?.refreshDeviceStateAsync()
?.await()
}
true true
} }
} }
private fun getChangeListenerForDeviceName(): Preference.OnPreferenceChangeListener { private fun getChangeListenerForDeviceName(): Preference.OnPreferenceChangeListener {
return Preference.OnPreferenceChangeListener { _, newValue -> return Preference.OnPreferenceChangeListener { _, newValue ->
// The network request requires a nonempty string, so don't persist any changes if the user inputs one. accountSettingsInteractor.onChangeDeviceName(newValue as String)
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
} }
} }
@ -189,8 +220,6 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
view?.announceForAccessibility(getString(R.string.sync_syncing_in_progress)) view?.announceForAccessibility(getString(R.string.sync_syncing_in_progress))
pref?.title = getString(R.string.sync_syncing_in_progress) pref?.title = getString(R.string.sync_syncing_in_progress)
pref?.isEnabled = false pref?.isEnabled = false
updateSyncingItemsPreference()
} }
} }
@ -201,7 +230,9 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
pref?.let { pref?.let {
pref.title = getString(R.string.preferences_sync_now) pref.title = getString(R.string.preferences_sync_now)
pref.isEnabled = true 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?.let {
pref.title = getString(R.string.preferences_sync_now) pref.title = getString(R.string.preferences_sync_now)
pref.isEnabled = true 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 { private val deviceConstellationObserver = object : DeviceConstellationObserver {
override fun onDevicesUpdate(constellation: ConstellationState) { override fun onDevicesUpdate(constellation: ConstellationState) {
val deviceNameKey = context!!.getPreferenceKey(R.string.pref_key_sync_device_name) constellation.currentDevice?.displayName?.also {
val preferenceDeviceName = findPreference<Preference>(deviceNameKey) accountSettingsStore.dispatch(AccountSettingsAction.UpdateDeviceName(it))
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)
}
} }
} }
} }
fun updateLastSyncedTimePref(context: Context, pref: Preference, failed: Boolean = false) { private fun updateDeviceName(state: AccountSettingsState) {
val lastSyncTime = getLastSynced(context) 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) { private fun updateLastSyncTimePref(state: AccountSettingsState) {
// Never tried to sync. val value = when (state.lastSyncedDate) {
getString(R.string.sync_never_synced_summary) LastSyncTime.Never -> getString(R.string.sync_never_synced_summary)
} else if (failed && lastSyncTime == 0L) { is LastSyncTime.Failed -> {
// Failed to sync, never succeeded before. if (state.lastSyncedDate.lastSync == 0L) {
getString(R.string.sync_failed_never_synced_summary) getString(R.string.sync_failed_never_synced_summary)
} else if (!failed && lastSyncTime != 0L) { } else {
// Successfully synced. getString(
getString( R.string.sync_failed_summary,
DateUtils.getRelativeTimeSpanString(state.lastSyncedDate.lastSync)
)
}
}
is LastSyncTime.Success -> getString(
R.string.sync_last_synced_summary, R.string.sync_last_synced_summary,
DateUtils.getRelativeTimeSpanString(lastSyncTime) DateUtils.getRelativeTimeSpanString(state.lastSyncedDate.lastSync)
)
} else {
// Failed to sync, succeeded before.
getString(
R.string.sync_failed_summary,
DateUtils.getRelativeTimeSpanString(lastSyncTime)
) )
} }
val syncNow = context!!.getPreferenceKey(R.string.pref_key_sync_now)
findPreference<Preference>(syncNow)?.summary = value
} }
companion object { 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" /> android:label="@string/preferences_accessibility" />
<fragment <fragment
android:id="@+id/accountSettingsFragment" 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"> android:label="@string/preferences_account_settings">
<action <action
android:id="@+id/action_accountSettingsFragment_to_signOutFragment" 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())
}
}
}