[components] Account manager state machine refactoring

This commit is contained in:
Grisha Kruglov
2020-06-30 11:23:53 -07:00
committed by mergify[bot]
parent b30bd15687
commit ee28f42d71
38 changed files with 1822 additions and 2133 deletions

View File

@@ -19,17 +19,17 @@ typealias OuterDeviceCommandIncoming = DeviceCommandIncoming
*/
sealed class AccountEvent {
/** An incoming command from another device */
class DeviceCommandIncoming(val command: OuterDeviceCommandIncoming) : AccountEvent()
data class DeviceCommandIncoming(val command: OuterDeviceCommandIncoming) : AccountEvent()
/** The account's profile was updated */
class ProfileUpdated : AccountEvent()
object ProfileUpdated : AccountEvent()
/** The authentication state of the account changed - eg, the password changed */
class AccountAuthStateChanged : AccountEvent()
object AccountAuthStateChanged : AccountEvent()
/** The account itself was destroyed */
class AccountDestroyed : AccountEvent()
object AccountDestroyed : AccountEvent()
/** Another device connected to the account */
class DeviceConnected(val deviceName: String) : AccountEvent()
data class DeviceConnected(val deviceName: String) : AccountEvent()
/** A device (possibly this one) disconnected from the account */
class DeviceDisconnected(val deviceId: String, val isLocalDevice: Boolean) : AccountEvent()
data class DeviceDisconnected(val deviceId: String, val isLocalDevice: Boolean) : AccountEvent()
}
/**

View File

@@ -6,36 +6,39 @@ package mozilla.components.concept.sync
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.Deferred
import mozilla.components.support.base.observer.Observable
/**
* Represents a result of interacting with a backend service which may return an authentication error.
*/
sealed class ServiceResult {
/**
* All good.
*/
object Ok : ServiceResult()
/**
* Auth error.
*/
object AuthError : ServiceResult()
/**
* Error that isn't auth.
*/
object OtherError : ServiceResult()
}
/**
* Describes available interactions with the current device and other devices associated with an [OAuthAccount].
*/
interface DeviceConstellation : Observable<AccountEventsObserver> {
/**
* Register current device in the associated [DeviceConstellation].
*
* @param name An initial name for the current device. This may be changed via [setDeviceNameAsync].
* @param type Type of the current device. This can't be changed.
* @param capabilities A list of capabilities that the current device claims to have.
* @return A [Deferred] that will be resolved with a success flag once operation is complete.
* Perform actions necessary to finalize device initialization based on [authType].
* @param authType Type of an authentication event we're experiencing.
* @param config A [DeviceConfig] that describes current device.
* @return A boolean success flag.
*/
fun initDeviceAsync(
name: String,
type: DeviceType = DeviceType.MOBILE,
capabilities: Set<DeviceCapability>
): Deferred<Boolean>
/**
* Ensure that all passed in [capabilities] are configured.
* This may involve backend service registration, or other work involving network/disc access.
* @param capabilities A list of capabilities to configure. This is expected to be the same or
* longer list than what was passed into [initDeviceAsync]. Removing capabilities is currently
* not supported.
* @return A [Deferred] that will be resolved with a success flag once operation is complete.
*/
fun ensureCapabilitiesAsync(capabilities: Set<DeviceCapability>): Deferred<Boolean>
suspend fun finalizeDevice(authType: AuthType, config: DeviceConfig): ServiceResult
/**
* Current state of the constellation. May be missing if state was never queried.
@@ -53,46 +56,46 @@ interface DeviceConstellation : Observable<AccountEventsObserver> {
* Set name of the current device.
* @param name New device name.
* @param context An application context, used for updating internal caches.
* @return A [Deferred] that will be resolved with a success flag once operation is complete.
* @return A boolean success flag.
*/
fun setDeviceNameAsync(name: String, context: Context): Deferred<Boolean>
suspend fun setDeviceName(name: String, context: Context): Boolean
/**
* Set a [DevicePushSubscription] for the current device.
* @param subscription A new [DevicePushSubscription].
* @return A [Deferred] that will be resolved with a success flag once operation is complete.
* @return A boolean success flag.
*/
fun setDevicePushSubscriptionAsync(subscription: DevicePushSubscription): Deferred<Boolean>
suspend fun setDevicePushSubscription(subscription: DevicePushSubscription): Boolean
/**
* Send a command to a specified device.
* @param targetDeviceId A device ID of the recipient.
* @param outgoingCommand An event to send.
* @return A [Deferred] that will be resolved with a success flag once operation is complete.
* @return A boolean success flag.
*/
fun sendCommandToDeviceAsync(targetDeviceId: String, outgoingCommand: DeviceCommandOutgoing): Deferred<Boolean>
suspend fun sendCommandToDevice(targetDeviceId: String, outgoingCommand: DeviceCommandOutgoing): Boolean
/**
* Process a raw event, obtained via a push message or some other out-of-band mechanism.
* @param payload A raw, plaintext payload to be processed.
* @return A [Deferred] that will be resolved with a success flag once operation is complete.
* @return A boolean success flag.
*/
fun processRawEventAsync(payload: String): Deferred<Boolean>
suspend fun processRawEvent(payload: String): Boolean
/**
* Refreshes [ConstellationState]. Registered [DeviceConstellationObserver] observers will be notified.
*
* @return A [Deferred] that will be resolved with a success flag once operation is complete.
* @return A boolean success flag.
*/
fun refreshDevicesAsync(): Deferred<Boolean>
suspend fun refreshDevices(): Boolean
/**
* Polls for any pending [DeviceCommandIncoming] commands.
* In case of new commands, registered [AccountEventsObserver] observers will be notified.
*
* @return A [Deferred] that will be resolved with a success flag once operation is complete.
* @return A boolean success flag.
*/
fun pollForCommandsAsync(): Deferred<Boolean>
suspend fun pollForCommands(): Boolean
}
/**
@@ -128,6 +131,24 @@ data class DevicePushSubscription(
val authKey: String
)
/**
* Configuration for the current device.
*
* @property name An initial name to use for the device record which will be created during authentication.
* This can be changed later via [DeviceConstellation.setDeviceName].
* @property type Type of a device - mobile, desktop - used for displaying identifying icons on other devices.
* This cannot be changed once device record is created.
* @property capabilities A set of device capabilities, such as SEND_TAB.
* @property secureStateAtRest A flag indicating whether or not to use encrypted storage for the persisted account
* state.
*/
data class DeviceConfig(
val name: String,
val type: DeviceType,
val capabilities: Set<DeviceCapability>,
val secureStateAtRest: Boolean = false
)
/**
* Capabilities that a [Device] may have.
*/

View File

@@ -7,17 +7,6 @@ package mozilla.components.concept.sync
import kotlinx.coroutines.Deferred
import org.json.JSONObject
/**
* An auth-related exception type, for use with [AuthException].
*
* @property msg string value of the auth exception type
*/
enum class AuthExceptionType(val msg: String) {
KEY_INFO("Missing key info"),
NO_TOKEN("Missing access token"),
UNAUTHORIZED("Unauthorized")
}
/**
* The access-type determines whether the code can be exchanged for a refresh token for
* offline use or not.
@@ -29,40 +18,39 @@ enum class AccessType(val msg: String) {
OFFLINE("offline")
}
/**
* An exception which may happen while obtaining auth information using [OAuthAccount].
*/
class AuthException(type: AuthExceptionType, cause: Exception? = null) : Throwable(type.msg, cause)
/**
* An object that represents a login flow initiated by [OAuthAccount].
* @property state OAuth state parameter, identifying a specific authentication flow.
* This string is randomly generated during [OAuthAccount.beginOAuthFlowAsync] and [OAuthAccount.beginPairingFlowAsync].
* This string is randomly generated during [OAuthAccount.beginOAuthFlow] and [OAuthAccount.beginPairingFlow].
* @property url Url which needs to be loaded to go through the authentication flow identified by [state].
*/
data class AuthFlowUrl(val state: String, val url: String)
/**
* Represents a specific type of an "in-flight" migration state that could result from intermittent
* issues during [OAuthAccount.migrateFromSessionTokenAsync] or [OAuthAccount.copyFromSessionTokenAsync].
* issues during [OAuthAccount.migrateFromAccount].
*/
enum class InFlightMigrationState {
enum class InFlightMigrationState(val reuseSessionToken: Boolean) {
/**
* No in-flight migration.
* "Copy" in-flight migration present. Can retry migration via [OAuthAccount.retryMigrateFromSessionToken].
*/
NONE,
COPY_SESSION_TOKEN(false),
/**
* "Copy" in-flight migration present. Can retry migration via [OAuthAccount.retryMigrateFromSessionTokenAsync].
* "Reuse" in-flight migration present. Can retry migration via [OAuthAccount.retryMigrateFromSessionToken].
*/
COPY_SESSION_TOKEN,
/**
* "Reuse" in-flight migration present. Can retry migration via [OAuthAccount.retryMigrateFromSessionTokenAsync].
*/
REUSE_SESSION_TOKEN
REUSE_SESSION_TOKEN(true)
}
/**
* Data structure describing FxA and Sync credentials necessary to sign-in into an FxA account.
*/
data class MigratingAccountInfo(
val sessionToken: String,
val kSync: String,
val kXCS: String
)
/**
* Facilitates testing consumers of FirefoxAccount.
*/
@@ -76,12 +64,12 @@ interface OAuthAccount : AutoCloseable {
* @param entryPoint The UI entryPoint used to start this flow. An arbitrary
* string which is recorded in telemetry by the server to help analyze the
* most effective touchpoints
* @return Deferred AuthFlowUrl that resolves to the flow URL when complete
* @return [AuthFlowUrl] if available, `null` in case of a failure
*/
fun beginOAuthFlowAsync(
suspend fun beginOAuthFlow(
scopes: Set<String>,
entryPoint: String = "android-components"
): Deferred<AuthFlowUrl?>
): AuthFlowUrl?
/**
* Constructs a URL used to begin the pairing flow for the requested scopes and pairingUrl.
@@ -91,13 +79,13 @@ interface OAuthAccount : AutoCloseable {
* @param entryPoint The UI entryPoint used to start this flow. An arbitrary
* string which is recorded in telemetry by the server to help analyze the
* most effective touchpoints
* @return Deferred AuthFlowUrl Optional that resolves to the flow URL when complete
* @return [AuthFlowUrl] if available, `null` in case of a failure
*/
fun beginPairingFlowAsync(
suspend fun beginPairingFlow(
pairingUrl: String,
scopes: Set<String>,
entryPoint: String = "android-components"
): Deferred<AuthFlowUrl?>
): AuthFlowUrl?
/**
* Returns current FxA Device ID for an authenticated account.
@@ -123,12 +111,12 @@ interface OAuthAccount : AutoCloseable {
* the code can be exchanged for a refresh token to be used offline or not
* @return Deferred authorized auth code string, or `null` in case of failure.
*/
fun authorizeOAuthCodeAsync(
suspend fun authorizeOAuthCode(
clientId: String,
scopes: Array<String>,
state: String,
accessType: AccessType = AccessType.ONLINE
): Deferred<String?>
): String?
/**
* Fetches the profile object for the current client either from the existing cached state
@@ -137,18 +125,18 @@ interface OAuthAccount : AutoCloseable {
* @param ignoreCache Fetch the profile information directly from the server
* @return Profile (optional, if successfully retrieved) representing the user's basic profile info
*/
fun getProfileAsync(ignoreCache: Boolean = false): Deferred<Profile?>
suspend fun getProfile(ignoreCache: Boolean = false): Profile?
/**
* Authenticates the current account using the [code] and [state] parameters obtained via the
* OAuth flow initiated by [beginOAuthFlowAsync].
* OAuth flow initiated by [beginOAuthFlow].
*
* Modifies the FirefoxAccount state.
* @param code OAuth code string
* @param state state token string
* @return Deferred boolean representing success or failure
*/
fun completeOAuthFlowAsync(code: String, state: String): Deferred<Boolean>
suspend fun completeOAuthFlow(code: String, state: String): Boolean
/**
* Tries to fetch an access token for the given scope.
@@ -157,11 +145,11 @@ interface OAuthAccount : AutoCloseable {
* @return [AccessTokenInfo] that stores the token, along with its scope, key and
* expiration timestamp (in seconds) since epoch when complete
*/
fun getAccessTokenAsync(singleScope: String): Deferred<AccessTokenInfo?>
suspend fun getAccessToken(singleScope: String): AccessTokenInfo?
/**
* Call this whenever an authentication error was encountered while using an access token
* issued by [getAccessTokenAsync].
* issued by [getAccessToken].
*/
fun authErrorDetected()
@@ -176,7 +164,7 @@ interface OAuthAccount : AutoCloseable {
* @return An optional [Boolean] flag indicating if we're connected, or need to go through
* re-authentication. A null result means we were not able to determine state at this time.
*/
fun checkAuthorizationStatusAsync(singleScope: String): Deferred<Boolean?>
suspend fun checkAuthorizationStatus(singleScope: String): Boolean?
/**
* Fetches the token server endpoint, for authentication using the SAML bearer flow.
@@ -203,36 +191,23 @@ interface OAuthAccount : AutoCloseable {
* Attempts to migrate from an existing session token without user input.
* Passed-in session token will be reused.
*
* @param sessionToken token string to use for login
* @param kSync sync string for login
* @param kXCS XCS string for login
* @param authInfo Auth info necessary for signing in
* @param reuseSessionToken Whether or not session token should be reused; reusing session token
* means that FxA device record will be inherited
* @return JSON object with the result of the migration or 'null' if it failed.
* For up-to-date schema, see underlying implementation in https://github.com/mozilla/application-services/blob/v0.49.0/components/fxa-client/src/migrator.rs#L10
* For up-to-date schema, see underlying implementation in
* https://github.com/mozilla/application-services/blob/v0.49.0/components/fxa-client/src/migrator.rs#L10
* At the moment, it's just "{total_duration: long}".
*/
fun migrateFromSessionTokenAsync(sessionToken: String, kSync: String, kXCS: String): Deferred<JSONObject?>
/**
* Attempts to migrate from an existing session token without user input.
* New session token will be created.
*
* @param sessionToken token string to use for login
* @param kSync sync string for login
* @param kXCS XCS string for login
* @return JSON object with the result of the migration or 'null' if it failed.
* For up-to-date schema, see underlying implementation in https://github.com/mozilla/application-services/blob/v0.49.0/components/fxa-client/src/migrator.rs#L10
* At the moment, it's just "{total_duration: long}".
*/
fun copyFromSessionTokenAsync(sessionToken: String, kSync: String, kXCS: String): Deferred<JSONObject?>
suspend fun migrateFromAccount(authInfo: MigratingAccountInfo, reuseSessionToken: Boolean): JSONObject?
/**
* Checks if there's a migration in-flight. An in-flight migration means that we've tried to migrate
* via either [migrateFromSessionTokenAsync] or [copyFromSessionTokenAsync], and failed for intermittent
* (e.g. network)
* reasons. When an in-flight migration is present, we can retry using [retryMigrateFromSessionTokenAsync].
* @return InFlightMigrationState indicating specific migration state.
* via [migrateFromAccount], and failed for intermittent (e.g. network) reasons. When an in-flight
* migration is present, we can retry using [retryMigrateFromSessionToken].
* @return InFlightMigrationState indicating specific migration state. [null] if not in a migration state.
*/
fun isInMigrationState(): InFlightMigrationState
fun isInMigrationState(): InFlightMigrationState?
/**
* Retries an in-flight migration attempt.
@@ -240,7 +215,7 @@ interface OAuthAccount : AutoCloseable {
* For up-to-date schema, see underlying implementation in https://github.com/mozilla/application-services/blob/v0.49.0/components/fxa-client/src/migrator.rs#L10
* At the moment, it's just "{total_duration: long}".
*/
fun retryMigrateFromSessionTokenAsync(): Deferred<JSONObject?>
suspend fun retryMigrateFromSessionToken(): JSONObject?
/**
* Returns the device constellation for the current account
@@ -258,7 +233,7 @@ interface OAuthAccount : AutoCloseable {
* Failure indicates that we may have failed to destroy current device record. Nothing to do for
* the consumer; device record will be cleaned up eventually via TTL.
*/
fun disconnectAsync(): Deferred<Boolean>
suspend fun disconnect(): Boolean
/**
* Serializes the current account's authentication state as a JSON string, for persistence in
@@ -307,9 +282,14 @@ sealed class AuthType {
data class OtherExternal(val action: String?) : AuthType()
/**
* Account created via a shared account state from another app.
* Account created via a shared account state from another app via the copy token flow.
*/
object Shared : AuthType()
object MigratedCopy : AuthType()
/**
* Account created via a shared account state from another app via the reuse token flow.
*/
object MigratedReuse : AuthType()
/**
* Existing account was recovered from an authentication problem.
@@ -317,6 +297,27 @@ sealed class AuthType {
object Recovered : AuthType()
}
/**
* Different types of errors that may be encountered during authorization and migration flows.
* Intermittent network problems are the most common reason for these errors.
*/
enum class AuthFlowError {
/**
* Couldn't begin authorization, i.e. failed to obtain an authorization URL.
*/
FailedToBeginAuth,
/**
* Couldn't complete authorization after user entered valid credentials/paired correctly.
*/
FailedToCompleteAuth,
/**
* Unrecoverable error during account migration.
*/
FailedToMigrate
}
/**
* Observer interface which lets its users monitor account state changes and major events.
* (XXX - there's some tension between this and the
@@ -346,6 +347,12 @@ interface AccountObserver {
* Account needs to be re-authenticated (e.g. due to a password change).
*/
fun onAuthenticationProblems() = Unit
/**
* Encountered an error during an authentication or migration flow.
* @param error Exact error encountered.
*/
fun onFlowError(error: AuthFlowError) = Unit
}
data class Avatar(

View File

@@ -8,6 +8,9 @@ import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mozilla.components.concept.push.PushProcessor
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.ConstellationState
@@ -122,7 +125,9 @@ internal class AccountObserver(
logger.debug("Subscribing for FxaPushScope ($fxaPushScope) events.")
push.subscribe(fxaPushScope) { subscription ->
account.deviceConstellation().setDevicePushSubscriptionAsync(subscription.into())
CoroutineScope(Dispatchers.Main).launch {
account.deviceConstellation().setDevicePushSubscription(subscription.into())
}
}
}
@@ -191,7 +196,9 @@ internal class AutoPushObserver(
val rawEvent = message ?: return
accountManager.withConstellation {
processRawEventAsync(String(rawEvent))
CoroutineScope(Dispatchers.Main).launch {
processRawEvent(String(rawEvent))
}
}
}
@@ -210,7 +217,9 @@ internal class AutoPushObserver(
return@subscribe
}
account.deviceConstellation().setDevicePushSubscriptionAsync(subscription.into())
CoroutineScope(Dispatchers.Main).launch {
account.deviceConstellation().setDevicePushSubscription(subscription.into())
}
}
}
}
@@ -325,7 +334,9 @@ class OneTimeFxaPushReset(
pushFeature.unsubscribe(pushScope)
pushFeature.subscribe(newPushScope) { subscription ->
account.deviceConstellation().setDevicePushSubscriptionAsync(subscription.into())
CoroutineScope(Dispatchers.Main).launch {
account.deviceConstellation().setDevicePushSubscription(subscription.into())
}
}
preference(context).edit().putString(PREF_FXA_SCOPE, newPushScope).apply()

View File

@@ -54,7 +54,7 @@ internal class EventsObserver(
override fun onEvents(events: List<AccountEvent>) {
events.asSequence()
.filterIsInstance<AccountEvent.DeviceCommandIncoming>()
.map({ it.command })
.map { it.command }
.filterIsInstance<DeviceCommandIncoming.TabReceived>()
.forEach { command ->
logger.debug("Showing ${command.entries.size} tab(s) received from deviceID=${command.from?.id}")

View File

@@ -75,10 +75,10 @@ class SendTabUseCases(
it.id == deviceId
}
device?.let {
return constellation.sendCommandToDeviceAsync(
return constellation.sendCommandToDevice(
device.id,
SendTab(tab.title, tab.url)
).await()
)
}
}
@@ -131,10 +131,10 @@ class SendTabUseCases(
// Get a list of device-tab combinations that we want to send.
return block(devices).map { (device, tab) ->
// Send the tab!
constellation.sendCommandToDeviceAsync(
constellation.sendCommandToDevice(
device.id,
SendTab(tab.title, tab.url)
).await()
)
}.fold(true) { acc, result ->
// Collect the results and reduce them into one final result.
acc and result

View File

@@ -4,9 +4,9 @@
package mozilla.components.feature.accounts.push
import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.runBlocking
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.DeviceConstellation
import mozilla.components.concept.sync.OAuthAccount
@@ -35,7 +35,6 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AccountObserverTest {
private val context: Context = mock()
private val accountManager: FxaAccountManager = mock()
private val pushFeature: AutoPushFeature = mock()
private val pushScope: String = "testScope"
@@ -151,7 +150,7 @@ class AccountObserverTest {
}
@Test
fun `notify account of new subscriptions`() {
fun `notify account of new subscriptions`() = runBlocking {
val observer = AccountObserver(
testContext,
pushFeature,
@@ -164,7 +163,8 @@ class AccountObserverTest {
observer.onAuthenticated(account, AuthType.Signin)
verify(constellation).setDevicePushSubscriptionAsync(any())
verify(constellation).setDevicePushSubscription(any())
Unit
}
@Test

View File

@@ -4,6 +4,11 @@
package mozilla.components.feature.accounts.push
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.setMain
import mozilla.components.concept.sync.DeviceConstellation
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.feature.push.AutoPushFeature
@@ -25,8 +30,10 @@ class AutoPushObserverTest {
private val constellation: DeviceConstellation = mock()
private val pushFeature: AutoPushFeature = mock()
@ExperimentalCoroutinesApi
@Test
fun `messages are forwarded to account manager`() {
fun `messages are forwarded to account manager`() = runBlocking {
Dispatchers.setMain(TestCoroutineDispatcher())
val observer = AutoPushObserver(manager, mock(), "test")
`when`(manager.authenticatedAccount()).thenReturn(account)
@@ -34,30 +41,35 @@ class AutoPushObserverTest {
observer.onMessageReceived("test", "foobar".toByteArray())
verify(constellation).processRawEventAsync("foobar")
verify(constellation).processRawEvent("foobar")
Unit
}
@Test
fun `account manager is not invoked if no account is available`() {
fun `account manager is not invoked if no account is available`() = runBlocking {
val observer = AutoPushObserver(manager, mock(), "test")
observer.onMessageReceived("test", "foobar".toByteArray())
verify(constellation, never()).setDevicePushSubscriptionAsync(any())
verify(constellation, never()).processRawEventAsync("foobar")
verify(constellation, never()).setDevicePushSubscription(any())
verify(constellation, never()).processRawEvent("foobar")
Unit
}
@Test
fun `messages are not forwarded to account manager if they are for a different scope`() {
fun `messages are not forwarded to account manager if they are for a different scope`() = runBlocking {
val observer = AutoPushObserver(manager, mock(), "fake")
observer.onMessageReceived("test", "foobar".toByteArray())
verify(constellation, never()).processRawEventAsync(any())
verify(constellation, never()).processRawEvent(any())
Unit
}
@ExperimentalCoroutinesApi
@Test
fun `subscription changes are forwarded to account manager`() {
fun `subscription changes are forwarded to account manager`() = runBlocking {
Dispatchers.setMain(TestCoroutineDispatcher())
val observer = AutoPushObserver(manager, pushFeature, "test")
whenSubscribe()
@@ -67,22 +79,24 @@ class AutoPushObserverTest {
observer.onSubscriptionChanged("test")
verify(constellation).setDevicePushSubscriptionAsync(any())
verify(constellation).setDevicePushSubscription(any())
Unit
}
@Test
fun `do nothing if there is no account manager`() {
fun `do nothing if there is no account manager`() = runBlocking {
val observer = AutoPushObserver(manager, pushFeature, "test")
whenSubscribe()
observer.onSubscriptionChanged("test")
verify(constellation, never()).setDevicePushSubscriptionAsync(any())
verify(constellation, never()).setDevicePushSubscription(any())
Unit
}
@Test
fun `subscription changes are not forwarded to account manager if they are for a different scope`() {
fun `subscription changes are not forwarded to account manager if they are for a different scope`() = runBlocking {
val observer = AutoPushObserver(manager, mock(), "fake")
`when`(manager.authenticatedAccount()).thenReturn(account)
@@ -90,7 +104,7 @@ class AutoPushObserverTest {
observer.onSubscriptionChanged("test")
verify(constellation, never()).setDevicePushSubscriptionAsync(any())
verify(constellation, never()).setDevicePushSubscription(any())
verifyZeroInteractions(pushFeature)
}

View File

@@ -36,7 +36,7 @@ class EventsObserverTest {
val callback: (Device?, List<TabData>) -> Unit = mock()
val observer = EventsObserver(callback)
val events = listOf(
AccountEvent.ProfileUpdated(),
AccountEvent.ProfileUpdated,
AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(mock(), mock())),
AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(mock(), mock()))
)

View File

@@ -6,6 +6,7 @@
package mozilla.components.feature.accounts.push
import kotlinx.coroutines.runBlocking
import mozilla.components.concept.sync.DeviceConstellation
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.feature.accounts.push.FxaPushSupportFeature.Companion.PUSH_SCOPE_PREFIX
@@ -68,7 +69,7 @@ class OneTimeFxaPushResetTest {
@Suppress("UNCHECKED_CAST")
@Test
fun `existing invalid scope format is updated`() {
fun `existing invalid scope format is updated`() = runBlocking {
preference(testContext).edit().putString(PREF_FXA_SCOPE, "12345").apply()
val validPushScope = PUSH_SCOPE_PREFIX + "12345"
@@ -92,7 +93,7 @@ class OneTimeFxaPushResetTest {
verify(pushFeature).unsubscribe(eq("12345"), any(), any())
verify(pushFeature).subscribe(eq(validPushScope), nullable(), any(), any())
verify(constellation).setDevicePushSubscriptionAsync(any())
verify(constellation).setDevicePushSubscription(any())
assertEquals(validPushScope, preference(testContext).getString(PREF_FXA_SCOPE, null))
}
}

View File

@@ -4,7 +4,6 @@
package mozilla.components.feature.accounts.push
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runBlockingTest
@@ -49,12 +48,12 @@ class SendTabUseCasesTest {
val device: Device = generateDevice()
`when`(state.otherDevices).thenReturn(listOf(device))
`when`(constellation.sendCommandToDeviceAsync(any(), any()))
.thenReturn(CompletableDeferred(true))
`when`(constellation.sendCommandToDevice(any(), any()))
.thenReturn(true)
useCases.sendToDeviceAsync(device.id, TabData("Title", "http://example.com"))
verify(constellation).sendCommandToDeviceAsync(any(), any())
verify(constellation).sendCommandToDevice(any(), any())
}
@Test
@@ -64,12 +63,12 @@ class SendTabUseCasesTest {
val tab = TabData("Title", "http://example.com")
`when`(state.otherDevices).thenReturn(listOf(device))
`when`(constellation.sendCommandToDeviceAsync(any(), any()))
.thenReturn(CompletableDeferred(true))
`when`(constellation.sendCommandToDevice(any(), any()))
.thenReturn(true)
useCases.sendToDeviceAsync(device.id, listOf(tab, tab))
verify(constellation, times(2)).sendCommandToDeviceAsync(any(), any())
verify(constellation, times(2)).sendCommandToDevice(any(), any())
}
@Test
@@ -80,16 +79,16 @@ class SendTabUseCasesTest {
useCases.sendToDeviceAsync("123", listOf(tab, tab))
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
`when`(device.id).thenReturn("123")
`when`(state.otherDevices).thenReturn(listOf(device))
`when`(constellation.sendCommandToDeviceAsync(any(), any()))
.thenReturn(CompletableDeferred(false))
`when`(constellation.sendCommandToDevice(any(), any()))
.thenReturn(false)
useCases.sendToDeviceAsync("123", listOf(tab, tab))
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
}
@Test
@@ -100,19 +99,19 @@ class SendTabUseCasesTest {
useCases.sendToDeviceAsync("123", tab)
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
`when`(state.otherDevices).thenReturn(listOf(device))
`when`(constellation.sendCommandToDeviceAsync(any(), any()))
.thenReturn(CompletableDeferred(false))
`when`(constellation.sendCommandToDevice(any(), any()))
.thenReturn(false)
useCases.sendToDeviceAsync("456", tab)
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
useCases.sendToDeviceAsync("123", tab)
verify(constellation).sendCommandToDeviceAsync(any(), any())
verify(constellation).sendCommandToDevice(any(), any())
}
@Test
@@ -123,19 +122,19 @@ class SendTabUseCasesTest {
useCases.sendToDeviceAsync("123", listOf(tab))
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
`when`(state.otherDevices).thenReturn(listOf(device))
`when`(constellation.sendCommandToDeviceAsync(any(), any()))
.thenReturn(CompletableDeferred(false))
`when`(constellation.sendCommandToDevice(any(), any()))
.thenReturn(false)
useCases.sendToDeviceAsync("456", listOf(tab))
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
useCases.sendToDeviceAsync("123", listOf(tab))
verify(constellation).sendCommandToDeviceAsync(any(), any())
verify(constellation).sendCommandToDevice(any(), any())
}
@Test
@@ -145,14 +144,14 @@ class SendTabUseCasesTest {
val device2: Device = generateDevice()
`when`(state.otherDevices).thenReturn(listOf(device, device2))
`when`(constellation.sendCommandToDeviceAsync(any(), any()))
.thenReturn(CompletableDeferred(false))
`when`(constellation.sendCommandToDevice(any(), any()))
.thenReturn(false)
val tab = TabData("Mozilla", "https://mozilla.org")
useCases.sendToAllAsync(tab)
verify(constellation, times(2)).sendCommandToDeviceAsync(any(), any())
verify(constellation, times(2)).sendCommandToDevice(any(), any())
}
@Test
@@ -162,15 +161,15 @@ class SendTabUseCasesTest {
val device2: Device = generateDevice()
`when`(state.otherDevices).thenReturn(listOf(device, device2))
`when`(constellation.sendCommandToDeviceAsync(any(), any()))
.thenReturn(CompletableDeferred(false))
`when`(constellation.sendCommandToDevice(any(), any()))
.thenReturn(false)
val tab = TabData("Mozilla", "https://mozilla.org")
val tab2 = TabData("Firefox", "https://firefox.com")
useCases.sendToAllAsync(listOf(tab, tab2))
verify(constellation, times(4)).sendCommandToDeviceAsync(any(), any())
verify(constellation, times(4)).sendCommandToDevice(any(), any())
}
@Test
@@ -183,7 +182,7 @@ class SendTabUseCasesTest {
runBlocking {
useCases.sendToAllAsync(tab)
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
`when`(device.id).thenReturn("123")
`when`(device2.id).thenReturn("456")
@@ -191,7 +190,7 @@ class SendTabUseCasesTest {
useCases.sendToAllAsync(tab)
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
}
}
@@ -206,7 +205,7 @@ class SendTabUseCasesTest {
runBlocking {
useCases.sendToAllAsync(tab)
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
`when`(device.id).thenReturn("123")
`when`(device2.id).thenReturn("456")
@@ -214,8 +213,8 @@ class SendTabUseCasesTest {
useCases.sendToAllAsync(listOf(tab, tab2))
verify(constellation, never()).sendCommandToDeviceAsync(eq("123"), any())
verify(constellation, never()).sendCommandToDeviceAsync(eq("456"), any())
verify(constellation, never()).sendCommandToDevice(eq("123"), any())
verify(constellation, never()).sendCommandToDevice(eq("456"), any())
}
}
@@ -227,17 +226,17 @@ class SendTabUseCasesTest {
useCases.sendToDeviceAsync("123", listOf(tab, tab))
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
`when`(device.id).thenReturn("123")
`when`(state.otherDevices).thenReturn(listOf(device))
`when`(constellation.sendCommandToDeviceAsync(any(), any()))
.thenReturn(CompletableDeferred(true))
.thenReturn(CompletableDeferred(true))
`when`(constellation.sendCommandToDevice(any(), any()))
.thenReturn(true)
.thenReturn(true)
val result = useCases.sendToDeviceAsync("123", listOf(tab, tab))
verify(constellation, never()).sendCommandToDeviceAsync(any(), any())
verify(constellation, never()).sendCommandToDevice(any(), any())
Assert.assertFalse(result.await())
}

View File

@@ -34,13 +34,13 @@ class FirefoxAccountsAuthFeature(
) {
fun beginAuthentication(context: Context) {
beginAuthenticationAsync(context) {
accountManager.beginAuthenticationAsync().await()
accountManager.beginAuthentication()
}
}
fun beginPairingAuthentication(context: Context, pairingUrl: String) {
beginAuthenticationAsync(context) {
accountManager.beginAuthenticationAsync(pairingUrl).await()
accountManager.beginAuthentication(pairingUrl)
}
}
@@ -81,11 +81,13 @@ class FirefoxAccountsAuthFeature(
val state = parsedUri.getQueryParameter("state") as String
// Notify the state machine about our success.
accountManager.finishAuthenticationAsync(FxaAuthData(
authType = authType,
code = code,
state = state
))
CoroutineScope(Dispatchers.Main).launch {
accountManager.finishAuthentication(FxaAuthData(
authType = authType,
code = code,
state = state
))
}
return RequestInterceptor.InterceptionResponse.Url(redirectUrl)
}

View File

@@ -7,9 +7,11 @@ package mozilla.components.feature.accounts
import android.content.Context
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.EngineSession
@@ -299,12 +301,14 @@ class FxaWebChannelFeature(
return null
}
accountManager.finishAuthenticationAsync(FxaAuthData(
authType = authType,
code = code,
state = state,
declinedEngines = declinedEngines?.toSyncEngines()
))
CoroutineScope(Dispatchers.Main).launch {
accountManager.finishAuthentication(FxaAuthData(
authType = authType,
code = code,
state = state,
declinedEngines = declinedEngines?.toSyncEngines()
))
}
return null
}

View File

@@ -5,7 +5,6 @@
package mozilla.components.feature.accounts
import android.content.Context
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import mozilla.components.concept.sync.DeviceType
import mozilla.components.concept.sync.OAuthAccount
@@ -21,10 +20,11 @@ import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.`when`
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CompletableDeferred
import mozilla.components.concept.engine.request.RequestInterceptor
import mozilla.components.concept.sync.AuthFlowUrl
import mozilla.components.concept.sync.AuthType
import mozilla.components.service.fxa.DeviceConfig
import mozilla.components.concept.sync.DeviceConfig
import mozilla.components.service.fxa.FxaAuthData
import mozilla.components.service.fxa.Server
import org.junit.Assert.assertEquals
@@ -32,6 +32,7 @@ import org.junit.Assert.assertNull
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.robolectric.annotation.Config
import kotlin.coroutines.CoroutineContext
// Same as the actual account manager, except we get to control how FirefoxAccountShaped instances
// are created. This is necessary because due to some build issues (native dependencies not available
@@ -41,107 +42,101 @@ class TestableFxaAccountManager(
context: Context,
config: ServerConfig,
scopes: Set<String>,
coroutineContext: CoroutineContext,
val block: () -> OAuthAccount = { mock() }
) : FxaAccountManager(context, config, DeviceConfig("test", DeviceType.MOBILE, setOf()), null, scopes) {
override fun createAccount(config: ServerConfig): OAuthAccount {
) : FxaAccountManager(context, config, DeviceConfig("test", DeviceType.MOBILE, setOf()), null, scopes, null, coroutineContext) {
override fun obtainAccount(config: ServerConfig): OAuthAccount {
return block()
}
}
@RunWith(AndroidJUnit4::class)
class FirefoxAccountsAuthFeatureTest {
class TestOnBeginAuthentication : (Context, String) -> Unit {
var url: String? = null
override fun invoke(context: Context, authUrl: String) {
url = authUrl
}
}
// Note that tests that involve secure storage specify API=21, because of issues testing secure storage on
// 23+ API levels. See https://github.com/mozilla-mobile/android-components/issues/4956
@Config(sdk = [22])
@Test
fun `begin authentication`() {
val manager = prepareAccountManagerForSuccessfulAuthentication()
val authLabmda = TestOnBeginAuthentication()
runBlocking {
val feature = FirefoxAccountsAuthFeature(
manager,
"somePath",
this.coroutineContext,
authLabmda
)
feature.beginAuthentication(testContext)
fun `begin authentication`() = runBlocking {
val manager = prepareAccountManagerForSuccessfulAuthentication(
this.coroutineContext
)
val authUrl = CompletableDeferred<String>()
val feature = FirefoxAccountsAuthFeature(
manager,
"somePath",
this.coroutineContext
) { _, url ->
authUrl.complete(url)
}
assertEquals("auth://url", authLabmda.url)
feature.beginAuthentication(testContext)
authUrl.await()
assertEquals("auth://url", authUrl.getCompleted())
}
@Config(sdk = [22])
@Test
fun `begin pairing authentication`() {
val manager = prepareAccountManagerForSuccessfulAuthentication()
val authLabmda = TestOnBeginAuthentication()
runBlocking {
val feature = FirefoxAccountsAuthFeature(
manager,
"somePath",
this.coroutineContext,
authLabmda
)
feature.beginPairingAuthentication(testContext, "auth://pair")
fun `begin pairing authentication`() = runBlocking {
val manager = prepareAccountManagerForSuccessfulAuthentication(
this.coroutineContext
)
val authUrl = CompletableDeferred<String>()
val feature = FirefoxAccountsAuthFeature(
manager,
"somePath",
this.coroutineContext
) { _, url ->
authUrl.complete(url)
}
assertEquals("auth://url", authLabmda.url)
feature.beginPairingAuthentication(testContext, "auth://pair")
authUrl.await()
assertEquals("auth://url", authUrl.getCompleted())
}
@Config(sdk = [22])
@Test
fun `begin authentication with errors`() {
val manager = prepareAccountManagerForFailedAuthentication()
val authLambda = TestOnBeginAuthentication()
fun `begin authentication with errors`() = runBlocking {
val manager = prepareAccountManagerForFailedAuthentication(
this.coroutineContext
)
val authUrl = CompletableDeferred<String>()
runBlocking {
val feature = FirefoxAccountsAuthFeature(
manager,
"somePath",
this.coroutineContext,
authLambda
)
feature.beginAuthentication(testContext)
val feature = FirefoxAccountsAuthFeature(
manager,
"somePath",
this.coroutineContext
) { _, url ->
authUrl.complete(url)
}
feature.beginAuthentication(testContext)
authUrl.await()
// Fallback url is invoked.
assertEquals("https://accounts.firefox.com/signin", authLambda.url)
assertEquals("https://accounts.firefox.com/signin", authUrl.getCompleted())
}
@Config(sdk = [22])
@Test
fun `begin pairing authentication with errors`() {
val manager = prepareAccountManagerForFailedAuthentication()
val authLambda = TestOnBeginAuthentication()
fun `begin pairing authentication with errors`() = runBlocking {
val manager = prepareAccountManagerForFailedAuthentication(
this.coroutineContext
)
val authUrl = CompletableDeferred<String>()
runBlocking {
val feature = FirefoxAccountsAuthFeature(
manager,
"somePath",
this.coroutineContext,
authLambda
)
feature.beginPairingAuthentication(testContext, "auth://pair")
val feature = FirefoxAccountsAuthFeature(
manager,
"somePath",
this.coroutineContext
) { _, url ->
authUrl.complete(url)
}
feature.beginPairingAuthentication(testContext, "auth://pair")
authUrl.await()
// Fallback url is invoked.
assertEquals("https://accounts.firefox.com/signin", authLambda.url)
assertEquals("https://accounts.firefox.com/signin", authUrl.getCompleted())
}
@Test
fun `auth interceptor`() {
fun `auth interceptor`() = runBlocking {
val manager = mock<FxaAccountManager>()
val redirectUrl = "https://accounts.firefox.com/oauth/success/123"
val feature = FirefoxAccountsAuthFeature(
@@ -152,26 +147,26 @@ class FirefoxAccountsAuthFeatureTest {
// Non-final FxA url.
assertNull(feature.interceptor.onLoadRequest(mock(), "https://accounts.firefox.com/not/the/right/url", null, false, false, false, false, false))
verify(manager, never()).finishAuthenticationAsync(any())
verify(manager, never()).finishAuthentication(any())
// Non-FxA url.
assertNull(feature.interceptor.onLoadRequest(mock(), "https://www.wikipedia.org", null, false, false, false, false, false))
verify(manager, never()).finishAuthenticationAsync(any())
verify(manager, never()).finishAuthentication(any())
// Redirect url, without code/state.
assertNull(feature.interceptor.onLoadRequest(mock(), "https://accounts.firefox.com/oauth/success/123/", null, false, false, false, false, false))
verify(manager, never()).finishAuthenticationAsync(any())
verify(manager, never()).finishAuthentication(any())
// Redirect url, without code/state.
assertNull(feature.interceptor.onLoadRequest(mock(), "https://accounts.firefox.com/oauth/success/123/test", null, false, false, false, false, false))
verify(manager, never()).finishAuthenticationAsync(any())
verify(manager, never()).finishAuthentication(any())
// Code+state, no action.
assertEquals(
RequestInterceptor.InterceptionResponse.Url(redirectUrl),
feature.interceptor.onLoadRequest(mock(), "https://accounts.firefox.com/oauth/success/123/?code=testCode1&state=testState1", null, false, false, false, false, false)
)
verify(manager).finishAuthenticationAsync(
verify(manager).finishAuthentication(
FxaAuthData(authType = AuthType.OtherExternal(null), code = "testCode1", state = "testState1")
)
@@ -180,7 +175,7 @@ class FirefoxAccountsAuthFeatureTest {
RequestInterceptor.InterceptionResponse.Url(redirectUrl),
feature.interceptor.onLoadRequest(mock(), "https://accounts.firefox.com/oauth/success/123/?code=testCode2&state=testState2&action=signin", null, false, false, false, false, false)
)
verify(manager).finishAuthenticationAsync(
verify(manager).finishAuthentication(
FxaAuthData(authType = AuthType.Signin, code = "testCode2", state = "testState2")
)
@@ -189,7 +184,7 @@ class FirefoxAccountsAuthFeatureTest {
RequestInterceptor.InterceptionResponse.Url(redirectUrl),
feature.interceptor.onLoadRequest(mock(), "https://accounts.firefox.com/oauth/success/123/?code=testCode3&state=testState3&action=signup", null, false, false, false, false, false)
)
verify(manager).finishAuthenticationAsync(
verify(manager).finishAuthentication(
FxaAuthData(authType = AuthType.Signup, code = "testCode3", state = "testState3")
)
@@ -198,7 +193,7 @@ class FirefoxAccountsAuthFeatureTest {
RequestInterceptor.InterceptionResponse.Url(redirectUrl),
feature.interceptor.onLoadRequest(mock(), "https://accounts.firefox.com/oauth/success/123/?code=testCode4&state=testState4&action=pairing", null, false, false, false, false, false)
)
verify(manager).finishAuthenticationAsync(
verify(manager).finishAuthentication(
FxaAuthData(authType = AuthType.Pairing, code = "testCode4", state = "testState4")
)
@@ -207,58 +202,62 @@ class FirefoxAccountsAuthFeatureTest {
RequestInterceptor.InterceptionResponse.Url(redirectUrl),
feature.interceptor.onLoadRequest(mock(), "https://accounts.firefox.com/oauth/success/123/?code=testCode5&state=testState5&action=someNewActionType", null, false, false, false, false, false)
)
verify(manager).finishAuthenticationAsync(
verify(manager).finishAuthentication(
FxaAuthData(authType = AuthType.OtherExternal("someNewActionType"), code = "testCode5", state = "testState5")
)
Unit
}
@Config(sdk = [22])
private fun prepareAccountManagerForSuccessfulAuthentication(): TestableFxaAccountManager {
private suspend fun prepareAccountManagerForSuccessfulAuthentication(
coroutineContext: CoroutineContext
): TestableFxaAccountManager {
val mockAccount: OAuthAccount = mock()
val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile")
`when`(mockAccount.getProfileAsync(anyBoolean())).thenReturn(CompletableDeferred(profile))
`when`(mockAccount.beginOAuthFlowAsync(any(), anyString())).thenReturn(CompletableDeferred(AuthFlowUrl("authState", "auth://url")))
`when`(mockAccount.beginPairingFlowAsync(anyString(), any(), anyString())).thenReturn(CompletableDeferred(AuthFlowUrl("authState", "auth://url")))
`when`(mockAccount.completeOAuthFlowAsync(anyString(), anyString())).thenReturn(CompletableDeferred(true))
`when`(mockAccount.deviceConstellation()).thenReturn(mock())
`when`(mockAccount.getProfile(anyBoolean())).thenReturn(profile)
`when`(mockAccount.beginOAuthFlow(any(), anyString())).thenReturn(AuthFlowUrl("authState", "auth://url"))
`when`(mockAccount.beginPairingFlow(anyString(), any(), anyString())).thenReturn(AuthFlowUrl("authState", "auth://url"))
`when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true)
val manager = TestableFxaAccountManager(
testContext,
ServerConfig(Server.RELEASE, "dummyId", "bad://url"),
setOf("test-scope")
setOf("test-scope"),
coroutineContext
) {
mockAccount
}
runBlocking {
manager.initAsync().await()
}
manager.start()
return manager
}
@Config(sdk = [22])
private fun prepareAccountManagerForFailedAuthentication(): TestableFxaAccountManager {
private suspend fun prepareAccountManagerForFailedAuthentication(
coroutineContext: CoroutineContext
): TestableFxaAccountManager {
val mockAccount: OAuthAccount = mock()
val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile")
`when`(mockAccount.getProfileAsync(anyBoolean())).thenReturn(CompletableDeferred(profile))
`when`(mockAccount.beginOAuthFlowAsync(any(), anyString())).thenReturn(CompletableDeferred(value = null))
`when`(mockAccount.beginPairingFlowAsync(anyString(), any(), anyString())).thenReturn(CompletableDeferred(value = null))
`when`(mockAccount.completeOAuthFlowAsync(anyString(), anyString())).thenReturn(CompletableDeferred(true))
`when`(mockAccount.getProfile(anyBoolean())).thenReturn(profile)
`when`(mockAccount.deviceConstellation()).thenReturn(mock())
`when`(mockAccount.beginOAuthFlow(any(), anyString())).thenReturn(null)
`when`(mockAccount.beginPairingFlow(anyString(), any(), anyString())).thenReturn(null)
`when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true)
val manager = TestableFxaAccountManager(
testContext,
ServerConfig(Server.RELEASE, "dummyId", "bad://url"),
setOf("test-scope")
setOf("test-scope"),
coroutineContext
) {
mockAccount
}
runBlocking {
manager.initAsync().await()
}
manager.start()
return manager
}

View File

@@ -5,7 +5,7 @@
package mozilla.components.feature.accounts
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.state.BrowserState
@@ -253,7 +253,6 @@ class FxaWebChannelFeatureTest {
val ext: WebExtension = mock()
val port: Port = mock()
val expectedEngines: Set<SyncEngine> = setOf(SyncEngine.History)
val logoutDeferred = CompletableDeferred<Unit>()
val messageHandler = argumentCaptor<MessageHandler>()
val responseToTheWebChannel = argumentCaptor<JSONObject>()
@@ -263,7 +262,6 @@ class FxaWebChannelFeatureTest {
whenever(accountManager.accountProfile()).thenReturn(profile)
whenever(accountManager.authenticatedAccount()).thenReturn(account)
whenever(accountManager.supportedSyncEngines()).thenReturn(expectedEngines)
whenever(accountManager.logoutAsync()).thenReturn(logoutDeferred)
val webchannelFeature = prepareFeatureForTest(ext, port, engineSession, expectedEngines, emptySet(), accountManager)
webchannelFeature.start()
@@ -308,7 +306,6 @@ class FxaWebChannelFeatureTest {
val ext: WebExtension = mock()
val port: Port = mock()
val expectedEngines: Set<SyncEngine> = setOf(SyncEngine.History)
val logoutDeferred = CompletableDeferred<Unit>()
val messageHandler = argumentCaptor<MessageHandler>()
val responseToTheWebChannel = argumentCaptor<JSONObject>()
@@ -317,7 +314,6 @@ class FxaWebChannelFeatureTest {
whenever(accountManager.accountProfile()).thenReturn(null)
whenever(accountManager.authenticatedAccount()).thenReturn(account)
whenever(accountManager.supportedSyncEngines()).thenReturn(expectedEngines)
whenever(accountManager.logoutAsync()).thenReturn(logoutDeferred)
val webchannelFeature = prepareFeatureForTest(ext, port, engineSession, expectedEngines, emptySet(), accountManager)
webchannelFeature.start()
@@ -485,14 +481,14 @@ class FxaWebChannelFeatureTest {
// Receiving an oauth-login message account manager accepts the request
@Test
fun `COMMAND_OAUTH_LOGIN web-channel must be processed through when the accountManager accepts the request`() {
fun `COMMAND_OAUTH_LOGIN web-channel must be processed through when the accountManager accepts the request`() = runBlocking {
val accountManager: FxaAccountManager = mock() // syncConfig is null by default (is not configured)
val engineSession: EngineSession = mock()
val ext: WebExtension = mock()
val port: Port = mock()
val messageHandler = argumentCaptor<MessageHandler>()
whenever(accountManager.finishAuthenticationAsync(any())).thenReturn(CompletableDeferred(false))
whenever(accountManager.finishAuthentication(any())).thenReturn(false)
WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
val webchannelFeature = prepareFeatureForTest(ext, port, engineSession, null, emptySet(), accountManager)
@@ -517,14 +513,14 @@ class FxaWebChannelFeatureTest {
// Receiving an oauth-login message account manager refuses the request
@Test
fun `COMMAND_OAUTH_LOGIN web-channel must be processed when the accountManager refuses the request`() {
fun `COMMAND_OAUTH_LOGIN web-channel must be processed when the accountManager refuses the request`() = runBlocking {
val accountManager: FxaAccountManager = mock() // syncConfig is null by default (is not configured)
val engineSession: EngineSession = mock()
val ext: WebExtension = mock()
val port: Port = mock()
val messageHandler = argumentCaptor<MessageHandler>()
whenever(accountManager.finishAuthenticationAsync(any())).thenReturn(CompletableDeferred(false))
whenever(accountManager.finishAuthentication(any())).thenReturn(false)
WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
val webchannelFeature = prepareFeatureForTest(ext, port, engineSession, null, emptySet(), accountManager)
@@ -663,7 +659,7 @@ class FxaWebChannelFeatureTest {
.getBoolean("ok")
}
private fun verifyOauthLogin(action: String, expectedAuthType: AuthType, code: String, state: String, declined: Set<SyncEngine>?, messageHandler: MessageHandler, accountManager: FxaAccountManager) {
private suspend fun verifyOauthLogin(action: String, expectedAuthType: AuthType, code: String, state: String, declined: Set<SyncEngine>?, messageHandler: MessageHandler, accountManager: FxaAccountManager) {
val jsonToWebChannel = jsonOauthLogin(action, code, state, declined ?: emptySet())
val port = mock<Port>()
whenever(port.senderUrl()).thenReturn("https://foo.bar/email")
@@ -675,7 +671,7 @@ class FxaWebChannelFeatureTest {
state = state,
declinedEngines = declined ?: emptySet()
)
verify(accountManager).finishAuthenticationAsync(expectedAuthData)
verify(accountManager).finishAuthentication(expectedAuthData)
}
private fun jsonOauthLogin(action: String, code: String, state: String, declined: Set<SyncEngine>): JSONObject {

View File

@@ -57,10 +57,8 @@ internal class DefaultController(
*/
override fun syncAccount() {
scope.launch {
accountManager.withConstellation {
refreshDevicesAsync().await()
}
accountManager.syncNowAsync(SyncReason.User)
accountManager.withConstellation { refreshDevices() }
accountManager.syncNow(SyncReason.User)
}
}
}

View File

@@ -5,7 +5,6 @@
package mozilla.components.feature.syncedtabs.controller
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runBlockingTest
@@ -160,11 +159,11 @@ class DefaultControllerTest {
`when`(accountManager.authenticatedAccount()).thenReturn(account)
`when`(account.deviceConstellation()).thenReturn(constellation)
`when`(constellation.refreshDevicesAsync()).thenReturn(CompletableDeferred(true))
`when`(constellation.refreshDevices()).thenReturn(true)
controller.syncAccount()
verify(constellation).refreshDevicesAsync()
verify(accountManager).syncNowAsync(SyncReason.User, false)
verify(constellation).refreshDevices()
verify(accountManager).syncNow(SyncReason.User, false)
}
}

View File

@@ -75,6 +75,7 @@ dependencies {
testImplementation files(configurations.jnaForTest.copyRecursive().files)
testImplementation Dependencies.mozilla_full_megazord_forUnitTests
testImplementation Dependencies.kotlin_reflect
}
apply from: '../../../publish.gradle'

View File

@@ -4,53 +4,30 @@
package mozilla.components.service.fxa
import mozilla.components.concept.sync.DeviceCapability
import mozilla.components.concept.sync.DeviceType
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.sync.GlobalSyncableStoreProvider
typealias ServerConfig = mozilla.appservices.fxaclient.Config
typealias Server = mozilla.appservices.fxaclient.Config.Server
/**
* Configuration for the current device.
*
* @property name An initial name to use for the device record which will be created during authentication.
* This can be changed later via [FxaDeviceConstellation].
*
* @property type Type of a device - mobile, desktop - used for displaying identifying icons on other devices.
* This cannot be changed once device record is created.
*
* @property capabilities A set of device capabilities, such as SEND_TAB. This set can be expanded by
* re-initializing [FxaAccountManager] with a new set (e.g. on app restart).
* Shrinking a set of capabilities is currently not supported.
*
* @property secureStateAtRest A flag indicating whether or not to use encrypted storage for the persisted account
* state. If set to `true`, [SecureAbove22AccountStorage] will be used as a storage layer. As the name suggests,
* account state will only by encrypted on Android API 23+. Otherwise, even if this flag is set to `true`, account state
* will be stored in plaintext.
*
* Default value of `false` configures the plaintext version of account storage to be used, [SharedPrefAccountStorage].
*
* Switching of this flag's values is supported; account state will be migrated between the underlying storage layers.
* @property periodMinutes How frequently periodic sync should happen.
* @property initialDelayMinutes What should the initial delay for the periodic sync be.
*/
data class DeviceConfig(
val name: String,
val type: DeviceType,
val capabilities: Set<DeviceCapability>,
val secureStateAtRest: Boolean = false
data class PeriodicSyncConfig(
val periodMinutes: Int = 240,
val initialDelayMinutes: Int = 5
)
/**
* Configuration for sync.
*
* @property supportedEngines A set of supported sync engines, exposed via [GlobalSyncableStoreProvider].
* @property syncPeriodInMinutes Optional, how frequently periodic sync should happen. If this is `null`,
* periodic syncing will be disabled.
* @property periodicSyncConfig Optional configuration for running sync periodically.
* Periodic sync is disabled if this is `null`.
*/
data class SyncConfig(
val supportedEngines: Set<SyncEngine>,
val syncPeriodInMinutes: Long? = null
val periodicSyncConfig: PeriodicSyncConfig?
)
/**

View File

@@ -5,22 +5,21 @@
package mozilla.components.service.fxa
import android.net.Uri
import kotlinx.coroutines.async
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.plus
import mozilla.appservices.fxaclient.AuthorizationParams
import kotlinx.coroutines.withContext
import mozilla.appservices.fxaclient.FirefoxAccount as InternalFxAcct
import mozilla.components.concept.sync.AccessType
import mozilla.components.concept.sync.AuthFlowUrl
import mozilla.components.concept.sync.MigratingAccountInfo
import mozilla.components.concept.sync.DeviceConstellation
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.StatePersistenceCallback
import mozilla.components.concept.base.crash.CrashReporting
import mozilla.components.support.base.log.logger.Logger
import org.json.JSONObject
typealias PersistCallback = mozilla.appservices.fxaclient.FirefoxAccount.PersistCallback
@@ -30,7 +29,7 @@ typealias PersistCallback = mozilla.appservices.fxaclient.FirefoxAccount.Persist
@Suppress("TooManyFunctions")
class FirefoxAccount internal constructor(
private val inner: InternalFxAcct,
private val crashReporter: CrashReporting? = null
crashReporter: CrashReporting? = null
) : OAuthAccount {
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO) + job
@@ -77,14 +76,6 @@ class FirefoxAccount internal constructor(
/**
* Construct a FirefoxAccount from a [Config], a clientId, and a redirectUri.
*
* @param persistCallback This callback will be called every time the [FirefoxAccount]
* internal state has mutated.
* The FirefoxAccount instance can be later restored using the
* [FirefoxAccount.fromJSONString]` class method.
* It is the responsibility of the consumer to ensure the persisted data
* is saved in a secure location, as it can contain Sync Keys and
* OAuth tokens.
*
* @param crashReporter A crash reporter instance.
*
* Note that it is not necessary to `close` the Config if this constructor is used (however
@@ -92,9 +83,8 @@ class FirefoxAccount internal constructor(
*/
constructor(
config: ServerConfig,
persistCallback: PersistCallback? = null,
crashReporter: CrashReporting? = null
) : this(InternalFxAcct(config, persistCallback), crashReporter)
) : this(InternalFxAcct(config), crashReporter)
override fun close() {
job.cancel()
@@ -102,10 +92,11 @@ class FirefoxAccount internal constructor(
}
override fun registerPersistenceCallback(callback: StatePersistenceCallback) {
logger.info("Registering persistence callback")
persistCallback.setCallback(callback)
}
override fun beginOAuthFlowAsync(scopes: Set<String>, entryPoint: String) = scope.async {
override suspend fun beginOAuthFlow(scopes: Set<String>, entryPoint: String) = withContext(scope.coroutineContext) {
handleFxaExceptions(logger, "begin oauth flow", { null }) {
val url = inner.beginOAuthFlow(scopes.toTypedArray(), entryPoint)
val state = Uri.parse(url).getQueryParameter("state")!!
@@ -113,7 +104,10 @@ class FirefoxAccount internal constructor(
}
}
override fun beginPairingFlowAsync(pairingUrl: String, scopes: Set<String>, entryPoint: String) = scope.async {
override suspend fun beginPairingFlow(
pairingUrl: String,
scopes: Set<String>, entryPoint: String
) = withContext(scope.coroutineContext) {
// Eventually we should specify this as a param here, but for now, let's
// use a generic value (it's used only for server-side telemetry, so the
// actual value doesn't matter much)
@@ -124,7 +118,7 @@ class FirefoxAccount internal constructor(
}
}
override fun getProfileAsync(ignoreCache: Boolean) = scope.async {
override suspend fun getProfile(ignoreCache: Boolean) = withContext(scope.coroutineContext) {
handleFxaExceptions(logger, "getProfile", { null }) {
inner.getProfile(ignoreCache).into()
}
@@ -142,12 +136,12 @@ class FirefoxAccount internal constructor(
}
}
override fun authorizeOAuthCodeAsync(
override suspend fun authorizeOAuthCode(
clientId: String,
scopes: Array<String>,
state: String,
accessType: AccessType
) = scope.async {
) = withContext(scope.coroutineContext) {
handleFxaExceptions(logger, "authorizeOAuthCode", { null }) {
val params = AuthorizationParams(clientId, scopes, state, accessType.msg)
inner.authorizeOAuthCode(params)
@@ -155,9 +149,9 @@ class FirefoxAccount internal constructor(
}
override fun getSessionToken(): String? {
// This is awkward, yes. Underlying method simply reads some data from in-memory state, and yet it throws
// in case that data isn't there. See https://github.com/mozilla/application-services/issues/2202.
return try {
// This is awkward, yes. Underlying method simply reads some data from in-memory state, and yet it throws
// in case that data isn't there. See https://github.com/mozilla/application-services/issues/2202.
inner.getSessionToken()
} catch (e: FxaPanicException) {
throw e
@@ -166,21 +160,24 @@ class FirefoxAccount internal constructor(
}
}
override fun migrateFromSessionTokenAsync(sessionToken: String, kSync: String, kXCS: String) = scope.async {
handleFxaExceptions(logger, "migrateFromSessionToken", { null }) {
inner.migrateFromSessionToken(sessionToken, kSync, kXCS)
}
}
override fun copyFromSessionTokenAsync(sessionToken: String, kSync: String, kXCS: String) = scope.async {
handleFxaExceptions(logger, "copyFromSessionToken", { null }) {
inner.copyFromSessionToken(sessionToken, kSync, kXCS)
override suspend fun migrateFromAccount(
authInfo: MigratingAccountInfo,
reuseSessionToken: Boolean
) = withContext(scope.coroutineContext) {
if (reuseSessionToken) {
handleFxaExceptions(logger, "migrateFromSessionToken", { null }) {
inner.migrateFromSessionToken(authInfo.sessionToken, authInfo.kSync, authInfo.kXCS)
}
} else {
handleFxaExceptions(logger, "copyFromSessionToken", { null }) {
inner.copyFromSessionToken(authInfo.sessionToken, authInfo.kSync, authInfo.kXCS)
}
}
}
override fun isInMigrationState() = inner.isInMigrationState().into()
override fun retryMigrateFromSessionTokenAsync(): Deferred<JSONObject?> = scope.async {
override suspend fun retryMigrateFromSessionToken() = withContext(scope.coroutineContext) {
handleFxaExceptions(logger, "retryMigrateFromSessionToken", { null }) {
inner.retryMigrateFromSessionToken()
}
@@ -201,13 +198,13 @@ class FirefoxAccount internal constructor(
return inner.getConnectionSuccessURL()
}
override fun completeOAuthFlowAsync(code: String, state: String) = scope.async {
override suspend fun completeOAuthFlow(code: String, state: String) = withContext(scope.coroutineContext) {
handleFxaExceptions(logger, "complete oauth flow") {
inner.completeOAuthFlow(code, state)
}
}
override fun getAccessTokenAsync(singleScope: String) = scope.async {
override suspend fun getAccessToken(singleScope: String) = withContext(scope.coroutineContext) {
handleFxaExceptions(logger, "get access token", { null }) {
inner.getAccessToken(singleScope).into()
}
@@ -219,7 +216,7 @@ class FirefoxAccount internal constructor(
inner.clearAccessTokenCache()
}
override fun checkAuthorizationStatusAsync(singleScope: String) = scope.async {
override suspend fun checkAuthorizationStatus(singleScope: String) = withContext(scope.coroutineContext) {
// Now that internal token caches are cleared, we can perform a connectivity check.
// Do so by requesting a new access token using an internally-stored "refresh token".
// Success here means that we're still able to connect - our cached access token simply expired.
@@ -242,7 +239,7 @@ class FirefoxAccount internal constructor(
// Re-throw all other exceptions.
}
override fun disconnectAsync() = scope.async {
override suspend fun disconnect() = withContext(scope.coroutineContext) {
// TODO can this ever throw FxaUnauthorizedException? would that even make sense? or is that a bug?
handleFxaExceptions(logger, "disconnect", { false }) {
inner.disconnect()

View File

@@ -5,29 +5,36 @@
package mozilla.components.service.fxa
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.appservices.fxaclient.FirefoxAccount
import mozilla.appservices.fxaclient.FxaException
import mozilla.components.concept.sync.ConstellationState
import mozilla.components.concept.sync.Device
import mozilla.components.concept.sync.DeviceCapability
import mozilla.components.concept.sync.DeviceConstellation
import mozilla.components.concept.sync.DeviceConstellationObserver
import mozilla.components.concept.sync.AccountEvent
import mozilla.components.concept.sync.AccountEventsObserver
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.DeviceCommandOutgoing
import mozilla.components.concept.sync.DeviceConfig
import mozilla.components.concept.sync.DevicePushSubscription
import mozilla.components.concept.sync.DeviceType
import mozilla.components.concept.base.crash.CrashReporting
import mozilla.components.concept.sync.ServiceResult
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.base.observer.Observable
import mozilla.components.support.base.observer.ObserverRegistry
import mozilla.components.support.sync.telemetry.SyncTelemetry
internal sealed class FxaDeviceConstellationException : Exception() {
/**
* Failure while ensuring device capabilities.
*/
class EnsureCapabilitiesFailed : FxaDeviceConstellationException()
}
/**
* Provides an implementation of [DeviceConstellation] backed by a [FirefoxAccount].
*/
@@ -46,31 +53,68 @@ class FxaDeviceConstellation(
override fun state(): ConstellationState? = constellationState
override fun initDeviceAsync(
name: String,
type: DeviceType,
capabilities: Set<DeviceCapability>
): Deferred<Boolean> {
return scope.async {
handleFxaExceptions(logger, "initializing device") {
account.initializeDevice(name, type.into(), capabilities.map { it.into() }.toSet())
@VisibleForTesting
internal enum class DeviceFinalizeAction {
Initialize,
EnsureCapabilities,
None
}
@Suppress("ComplexMethod")
@Throws(FxaPanicException::class)
override suspend fun finalizeDevice(
authType: AuthType,
config: DeviceConfig
): ServiceResult = withContext(scope.coroutineContext) {
val finalizeAction = when (authType) {
AuthType.Signin,
AuthType.Signup,
AuthType.Pairing,
is AuthType.OtherExternal,
AuthType.MigratedCopy -> DeviceFinalizeAction.Initialize
AuthType.Existing,
AuthType.MigratedReuse -> DeviceFinalizeAction.EnsureCapabilities
AuthType.Recovered -> DeviceFinalizeAction.None
}
if (finalizeAction == DeviceFinalizeAction.None) {
ServiceResult.Ok
} else {
val capabilities = config.capabilities.map { it.into() }.toSet()
if (finalizeAction == DeviceFinalizeAction.Initialize) {
try {
account.initializeDevice(config.name, config.type.into(), capabilities)
ServiceResult.Ok
} catch (e: FxaPanicException) {
throw e
} catch (e: FxaUnauthorizedException) {
ServiceResult.AuthError
} catch (e: FxaException) {
ServiceResult.OtherError
}
} else {
try {
account.ensureCapabilities(capabilities)
ServiceResult.Ok
} catch (e: FxaPanicException) {
throw e
} catch (e: FxaUnauthorizedException) {
// Unless we've added a new capability, in practice 'ensureCapabilities' isn't
// actually expected to do any work: everything should have been done by initializeDevice.
// So if it did, and failed, let's report this so that we're aware of this!
// See https://github.com/mozilla-mobile/android-components/issues/8164
crashReporter?.submitCaughtException(FxaDeviceConstellationException.EnsureCapabilitiesFailed())
ServiceResult.AuthError
} catch (e: FxaException) {
ServiceResult.OtherError
}
}
}
}
override fun ensureCapabilitiesAsync(capabilities: Set<DeviceCapability>): Deferred<Boolean> {
return scope.async {
handleFxaExceptions(logger, "ensuring capabilities") {
account.ensureCapabilities(capabilities.map { it.into() }.toSet())
}
}
}
override fun processRawEventAsync(payload: String): Deferred<Boolean> {
return scope.async {
handleFxaExceptions(logger, "processing raw commands") {
processEvents(account.handlePushMessage(payload).map { it.into() })
}
override suspend fun processRawEvent(payload: String) = withContext(scope.coroutineContext) {
handleFxaExceptions(logger, "processing raw commands") {
processEvents(account.handlePushMessage(payload).map { it.into() })
}
}
@@ -82,108 +126,99 @@ class FxaDeviceConstellation(
deviceObserverRegistry.register(observer, owner, autoPause)
}
override fun setDeviceNameAsync(name: String, context: Context): Deferred<Boolean> {
return scope.async {
val rename = handleFxaExceptions(logger, "changing device name") {
account.setDeviceDisplayName(name)
}
FxaDeviceSettingsCache(context).updateCachedName(name)
// See the latest device (name) changes after changing it.
val refreshDevices = refreshDevicesAsync().await()
override suspend fun setDeviceName(name: String, context: Context) = withContext(scope.coroutineContext) {
val rename = handleFxaExceptions(logger, "changing device name") {
account.setDeviceDisplayName(name)
}
FxaDeviceSettingsCache(context).updateCachedName(name)
// See the latest device (name) changes after changing it.
rename && refreshDevices
rename && refreshDevices()
}
override suspend fun setDevicePushSubscription(
subscription: DevicePushSubscription
) = withContext(scope.coroutineContext) {
handleFxaExceptions(logger, "updating device push subscription") {
account.setDevicePushSubscription(
subscription.endpoint, subscription.publicKey, subscription.authKey
)
}
}
override fun setDevicePushSubscriptionAsync(subscription: DevicePushSubscription): Deferred<Boolean> {
return scope.async {
handleFxaExceptions(logger, "updating device push subscription") {
account.setDevicePushSubscription(
subscription.endpoint, subscription.publicKey, subscription.authKey
)
}
}
}
override fun sendCommandToDeviceAsync(
override suspend fun sendCommandToDevice(
targetDeviceId: String,
outgoingCommand: DeviceCommandOutgoing
): Deferred<Boolean> {
return scope.async {
handleFxaExceptions(logger, "sending device command") {
when (outgoingCommand) {
is DeviceCommandOutgoing.SendTab -> {
account.sendSingleTab(targetDeviceId, outgoingCommand.title, outgoingCommand.url)
SyncTelemetry.processFxaTelemetry(account.gatherTelemetry(), crashReporter)
}
else -> logger.debug("Skipped sending unsupported command type: $outgoingCommand")
) = withContext(scope.coroutineContext) {
handleFxaExceptions(logger, "sending device command") {
when (outgoingCommand) {
is DeviceCommandOutgoing.SendTab -> {
account.sendSingleTab(targetDeviceId, outgoingCommand.title, outgoingCommand.url)
SyncTelemetry.processFxaTelemetry(account.gatherTelemetry(), crashReporter)
}
else -> logger.debug("Skipped sending unsupported command type: $outgoingCommand")
}
}
}
// Poll for missed commands. Commands are the only event-type that can be
// polled for, although missed commands will be delivered as AccountEvents.
override fun pollForCommandsAsync(): Deferred<Boolean> {
return scope.async {
val events = handleFxaExceptions(logger, "polling for device commands", { null }) {
account.pollDeviceCommands().map { AccountEvent.DeviceCommandIncoming(command = it.into()) }
}
override suspend fun pollForCommands() = withContext(scope.coroutineContext) {
val events = handleFxaExceptions(logger, "polling for device commands", { null }) {
account.pollDeviceCommands().map { AccountEvent.DeviceCommandIncoming(command = it.into()) }
}
if (events == null) {
false
} else {
processEvents(events)
SyncTelemetry.processFxaTelemetry(account.gatherTelemetry(), crashReporter)
true
}
if (events == null) {
false
} else {
processEvents(events)
SyncTelemetry.processFxaTelemetry(account.gatherTelemetry(), crashReporter)
true
}
}
private fun processEvents(events: List<AccountEvent>) = CoroutineScope(Dispatchers.Main).launch {
private fun processEvents(events: List<AccountEvent>) {
notifyObservers { onEvents(events) }
}
override fun refreshDevicesAsync(): Deferred<Boolean> = scope.async {
logger.info("Refreshing device list...")
override suspend fun refreshDevices(): Boolean {
return withContext(scope.coroutineContext) {
logger.info("Refreshing device list...")
// Attempt to fetch devices, or bail out on failure.
val allDevices = fetchAllDevicesAsync().await() ?: return@async false
// Attempt to fetch devices, or bail out on failure.
val allDevices = fetchAllDevices() ?: return@withContext false
// Find the current device.
val currentDevice = allDevices.find { it.isCurrentDevice }?.also {
// Check if our current device's push subscription needs to be renewed.
if (it.subscriptionExpired) {
logger.info("Current device needs push endpoint registration")
// Find the current device.
val currentDevice = allDevices.find { it.isCurrentDevice }?.also {
// Check if our current device's push subscription needs to be renewed.
if (it.subscriptionExpired) {
logger.info("Current device needs push endpoint registration")
}
}
}
// Filter out the current devices.
val otherDevices = allDevices.filter { !it.isCurrentDevice }
// Filter out the current devices.
val otherDevices = allDevices.filter { !it.isCurrentDevice }
val newState = ConstellationState(currentDevice, otherDevices)
constellationState = newState
val newState = ConstellationState(currentDevice, otherDevices)
constellationState = newState
logger.info("Refreshed device list; saw ${allDevices.size} device(s).")
logger.info("Refreshed device list; saw ${allDevices.size} device(s).")
CoroutineScope(Dispatchers.Main).launch {
// NB: at this point, 'constellationState' might have changed.
// Notify with an immutable, local 'newState' instead.
deviceObserverRegistry.notifyObservers { onDevicesUpdate(newState) }
}
true
true
}
}
/**
* Get all devices in the constellation.
* @return A list of all devices in the constellation, or `null` on failure.
*/
private fun fetchAllDevicesAsync(): Deferred<List<Device>?> {
return scope.async {
handleFxaExceptions(logger, "fetching all devices", { null }) {
account.getDevices().map { it.into() }
}
private suspend fun fetchAllDevices(): List<Device>? {
return handleFxaExceptions(logger, "fetching all devices", { null }) {
account.getDevices().map { it.into() }
}
}
}

View File

@@ -12,8 +12,6 @@ import mozilla.appservices.fxaclient.MigrationState
import mozilla.appservices.fxaclient.Profile
import mozilla.appservices.fxaclient.ScopedKey
import mozilla.appservices.fxaclient.TabHistoryEntry
import mozilla.components.concept.sync.AuthException
import mozilla.components.concept.sync.AuthExceptionType
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.Avatar
import mozilla.components.concept.sync.DeviceCapability
@@ -49,7 +47,11 @@ data class FxaAuthData(
val code: String,
val state: String,
val declinedEngines: Set<SyncEngine>? = null
)
) {
override fun toString(): String {
return "authType: $authType, code: XXX, state: XXX, declinedEngines: $declinedEngines"
}
}
// The rest of this file describes translations between fxaclient's internal type definitions and analogous
// types defined by concept-sync. It's a little tedious, but ensures decoupling between abstract
@@ -70,10 +72,10 @@ fun AccessTokenInfo.into(): mozilla.components.concept.sync.AccessTokenInfo {
* may be used for data synchronization.
*
* @return An [SyncAuthInfo] which is guaranteed to have a sync key.
* @throws AuthException if [AccessTokenInfo] didn't have key information.
* @throws IllegalStateException if [AccessTokenInfo] didn't have key information.
*/
fun mozilla.components.concept.sync.AccessTokenInfo.asSyncAuthInfo(tokenServerUrl: String): SyncAuthInfo {
val keyInfo = this.key ?: throw AuthException(AuthExceptionType.KEY_INFO)
val keyInfo = this.key ?: throw IllegalStateException("missing OAuthScopedKey")
return SyncAuthInfo(
kid = keyInfo.kid,
@@ -212,11 +214,11 @@ fun AccountEvent.into(): mozilla.components.concept.sync.AccountEvent {
is AccountEvent.IncomingDeviceCommand ->
mozilla.components.concept.sync.AccountEvent.DeviceCommandIncoming(command = this.command.into())
is AccountEvent.ProfileUpdated ->
mozilla.components.concept.sync.AccountEvent.ProfileUpdated()
mozilla.components.concept.sync.AccountEvent.ProfileUpdated
is AccountEvent.AccountAuthStateChanged ->
mozilla.components.concept.sync.AccountEvent.AccountAuthStateChanged()
mozilla.components.concept.sync.AccountEvent.AccountAuthStateChanged
is AccountEvent.AccountDestroyed ->
mozilla.components.concept.sync.AccountEvent.AccountDestroyed()
mozilla.components.concept.sync.AccountEvent.AccountDestroyed
is AccountEvent.DeviceConnected ->
mozilla.components.concept.sync.AccountEvent.DeviceConnected(deviceName = this.deviceName)
is AccountEvent.DeviceDisconnected ->
@@ -241,9 +243,9 @@ fun IncomingDeviceCommand.TabReceived.into(): mozilla.components.concept.sync.De
/**
* Conversion function from fxaclient's data structure to ours.
*/
fun MigrationState.into(): InFlightMigrationState {
fun MigrationState.into(): InFlightMigrationState? {
return when (this) {
MigrationState.NONE -> InFlightMigrationState.NONE
MigrationState.NONE -> null
MigrationState.COPY_SESSION_TOKEN -> InFlightMigrationState.COPY_SESSION_TOKEN
MigrationState.REUSE_SESSION_TOKEN -> InFlightMigrationState.REUSE_SESSION_TOKEN
}

View File

@@ -4,169 +4,135 @@
package mozilla.components.service.fxa.manager
import mozilla.components.concept.sync.AuthType
import mozilla.components.service.fxa.FxaAuthData
import mozilla.components.service.fxa.sharing.ShareableAccount
/**
* States of the [FxaAccountManager].
*/
enum class AccountState {
Start,
internal enum class AccountState {
NotAuthenticated,
AuthenticationProblem,
CanAutoRetryAuthenticationViaTokenCopy,
CanAutoRetryAuthenticationViaTokenReuse,
AuthenticatedNoProfile,
AuthenticatedWithProfile,
IncompleteMigration,
Authenticated,
AuthenticationProblem
}
internal enum class ProgressState {
Initializing,
BeginningAuthentication,
CompletingAuthentication,
MigratingAccount,
RecoveringFromAuthProblem,
LoggingOut,
}
/**
* Base class for [FxaAccountManager] state machine events.
* Events aren't a simple enum class because we might want to pass data along with some of the events.
*/
internal sealed class Event {
override fun toString(): String {
// For a better logcat experience.
return this.javaClass.simpleName
internal sealed class Account : Event() {
internal object Start : Account()
object BeginEmailFlow : Account()
data class BeginPairingFlow(val pairingUrl: String?) : Account()
data class AuthenticationError(val operation: String, val errorCountWithinTheTimeWindow: Int = 1) : Account() {
override fun toString(): String {
return "${this.javaClass.simpleName} - $operation"
}
}
data class MigrateFromAccount(val account: ShareableAccount, val reuseSessionToken: Boolean) : Account() {
override fun toString(): String {
return this.javaClass.simpleName
}
}
object RetryMigration : Account()
object Logout : Account()
}
internal object Init : Event()
internal sealed class Progress : Event() {
object AccountNotFound : Progress()
object AccountRestored : Progress()
data class IncompleteMigration(val reuseSessionToken: Boolean) : Progress()
object AccountNotFound : Event()
object AccountRestored : Event()
data class AuthData(val authData: FxaAuthData) : Progress()
data class Migrated(val reusedSessionToken: Boolean) : Progress()
object Authenticate : Event()
data class Authenticated(val authData: FxaAuthData) : Event() {
override fun toString(): String {
// data classes define their own toString, so we override it here as well as in the base
// class to avoid exposing 'code' and 'state' in logs.
return this.javaClass.simpleName
}
}
object FailedToCompleteMigration : Progress()
object FailedToBeginAuth : Progress()
object FailedToCompleteAuthRestore : Progress()
object FailedToCompleteAuth : Progress()
/**
* Fired during account init, if an in-flight copy migration was detected.
*/
object InFlightCopyMigration : Event()
object FailedToRecoverFromAuthenticationProblem : Progress()
object RecoveredFromAuthenticationProblem : Progress()
/**
* Fired during account init, if an in-flight copy migration was detected.
*/
object InFlightReuseMigration : Event()
object LoggedOut : Progress()
data class AuthenticationError(val operation: String, val errorCountWithinTheTimeWindow: Int = 1) : Event() {
override fun toString(): String {
return "${this.javaClass.simpleName} - $operation"
}
}
data class SignInShareableAccount(val account: ShareableAccount, val reuseAccount: Boolean) : Event() {
override fun toString(): String {
return this.javaClass.simpleName
}
}
data class SignedInShareableAccount(val reuseAccount: Boolean) : Event()
/**
* Fired during SignInShareableAccount(reuseAccount=true) processing if an intermittent problem is encountered.
*/
object RetryLaterViaTokenReuse : Event()
/**
* Fired during SignInShareableAccount(reuseAccount=false) processing if an intermittent problem is encountered.
*/
object RetryLaterViaTokenCopy : Event()
/**
* Fired to trigger a migration retry.
*/
object RetryMigration : Event()
object RecoveredFromAuthenticationProblem : Event()
object FetchProfile : Event()
object FetchedProfile : Event()
object FailedToAuthenticate : Event()
object FailedToFetchProfile : Event()
object Logout : Event()
data class Pair(val pairingUrl: String) : Event() {
override fun toString(): String {
// data classes define their own toString, so we override it here as well as in the base
// class to avoid exposing the 'pairingUrl' in logs.
return this.javaClass.simpleName
}
data class CompletedAuthentication(val authType: AuthType) : Progress()
}
}
internal object FxaStateMatrix {
/**
* State transition matrix.
* @return An optional [AccountState] if provided state+event combination results in a
* state transition. Note that states may transition into themselves.
*/
internal fun nextState(state: AccountState, event: Event): AccountState? =
when (state) {
AccountState.Start -> when (event) {
Event.Init -> AccountState.Start
Event.AccountNotFound -> AccountState.NotAuthenticated
Event.AccountRestored -> AccountState.AuthenticatedNoProfile
Event.InFlightCopyMigration -> AccountState.CanAutoRetryAuthenticationViaTokenCopy
Event.InFlightReuseMigration -> AccountState.CanAutoRetryAuthenticationViaTokenReuse
else -> null
}
AccountState.NotAuthenticated -> when (event) {
Event.Authenticate -> AccountState.NotAuthenticated
Event.FailedToAuthenticate -> AccountState.NotAuthenticated
is Event.SignInShareableAccount -> AccountState.NotAuthenticated
is Event.SignedInShareableAccount -> AccountState.AuthenticatedNoProfile
is Event.RetryLaterViaTokenCopy -> AccountState.CanAutoRetryAuthenticationViaTokenCopy
is Event.RetryLaterViaTokenReuse -> AccountState.CanAutoRetryAuthenticationViaTokenReuse
is Event.Pair -> AccountState.NotAuthenticated
is Event.Authenticated -> AccountState.AuthenticatedNoProfile
else -> null
}
AccountState.CanAutoRetryAuthenticationViaTokenCopy -> when (event) {
Event.RetryMigration -> AccountState.CanAutoRetryAuthenticationViaTokenCopy
Event.FailedToAuthenticate -> AccountState.NotAuthenticated
is Event.SignedInShareableAccount -> AccountState.AuthenticatedNoProfile
Event.RetryLaterViaTokenCopy -> AccountState.CanAutoRetryAuthenticationViaTokenCopy
Event.Logout -> AccountState.NotAuthenticated
else -> null
}
AccountState.CanAutoRetryAuthenticationViaTokenReuse -> when (event) {
Event.RetryMigration -> AccountState.CanAutoRetryAuthenticationViaTokenReuse
Event.FailedToAuthenticate -> AccountState.NotAuthenticated
is Event.SignedInShareableAccount -> AccountState.AuthenticatedNoProfile
Event.RetryLaterViaTokenReuse -> AccountState.CanAutoRetryAuthenticationViaTokenReuse
Event.Logout -> AccountState.NotAuthenticated
else -> null
}
AccountState.AuthenticatedNoProfile -> when (event) {
is Event.AuthenticationError -> AccountState.AuthenticationProblem
Event.FetchProfile -> AccountState.AuthenticatedNoProfile
Event.FetchedProfile -> AccountState.AuthenticatedWithProfile
Event.FailedToFetchProfile -> AccountState.AuthenticatedNoProfile
Event.FailedToAuthenticate -> AccountState.NotAuthenticated
Event.Logout -> AccountState.NotAuthenticated
else -> null
}
AccountState.AuthenticatedWithProfile -> when (event) {
is Event.AuthenticationError -> AccountState.AuthenticationProblem
Event.Logout -> AccountState.NotAuthenticated
else -> null
}
AccountState.AuthenticationProblem -> when (event) {
Event.Authenticate -> AccountState.AuthenticationProblem
Event.FailedToAuthenticate -> AccountState.AuthenticationProblem
Event.RecoveredFromAuthenticationProblem -> AccountState.AuthenticatedNoProfile
is Event.Authenticated -> AccountState.AuthenticatedNoProfile
Event.Logout -> AccountState.NotAuthenticated
else -> null
}
}
internal sealed class State {
data class Idle(val accountState: AccountState) : State()
data class Active(val progressState: ProgressState) : State()
}
internal fun State.next(event: Event): State? = when (this) {
// Reacting to external events.
is State.Idle -> when (this.accountState) {
AccountState.NotAuthenticated -> when (event) {
Event.Account.Start -> State.Active(ProgressState.Initializing)
Event.Account.BeginEmailFlow -> State.Active(ProgressState.BeginningAuthentication)
is Event.Account.BeginPairingFlow -> State.Active(ProgressState.BeginningAuthentication)
is Event.Account.MigrateFromAccount -> State.Active(ProgressState.MigratingAccount)
else -> null
}
AccountState.IncompleteMigration -> when (event) {
is Event.Account.RetryMigration -> State.Active(ProgressState.MigratingAccount)
is Event.Account.Logout -> State.Active(ProgressState.LoggingOut)
else -> null
}
AccountState.Authenticated -> when (event) {
is Event.Account.AuthenticationError -> State.Active(ProgressState.RecoveringFromAuthProblem)
is Event.Account.Logout -> State.Active(ProgressState.LoggingOut)
else -> null
}
AccountState.AuthenticationProblem -> when (event) {
is Event.Account.Logout -> State.Active(ProgressState.LoggingOut)
is Event.Account.BeginEmailFlow -> State.Active(ProgressState.BeginningAuthentication)
else -> null
}
}
// Reacting to internal events.
is State.Active -> when (this.progressState) {
ProgressState.Initializing -> when (event) {
Event.Progress.AccountNotFound -> State.Idle(AccountState.NotAuthenticated)
Event.Progress.AccountRestored -> State.Active(ProgressState.CompletingAuthentication)
is Event.Progress.IncompleteMigration -> State.Active(ProgressState.MigratingAccount)
else -> null
}
ProgressState.BeginningAuthentication -> when (event) {
is Event.Progress.AuthData -> State.Active(ProgressState.CompletingAuthentication)
Event.Progress.FailedToBeginAuth -> State.Idle(AccountState.NotAuthenticated)
else -> null
}
ProgressState.CompletingAuthentication -> when (event) {
is Event.Progress.CompletedAuthentication -> State.Idle(AccountState.Authenticated)
is Event.Account.AuthenticationError -> State.Active(ProgressState.RecoveringFromAuthProblem)
Event.Progress.FailedToCompleteAuthRestore -> State.Idle(AccountState.NotAuthenticated)
Event.Progress.FailedToCompleteAuth -> State.Idle(AccountState.NotAuthenticated)
else -> null
}
ProgressState.MigratingAccount -> when (event) {
is Event.Progress.Migrated -> State.Active(ProgressState.CompletingAuthentication)
Event.Progress.FailedToCompleteMigration -> State.Idle(AccountState.NotAuthenticated)
is Event.Progress.IncompleteMigration -> State.Idle(AccountState.IncompleteMigration)
else -> null
}
ProgressState.RecoveringFromAuthProblem -> when (event) {
Event.Progress.RecoveredFromAuthenticationProblem -> State.Idle(AccountState.Authenticated)
Event.Progress.FailedToRecoverFromAuthenticationProblem -> State.Idle(AccountState.AuthenticationProblem)
else -> null
}
ProgressState.LoggingOut -> when (event) {
Event.Progress.LoggedOut -> State.Idle(AccountState.NotAuthenticated)
else -> null
}
}
}

View File

@@ -9,6 +9,7 @@ import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import mozilla.components.concept.sync.MigratingAccountInfo
import mozilla.components.support.ktx.kotlin.toHexString
import mozilla.components.support.ktx.kotlin.toSha256Digest
@@ -18,16 +19,7 @@ import mozilla.components.support.ktx.kotlin.toSha256Digest
data class ShareableAccount(
val email: String,
val sourcePackage: String,
val authInfo: ShareableAuthInfo
)
/**
* Data structure describing FxA and Sync credentials necessary to share an FxA account.
*/
data class ShareableAuthInfo(
val sessionToken: String,
val kSync: String,
val kXCS: String
val authInfo: MigratingAccountInfo
)
/**
@@ -109,7 +101,7 @@ object AccountSharing {
ShareableAccount(
email = email,
sourcePackage = packageName,
authInfo = ShareableAuthInfo(sessionToken, kSync, kXSCS)
authInfo = MigratingAccountInfo(sessionToken, kSync, kXSCS)
)
} else {
null

View File

@@ -97,7 +97,7 @@ object GlobalSyncableStoreProvider {
internal interface SyncDispatcher : Closeable, Observable<SyncStatusObserver> {
fun isSyncActive(): Boolean
fun syncNow(reason: SyncReason, debounce: Boolean = false)
fun startPeriodicSync(unit: TimeUnit, period: Long)
fun startPeriodicSync(unit: TimeUnit, period: Long, initialDelay: Long)
fun stopPeriodicSync()
fun workersStateChanged(isRunning: Boolean)
}
@@ -107,31 +107,9 @@ internal interface SyncDispatcher : Closeable, Observable<SyncStatusObserver> {
* @param syncConfig A [SyncConfig] object describing how sync should behave.
*/
@SuppressWarnings("TooManyFunctions")
abstract class SyncManager(
internal abstract class SyncManager(
private val syncConfig: SyncConfig
) {
companion object {
// Periodically sync in the background, to make our syncs a little more incremental.
// This isn't strictly necessary, and could be considered an optimization.
//
// Assuming that we synchronize during app startup, our trade-offs are:
// - not syncing in the background at all might mean longer syncs, more arduous startup syncs
// - on a slow mobile network, these delays could be significant
// - a delay during startup sync may affect user experience, since our data will be stale
// for longer
// - however, background syncing eats up some of the device resources
// - ... so we only do so a few times per day
// - we also rely on the OS and the WorkManager APIs to minimize those costs. It promises to
// bundle together tasks from different applications that have similar resource-consumption
// profiles. Specifically, we need device radio to be ON to talk to our servers; OS will run
// our periodic syncs bundled with another tasks that also need radio to be ON, thus "spreading"
// the overall cost.
//
// If we wanted to be very fancy, this period could be driven by how much new activity an
// account is actually expected to generate. For now, it's just a hard-coded constant.
val SYNC_PERIOD_UNIT = TimeUnit.MINUTES
}
open val logger = Logger("SyncManager")
// A SyncDispatcher instance bound to an account and a set of syncable stores.
@@ -164,18 +142,15 @@ abstract class SyncManager(
if (syncDispatcher == null) {
logger.info("Sync is not enabled. Ignoring 'sync now' request.")
}
syncDispatcher?.let {
logger.debug("Requesting immediate sync, reason: $reason, debounce: $debounce")
it.syncNow(reason, debounce)
}
syncDispatcher?.syncNow(reason, debounce)
}
/**
* Enables synchronization, with behaviour described in [syncConfig].
*/
internal fun start(reason: SyncReason) = synchronized(this) {
internal fun start() = synchronized(this) {
logger.debug("Enabling...")
syncDispatcher = initDispatcher(newDispatcher(syncDispatcher, syncConfig.supportedEngines), reason)
syncDispatcher = initDispatcher(newDispatcher(syncDispatcher, syncConfig.supportedEngines))
logger.debug("set and initialized new dispatcher: $syncDispatcher")
}
@@ -184,8 +159,6 @@ abstract class SyncManager(
*/
internal fun stop() = synchronized(this) {
logger.debug("Disabling...")
syncDispatcher?.unregister(dispatcherStatusObserver)
syncDispatcher?.stopPeriodicSync()
syncDispatcher?.close()
syncDispatcher = null
}
@@ -208,11 +181,14 @@ abstract class SyncManager(
return createDispatcher(supportedEngines)
}
private fun initDispatcher(dispatcher: SyncDispatcher, reason: SyncReason): SyncDispatcher {
private fun initDispatcher(dispatcher: SyncDispatcher): SyncDispatcher {
dispatcher.register(dispatcherStatusObserver)
dispatcher.syncNow(reason)
if (syncConfig.syncPeriodInMinutes != null) {
dispatcher.startPeriodicSync(SYNC_PERIOD_UNIT, syncConfig.syncPeriodInMinutes)
syncConfig.periodicSyncConfig?.let {
dispatcher.startPeriodicSync(
TimeUnit.MINUTES,
period = it.periodMinutes.toLong(),
initialDelay = it.initialDelayMinutes.toLong()
)
}
dispatcherUpdated(dispatcher)
return dispatcher

View File

@@ -67,10 +67,10 @@ internal class WorkManagerSyncManager(
init {
WorkersLiveDataObserver.init(context)
if (syncConfig.syncPeriodInMinutes == null) {
if (syncConfig.periodicSyncConfig == null) {
logger.info("Periodic syncing is disabled.")
} else {
logger.info("Periodic syncing enabled at a ${syncConfig.syncPeriodInMinutes} interval")
logger.info("Periodic syncing enabled: ${syncConfig.periodicSyncConfig}")
}
}
@@ -128,7 +128,7 @@ internal object WorkersLiveDataObserver {
}
}
class WorkManagerSyncDispatcher(
internal class WorkManagerSyncDispatcher(
private val context: Context,
private val supportedEngines: Set<SyncEngine>
) : SyncDispatcher, Observable<SyncStatusObserver> by ObserverRegistry(), Closeable {
@@ -178,6 +178,7 @@ class WorkManagerSyncDispatcher(
}
override fun close() {
unregisterObservers()
stopPeriodicSync()
}
@@ -185,14 +186,14 @@ class WorkManagerSyncDispatcher(
* Periodic background syncing is mainly intended to reduce workload when we sync during
* application startup.
*/
override fun startPeriodicSync(unit: TimeUnit, period: Long) {
override fun startPeriodicSync(unit: TimeUnit, period: Long, initialDelay: Long) {
logger.debug("Starting periodic syncing, period = $period, time unit = $unit")
// Use the 'replace' policy as a simple way to upgrade periodic worker configurations across
// application versions. We do this instead of versioning workers.
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
SyncWorkerName.Periodic.name,
ExistingPeriodicWorkPolicy.REPLACE,
periodicSyncWorkRequest(unit, period)
periodicSyncWorkRequest(unit, period, initialDelay)
)
}
@@ -205,11 +206,11 @@ class WorkManagerSyncDispatcher(
WorkManager.getInstance(context).cancelUniqueWork(SyncWorkerName.Periodic.name)
}
private fun periodicSyncWorkRequest(unit: TimeUnit, period: Long): PeriodicWorkRequest {
private fun periodicSyncWorkRequest(unit: TimeUnit, period: Long, initialDelay: Long): PeriodicWorkRequest {
val data = getWorkerData(SyncReason.Scheduled)
// Periodic interval must be at least PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS,
// e.g. not more frequently than 15 minutes.
return PeriodicWorkRequestBuilder<WorkManagerSyncWorker>(period, unit)
return PeriodicWorkRequestBuilder<WorkManagerSyncWorker>(period, unit, initialDelay, unit)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
@@ -258,7 +259,7 @@ class WorkManagerSyncDispatcher(
}
}
class WorkManagerSyncWorker(
internal class WorkManagerSyncWorker(
private val context: Context,
private val params: WorkerParameters
) : CoroutineWorker(context, params) {
@@ -467,7 +468,7 @@ private const val SYNC_STATE_PREFS_KEY = "syncPrefs"
private const val SYNC_LAST_SYNCED_KEY = "lastSynced"
private const val SYNC_STATE_KEY = "persistedState"
private const val SYNC_STAGGER_BUFFER_MS = 10 * 60 * 1000L // 10 minutes.
private const val SYNC_STAGGER_BUFFER_MS = 5 * 60 * 1000L // 5 minutes.
private const val SYNC_STARTUP_DELAY_MS = 5 * 1000L // 5 seconds.
fun getLastSynced(context: Context): Long {

View File

@@ -23,6 +23,8 @@ import mozilla.components.concept.sync.DeviceCommandIncoming
import mozilla.components.concept.sync.DeviceCommandOutgoing
import mozilla.components.concept.sync.AccountEventsObserver
import mozilla.components.concept.sync.AccountEvent
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.DeviceConfig
import mozilla.components.concept.sync.DevicePushSubscription
import mozilla.components.concept.sync.DeviceType
import mozilla.components.concept.sync.TabData
@@ -38,8 +40,10 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.`when`
import org.mockito.Mockito.reset
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyZeroInteractions
import mozilla.appservices.fxaclient.Device as NativeDevice
import mozilla.appservices.fxaclient.FirefoxAccount as NativeFirefoxAccount
import mozilla.appservices.syncmanager.DeviceType as RustDeviceType
@@ -61,21 +65,44 @@ class FxaDeviceConstellationTest {
}
@Test
fun `initializing device`() = runBlocking(coroutinesTestRule.testDispatcher) {
constellation.initDeviceAsync("test name", DeviceType.TABLET, setOf()).await()
verify(account).initializeDevice("test name", NativeDevice.Type.TABLET, setOf())
constellation.initDeviceAsync("VR device", DeviceType.VR, setOf(DeviceCapability.SEND_TAB)).await()
verify(account).initializeDevice("VR device", NativeDevice.Type.VR, setOf(mozilla.appservices.fxaclient.Device.Capability.SEND_TAB))
}
@Test
fun `ensure capabilities`() = runBlocking(coroutinesTestRule.testDispatcher) {
constellation.ensureCapabilitiesAsync(setOf()).await()
verify(account).ensureCapabilities(setOf())
constellation.ensureCapabilitiesAsync(setOf(DeviceCapability.SEND_TAB)).await()
verify(account).ensureCapabilities(setOf(mozilla.appservices.fxaclient.Device.Capability.SEND_TAB))
fun `finalize device`() = runBlocking(coroutinesTestRule.testDispatcher) {
fun expectedFinalizeAction(authType: AuthType): FxaDeviceConstellation.DeviceFinalizeAction = when (authType) {
AuthType.Existing -> FxaDeviceConstellation.DeviceFinalizeAction.EnsureCapabilities
AuthType.Signin -> FxaDeviceConstellation.DeviceFinalizeAction.Initialize
AuthType.Signup -> FxaDeviceConstellation.DeviceFinalizeAction.Initialize
AuthType.Pairing -> FxaDeviceConstellation.DeviceFinalizeAction.Initialize
is AuthType.OtherExternal -> FxaDeviceConstellation.DeviceFinalizeAction.Initialize
AuthType.MigratedCopy -> FxaDeviceConstellation.DeviceFinalizeAction.Initialize
AuthType.MigratedReuse -> FxaDeviceConstellation.DeviceFinalizeAction.EnsureCapabilities
AuthType.Recovered -> FxaDeviceConstellation.DeviceFinalizeAction.None
}
fun initAuthType(simpleClassName: String): AuthType = when (simpleClassName) {
"Existing" -> AuthType.Existing
"Signin" -> AuthType.Signin
"Signup" -> AuthType.Signup
"Pairing" -> AuthType.Pairing
"OtherExternal" -> AuthType.OtherExternal("test")
"MigratedCopy" -> AuthType.MigratedCopy
"MigratedReuse" -> AuthType.MigratedReuse
"Recovered" -> AuthType.Recovered
else -> throw AssertionError("Unknown AuthType: $simpleClassName")
}
val config = DeviceConfig("test name", DeviceType.TABLET, setOf(DeviceCapability.SEND_TAB))
AuthType::class.sealedSubclasses.map { initAuthType(it.simpleName!!) }.forEach {
constellation.finalizeDevice(it, config)
when (expectedFinalizeAction(it)) {
FxaDeviceConstellation.DeviceFinalizeAction.Initialize -> {
verify(account).initializeDevice("test name", NativeDevice.Type.TABLET, setOf(mozilla.appservices.fxaclient.Device.Capability.SEND_TAB))
}
FxaDeviceConstellation.DeviceFinalizeAction.EnsureCapabilities -> {
verify(account).ensureCapabilities(setOf(mozilla.appservices.fxaclient.Device.Capability.SEND_TAB))
}
FxaDeviceConstellation.DeviceFinalizeAction.None -> {
verifyZeroInteractions(account)
}
}
reset(account)
}
}
@Test
@@ -86,7 +113,7 @@ class FxaDeviceConstellationTest {
// Can't update cached value in an empty cache
try {
constellation.setDeviceNameAsync("new name", testContext).await()
constellation.setDeviceName("new name", testContext)
fail()
} catch (e: IllegalStateException) {}
@@ -94,7 +121,7 @@ class FxaDeviceConstellationTest {
cache.setToCache(DeviceSettings("someId", "test name", RustDeviceType.MOBILE))
// No device state observer.
assertTrue(constellation.setDeviceNameAsync("new name", testContext).await())
assertTrue(constellation.setDeviceName("new name", testContext))
verify(account, times(2)).setDeviceDisplayName("new name")
assertEquals(DeviceSettings("someId", "new name", RustDeviceType.MOBILE), cache.getCached())
@@ -109,7 +136,7 @@ class FxaDeviceConstellationTest {
}
constellation.registerDeviceObserver(observer, startedLifecycleOwner(), false)
assertTrue(constellation.setDeviceNameAsync("another name", testContext).await())
assertTrue(constellation.setDeviceName("another name", testContext))
verify(account).setDeviceDisplayName("another name")
assertEquals(DeviceSettings("someId", "another name", RustDeviceType.MOBILE), cache.getCached())
@@ -123,7 +150,7 @@ class FxaDeviceConstellationTest {
@ExperimentalCoroutinesApi
fun `set device push subscription`() = runBlocking(coroutinesTestRule.testDispatcher) {
val subscription = DevicePushSubscription("http://endpoint.com", "pk", "auth key")
constellation.setDevicePushSubscriptionAsync(subscription).await()
constellation.setDevicePushSubscription(subscription)
verify(account).setDevicePushSubscription("http://endpoint.com", "pk", "auth key")
}
@@ -133,7 +160,7 @@ class FxaDeviceConstellationTest {
fun `process raw device command`() = runBlocking(coroutinesTestRule.testDispatcher) {
// No commands, no observer.
`when`(account.handlePushMessage("raw events payload")).thenReturn(emptyArray())
assertTrue(constellation.processRawEventAsync("raw events payload").await())
assertTrue(constellation.processRawEvent("raw events payload"))
// No commands, with observer.
val eventsObserver = object : AccountEventsObserver {
@@ -146,7 +173,7 @@ class FxaDeviceConstellationTest {
// No commands, with an observer.
constellation.register(eventsObserver)
assertTrue(constellation.processRawEventAsync("raw events payload").await())
assertTrue(constellation.processRawEvent("raw events payload"))
assertEquals(listOf<AccountEvent.DeviceCommandIncoming>(), eventsObserver.latestEvents)
// Some commands, with an observer. More detailed command handling tests below.
@@ -157,7 +184,7 @@ class FxaDeviceConstellationTest {
command = IncomingDeviceCommand.TabReceived(testDevice1, arrayOf(testTab1))
)
))
assertTrue(constellation.processRawEventAsync("raw events payload").await())
assertTrue(constellation.processRawEvent("raw events payload"))
val events = eventsObserver.latestEvents!!
val command = (events[0] as AccountEvent.DeviceCommandIncoming).command
@@ -168,9 +195,9 @@ class FxaDeviceConstellationTest {
@Test
fun `send command to device`() = runBlocking(coroutinesTestRule.testDispatcher) {
`when`(account.gatherTelemetry()).thenReturn("{}")
assertTrue(constellation.sendCommandToDeviceAsync(
assertTrue(constellation.sendCommandToDevice(
"targetID", DeviceCommandOutgoing.SendTab("Mozilla", "https://www.mozilla.org")
).await())
))
verify(account).sendSingleTab("targetID", "Mozilla", "https://www.mozilla.org")
}
@@ -181,7 +208,7 @@ class FxaDeviceConstellationTest {
// No devices, no observers.
`when`(account.getDevices()).thenReturn(emptyArray())
constellation.refreshDevicesAsync().await()
constellation.refreshDevices()
val observer = object : DeviceConstellationObserver {
var state: ConstellationState? = null
@@ -193,7 +220,7 @@ class FxaDeviceConstellationTest {
constellation.registerDeviceObserver(observer, startedLifecycleOwner(), false)
// No devices, with an observer.
constellation.refreshDevicesAsync().await()
constellation.refreshDevices()
assertEquals(ConstellationState(null, listOf()), observer.state)
val testDevice1 = testDevice("test1", false)
@@ -202,14 +229,14 @@ class FxaDeviceConstellationTest {
// Single device, no current device.
`when`(account.getDevices()).thenReturn(arrayOf(testDevice1))
constellation.refreshDevicesAsync().await()
constellation.refreshDevices()
assertEquals(ConstellationState(null, listOf(testDevice1.into())), observer.state)
assertEquals(ConstellationState(null, listOf(testDevice1.into())), constellation.state())
// Current device, no other devices.
`when`(account.getDevices()).thenReturn(arrayOf(currentDevice))
constellation.refreshDevicesAsync().await()
constellation.refreshDevices()
assertEquals(ConstellationState(currentDevice.into(), listOf()), observer.state)
assertEquals(ConstellationState(currentDevice.into(), listOf()), constellation.state())
@@ -217,7 +244,7 @@ class FxaDeviceConstellationTest {
`when`(account.getDevices()).thenReturn(arrayOf(
currentDevice, testDevice1, testDevice2
))
constellation.refreshDevicesAsync().await()
constellation.refreshDevices()
assertEquals(ConstellationState(currentDevice.into(), listOf(testDevice1.into(), testDevice2.into())), observer.state)
assertEquals(ConstellationState(currentDevice.into(), listOf(testDevice1.into(), testDevice2.into())), constellation.state())
@@ -227,7 +254,7 @@ class FxaDeviceConstellationTest {
`when`(account.getDevices()).thenReturn(arrayOf(
currentDeviceExpired, testDevice2
))
constellation.refreshDevicesAsync().await()
constellation.refreshDevices()
assertEquals(ConstellationState(currentDeviceExpired.into(), listOf(testDevice2.into())), observer.state)
assertEquals(ConstellationState(currentDeviceExpired.into(), listOf(testDevice2.into())), constellation.state())
@@ -239,7 +266,7 @@ class FxaDeviceConstellationTest {
// No commands, no observers.
`when`(account.gatherTelemetry()).thenReturn("{}")
`when`(account.pollDeviceCommands()).thenReturn(emptyArray())
assertTrue(constellation.pollForCommandsAsync().await())
assertTrue(constellation.pollForCommands())
val eventsObserver = object : AccountEventsObserver {
var latestEvents: List<AccountEvent>? = null
@@ -251,14 +278,14 @@ class FxaDeviceConstellationTest {
// No commands, with an observer.
constellation.register(eventsObserver)
assertTrue(constellation.pollForCommandsAsync().await())
assertTrue(constellation.pollForCommands())
assertEquals(listOf<AccountEvent>(), eventsObserver.latestEvents)
// Some commands.
`when`(account.pollDeviceCommands()).thenReturn(arrayOf(
IncomingDeviceCommand.TabReceived(null, emptyArray())
))
assertTrue(constellation.pollForCommandsAsync().await())
assertTrue(constellation.pollForCommands())
var command = (eventsObserver.latestEvents!![0] as AccountEvent.DeviceCommandIncoming).command
assertEquals(null, (command as DeviceCommandIncoming.TabReceived).from)
@@ -274,7 +301,7 @@ class FxaDeviceConstellationTest {
`when`(account.pollDeviceCommands()).thenReturn(arrayOf(
IncomingDeviceCommand.TabReceived(testDevice1, emptyArray())
))
assertTrue(constellation.pollForCommandsAsync().await())
assertTrue(constellation.pollForCommands())
Assert.assertNotNull(eventsObserver.latestEvents)
assertEquals(1, eventsObserver.latestEvents!!.size)
@@ -286,7 +313,7 @@ class FxaDeviceConstellationTest {
`when`(account.pollDeviceCommands()).thenReturn(arrayOf(
IncomingDeviceCommand.TabReceived(testDevice2, arrayOf(testTab1))
))
assertTrue(constellation.pollForCommandsAsync().await())
assertTrue(constellation.pollForCommands())
command = (eventsObserver.latestEvents!![0] as AccountEvent.DeviceCommandIncoming).command
assertEquals(testDevice2.into(), (command as DeviceCommandIncoming.TabReceived).from)
@@ -296,7 +323,7 @@ class FxaDeviceConstellationTest {
`when`(account.pollDeviceCommands()).thenReturn(arrayOf(
IncomingDeviceCommand.TabReceived(testDevice2, arrayOf(testTab1, testTab3))
))
assertTrue(constellation.pollForCommandsAsync().await())
assertTrue(constellation.pollForCommands())
command = (eventsObserver.latestEvents!![0] as AccountEvent.DeviceCommandIncoming).command
assertEquals(testDevice2.into(), (command as DeviceCommandIncoming.TabReceived).from)
@@ -307,7 +334,7 @@ class FxaDeviceConstellationTest {
IncomingDeviceCommand.TabReceived(testDevice2, arrayOf(testTab1, testTab2)),
IncomingDeviceCommand.TabReceived(testDevice1, arrayOf(testTab3))
))
assertTrue(constellation.pollForCommandsAsync().await())
assertTrue(constellation.pollForCommands())
command = (eventsObserver.latestEvents!![0] as AccountEvent.DeviceCommandIncoming).command
assertEquals(testDevice2.into(), (command as DeviceCommandIncoming.TabReceived).from)
@@ -321,7 +348,7 @@ class FxaDeviceConstellationTest {
// `when`(account.pollDeviceCommands()).thenThrow(FxaPanicException("Don't panic!"))
// try {
// runBlocking(coroutinesTestRule.testDispatcher) {
// constellation.refreshAsync().await()
// constellation.refreshAsync()
// }
// fail()
// } catch (e: FxaPanicException) {}
@@ -329,12 +356,12 @@ class FxaDeviceConstellationTest {
// // Network exception are handled.
// `when`(account.pollDeviceCommands()).thenThrow(FxaNetworkException("four oh four"))
// runBlocking(coroutinesTestRule.testDispatcher) {
// Assert.assertFalse(constellation.refreshAsync().await())
// Assert.assertFalse(constellation.refreshAsync())
// }
// // Unspecified exception are handled.
// `when`(account.pollDeviceCommands()).thenThrow(FxaUnspecifiedException("hmmm..."))
// runBlocking(coroutinesTestRule.testDispatcher) {
// Assert.assertFalse(constellation.refreshAsync().await())
// Assert.assertFalse(constellation.refreshAsync())
// }
// // Unauthorized exception are handled.
// val authErrorObserver = object : AuthErrorObserver {
@@ -352,7 +379,7 @@ class FxaDeviceConstellationTest {
// val authException = FxaUnauthorizedException("oh no you didn't!")
// `when`(account.pollDeviceCommands()).thenThrow(authException)
// runBlocking(coroutinesTestRule.testDispatcher) {
// Assert.assertFalse(constellation.refreshAsync().await())
// Assert.assertFalse(constellation.refreshAsync())
// }
// assertEquals(authErrorObserver.latestException!!.cause, authException)
}

View File

@@ -1,166 +0,0 @@
/* 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 mozilla.components.service.fxa.manager
import mozilla.components.concept.sync.AuthType
import mozilla.components.service.fxa.FxaAuthData
import mozilla.components.support.test.mock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class FxaStateMatrixTest {
@Test
fun `state transitions`() {
// State 'Start'.
var state = AccountState.Start
assertEquals(AccountState.Start, FxaStateMatrix.nextState(state, Event.Init))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.AccountNotFound))
assertEquals(AccountState.AuthenticatedNoProfile, FxaStateMatrix.nextState(state, Event.AccountRestored))
assertNull(FxaStateMatrix.nextState(state, Event.Authenticate))
assertNull(FxaStateMatrix.nextState(state, Event.Authenticated(FxaAuthData(AuthType.Signin, "code", "state"))))
assertNull(FxaStateMatrix.nextState(state, Event.FetchProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FetchedProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FailedToFetchProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FailedToAuthenticate))
assertNull(FxaStateMatrix.nextState(state, Event.Logout))
assertNull(FxaStateMatrix.nextState(state, Event.AuthenticationError("test")))
assertNull(FxaStateMatrix.nextState(state, Event.SignInShareableAccount(mock(), false)))
assertNull(FxaStateMatrix.nextState(state, Event.SignedInShareableAccount(false)))
assertEquals(AccountState.CanAutoRetryAuthenticationViaTokenReuse, FxaStateMatrix.nextState(state, Event.InFlightReuseMigration))
assertEquals(AccountState.CanAutoRetryAuthenticationViaTokenCopy, FxaStateMatrix.nextState(state, Event.InFlightCopyMigration))
assertNull(FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenReuse))
assertNull(FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenCopy))
assertNull(FxaStateMatrix.nextState(state, Event.RetryMigration))
// State 'NotAuthenticated'.
state = AccountState.NotAuthenticated
assertNull(FxaStateMatrix.nextState(state, Event.Init))
assertNull(FxaStateMatrix.nextState(state, Event.AccountNotFound))
assertNull(FxaStateMatrix.nextState(state, Event.AccountRestored))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.Authenticate))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.Pair("auth://pair")))
assertEquals(AccountState.AuthenticatedNoProfile, FxaStateMatrix.nextState(state, Event.Authenticated(FxaAuthData(AuthType.Signup, "code", "state"))))
assertNull(FxaStateMatrix.nextState(state, Event.FetchProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FetchedProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FailedToFetchProfile))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.FailedToAuthenticate))
assertNull(FxaStateMatrix.nextState(state, Event.Logout))
assertNull(FxaStateMatrix.nextState(state, Event.AuthenticationError("test")))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.SignInShareableAccount(mock(), false)))
assertEquals(AccountState.AuthenticatedNoProfile, FxaStateMatrix.nextState(state, Event.SignedInShareableAccount(false)))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightReuseMigration))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightCopyMigration))
assertEquals(AccountState.CanAutoRetryAuthenticationViaTokenReuse, FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenReuse))
assertEquals(AccountState.CanAutoRetryAuthenticationViaTokenCopy, FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenCopy))
assertNull(FxaStateMatrix.nextState(state, Event.RetryMigration))
// State 'AuthenticatedNoProfile'.
state = AccountState.AuthenticatedNoProfile
assertNull(FxaStateMatrix.nextState(state, Event.Init))
assertNull(FxaStateMatrix.nextState(state, Event.AccountNotFound))
assertNull(FxaStateMatrix.nextState(state, Event.AccountRestored))
assertNull(FxaStateMatrix.nextState(state, Event.Authenticate))
assertNull(FxaStateMatrix.nextState(state, Event.Authenticated(FxaAuthData(AuthType.Pairing, "code", "state"))))
assertEquals(AccountState.AuthenticatedNoProfile, FxaStateMatrix.nextState(state, Event.FetchProfile))
assertEquals(AccountState.AuthenticatedWithProfile, FxaStateMatrix.nextState(state, Event.FetchedProfile))
assertEquals(AccountState.AuthenticatedNoProfile, FxaStateMatrix.nextState(state, Event.FailedToFetchProfile))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.FailedToAuthenticate))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.Logout))
assertEquals(AccountState.AuthenticationProblem, FxaStateMatrix.nextState(state, Event.AuthenticationError("test")))
assertNull(FxaStateMatrix.nextState(state, Event.SignInShareableAccount(mock(), false)))
assertNull(FxaStateMatrix.nextState(state, Event.SignedInShareableAccount(false)))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightReuseMigration))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightCopyMigration))
assertNull(FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenReuse))
assertNull(FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenCopy))
assertNull(FxaStateMatrix.nextState(state, Event.RetryMigration))
// State 'AuthenticatedWithProfile'.
state = AccountState.AuthenticatedWithProfile
assertNull(FxaStateMatrix.nextState(state, Event.Init))
assertNull(FxaStateMatrix.nextState(state, Event.AccountNotFound))
assertNull(FxaStateMatrix.nextState(state, Event.AccountRestored))
assertNull(FxaStateMatrix.nextState(state, Event.Authenticate))
assertNull(FxaStateMatrix.nextState(state, Event.Authenticated(FxaAuthData(AuthType.OtherExternal("oi"), "code", "state"))))
assertNull(FxaStateMatrix.nextState(state, Event.FetchProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FetchedProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FailedToFetchProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FailedToAuthenticate))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.Logout))
assertEquals(AccountState.AuthenticationProblem, FxaStateMatrix.nextState(state, Event.AuthenticationError("test")))
assertNull(FxaStateMatrix.nextState(state, Event.SignInShareableAccount(mock(), false)))
assertNull(FxaStateMatrix.nextState(state, Event.SignedInShareableAccount(false)))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightReuseMigration))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightCopyMigration))
assertNull(FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenReuse))
assertNull(FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenCopy))
assertNull(FxaStateMatrix.nextState(state, Event.RetryMigration))
// State 'AuthenticationProblem'.
state = AccountState.AuthenticationProblem
assertNull(FxaStateMatrix.nextState(state, Event.Init))
assertNull(FxaStateMatrix.nextState(state, Event.AccountNotFound))
assertNull(FxaStateMatrix.nextState(state, Event.AccountRestored))
assertEquals(AccountState.AuthenticationProblem, FxaStateMatrix.nextState(state, Event.Authenticate))
assertEquals(AccountState.AuthenticatedNoProfile, FxaStateMatrix.nextState(state, Event.Authenticated(FxaAuthData(AuthType.Signin, "code", "state"))))
assertNull(FxaStateMatrix.nextState(state, Event.FetchProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FetchedProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FailedToFetchProfile))
assertEquals(AccountState.AuthenticationProblem, FxaStateMatrix.nextState(state, Event.FailedToAuthenticate))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.Logout))
assertNull(FxaStateMatrix.nextState(state, Event.AuthenticationError("test")))
assertNull(FxaStateMatrix.nextState(state, Event.SignInShareableAccount(mock(), false)))
assertNull(FxaStateMatrix.nextState(state, Event.SignedInShareableAccount(false)))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightReuseMigration))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightCopyMigration))
assertNull(FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenReuse))
assertNull(FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenCopy))
assertNull(FxaStateMatrix.nextState(state, Event.RetryMigration))
// State 'CanAutoRetryAuthenticationViaTokenReuse'.
state = AccountState.CanAutoRetryAuthenticationViaTokenReuse
assertNull(FxaStateMatrix.nextState(state, Event.Init))
assertNull(FxaStateMatrix.nextState(state, Event.AccountNotFound))
assertNull(FxaStateMatrix.nextState(state, Event.AccountRestored))
assertNull(FxaStateMatrix.nextState(state, Event.Authenticate))
assertNull(FxaStateMatrix.nextState(state, Event.Authenticated(FxaAuthData(AuthType.Signin, "code", "state"))))
assertNull(FxaStateMatrix.nextState(state, Event.FetchProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FetchedProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FailedToFetchProfile))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.FailedToAuthenticate))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.Logout))
assertNull(FxaStateMatrix.nextState(state, Event.AuthenticationError("test")))
assertNull(FxaStateMatrix.nextState(state, Event.SignInShareableAccount(mock(), false)))
assertEquals(AccountState.AuthenticatedNoProfile, FxaStateMatrix.nextState(state, Event.SignedInShareableAccount(false)))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightReuseMigration))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightCopyMigration))
assertEquals(AccountState.CanAutoRetryAuthenticationViaTokenReuse, FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenReuse))
assertNull(FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenCopy))
assertEquals(AccountState.CanAutoRetryAuthenticationViaTokenReuse, FxaStateMatrix.nextState(state, Event.RetryMigration))
// State 'CanAutoRetryAuthenticationViaTokenCopy'.
state = AccountState.CanAutoRetryAuthenticationViaTokenCopy
assertNull(FxaStateMatrix.nextState(state, Event.Init))
assertNull(FxaStateMatrix.nextState(state, Event.AccountNotFound))
assertNull(FxaStateMatrix.nextState(state, Event.AccountRestored))
assertNull(FxaStateMatrix.nextState(state, Event.Authenticate))
assertNull(FxaStateMatrix.nextState(state, Event.Authenticated(FxaAuthData(AuthType.Signin, "code", "state"))))
assertNull(FxaStateMatrix.nextState(state, Event.FetchProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FetchedProfile))
assertNull(FxaStateMatrix.nextState(state, Event.FailedToFetchProfile))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.FailedToAuthenticate))
assertEquals(AccountState.NotAuthenticated, FxaStateMatrix.nextState(state, Event.Logout))
assertNull(FxaStateMatrix.nextState(state, Event.AuthenticationError("test")))
assertNull(FxaStateMatrix.nextState(state, Event.SignInShareableAccount(mock(), false)))
assertEquals(AccountState.AuthenticatedNoProfile, FxaStateMatrix.nextState(state, Event.SignedInShareableAccount(false)))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightReuseMigration))
assertNull(FxaStateMatrix.nextState(state, Event.InFlightCopyMigration))
assertNull(FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenReuse))
assertEquals(AccountState.CanAutoRetryAuthenticationViaTokenCopy, FxaStateMatrix.nextState(state, Event.RetryLaterViaTokenCopy))
assertEquals(AccountState.CanAutoRetryAuthenticationViaTokenCopy, FxaStateMatrix.nextState(state, Event.RetryMigration))
}
}

View File

@@ -0,0 +1,130 @@
/* 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 mozilla.components.service.fxa.manager
import mozilla.components.support.test.mock
import org.junit.Assert.assertEquals
import org.junit.Test
class StateKtTest {
private fun assertNextStateForStateEventPair(state: State, event: Event, nextState: State?) {
val expectedNextState = when (state) {
is State.Idle -> when (state.accountState) {
AccountState.NotAuthenticated -> when (event) {
Event.Account.Start -> State.Active(ProgressState.Initializing)
Event.Account.BeginEmailFlow -> State.Active(ProgressState.BeginningAuthentication)
is Event.Account.BeginPairingFlow -> State.Active(ProgressState.BeginningAuthentication)
is Event.Account.MigrateFromAccount -> State.Active(ProgressState.MigratingAccount)
else -> null
}
AccountState.IncompleteMigration -> when (event) {
Event.Account.RetryMigration -> State.Active(ProgressState.MigratingAccount)
Event.Account.Logout -> State.Active(ProgressState.LoggingOut)
else -> null
}
AccountState.Authenticated -> when (event) {
is Event.Account.AuthenticationError -> State.Active(ProgressState.RecoveringFromAuthProblem)
Event.Account.Logout -> State.Active(ProgressState.LoggingOut)
else -> null
}
AccountState.AuthenticationProblem -> when (event) {
Event.Account.BeginEmailFlow -> State.Active(ProgressState.BeginningAuthentication)
Event.Account.Logout -> State.Active(ProgressState.LoggingOut)
else -> null
}
}
is State.Active -> when (state.progressState) {
ProgressState.Initializing -> when (event) {
Event.Progress.AccountNotFound -> State.Idle(AccountState.NotAuthenticated)
Event.Progress.AccountRestored -> State.Active(ProgressState.CompletingAuthentication)
is Event.Progress.IncompleteMigration -> State.Active(ProgressState.MigratingAccount)
else -> null
}
ProgressState.BeginningAuthentication -> when (event) {
Event.Progress.FailedToBeginAuth -> State.Idle(AccountState.NotAuthenticated)
is Event.Progress.AuthData -> State.Active(ProgressState.CompletingAuthentication)
else -> null
}
ProgressState.CompletingAuthentication -> when (event) {
Event.Progress.FailedToCompleteAuth -> State.Idle(AccountState.NotAuthenticated)
Event.Progress.FailedToCompleteAuthRestore -> State.Idle(AccountState.NotAuthenticated)
is Event.Progress.CompletedAuthentication -> State.Idle(AccountState.Authenticated)
else -> null
}
ProgressState.MigratingAccount -> when (event) {
Event.Progress.FailedToCompleteMigration -> State.Idle(AccountState.NotAuthenticated)
is Event.Progress.Migrated -> State.Active(ProgressState.CompletingAuthentication)
is Event.Progress.IncompleteMigration -> State.Idle(AccountState.IncompleteMigration)
else -> null
}
ProgressState.RecoveringFromAuthProblem -> when (event) {
Event.Progress.RecoveredFromAuthenticationProblem -> State.Idle(AccountState.Authenticated)
Event.Progress.FailedToRecoverFromAuthenticationProblem -> State.Idle(AccountState.AuthenticationProblem)
else -> null
}
ProgressState.LoggingOut -> when (event) {
Event.Progress.LoggedOut -> State.Idle(AccountState.NotAuthenticated)
else -> null
}
}
}
assertEquals(expectedNextState, nextState)
}
private fun instantiateEvent(eventClassSimpleName: String): Event {
return when (eventClassSimpleName) {
"Start" -> Event.Account.Start
"BeginPairingFlow" -> Event.Account.BeginPairingFlow("http://some.pairing.url.com")
"BeginEmailFlow" -> Event.Account.BeginEmailFlow
"AuthenticationError" -> Event.Account.AuthenticationError("fxa op")
"MigrateFromAccount" -> Event.Account.MigrateFromAccount(mock(), true)
"RetryMigration" -> Event.Account.RetryMigration
"Logout" -> Event.Account.Logout
"AccountNotFound" -> Event.Progress.AccountNotFound
"AccountRestored" -> Event.Progress.AccountRestored
"AuthData" -> Event.Progress.AuthData(mock())
"IncompleteMigration" -> Event.Progress.IncompleteMigration(true)
"Migrated" -> Event.Progress.Migrated(true)
"LoggedOut" -> Event.Progress.LoggedOut
"FailedToRecoverFromAuthenticationProblem" -> Event.Progress.FailedToRecoverFromAuthenticationProblem
"RecoveredFromAuthenticationProblem" -> Event.Progress.RecoveredFromAuthenticationProblem
"CompletedAuthentication" -> Event.Progress.CompletedAuthentication(mock())
"FailedToBeginAuth" -> Event.Progress.FailedToBeginAuth
"FailedToCompleteAuth" -> Event.Progress.FailedToCompleteAuth
"FailedToCompleteMigration" -> Event.Progress.FailedToCompleteMigration
"FailedToCompleteAuthRestore" -> Event.Progress.FailedToCompleteAuthRestore
else -> {
throw AssertionError("Unknown event: $eventClassSimpleName")
}
}
}
@Test
fun `state transition matrix`() {
// We want to test every combination of state/event. Do that by iterating over entire sets.
ProgressState.values().forEach { state ->
Event.Progress::class.sealedSubclasses.map { instantiateEvent(it.simpleName!!) }.forEach {
val ss = State.Active(state)
assertNextStateForStateEventPair(
ss,
it,
ss.next(it)
)
}
}
AccountState.values().forEach { state ->
Event.Account::class.sealedSubclasses.map { instantiateEvent(it.simpleName!!) }.forEach {
val ss = State.Idle(state)
assertNextStateForStateEventPair(
ss,
it,
ss.next(it)
)
}
}
}
}

View File

@@ -13,6 +13,7 @@ import android.content.pm.Signature
import android.content.pm.SigningInfo
import android.database.Cursor
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.concept.sync.MigratingAccountInfo
import mozilla.components.service.fxa.sharing.AccountSharing.KEY_EMAIL
import mozilla.components.service.fxa.sharing.AccountSharing.KEY_KSYNC
import mozilla.components.service.fxa.sharing.AccountSharing.KEY_KXSCS
@@ -166,7 +167,7 @@ class AccountSharingTest {
val expectedAccountRelease = ShareableAccount(
"user@mozilla.org",
packageNameRelease,
ShareableAuthInfo(
MigratingAccountInfo(
"sessionToken".toByteArray().toHexString(),
"ksync".toByteArray().toHexString(),
"kxscs"
@@ -177,7 +178,7 @@ class AccountSharingTest {
val expectedAccountBeta = ShareableAccount(
"user@mozilla.org",
packageNameBeta,
ShareableAuthInfo(
MigratingAccountInfo(
"sessionToken".toByteArray().toHexString(),
"ksync".toByteArray().toHexString(),
"kxscs"

View File

@@ -5,10 +5,10 @@
package mozilla.components.support.migration
import android.content.Context
import mozilla.components.concept.sync.MigratingAccountInfo
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.manager.SignInWithShareableAccountResult
import mozilla.components.service.fxa.manager.MigrationResult
import mozilla.components.service.fxa.sharing.ShareableAccount
import mozilla.components.service.fxa.sharing.ShareableAuthInfo
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.migration.FxaMigrationResult.Failure
import mozilla.components.support.migration.FxaMigrationResult.Success.SignedInIntoAuthenticatedAccount
@@ -147,7 +147,7 @@ private object AuthenticatedAccountProcessor {
): Result<FxaMigrationResult> {
require(fennecAccount.authInfo != null) { "authInfo must be present in order to sign-in a FennecAccount" }
val fennecAuthInfo = ShareableAuthInfo(
val fennecAuthInfo = MigratingAccountInfo(
sessionToken = fennecAccount.authInfo.sessionToken,
kSync = fennecAccount.authInfo.kSync,
kXCS = fennecAccount.authInfo.kXCS
@@ -158,13 +158,13 @@ private object AuthenticatedAccountProcessor {
authInfo = fennecAuthInfo
)
val signInResult = accountManager.signInWithShareableAccountAsync(
val signInResult = accountManager.migrateFromAccount(
shareableAccount,
reuseSessionToken = true
).await()
)
return when (signInResult) {
SignInWithShareableAccountResult.Failure -> {
MigrationResult.Failure -> {
Result.Failure(
FxaMigrationException(Failure.FailedToSignIntoAuthenticatedAccount(
email = fennecAccount.email,
@@ -172,13 +172,13 @@ private object AuthenticatedAccountProcessor {
))
)
}
SignInWithShareableAccountResult.Success -> {
MigrationResult.Success -> {
Result.Success(SignedInIntoAuthenticatedAccount(
email = fennecAccount.email,
stateLabel = fennecAccount.stateLabel
))
}
SignInWithShareableAccountResult.WillRetry -> {
MigrationResult.WillRetry -> {
Result.Success(FxaMigrationResult.Success.WillAutoRetrySignInLater(
email = fennecAccount.email,
stateLabel = fennecAccount.stateLabel

View File

@@ -5,10 +5,9 @@
package mozilla.components.support.migration
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.manager.SignInWithShareableAccountResult
import mozilla.components.service.fxa.manager.MigrationResult
import mozilla.components.service.fxa.sharing.ShareableAccount
import mozilla.components.support.test.any
import mozilla.components.support.test.argumentCaptor
@@ -82,9 +81,7 @@ class FennecFxaMigrationTest {
val fxaPath = File(getTestPath("fxa"), "married-v4.json")
val accountManager: FxaAccountManager = mock()
`when`(accountManager.signInWithShareableAccountAsync(any(), eq(true))).thenReturn(
CompletableDeferred(SignInWithShareableAccountResult.Success)
)
`when`(accountManager.migrateFromAccount(any(), eq(true))).thenReturn(MigrationResult.Success)
with(FennecFxaMigration.migrate(fxaPath, testContext, accountManager) as Result.Success) {
assertEquals(FxaMigrationResult.Success.SignedInIntoAuthenticatedAccount::class, this.value::class)
@@ -92,7 +89,7 @@ class FennecFxaMigrationTest {
assertEquals("Married", (this.value as FxaMigrationResult.Success.SignedInIntoAuthenticatedAccount).stateLabel)
val captor = argumentCaptor<ShareableAccount>()
verify(accountManager).signInWithShareableAccountAsync(captor.capture(), eq(true))
verify(accountManager).migrateFromAccount(captor.capture(), eq(true))
assertEquals("test@example.com", captor.value.email)
assertEquals("252fsvj8932vj32movj97325hjfksdhfjstrg23yurt267r23", captor.value.authInfo.kSync)
@@ -106,9 +103,7 @@ class FennecFxaMigrationTest {
val fxaPath = File(getTestPath("fxa"), "cohabiting-v4.json")
val accountManager: FxaAccountManager = mock()
`when`(accountManager.signInWithShareableAccountAsync(any(), eq(true))).thenReturn(
CompletableDeferred(SignInWithShareableAccountResult.Success)
)
`when`(accountManager.migrateFromAccount(any(), eq(true))).thenReturn(MigrationResult.Success)
with(FennecFxaMigration.migrate(fxaPath, testContext, accountManager) as Result.Success) {
assertEquals(FxaMigrationResult.Success.SignedInIntoAuthenticatedAccount::class, this.value::class)
@@ -116,7 +111,7 @@ class FennecFxaMigrationTest {
assertEquals("Cohabiting", (this.value as FxaMigrationResult.Success.SignedInIntoAuthenticatedAccount).stateLabel)
val captor = argumentCaptor<ShareableAccount>()
verify(accountManager).signInWithShareableAccountAsync(captor.capture(), eq(true))
verify(accountManager).migrateFromAccount(captor.capture(), eq(true))
assertEquals("test@example.com", captor.value.email)
assertEquals("252bc4ccc3a239fsdfsdf32fg32wf3w4e3472d41d1a204890", captor.value.authInfo.kSync)
@@ -130,9 +125,7 @@ class FennecFxaMigrationTest {
val fxaPath = File(getTestPath("fxa"), "cohabiting-v4.json")
val accountManager: FxaAccountManager = mock()
`when`(accountManager.signInWithShareableAccountAsync(any(), eq(true))).thenReturn(
CompletableDeferred(SignInWithShareableAccountResult.WillRetry)
)
`when`(accountManager.migrateFromAccount(any(), eq(true))).thenReturn(MigrationResult.WillRetry)
with(FennecFxaMigration.migrate(fxaPath, testContext, accountManager) as Result.Success) {
assertEquals(FxaMigrationResult.Success.WillAutoRetrySignInLater::class, this.value::class)
@@ -140,7 +133,7 @@ class FennecFxaMigrationTest {
assertEquals("Cohabiting", (this.value as FxaMigrationResult.Success.WillAutoRetrySignInLater).stateLabel)
val captor = argumentCaptor<ShareableAccount>()
verify(accountManager).signInWithShareableAccountAsync(captor.capture(), eq(true))
verify(accountManager).migrateFromAccount(captor.capture(), eq(true))
assertEquals("test@example.com", captor.value.email)
assertEquals("252bc4ccc3a239fsdfsdf32fg32wf3w4e3472d41d1a204890", captor.value.authInfo.kSync)
@@ -154,9 +147,7 @@ class FennecFxaMigrationTest {
val fxaPath = File(getTestPath("fxa"), "married-v4.json")
val accountManager: FxaAccountManager = mock()
`when`(accountManager.signInWithShareableAccountAsync(any(), eq(true))).thenReturn(
CompletableDeferred(SignInWithShareableAccountResult.Failure)
)
`when`(accountManager.migrateFromAccount(any(), eq(true))).thenReturn(MigrationResult.Failure)
with(FennecFxaMigration.migrate(fxaPath, testContext, accountManager) as Result.Failure) {
val unwrapped = this.throwables.first() as FxaMigrationException
@@ -166,7 +157,7 @@ class FennecFxaMigrationTest {
assertEquals("Married", (unwrapped.failure as FxaMigrationResult.Failure.FailedToSignIntoAuthenticatedAccount).stateLabel)
val captor = argumentCaptor<ShareableAccount>()
verify(accountManager).signInWithShareableAccountAsync(captor.capture(), eq(true))
verify(accountManager).migrateFromAccount(captor.capture(), eq(true))
assertEquals("test@example.com", captor.value.email)
assertEquals("252fsvj8932vj32movj97325hjfksdhfjstrg23yurt267r23", captor.value.authInfo.kSync)
@@ -222,9 +213,7 @@ class FennecFxaMigrationTest {
val fxaPath = File(getTestPath("fxa"), "cohabiting-v4.json")
val accountManager: FxaAccountManager = mock()
`when`(accountManager.signInWithShareableAccountAsync(any(), eq(true))).thenReturn(
CompletableDeferred(SignInWithShareableAccountResult.Failure)
)
`when`(accountManager.migrateFromAccount(any(), eq(true))).thenReturn(MigrationResult.Failure)
with(FennecFxaMigration.migrate(fxaPath, testContext, accountManager) as Result.Failure) {
val unwrapped = this.throwables.first() as FxaMigrationException
@@ -234,7 +223,7 @@ class FennecFxaMigrationTest {
assertEquals("Cohabiting", unwrappedFailure.stateLabel)
val captor = argumentCaptor<ShareableAccount>()
verify(accountManager).signInWithShareableAccountAsync(captor.capture(), eq(true))
verify(accountManager).migrateFromAccount(captor.capture(), eq(true))
assertEquals("test@example.com", captor.value.email)
assertEquals("252bc4ccc3a239fsdfsdf32fg32wf3w4e3472d41d1a204890", captor.value.authInfo.kSync)

View File

@@ -28,14 +28,13 @@ import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyZeroInteractions
import java.io.File
import java.lang.IllegalStateException
import kotlinx.coroutines.CompletableDeferred
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.concept.engine.Engine
import mozilla.components.concept.engine.webextension.WebExtension
import mozilla.components.feature.addons.amo.AddonCollectionProvider
import mozilla.components.feature.addons.update.AddonUpdater
import mozilla.components.feature.top.sites.PinnedSiteStorage
import mozilla.components.service.fxa.manager.SignInWithShareableAccountResult
import mozilla.components.service.fxa.manager.MigrationResult
import mozilla.components.service.fxa.sharing.ShareableAccount
import mozilla.components.service.sync.logins.SyncableLoginsStorage
import mozilla.components.concept.base.crash.CrashReporting
@@ -581,9 +580,7 @@ class FennecMigratorTest {
.setBrowserDbPath(File(getTestPath("combined"), "basic/browser.db").absolutePath)
.build()
`when`(accountManager.signInWithShareableAccountAsync(any(), eq(true))).thenReturn(
CompletableDeferred(SignInWithShareableAccountResult.Success)
)
`when`(accountManager.migrateFromAccount(any(), eq(true))).thenReturn(MigrationResult.Success)
with(migrator.migrateAsync(mock()).await()) {
assertEquals(1, this.size)
@@ -592,7 +589,7 @@ class FennecMigratorTest {
}
val captor = argumentCaptor<ShareableAccount>()
verify(accountManager).signInWithShareableAccountAsync(captor.capture(), eq(true))
verify(accountManager).migrateFromAccount(captor.capture(), eq(true))
assertEquals("test@example.com", captor.value.email)
// This is going to be package name (org.mozilla.firefox) in actual builds.
@@ -620,9 +617,7 @@ class FennecMigratorTest {
.setBrowserDbPath(File(getTestPath("combined"), "basic/browser.db").absolutePath)
.build()
`when`(accountManager.signInWithShareableAccountAsync(any(), eq(true))).thenReturn(
CompletableDeferred(SignInWithShareableAccountResult.WillRetry)
)
`when`(accountManager.migrateFromAccount(any(), eq(true))).thenReturn(MigrationResult.WillRetry)
with(migrator.migrateAsync(mock()).await()) {
assertEquals(1, this.size)
@@ -631,7 +626,7 @@ class FennecMigratorTest {
}
val captor = argumentCaptor<ShareableAccount>()
verify(accountManager).signInWithShareableAccountAsync(captor.capture(), eq(true))
verify(accountManager).migrateFromAccount(captor.capture(), eq(true))
assertEquals("test@example.com", captor.value.email)
// This is going to be package name (org.mozilla.firefox) in actual builds.
@@ -660,9 +655,7 @@ class FennecMigratorTest {
.build()
// For now, we don't treat sign-in failure any different from success. E.g. it's a one-shot attempt.
`when`(accountManager.signInWithShareableAccountAsync(any(), eq(true))).thenReturn(
CompletableDeferred(SignInWithShareableAccountResult.Failure)
)
`when`(accountManager.migrateFromAccount(any(), eq(true))).thenReturn(MigrationResult.Failure)
with(migrator.migrateAsync(mock()).await()) {
assertEquals(1, this.size)
@@ -671,7 +664,7 @@ class FennecMigratorTest {
}
val captor = argumentCaptor<ShareableAccount>()
verify(accountManager).signInWithShareableAccountAsync(captor.capture(), eq(true))
verify(accountManager).migrateFromAccount(captor.capture(), eq(true))
assertEquals("test@example.com", captor.value.email)
// This is going to be package name (org.mozilla.firefox) in actual builds.

View File

@@ -72,7 +72,7 @@ open class MainActivity : AppCompatActivity(), LoginFragment.OnLoginCompleteList
},
onScanResult = { pairingUrl ->
launch {
val url = account.beginPairingFlowAsync(pairingUrl, scopes).await()
val url = account.beginPairingFlow(pairingUrl, scopes)
if (url == null) {
Log.log(
Log.Priority.ERROR,
@@ -91,7 +91,7 @@ open class MainActivity : AppCompatActivity(), LoginFragment.OnLoginCompleteList
findViewById<View>(R.id.buttonCustomTabs).setOnClickListener {
launch {
account.beginOAuthFlowAsync(scopes).await()?.let {
account.beginOAuthFlow(scopes)?.let {
openTab(it.url)
}
}
@@ -99,7 +99,7 @@ open class MainActivity : AppCompatActivity(), LoginFragment.OnLoginCompleteList
findViewById<View>(R.id.buttonWebView).setOnClickListener {
launch {
account.beginOAuthFlowAsync(scopes).await()?.let {
account.beginOAuthFlow(scopes)?.let {
openWebView(it.url)
}
}
@@ -123,8 +123,8 @@ open class MainActivity : AppCompatActivity(), LoginFragment.OnLoginCompleteList
private fun initAccount(): FirefoxAccount {
getAuthenticatedAccount()?.let {
launch {
it.getProfileAsync(true).await()?.let {
displayProfile(it)
it.getProfile(true)?.let { profile ->
displayProfile(profile)
}
}
return it
@@ -189,8 +189,8 @@ open class MainActivity : AppCompatActivity(), LoginFragment.OnLoginCompleteList
private fun displayAndPersistProfile(code: String, state: String) {
launch {
account.completeOAuthFlowAsync(code, state).await()
account.getProfileAsync().await()?.let {
account.completeOAuthFlow(code, state)
account.getProfile()?.let {
displayProfile(it)
}
account.toJSONString().let {

View File

@@ -16,6 +16,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.DeviceConfig
import mozilla.components.concept.sync.DeviceType
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile
@@ -23,8 +24,8 @@ import mozilla.components.service.fxa.FirefoxAccount
import mozilla.components.lib.dataprotect.SecureAbove22Preferences
import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.DeviceConfig
import mozilla.components.service.fxa.FxaAuthData
import mozilla.components.service.fxa.PeriodicSyncConfig
import mozilla.components.service.fxa.Server
import mozilla.components.service.fxa.ServerConfig
import mozilla.components.service.fxa.SyncConfig
@@ -59,7 +60,7 @@ class MainActivity : AppCompatActivity(), LoginFragment.OnLoginCompleteListener,
applicationContext,
ServerConfig(Server.RELEASE, CLIENT_ID, REDIRECT_URL),
DeviceConfig("A-C Logins Sync Sample", DeviceType.MOBILE, setOf()),
SyncConfig(setOf(SyncEngine.Passwords))
SyncConfig(setOf(SyncEngine.Passwords), PeriodicSyncConfig())
)
}
@@ -98,12 +99,12 @@ class MainActivity : AppCompatActivity(), LoginFragment.OnLoginCompleteListener,
// kicking off the accountManager.
GlobalSyncableStoreProvider.configureStore(SyncEngine.Passwords to loginsStorage)
accountManager.initAsync().await()
accountManager.start()
}
findViewById<View>(R.id.buttonWebView).setOnClickListener {
launch {
val authUrl = accountManager.beginAuthenticationAsync().await()
val authUrl = accountManager.beginAuthentication()
if (authUrl == null) {
Toast.makeText(this@MainActivity, "Account auth error", Toast.LENGTH_LONG).show()
return@launch
@@ -119,7 +120,7 @@ class MainActivity : AppCompatActivity(), LoginFragment.OnLoginCompleteListener,
override fun onLoggedOut() {}
override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
accountManager.syncNowAsync(SyncReason.User)
launch { accountManager.syncNow(SyncReason.User) }
}
@Suppress("EmptyFunctionBlock")
@@ -148,9 +149,9 @@ class MainActivity : AppCompatActivity(), LoginFragment.OnLoginCompleteListener,
override fun onLoginComplete(code: String, state: String, action: String, fragment: LoginFragment) {
launch {
accountManager.finishAuthenticationAsync(
accountManager.finishAuthentication(
FxaAuthData(action.toAuthType(), code = code, state = state)
).await()
)
supportFragmentManager.popBackStack()
}
}

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.browser.storage.sync.PlacesBookmarksStorage
import mozilla.components.browser.storage.sync.PlacesHistoryStorage
import mozilla.components.concept.storage.BookmarkNode
@@ -29,12 +30,13 @@ import mozilla.components.concept.sync.DeviceCommandOutgoing
import mozilla.components.concept.sync.DeviceType
import mozilla.components.concept.sync.AccountEventsObserver
import mozilla.components.concept.sync.AccountEvent
import mozilla.components.concept.sync.AuthFlowError
import mozilla.components.concept.sync.DeviceConfig
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile
import mozilla.components.lib.dataprotect.SecureAbove22Preferences
import mozilla.components.lib.dataprotect.generateEncryptionKey
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.DeviceConfig
import mozilla.components.service.fxa.ServerConfig
import mozilla.components.service.fxa.SyncConfig
import mozilla.components.service.fxa.sync.GlobalSyncableStoreProvider
@@ -42,6 +44,7 @@ import mozilla.components.service.fxa.sync.SyncStatusObserver
import mozilla.components.support.base.log.Log
import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient
import mozilla.components.service.fxa.FxaAuthData
import mozilla.components.service.fxa.PeriodicSyncConfig
import mozilla.components.service.fxa.Server
import mozilla.components.service.fxa.SyncEngine
import mozilla.components.service.fxa.sync.SyncReason
@@ -92,7 +95,7 @@ class MainActivity :
),
SyncConfig(
setOf(SyncEngine.History, SyncEngine.Bookmarks, SyncEngine.Passwords),
syncPeriodInMinutes = 15L
periodicSyncConfig = PeriodicSyncConfig(periodMinutes = 15, initialDelayMinutes = 5)
)
)
}
@@ -119,24 +122,16 @@ class MainActivity :
findViewById<View>(R.id.buttonSignIn).setOnClickListener {
launch {
val authUrl = accountManager.beginAuthenticationAsync().await()
if (authUrl == null) {
val txtView: TextView = findViewById(R.id.fxaStatusView)
txtView.text = getString(R.string.account_error, null)
return@launch
}
openWebView(authUrl)
accountManager.beginAuthentication()?.let { openWebView(it) }
}
}
findViewById<View>(R.id.buttonLogout).setOnClickListener {
launch {
accountManager.logoutAsync().await()
}
launch { accountManager.logout() }
}
findViewById<View>(R.id.refreshDevice).setOnClickListener {
launch { accountManager.authenticatedAccount()?.deviceConstellation()?.refreshDevicesAsync()?.await() }
launch { accountManager.authenticatedAccount()?.deviceConstellation()?.refreshDevices() }
}
findViewById<View>(R.id.sendTab).setOnClickListener {
@@ -148,9 +143,9 @@ class MainActivity :
}
targets?.forEach {
constellation.sendCommandToDeviceAsync(
constellation.sendCommandToDevice(
it.id, DeviceCommandOutgoing.SendTab("Sample tab", "https://www.mozilla.org")
).await()
)
}
Toast.makeText(
@@ -171,17 +166,20 @@ class MainActivity :
// Observe incoming device commands.
accountManager.registerForAccountEvents(accountEventsObserver, owner = this, autoPause = true)
launch {
GlobalSyncableStoreProvider.configureStore(SyncEngine.History to historyStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.Bookmarks to bookmarksStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.Passwords to passwordsStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.History to historyStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.Bookmarks to bookmarksStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.Passwords to passwordsStorage)
launch {
// Now that our account state observer is registered, we can kick off the account manager.
accountManager.initAsync().await()
accountManager.start()
}
findViewById<View>(R.id.buttonSync).setOnClickListener {
accountManager.syncNowAsync(SyncReason.User)
launch {
accountManager.syncNow(SyncReason.User)
accountManager.authenticatedAccount()?.deviceConstellation()?.pollForCommands()
}
}
}
@@ -194,9 +192,9 @@ class MainActivity :
override fun onLoginComplete(code: String, state: String, action: String, fragment: LoginFragment) {
launch {
supportFragmentManager.popBackStack()
accountManager.finishAuthenticationAsync(
accountManager.finishAuthentication(
FxaAuthData(action.toAuthType(), code = code, state = state)
).await()
)
}
}
@@ -222,24 +220,26 @@ class MainActivity :
private val deviceConstellationObserver = object : DeviceConstellationObserver {
override fun onDevicesUpdate(constellation: ConstellationState) {
val currentDevice = constellation.currentDevice
launch {
val currentDevice = constellation.currentDevice
val currentDeviceView: TextView = findViewById(R.id.currentDevice)
if (currentDevice != null) {
currentDeviceView.text = getString(
R.string.full_device_details,
currentDevice.id, currentDevice.displayName, currentDevice.deviceType,
currentDevice.subscriptionExpired, currentDevice.subscription,
currentDevice.capabilities, currentDevice.lastAccessTime
)
} else {
currentDeviceView.text = getString(R.string.current_device_unknown)
val currentDeviceView: TextView = findViewById(R.id.currentDevice)
if (currentDevice != null) {
currentDeviceView.text = getString(
R.string.full_device_details,
currentDevice.id, currentDevice.displayName, currentDevice.deviceType,
currentDevice.subscriptionExpired, currentDevice.subscription,
currentDevice.capabilities, currentDevice.lastAccessTime
)
} else {
currentDeviceView.text = getString(R.string.current_device_unknown)
}
val devicesFragment = supportFragmentManager.findFragmentById(R.id.devices_fragment) as DeviceFragment
devicesFragment.updateDevices(constellation.otherDevices)
Toast.makeText(this@MainActivity, "Devices updated", Toast.LENGTH_SHORT).show()
}
val devicesFragment = supportFragmentManager.findFragmentById(R.id.devices_fragment) as DeviceFragment
devicesFragment.updateDevices(constellation.otherDevices)
Toast.makeText(this@MainActivity, "Devices updated", Toast.LENGTH_SHORT).show()
}
}
@@ -253,7 +253,6 @@ class MainActivity :
when (it.command) {
is DeviceCommandIncoming.TabReceived -> {
val cmd = it.command as DeviceCommandIncoming.TabReceived
txtView.text = "A tab was received"
var tabsStringified = "Tab(s) from: ${cmd.from?.displayName}\n"
cmd.entries.forEach { tab ->
tabsStringified += "${tab.title}: ${tab.url}\n"
@@ -362,6 +361,20 @@ class MainActivity :
)
}
}
override fun onFlowError(error: AuthFlowError) {
launch {
val txtView: TextView = findViewById(R.id.fxaStatusView)
txtView.text = getString(
R.string.account_error,
when (error) {
AuthFlowError.FailedToBeginAuth -> "Failed to begin authentication"
AuthFlowError.FailedToCompleteAuth -> "Failed to complete authentication"
AuthFlowError.FailedToMigrate -> "Failed to migrate"
}
)
}
}
}
private val syncObserver = object : SyncStatusObserver {
@@ -378,7 +391,7 @@ class MainActivity :
syncStatus?.text = getString(R.string.sync_idle)
val historyResultTextView: TextView = findViewById(R.id.historySyncResult)
val visitedCount = historyStorage.value.getVisited().size
val visitedCount = withContext(Dispatchers.IO) { historyStorage.value.getVisited().size }
// visitedCount is passed twice: to get the correct plural form, and then as
// an argument for string formatting.
historyResultTextView.text = resources.getQuantityString(
@@ -386,22 +399,24 @@ class MainActivity :
)
val bookmarksResultTextView: TextView = findViewById(R.id.bookmarksSyncResult)
val bookmarksRoot = bookmarksStorage.value.getTree("root________", recursive = true)
if (bookmarksRoot == null) {
bookmarksResultTextView.text = getString(R.string.no_bookmarks_root)
} else {
var bookmarksRootAndChildren = "BOOKMARKS\n"
fun addTreeNode(node: BookmarkNode, depth: Int) {
val desc = " ".repeat(depth * 2) + "${node.title} - ${node.url} (${node.guid})\n"
bookmarksRootAndChildren += desc
node.children?.forEach {
addTreeNode(it, depth + 1)
bookmarksResultTextView.setHorizontallyScrolling(true)
bookmarksResultTextView.movementMethod = ScrollingMovementMethod.getInstance()
bookmarksResultTextView.text = withContext(Dispatchers.IO) {
val bookmarksRoot = bookmarksStorage.value.getTree("root________", recursive = true)
if (bookmarksRoot == null) {
getString(R.string.no_bookmarks_root)
} else {
var bookmarksRootAndChildren = "BOOKMARKS\n"
fun addTreeNode(node: BookmarkNode, depth: Int) {
val desc = " ".repeat(depth * 2) + "${node.title} - ${node.url} (${node.guid})\n"
bookmarksRootAndChildren += desc
node.children?.forEach {
addTreeNode(it, depth + 1)
}
}
addTreeNode(bookmarksRoot, 0)
bookmarksRootAndChildren
}
addTreeNode(bookmarksRoot, 0)
bookmarksResultTextView.setHorizontallyScrolling(true)
bookmarksResultTextView.setMovementMethod(ScrollingMovementMethod.getInstance())
bookmarksResultTextView.text = bookmarksRootAndChildren
}
}
}