diff --git a/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt b/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt index 6f9b02f3a0e..fc1bde389f6 100644 --- a/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt +++ b/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt @@ -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() } /** diff --git a/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt b/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt index c71b6fd8449..8bbec600a83 100644 --- a/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt +++ b/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt @@ -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 /** - * Describes available interactions with the current device and other devices associated with an [OAuthAccount]. + * Represents a result of interacting with a backend service which may return an authentication error. */ -interface DeviceConstellation : Observable { +sealed class ServiceResult { /** - * 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. + * All good. + */ + object Ok : ServiceResult() + + /** + * Auth error. + */ + object AuthError : ServiceResult() + + /** + * Error that isn't auth. */ - fun initDeviceAsync( - name: String, - type: DeviceType = DeviceType.MOBILE, - capabilities: Set - ): Deferred + object OtherError : ServiceResult() +} +/** + * Describes available interactions with the current device and other devices associated with an [OAuthAccount]. + */ +interface DeviceConstellation : Observable { /** - * 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. + * 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 ensureCapabilitiesAsync(capabilities: Set): Deferred + 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 { * 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 + 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 + 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 + 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 + 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 + 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 + 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, + val secureStateAtRest: Boolean = false +) + /** * Capabilities that a [Device] may have. */ diff --git a/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt b/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt index c24d6a145a7..d12bcba6a95 100644 --- a/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt +++ b/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt @@ -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 { - /** - * No in-flight migration. - */ - NONE, - +enum class InFlightMigrationState(val reuseSessionToken: Boolean) { /** - * "Copy" in-flight migration present. Can retry migration via [OAuthAccount.retryMigrateFromSessionTokenAsync]. + * "Copy" in-flight migration present. Can retry migration via [OAuthAccount.retryMigrateFromSessionToken]. */ - COPY_SESSION_TOKEN, + COPY_SESSION_TOKEN(false), /** - * "Reuse" in-flight migration present. Can retry migration via [OAuthAccount.retryMigrateFromSessionTokenAsync]. + * "Reuse" in-flight migration present. Can retry migration via [OAuthAccount.retryMigrateFromSessionToken]. */ - 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. */ @@ -73,18 +61,18 @@ interface OAuthAccount : AutoCloseable { * Constructs a URL used to begin the OAuth flow for the requested scopes and keys. * * @param scopes List of OAuth scopes for which the client wants access - * @return Deferred AuthFlowUrl that resolves to the flow URL when complete + * @return [AuthFlowUrl] if available, `null` in case of a failure */ - fun beginOAuthFlowAsync(scopes: Set): Deferred + suspend fun beginOAuthFlow(scopes: Set): AuthFlowUrl? /** * Constructs a URL used to begin the pairing flow for the requested scopes and pairingUrl. * * @param pairingUrl URL string for pairing * @param scopes List of OAuth scopes for which the client wants access - * @return Deferred AuthFlowUrl Optional that resolves to the flow URL when complete + * @return [AuthFlowUrl] if available, `null` in case of a failure */ - fun beginPairingFlowAsync(pairingUrl: String, scopes: Set): Deferred + suspend fun beginPairingFlow(pairingUrl: String, scopes: Set): AuthFlowUrl? /** * Returns current FxA Device ID for an authenticated account. @@ -110,12 +98,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, state: String, accessType: AccessType = AccessType.ONLINE - ): Deferred + ): String? /** * Fetches the profile object for the current client either from the existing cached state @@ -124,18 +112,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 + 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 + suspend fun completeOAuthFlow(code: String, state: String): Boolean /** * Tries to fetch an access token for the given scope. @@ -144,11 +132,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 + 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() @@ -163,7 +151,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 + suspend fun checkAuthorizationStatus(singleScope: String): Boolean? /** * Fetches the token server endpoint, for authentication using the SAML bearer flow. @@ -190,36 +178,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 - * @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 migrateFromSessionTokenAsync(sessionToken: String, kSync: String, kXCS: String): Deferred - - /** - * 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 + * @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 copyFromSessionTokenAsync(sessionToken: String, kSync: String, kXCS: String): Deferred + 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. @@ -227,7 +202,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 + suspend fun retryMigrateFromSessionToken(): JSONObject? /** * Returns the device constellation for the current account @@ -245,7 +220,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 + suspend fun disconnect(): Boolean /** * Serializes the current account's authentication state as a JSON string, for persistence in @@ -294,9 +269,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 MigratedCopy : AuthType() + + /** + * Account created via a shared account state from another app via the reuse token flow. */ - object Shared : AuthType() + object MigratedReuse : AuthType() /** * Existing account was recovered from an authentication problem. @@ -304,6 +284,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 @@ -333,6 +334,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( diff --git a/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/FxaPushSupportFeature.kt b/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/FxaPushSupportFeature.kt index 81108c394d1..e0c0277a223 100644 --- a/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/FxaPushSupportFeature.kt +++ b/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/FxaPushSupportFeature.kt @@ -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() diff --git a/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt b/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt index de536ac8a55..71a3b217231 100644 --- a/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt +++ b/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt @@ -54,7 +54,7 @@ internal class EventsObserver( override fun onEvents(events: List) { events.asSequence() .filterIsInstance() - .map({ it.command }) + .map { it.command } .filterIsInstance() .forEach { command -> logger.debug("Showing ${command.entries.size} tab(s) received from deviceID=${command.from?.id}") diff --git a/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabUseCases.kt b/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabUseCases.kt index ea6d32065da..58ee124a629 100644 --- a/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabUseCases.kt +++ b/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabUseCases.kt @@ -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 diff --git a/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/AccountObserverTest.kt b/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/AccountObserverTest.kt index fa3d4717c6d..cf3ed43be97 100644 --- a/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/AccountObserverTest.kt +++ b/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/AccountObserverTest.kt @@ -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 diff --git a/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/AutoPushObserverTest.kt b/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/AutoPushObserverTest.kt index c94060cbe37..8987e1bbc99 100644 --- a/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/AutoPushObserverTest.kt +++ b/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/AutoPushObserverTest.kt @@ -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) } diff --git a/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/EventsObserverTest.kt b/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/EventsObserverTest.kt index 3122da965eb..e8c48f9a3d7 100644 --- a/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/EventsObserverTest.kt +++ b/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/EventsObserverTest.kt @@ -36,7 +36,7 @@ class EventsObserverTest { val callback: (Device?, List) -> 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())) ) diff --git a/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/OneTimeFxaPushResetTest.kt b/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/OneTimeFxaPushResetTest.kt index 5ea07c99dcd..ba7a42d3fb4 100644 --- a/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/OneTimeFxaPushResetTest.kt +++ b/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/OneTimeFxaPushResetTest.kt @@ -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)) } } diff --git a/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/SendTabUseCasesTest.kt b/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/SendTabUseCasesTest.kt index 07c3468c4e9..b914c44328a 100644 --- a/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/SendTabUseCasesTest.kt +++ b/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/SendTabUseCasesTest.kt @@ -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()) } diff --git a/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt b/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt index fc37f252f37..6fbe553fb7c 100644 --- a/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt +++ b/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt @@ -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) } diff --git a/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FxaWebChannelFeature.kt b/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FxaWebChannelFeature.kt index dedf6c25128..a28220978db 100644 --- a/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FxaWebChannelFeature.kt +++ b/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FxaWebChannelFeature.kt @@ -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 } diff --git a/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt b/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt index 3b5dcac9fbe..98f63320555 100644 --- a/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt +++ b/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt @@ -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, + 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() + 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() + 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() - 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() - 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() 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())).thenReturn(CompletableDeferred(AuthFlowUrl("authState", "auth://url"))) - `when`(mockAccount.beginPairingFlowAsync(anyString(), any())).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())).thenReturn(AuthFlowUrl("authState", "auth://url")) + `when`(mockAccount.beginPairingFlow(anyString(), any())).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())).thenReturn(CompletableDeferred(value = null)) - `when`(mockAccount.beginPairingFlowAsync(anyString(), any())).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())).thenReturn(null) + `when`(mockAccount.beginPairingFlow(anyString(), any())).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 } diff --git a/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FxaWebChannelFeatureTest.kt b/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FxaWebChannelFeatureTest.kt index 19b7d821395..e53bcf82772 100644 --- a/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FxaWebChannelFeatureTest.kt +++ b/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FxaWebChannelFeatureTest.kt @@ -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 = setOf(SyncEngine.History) - val logoutDeferred = CompletableDeferred() val messageHandler = argumentCaptor() val responseToTheWebChannel = argumentCaptor() @@ -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 = setOf(SyncEngine.History) - val logoutDeferred = CompletableDeferred() val messageHandler = argumentCaptor() val responseToTheWebChannel = argumentCaptor() @@ -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() - 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() - 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?, messageHandler: MessageHandler, accountManager: FxaAccountManager) { + private suspend fun verifyOauthLogin(action: String, expectedAuthType: AuthType, code: String, state: String, declined: Set?, messageHandler: MessageHandler, accountManager: FxaAccountManager) { val jsonToWebChannel = jsonOauthLogin(action, code, state, declined ?: emptySet()) val port = mock() 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): JSONObject { diff --git a/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/DefaultController.kt b/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/DefaultController.kt index 4df77706fd5..6b1572449e6 100644 --- a/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/DefaultController.kt +++ b/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/controller/DefaultController.kt @@ -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) } } } diff --git a/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/controller/DefaultControllerTest.kt b/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/controller/DefaultControllerTest.kt index 5c1b6ba48a4..cf3b83a5686 100644 --- a/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/controller/DefaultControllerTest.kt +++ b/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/controller/DefaultControllerTest.kt @@ -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) } } diff --git a/components/service/firefox-accounts/build.gradle b/components/service/firefox-accounts/build.gradle index aee2b6bbf19..b178ac0d6c6 100644 --- a/components/service/firefox-accounts/build.gradle +++ b/components/service/firefox-accounts/build.gradle @@ -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' diff --git a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Config.kt b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Config.kt index a8a252f19d0..70a715072ec 100644 --- a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Config.kt +++ b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Config.kt @@ -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, - 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, - val syncPeriodInMinutes: Long? = null + val periodicSyncConfig: PeriodicSyncConfig? ) /** diff --git a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt index 174adda61fc..84bec34445e 100644 --- a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt +++ b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt @@ -5,21 +5,20 @@ 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 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 @@ -29,7 +28,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 @@ -76,14 +75,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 @@ -91,9 +82,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() @@ -101,10 +91,11 @@ class FirefoxAccount internal constructor( } override fun registerPersistenceCallback(callback: StatePersistenceCallback) { + logger.info("Registering persistence callback") persistCallback.setCallback(callback) } - override fun beginOAuthFlowAsync(scopes: Set) = scope.async { + override suspend fun beginOAuthFlow(scopes: Set) = withContext(scope.coroutineContext) { handleFxaExceptions(logger, "begin oauth flow", { null }) { val url = inner.beginOAuthFlow(scopes.toTypedArray()) val state = Uri.parse(url).getQueryParameter("state")!! @@ -112,7 +103,10 @@ class FirefoxAccount internal constructor( } } - override fun beginPairingFlowAsync(pairingUrl: String, scopes: Set) = scope.async { + override suspend fun beginPairingFlow( + pairingUrl: String, + scopes: Set + ) = withContext(scope.coroutineContext) { handleFxaExceptions(logger, "begin oauth pairing flow", { null }) { val url = inner.beginPairingFlow(pairingUrl, scopes.toTypedArray()) val state = Uri.parse(url).getQueryParameter("state")!! @@ -120,7 +114,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() } @@ -138,21 +132,21 @@ class FirefoxAccount internal constructor( } } - override fun authorizeOAuthCodeAsync( + override suspend fun authorizeOAuthCode( clientId: String, scopes: Array, state: String, accessType: AccessType - ) = scope.async { + ) = withContext(scope.coroutineContext) { handleFxaExceptions(logger, "authorizeOAuthCode", { null }) { inner.authorizeOAuthCode(clientId, scopes, state, accessType.msg) } } 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 @@ -161,21 +155,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 = scope.async { + override suspend fun retryMigrateFromSessionToken() = withContext(scope.coroutineContext) { handleFxaExceptions(logger, "retryMigrateFromSessionToken", { null }) { inner.retryMigrateFromSessionToken() } @@ -196,13 +193,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() } @@ -214,7 +211,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. @@ -237,7 +234,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() diff --git a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt index 95b090a6c17..d048eac4822 100644 --- a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt +++ b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt @@ -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 - ): Deferred { - 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 } - override fun ensureCapabilitiesAsync(capabilities: Set): Deferred { - return scope.async { - handleFxaExceptions(logger, "ensuring capabilities") { - account.ensureCapabilities(capabilities.map { it.into() }.toSet()) + @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 processRawEventAsync(payload: String): Deferred { - 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 { - 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() - - rename && refreshDevices + 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() } - override fun setDevicePushSubscriptionAsync(subscription: DevicePushSubscription): Deferred { - return scope.async { - handleFxaExceptions(logger, "updating device push subscription") { - account.setDevicePushSubscription( - subscription.endpoint, subscription.publicKey, subscription.authKey - ) - } + 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 sendCommandToDeviceAsync( + override suspend fun sendCommandToDevice( targetDeviceId: String, outgoingCommand: DeviceCommandOutgoing - ): Deferred { - 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 { - 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) = CoroutineScope(Dispatchers.Main).launch { + private fun processEvents(events: List) { notifyObservers { onEvents(events) } } - override fun refreshDevicesAsync(): Deferred = 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?> { - return scope.async { - handleFxaExceptions(logger, "fetching all devices", { null }) { - account.getDevices().map { it.into() } - } + private suspend fun fetchAllDevices(): List? { + return handleFxaExceptions(logger, "fetching all devices", { null }) { + account.getDevices().map { it.into() } } } } diff --git a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt index adbc5466000..7c77790683f 100644 --- a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt +++ b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt @@ -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? = 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 } diff --git a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt index ed819ce9681..4693300f54d 100644 --- a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt +++ b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt @@ -8,29 +8,26 @@ import android.content.Context import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.launch import kotlinx.coroutines.cancel import kotlinx.coroutines.withContext import mozilla.appservices.syncmanager.DeviceSettings +import mozilla.components.concept.sync.AuthFlowError import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.DeviceCapability import mozilla.components.concept.sync.AuthFlowUrl import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.AccountEvent import mozilla.components.concept.sync.AccountEventsObserver +import mozilla.components.concept.sync.DeviceConfig import mozilla.components.concept.sync.InFlightMigrationState import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile +import mozilla.components.concept.sync.ServiceResult import mozilla.components.concept.sync.StatePersistenceCallback import mozilla.components.service.fxa.AccountManagerException import mozilla.components.service.fxa.AccountStorage -import mozilla.components.service.fxa.DeviceConfig import mozilla.components.service.fxa.FxaDeviceSettingsCache import mozilla.components.service.fxa.FirefoxAccount import mozilla.components.service.fxa.FxaAuthData @@ -55,6 +52,7 @@ import mozilla.components.concept.base.crash.CrashReporting import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.base.observer.Observable import mozilla.components.support.base.observer.ObserverRegistry +import org.json.JSONObject import java.io.Closeable import java.lang.Exception import java.lang.IllegalArgumentException @@ -63,22 +61,6 @@ import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executors import kotlin.coroutines.CoroutineContext -/** - * Observer interface which lets its consumers respond to authentication requests. - */ -private interface OAuthObserver { - /** - * Account manager is requesting for an OAUTH flow to begin. - * @param authUrl Starting point for the OAUTH flow. - */ - fun onBeginOAuthFlow(authFlowUrl: AuthFlowUrl) - - /** - * Account manager encountered an error during authentication. - */ - fun onError() -} - // Necessary to fetch a profile. const val SCOPE_PROFILE = "profile" // Necessary to obtain sync keys. @@ -95,10 +77,12 @@ const val AUTH_CHECK_CIRCUIT_BREAKER_COUNT = 10 // For example, initializing the account state machine & syncing after letting our access tokens expire // due to long period of inactivity will trigger a few 401s, and that shouldn't be a cause for concern. +const val MAX_NETWORK_RETRIES = 3 + /** - * Describes a result of running [FxaAccountManager.signInWithShareableAccountAsync]. + * Describes a result of running [FxaAccountManager.migrateFromAccount]. */ -enum class SignInWithShareableAccountResult { +enum class MigrationResult { /** * Sign-in failed due to an intermittent problem (such as a network failure). A retry attempt will * be performed automatically during account manager initialization, or as a side-effect of certain @@ -134,12 +118,12 @@ enum class SignInWithShareableAccountResult { * @param syncConfig Optional, initial sync behaviour configuration. Sync will be disabled if this is `null`. * @param applicationScopes A set of scopes which will be requested during account authentication. */ -@Suppress("TooManyFunctions", "LargeClass") +@Suppress("TooManyFunctions", "LargeClass", "LongParameterList") open class FxaAccountManager( private val context: Context, private val serverConfig: ServerConfig, private val deviceConfig: DeviceConfig, - @Volatile private var syncConfig: SyncConfig?, + private val syncConfig: SyncConfig?, private val applicationScopes: Set = emptySet(), private val crashReporter: CrashReporting? = null, // We want a single-threaded execution model for our account-related "actions" (state machine side-effects). @@ -149,29 +133,26 @@ open class FxaAccountManager( ) : Closeable, Observable by ObserverRegistry() { private val logger = Logger("FirefoxAccountStateMachine") + /** + * Observer interface which lets its consumers respond to authentication requests. + */ + private interface OAuthObserver { + /** + * Account manager is requesting for an OAUTH flow to begin. + * @param authUrl Starting point for the OAUTH flow. + */ + fun onBeginOAuthFlow(authFlowUrl: AuthFlowUrl) + + /** + * Account manager encountered an error during authentication. + */ + fun onError() {} + } + @Volatile private var latestAuthState: String? = null - // This is used during 'beginAuthenticationAsync' call, which returns a Deferred. - // 'deferredAuthUrl' is set on this observer and returned, and resolved once state machine goes - // through its motions. This allows us to keep around only one observer in the registry. - private val fxaOAuthObserver = FxaOAuthObserver() - private class FxaOAuthObserver : OAuthObserver { - @Volatile - lateinit var deferredAuthUrl: CompletableDeferred - - override fun onBeginOAuthFlow(authFlowUrl: AuthFlowUrl) { - deferredAuthUrl.complete(authFlowUrl.url) - } - - override fun onError() { - deferredAuthUrl.complete(null) - } - } private val oauthObservers = object : Observable by ObserverRegistry() {} - init { - oauthObservers.register(fxaOAuthObserver) - } init { GlobalAccountManager.setInstance(this) @@ -202,7 +183,7 @@ open class FxaAccountManager( // We'd like to persist this state, so that we can short-circuit transition to AuthenticationProblem on // initialization, instead of triggering the full state machine knowing in advance we'll hit auth problems. // See https://github.com/mozilla-mobile/android-components/issues/5102 - @Volatile private var state = AccountState.Start + @Volatile private var state: State = State.Idle(AccountState.NotAuthenticated) private val eventQueue = ConcurrentLinkedQueue() private val accountEventObserverRegistry = ObserverRegistry() @@ -225,11 +206,22 @@ open class FxaAccountManager( // Note that trying to perform a sync while account isn't authenticated will not succeed. @GuardedBy("this") private var syncManager: SyncManager? = null - private var syncManagerIntegration: AccountsToSyncIntegration? = null - private val internalSyncStatusObserver = SyncToAccountsIntegration(this) init { - syncConfig?.let { setSyncConfigAsync(it) } + syncConfig?.let { + // Initialize sync manager with the passed-in config. + if (syncConfig.supportedEngines.isEmpty()) { + throw IllegalArgumentException("Set of supported engines can't be empty") + } + + syncManager = createSyncManager(syncConfig).also { + // Observe account state changes. + this.register(AccountsToSyncIntegration(it)) + + // Observe sync status changes. + it.registerSyncStatusObserver(SyncToAccountsIntegration(this)) + } + } if (syncManager == null) { logger.info("Sync is disabled") @@ -241,7 +233,7 @@ open class FxaAccountManager( /** * Queries trusted FxA Auth providers available on the device, returning a list of [ShareableAccount] * in an order of preference. Any of the returned [ShareableAccount] may be used with - * [signInWithShareableAccountAsync] to sign-in into an FxA account without any required user input. + * [migrateFromAccount] to sign-in into an FxA account without any required user input. */ fun shareableAccounts(context: Context): List { return AccountSharing.queryShareableAccounts(context) @@ -255,79 +247,20 @@ open class FxaAccountManager( * @param reuseSessionToken Whether or not to reuse existing session token (which is part of the [ShareableAccount]. * @return A deferred boolean flag indicating success (if true) of the sign-in operation. */ - fun signInWithShareableAccountAsync( + suspend fun migrateFromAccount( fromAccount: ShareableAccount, reuseSessionToken: Boolean = false - ): Deferred { - val stateMachineTransition = processQueueAsync(Event.SignInShareableAccount(fromAccount, reuseSessionToken)) - val result = CompletableDeferred() - CoroutineScope(coroutineContext).launch { - stateMachineTransition.await() - - if (accountMigrationInFlight()) { - result.complete(SignInWithShareableAccountResult.WillRetry) - } else if (authenticatedAccount() != null) { - result.complete(SignInWithShareableAccountResult.Success) - } else { - result.complete(SignInWithShareableAccountResult.Failure) - } - } - return result - } - - /** - * Allows setting a new [SyncConfig], changing sync behaviour. - * - * @param config Sync behaviour configuration, see [SyncConfig]. - */ - fun setSyncConfigAsync(config: SyncConfig): Deferred = synchronized(this) { - logger.info("Enabling/updating sync with a new SyncConfig: $config") - - syncConfig = config - // Let the existing manager, if it's present, clean up (cancel periodic syncing, etc). - syncManager?.stop() - syncManagerIntegration = null - - val result = CompletableDeferred() - - // Initialize a new sync manager with the passed-in config. - if (config.supportedEngines.isEmpty()) { - throw IllegalArgumentException("Set of supported engines can't be empty") - } - - syncManager = createSyncManager(config).also { manager -> - // Observe account state changes. - syncManagerIntegration = AccountsToSyncIntegration(manager).also { accountToSync -> - this@FxaAccountManager.register(accountToSync) - } - // Observe sync status changes. - manager.registerSyncStatusObserver(internalSyncStatusObserver) - // If account is currently authenticated, 'enable' the sync manager. - if (authenticatedAccount() != null) { - CoroutineScope(coroutineContext).launch { - // Make sure auth-info cache is populated before starting sync manager. - maybeUpdateSyncAuthInfoCache() - - // We enabled syncing for an authenticated account, and we now need to kick-off sync. - // How do we know if this is going to be a first sync for an account? - // We can make two assumptions, that are probably roughly correct most of the time: - // - we know what the user data choices are - // - they were presented with CWTS ("choose what to sync") during sign-up/sign-in - // - ... or we're enabling sync for an existing account, with data choices already - // recorded on some other client, e.g. desktop. - // In either case, we need to behave as if we're in a "first sync": - // - persist local choice, if we've signed for an account up locally and have CWTS - // data from the user - // - or fetch existing CWTS data from the server, if we don't have it locally. - manager.start(SyncReason.FirstSync) - result.complete(Unit) - } - } else { - result.complete(Unit) + ): MigrationResult = withContext(coroutineContext) { + processQueue(Event.Account.MigrateFromAccount(fromAccount, reuseSessionToken)) + + when (val s = state) { + is State.Idle -> when (s.accountState) { + AccountState.Authenticated -> MigrationResult.Success + AccountState.IncompleteMigration -> MigrationResult.WillRetry + else -> MigrationResult.Failure } + else -> MigrationResult.Failure } - - return result } /** @@ -357,38 +290,36 @@ open class FxaAccountManager( * @param reason A [SyncReason] indicating why this sync is being requested. * @param debounce Boolean flag indicating if this sync may be debounced (in case another sync executed recently). */ - fun syncNowAsync( + suspend fun syncNow( reason: SyncReason, debounce: Boolean = false - ): Deferred = CoroutineScope(coroutineContext).async { - // We may be in a state where we're not quite authenticated yet - but can auto-retry our - // authentication, instead. - // This is one of our trigger points for retrying - when a user asks us to sync. - // Another trigger point is the initialization flow of the account manager itself. - if (accountMigrationInFlight()) { - // Only trigger a retry attempt if the sync request was caused by a direct user action. - // We don't attempt to retry migration on 'SyncReason.Startup' because a retry will happen during - // account manager initialization if necessary anyway. - // If the retry attempt succeeds, sync will run as a by-product of regular account authentication flow, - // which is why we don't check for the result of a retry attempt here. - when (reason) { - SyncReason.User, SyncReason.EngineChange -> processQueueAsync(Event.RetryMigration).await() - } - } else { - // Make sure auth cache is populated before we try to sync. - maybeUpdateSyncAuthInfoCache() - - // Access to syncManager is guarded by `this`. - synchronized(this) { - if (syncManager == null) { - throw IllegalStateException( - "Sync is not configured. Construct this class with a 'syncConfig' or use 'setSyncConfig'" - ) + ) = withContext(coroutineContext) { + when (val s = state) { + // Can't sync while we're still doing stuff. + is State.Active -> Unit + is State.Idle -> when (s.accountState) { + // If we're in an incomplete migration state, try to complete it. + // This is one of our trigger points for retrying - when a user asks us to sync. + // Another trigger point is the startup flow of the account manager itself. + AccountState.IncompleteMigration -> { + processQueue(Event.Account.RetryMigration) + } + // All good, request a sync. + AccountState.Authenticated -> { + // Make sure auth cache is populated before we try to sync. + maybeUpdateSyncAuthInfoCache() + + // Access to syncManager is guarded by `this`. + synchronized(this) { + checkNotNull(syncManager == null) { + "Sync is not configured. Construct this class with a 'syncConfig' or use 'setSyncConfig'" + } + syncManager?.now(reason, debounce) + } } - syncManager?.now(reason, debounce) + else -> logger.info("Ignoring syncNow request, not in the right state: $s") } } - Unit } /** @@ -399,9 +330,9 @@ open class FxaAccountManager( /** * Call this after registering your observers, and before interacting with this class. */ - fun initAsync(): Deferred { - statePersistenceCallback = FxaStatePersistenceCallback(WeakReference(this)) - return processQueueAsync(Event.Init) + suspend fun start() = withContext(coroutineContext) { + statePersistenceCallback = FxaStatePersistenceCallback(WeakReference(this@FxaAccountManager)) + processQueue(Event.Account.Start) } /** @@ -409,29 +340,18 @@ open class FxaAccountManager( * @return [OAuthAccount] if we're in an authenticated state, null otherwise. Returned [OAuthAccount] * may need to be re-authenticated; consumers are expected to check [accountNeedsReauth]. */ - fun authenticatedAccount(): OAuthAccount? = when (state) { - AccountState.AuthenticatedWithProfile, - AccountState.AuthenticatedNoProfile, - AccountState.AuthenticationProblem, - AccountState.CanAutoRetryAuthenticationViaTokenReuse, - AccountState.CanAutoRetryAuthenticationViaTokenCopy -> account + fun authenticatedAccount(): OAuthAccount? = when (val s = state) { + is State.Idle -> when (s.accountState) { + AccountState.Authenticated, + AccountState.IncompleteMigration, + AccountState.AuthenticationProblem -> account + else -> null + } else -> null } /** - * Checks if there's an in-flight account migration. An in-flight migration means that we've tried to "migrate" - * via [signInWithShareableAccountAsync] and failed for intermittent (e.g. network) reasons. - * A migration sign-in attempt will be retried automatically either during account manager initialization, - * or as a by-product of user-triggered [syncNowAsync]. - */ - fun accountMigrationInFlight(): Boolean = when (state) { - AccountState.CanAutoRetryAuthenticationViaTokenReuse, - AccountState.CanAutoRetryAuthenticationViaTokenCopy -> true - else -> false - } - - /** - * Indicates if account needs to be re-authenticated via [beginAuthenticationAsync]. + * Indicates if account needs to be re-authenticated via [beginAuthentication]. * Most common reason for an account to need re-authentication is a password change. * * TODO this may return a false-positive, if we're currently going through a recovery flow. @@ -439,38 +359,76 @@ open class FxaAccountManager( * * @return A boolean flag indicating if account needs to be re-authenticated. */ - fun accountNeedsReauth(): Boolean { - return when (state) { + fun accountNeedsReauth() = when (val s = state) { + is State.Idle -> when (s.accountState) { AccountState.AuthenticationProblem -> true else -> false } + else -> false } - fun accountProfile(): Profile? { - return when (state) { - AccountState.AuthenticatedWithProfile, + /** + * Returns a [Profile] for an account, attempting to fetch it if necessary. + * @return [Profile] if one is available and account is an authenticated state. + */ + fun accountProfile(): Profile? = when (val s = state) { + is State.Idle -> when (s.accountState) { + AccountState.Authenticated, AccountState.AuthenticationProblem -> profile else -> null } + else -> null } - fun updateProfileAsync(): Deferred { - return processQueueAsync(Event.FetchProfile) + /** + * Fetches an up-to-date [Profile] if account is in an authenticated state. + */ + suspend fun fetchProfile(): Profile? { + return refreshProfile(true) + } + + private suspend fun refreshProfile(ignoreCache: Boolean): Profile? { + return authenticatedAccount()?.getProfile(ignoreCache = ignoreCache)?.let { newProfile -> + profile = newProfile + notifyObservers { + onProfileUpdated(newProfile) + } + profile + } } - fun beginAuthenticationAsync(pairingUrl: String? = null): Deferred { - val deferredAuthUrl = CompletableDeferred() + /** + * Begins an authentication process. Should be finalized by calling [finishAuthentication] once + * user successfully goes through the authentication at the returned url. + * @param pairingUrl Optional pairing URL in case a pairing flow is being initiated. + * @return An authentication url which is to be presented to the user. + */ + suspend fun beginAuthentication(pairingUrl: String? = null): String? { + val event = if (pairingUrl != null) { + Event.Account.BeginPairingFlow(pairingUrl) + } else { + Event.Account.BeginEmailFlow + } - // Observer will 'complete' this deferred once state machine resolves its events. - fxaOAuthObserver.deferredAuthUrl = deferredAuthUrl + // 'deferredAuthUrl' will be set as the state machine reacts to the 'event'. + var deferredAuthUrl: String? = null + oauthObservers.register(object : OAuthObserver { + override fun onBeginOAuthFlow(authFlowUrl: AuthFlowUrl) { + deferredAuthUrl = authFlowUrl.url + } - val event = if (pairingUrl != null) Event.Pair(pairingUrl) else Event.Authenticate - processQueueAsync(event) + override fun onError() { + // No-op for now. + logger.warn("Got an error during 'beginAuthentication'") + } + }) + processQueue(event) + oauthObservers.unregisterObservers() return deferredAuthUrl } /** - * Finalize authentication that was started via [beginAuthenticationAsync]. + * Finalize authentication that was started via [beginAuthentication]. * * If authentication wasn't started via this manager we won't accept this authentication attempt, * returning `false`. This may happen if [WebChannelFeature] is enabled, and user is manually @@ -482,38 +440,39 @@ open class FxaAccountManager( * * @return A deferred boolean flag indicating if authentication state was accepted. */ - fun finishAuthenticationAsync(authData: FxaAuthData): Deferred { - val result = CompletableDeferred() - + suspend fun finishAuthentication(authData: FxaAuthData) = withContext(coroutineContext) { when { latestAuthState == null -> { logger.warn("Trying to finish authentication that was never started.") - result.complete(false) + false } authData.state != latestAuthState -> { logger.warn("Trying to finish authentication for an invalid auth state; ignoring.") - result.complete(false) + false } authData.state == latestAuthState -> { - CoroutineScope(coroutineContext).launch { - processQueueAsync(Event.Authenticated(authData)).await() - result.complete(true) - } + processQueue(Event.Progress.AuthData(authData)) + true } - else -> throw IllegalStateException("Unexpected finishAuthenticationAsync state") + else -> throw IllegalStateException("Unexpected finishAuthentication state") } - - return result } - fun logoutAsync(): Deferred { - return processQueueAsync(Event.Logout) - } + /** + * Logout of the account, if currently logged-in. + */ + suspend fun logout() = withContext(coroutineContext) { processQueue(Event.Account.Logout) } + /** + * Register a [AccountEventsObserver] to monitor events relevant to an account/device. + */ fun registerForAccountEvents(observer: AccountEventsObserver, owner: LifecycleOwner, autoPause: Boolean) { accountEventObserverRegistry.register(observer, owner, autoPause) } + /** + * Register a [SyncStatusObserver] to monitor sync activity performed by this manager. + */ fun registerForSyncEvents(observer: SyncStatusObserver, owner: LifecycleOwner, autoPause: Boolean) { syncStatusObserverRegistry.register(observer, owner, autoPause) } @@ -528,26 +487,26 @@ open class FxaAccountManager( operation: String, errorCountWithinTheTimeWindow: Int = 1 ) = withContext(coroutineContext) { - processQueueAsync( - Event.AuthenticationError(operation, errorCountWithinTheTimeWindow) - ).await() + processQueue( + Event.Account.AuthenticationError(operation, errorCountWithinTheTimeWindow) + ) } /** * Pumps the state machine until all events are processed and their side-effects resolve. */ - private fun processQueueAsync(event: Event): Deferred = CoroutineScope(coroutineContext).async { + private suspend fun processQueue(event: Event) = withContext(coroutineContext) { eventQueue.add(event) do { val toProcess: Event = eventQueue.poll()!! - val transitionInto = FxaStateMatrix.nextState(state, toProcess) + val transitionInto = state.next(toProcess) if (transitionInto == null) { - logger.warn("Got invalid event $toProcess for state $state.") + logger.warn("Got invalid event '$toProcess' for state $state.") continue } - logger.info("Processing event $toProcess for state $state. Next state is $transitionInto") + logger.info("Processing event '$toProcess' for state $state. Next state is $transitionInto") state = transitionInto @@ -558,365 +517,307 @@ open class FxaAccountManager( } while (!eventQueue.isEmpty()) } - /** - * Side-effects matrix. Defines non-pure operations that must take place for state+event combinations. - */ - @Suppress("ComplexMethod", "ReturnCount", "ThrowsCount", "NestedBlockDepth", "LongMethod") - private suspend fun stateActions(forState: AccountState, via: Event): Event? { - // We're about to enter a new state ('forState') via some event ('via'). - // States will have certain side-effects associated with different event transitions. - // In other words, the same state may have different side-effects depending on the event - // which caused a transition. - // For example, a "NotAuthenticated" state may be entered after a logoutAsync, and its side-effects - // will include clean-up and re-initialization of an account. Alternatively, it may be entered - // after we've checked local disk, and didn't find a persisted authenticated account. - return when (forState) { - AccountState.Start -> { - when (via) { - Event.Init -> { - // Locally corrupt accounts are simply treated as 'absent'. - account = try { - getAccountStorage().read() - } catch (e: FxaPanicException) { - // Don't swallow panics from the underlying library. - throw e - } catch (e: FxaException) { - logger.error("Failed to load saved account. Re-initializing...", e) - null - } ?: return Event.AccountNotFound - - // We may have attempted a migration previously, which failed in a way that allows - // us to retry it (e.g. a migration could have hit - when (account.isInMigrationState()) { - InFlightMigrationState.NONE -> Event.AccountRestored - InFlightMigrationState.REUSE_SESSION_TOKEN -> Event.InFlightReuseMigration - InFlightMigrationState.COPY_SESSION_TOKEN -> Event.InFlightCopyMigration - } - } - else -> null - } + private suspend fun accountStateSideEffects(forState: State.Idle, via: Event): Unit = when (forState.accountState) { + AccountState.NotAuthenticated -> when (via) { + Event.Progress.LoggedOut -> { + notifyObservers { onLoggedOut() } } - AccountState.NotAuthenticated -> { - when (via) { - Event.FailedToAuthenticate, Event.Logout -> { - // Clean up internal account state and destroy the current FxA device record. - if (account.disconnectAsync().await()) { - logger.info("Disconnected FxA account") - } else { - logger.warn("Failed to fully disconnect the FxA account") - } - // Clean up resources. - profile = null - account.close() - // Delete persisted state. - getAccountStorage().clear() - // Even though we might not have Sync enabled, clear out sync-related storage - // layers as well; if they're already empty (unused), nothing bad will happen - // and extra overhead is quite small. - SyncAuthInfoCache(context).clear() - SyncEnginesStorage(context).clear() - clearSyncState(context) - // Re-initialize account. - account = createAccount(serverConfig) - - if (via is Event.Logout) { - notifyObservers { onLoggedOut() } - } + Event.Progress.FailedToBeginAuth -> { + notifyObservers { onFlowError(AuthFlowError.FailedToBeginAuth) } + } + Event.Progress.FailedToCompleteAuth -> { + notifyObservers { onFlowError(AuthFlowError.FailedToCompleteAuth) } + } + is Event.Progress.FailedToCompleteMigration -> { + notifyObservers { onFlowError(AuthFlowError.FailedToMigrate) } + } + else -> Unit + } + AccountState.IncompleteMigration -> { + via as Event.Progress.IncompleteMigration + Unit + } + AccountState.Authenticated -> when (via) { + is Event.Progress.CompletedAuthentication -> { + notifyObservers { onAuthenticated(account, via.authType) } + refreshProfile(ignoreCache = false) + Unit + } + Event.Progress.RecoveredFromAuthenticationProblem -> { + notifyObservers { onAuthenticated(account, AuthType.Recovered) } + refreshProfile(ignoreCache = true) + Unit + } + else -> Unit + } + AccountState.AuthenticationProblem -> { + notifyObservers { onAuthenticationProblems() } + } + } - null - } - Event.AccountNotFound -> { - account = createAccount(serverConfig) + @Suppress("NestedBlockDepth", "LongMethod") + private suspend fun internalStateSideEffects( + forState: State.Active, + via: Event + ): Event? = when (forState.progressState) { + ProgressState.Initializing -> { + // Locally corrupt accounts are simply treated as 'absent'. + val hydratedAccount = try { + getAccountStorage().read() + } catch (e: FxaPanicException) { + // Don't swallow panics from the underlying library. + throw e + } catch (e: FxaException) { + logger.error("Failed to load saved account. Re-initializing...", e) + null + } + if (hydratedAccount == null) { + account = createAccount(serverConfig) + Event.Progress.AccountNotFound + } else { + account = hydratedAccount + account.registerPersistenceCallback(statePersistenceCallback) + account.deviceConstellation().register(accountEventsIntegration) + // We may have attempted a migration previously, which failed in a way that allows + // us to retry it (e.g. a migration could have hit + when (account.isInMigrationState()) { + null -> Event.Progress.AccountRestored + InFlightMigrationState.REUSE_SESSION_TOKEN -> Event.Progress.IncompleteMigration(true) + InFlightMigrationState.COPY_SESSION_TOKEN -> Event.Progress.IncompleteMigration(false) + } + } + } + ProgressState.LoggingOut -> { + resetAccount() + Event.Progress.LoggedOut + } + ProgressState.BeginningAuthentication -> when (via) { + is Event.Account.BeginPairingFlow, Event.Account.BeginEmailFlow -> { + val pairingUrl = if (via is Event.Account.BeginPairingFlow) { + via.pairingUrl + } else { + null + } + when (val result = withRetries(MAX_NETWORK_RETRIES) { pairingUrl.asAuthFlowUrl() }) { + is RetryResult.Success -> { + latestAuthState = result.value!!.state + oauthObservers.notifyObservers { onBeginOAuthFlow(result.value) } null } - Event.Authenticate -> { - return doAuthenticate() - } - is Event.SignInShareableAccount -> { - account.registerPersistenceCallback(statePersistenceCallback) - - // "reusing an account" in this case means that we will maintain the same - // session token, enabling us to re-use the same FxA device record and, in - // theory, push subscriptions. - val migrationResult = if (via.reuseAccount) { - account.migrateFromSessionTokenAsync( - via.account.authInfo.sessionToken, - via.account.authInfo.kSync, - via.account.authInfo.kXCS - ).await() - } else { - account.copyFromSessionTokenAsync( - via.account.authInfo.sessionToken, - via.account.authInfo.kSync, - via.account.authInfo.kXCS - ).await() - } - - // If a migration fails, we need to determine if it failed for a reason that allows - // retrying - e.g. an intermittent issue, where `isInMigrationState` will be 'true'. - // We model "ability to retry an in-flight migration" as a distinct state. - // This allows keeping this retry logic somewhat more contained, and not involved - // in a regular flow of things. - - // So, below we will return the following: - // - `null` if migration failed in an unrecoverable way - // - SignedInShareableAccount if we succeeded - // - One of the 'retry' events if we can retry later - - // Currently, we don't really care about the returned json blob. Right now all - // it contains is how long a migration took - which really is just measuring - // network performance for this particular operation. - - if (migrationResult != null) { - return Event.SignedInShareableAccount(via.reuseAccount) - } - - when (account.isInMigrationState()) { - InFlightMigrationState.NONE -> { - null - } - InFlightMigrationState.COPY_SESSION_TOKEN -> { - if (via.reuseAccount) { - throw IllegalStateException("Expected 'reuse' in-flight state, saw 'copy'") - } - Event.RetryLaterViaTokenCopy - } - InFlightMigrationState.REUSE_SESSION_TOKEN -> { - if (!via.reuseAccount) { - throw IllegalStateException("Expected 'copy' in-flight state, saw 'reuse'") - } - Event.RetryLaterViaTokenReuse - } - } - } - is Event.Pair -> { - val authFlowUrl = account.beginPairingFlowAsync(via.pairingUrl, scopes).await() - if (authFlowUrl == null) { - oauthObservers.notifyObservers { onError() } - return Event.FailedToAuthenticate - } - latestAuthState = authFlowUrl.state - oauthObservers.notifyObservers { onBeginOAuthFlow(authFlowUrl) } - null + RetryResult.Failure -> { + resetAccount() + oauthObservers.notifyObservers { onError() } + Event.Progress.FailedToBeginAuth } - else -> null } } - AccountState.CanAutoRetryAuthenticationViaTokenReuse -> { - when (via) { - Event.RetryMigration, - Event.InFlightReuseMigration -> { - logger.info("Registering persistence callback") - account.registerPersistenceCallback(statePersistenceCallback) - return retryMigration(reuseAccount = true) + else -> null + } + ProgressState.CompletingAuthentication -> when (via) { + Event.Progress.AccountRestored -> { + val authType = AuthType.Existing + when (withServiceRetries(MAX_NETWORK_RETRIES) { finalizeDevice(authType) }) { + ServiceResult.Ok -> { + // This method can "fail" for a number of reasons: + // - auth problems are encountered. In that case, GlobalAccountManager.authError + // will be invoked, which will place an AuthenticationError event on state + // machine's queue. + // If that happens, we'll end up either in an Authenticated state + // (if we're able to auto-recover) or in a 'AuthenticationProblem' state otherwise. + // In both cases, the 'CompletedAuthentication' event below will be discarded. + // - network errors are encountered. 'CompletedAuthentication' event will be processed, + // moving the state machine into an 'Authenticated' state. Next time user requests + // a sync, methods that failed will be re-ran, giving them a chance to succeed. + authenticationSideEffects(authType) + Event.Progress.CompletedAuthentication(authType) + } + ServiceResult.AuthError -> { + Event.Account.AuthenticationError("finalizeDevice") + } + ServiceResult.OtherError -> { + Event.Progress.FailedToCompleteAuthRestore } - else -> null } } - AccountState.CanAutoRetryAuthenticationViaTokenCopy -> { - when (via) { - Event.RetryMigration, - Event.InFlightCopyMigration -> { - logger.info("Registering persistence callback") - account.registerPersistenceCallback(statePersistenceCallback) - return retryMigration(reuseAccount = false) + is Event.Progress.AuthData -> { + val completeAuth = suspend { + withRetries(MAX_NETWORK_RETRIES) { + account.completeOAuthFlow(via.authData.code, via.authData.state) } - else -> null + } + val finalize = suspend { + withRetries(MAX_NETWORK_RETRIES) { finalizeDevice(via.authData.authType) } + } + // If we can't 'complete', we won't run 'finalize' due to short-circuiting. + if (completeAuth() is RetryResult.Failure || finalize() is RetryResult.Failure) { + resetAccount() + Event.Progress.FailedToCompleteAuth + } else { + via.authData.declinedEngines?.let { persistDeclinedEngines(it) } + authenticationSideEffects(via.authData.authType) + Event.Progress.CompletedAuthentication(via.authData.authType) } } - AccountState.AuthenticatedNoProfile -> { - when (via) { - is Event.Authenticated -> { - logger.info("Registering persistence callback") - account.registerPersistenceCallback(statePersistenceCallback) - - // TODO do not ignore this failure. - logger.info("Completing oauth flow") - - // Reasons this can fail: - // - network errors - // - unknown auth state - // -- authenticating via web-content; we didn't beginOAuthFlowAsync - account.completeOAuthFlowAsync(via.authData.code, via.authData.state).await() - - logger.info("Registering device constellation observer") - account.deviceConstellation().register(accountEventsIntegration) - - logger.info("Initializing device") - // NB: underlying API is expected to 'ensureCapabilities' as part of device initialization. - if (account.deviceConstellation().initDeviceAsync( - deviceConfig.name, deviceConfig.type, deviceConfig.capabilities - ).await()) { - postAuthenticated(via.authData.authType, via.authData.declinedEngines) - Event.FetchProfile - } else { - Event.FailedToAuthenticate - } - } - Event.AccountRestored -> { - logger.info("Registering persistence callback") - account.registerPersistenceCallback(statePersistenceCallback) - - logger.info("Registering device constellation observer") - account.deviceConstellation().register(accountEventsIntegration) - - // If this is the first time ensuring our capabilities, - logger.info("Ensuring device capabilities...") - if (account.deviceConstellation().ensureCapabilitiesAsync(deviceConfig.capabilities).await()) { - logger.info("Successfully ensured device capabilities. Continuing...") - postAuthenticated(AuthType.Existing) - Event.FetchProfile - } else { - logger.warn("Failed to ensure device capabilities. Stopping.") - null - } - } - is Event.SignedInShareableAccount -> { - // Note that we are not registering an account persistence callback here like - // we do in other `AuthenticatedNoProfile` methods, because it would have been - // already registered while handling any of the pre-cursor events, such as - // `Event.SignInShareableAccount`, `Event.InFlightCopyMigration` - // or `Event.InFlightReuseMigration`. - logger.info("Registering device constellation observer") - account.deviceConstellation().register(accountEventsIntegration) - - val deviceFinalizeSuccess = if (via.reuseAccount) { - logger.info("Configuring migrated account's device") - // At the minimum, we need to "ensure capabilities" - that is, register for Send Tab, etc. - account.deviceConstellation().ensureCapabilitiesAsync(deviceConfig.capabilities).await() - // Note that at this point, we do not rename a device record to our own default name. - // It's possible that user already customized their name, and we'd like to retain it. - } else { - logger.info("Initializing device") - // NB: underlying API is expected to 'ensureCapabilities' as part of device initialization. - account.deviceConstellation().initDeviceAsync( - deviceConfig.name, deviceConfig.type, deviceConfig.capabilities - ).await() - } - - if (deviceFinalizeSuccess) { - postAuthenticated(AuthType.Shared) - Event.FetchProfile - } else { - Event.FailedToAuthenticate - } - } - - Event.RecoveredFromAuthenticationProblem -> { - // This path is a blend of "authenticated" and "account restored". - // We need to re-initialize an fxa device, but we don't need to complete an auth. - logger.info("Registering persistence callback") - account.registerPersistenceCallback(statePersistenceCallback) - - logger.info("Registering device constellation observer") - account.deviceConstellation().register(accountEventsIntegration) - - // NB: we're running neither `initDevice` nor `ensureCapabilities` here, since - // this is a recovery flow, and these calls already ran at some point before. - - postAuthenticated(AuthType.Recovered) - - Event.FetchProfile + is Event.Progress.Migrated -> { + val authType = when (via.reusedSessionToken) { + true -> AuthType.MigratedReuse + false -> AuthType.MigratedCopy + } + when (withRetries(MAX_NETWORK_RETRIES) { finalizeDevice(authType) }) { + is RetryResult.Success -> { + authenticationSideEffects(authType) + Event.Progress.CompletedAuthentication(authType) } - Event.FetchProfile -> { - // Profile fetching and account authentication issues: - // https://github.com/mozilla/application-services/issues/483 - logger.info("Fetching profile...") - - // `account` provides us with intelligent profile caching, so make sure to use it. - profile = account.getProfileAsync(ignoreCache = false).await() - if (profile == null) { - return Event.FailedToFetchProfile - } - - Event.FetchedProfile + RetryResult.Failure -> { + resetAccount() + Event.Progress.FailedToCompleteMigration } - else -> null } } - AccountState.AuthenticatedWithProfile -> { - when (via) { - Event.FetchedProfile -> { - notifyObservers { - onProfileUpdated(profile!!) - } - null + else -> null + } + ProgressState.MigratingAccount -> when (via) { + Event.Account.RetryMigration -> { + val migrationState = account.isInMigrationState() + if (migrationState == null) { + // Expected to see ourselves in a migration state, but we weren't. + Event.Progress.FailedToCompleteMigration + } else { + tryToMigrate(migrationState.reuseSessionToken) { + account.retryMigrateFromSessionToken() } - else -> null } } - AccountState.AuthenticationProblem -> { - when (via) { - Event.Authenticate -> { - return doAuthenticate() + is Event.Account.MigrateFromAccount -> { + tryToMigrate(via.reuseSessionToken) { + account.migrateFromAccount(via.account.authInfo, via.reuseSessionToken) + } + } + is Event.Progress.IncompleteMigration -> { + tryToMigrate(via.reuseSessionToken) { + account.retryMigrateFromSessionToken() + } + } + else -> null + } + ProgressState.RecoveringFromAuthProblem -> { + via as Event.Account.AuthenticationError + // Somewhere in the system, we've just hit an authentication problem. + // There are two main causes: + // 1) an access token we've obtain from fxalib via 'getAccessToken' expired + // 2) password was changed, or device was revoked + // We can recover from (1) and test if we're in (2) by asking the fxalib + // to give us a new access token. If it succeeds, then we can go back to whatever + // state we were in before. Future operations that involve access tokens should + // succeed. + // If we fail with a 401, then we know we have a legitimate authentication problem. + logger.info("Hit auth problem. Trying to recover.") + + // Ensure we clear any auth-relevant internal state, such as access tokens. + account.authErrorDetected() + + // Clear our access token cache; it'll be re-populated as part of the + // regular state machine flow if we manage to recover. + SyncAuthInfoCache(context).clear() + + // Circuit-breaker logic to protect ourselves from getting into endless authorization + // check loops. If we determine that application is trying to check auth status too + // frequently, drive the state machine into an unauthorized state. + if (via.errorCountWithinTheTimeWindow >= AUTH_CHECK_CIRCUIT_BREAKER_COUNT) { + crashReporter?.submitCaughtException( + AccountManagerException.AuthRecoveryCircuitBreakerException(via.operation) + ) + logger.warn("Unable to recover from an auth problem, triggered a circuit breaker.") + + Event.Progress.FailedToRecoverFromAuthenticationProblem + } else { + // Since we haven't hit the circuit-breaker above, perform an authorization check. + // We request an access token for a "profile" scope since that's the only + // scope we're guaranteed to have at this point. That is, we don't rely on + // passed-in application-specific scopes. + when (account.checkAuthorizationStatus(SCOPE_PROFILE)) { + true -> { + logger.info("Able to recover from an auth problem.") + + // And now we can go back to our regular programming. + Event.Progress.RecoveredFromAuthenticationProblem } - is Event.AuthenticationError -> { - // Somewhere in the system, we've just hit an authentication problem. - // There are two main causes: - // 1) an access token we've obtain from fxalib via 'getAccessToken' expired - // 2) password was changed, or device was revoked - // We can recover from (1) and test if we're in (2) by asking the fxalib - // to give us a new access token. If it succeeds, then we can go back to whatever - // state we were in before. Future operations that involve access tokens should - // succeed. - // If we fail with a 401, then we know we have a legitimate authentication problem. - logger.info("Hit auth problem. Trying to recover.") - - // Ensure we clear any auth-relevant internal state, such as access tokens. - account.authErrorDetected() - - // Clear our access token cache; it'll be re-populated as part of the - // regular state machine flow if we manage to recover. - SyncAuthInfoCache(context).clear() - - // Circuit-breaker logic to protect ourselves from getting into endless authorization - // check loops. If we determine that application is trying to check auth status too - // frequently, drive the state machine into an unauthorized state. - if (via.errorCountWithinTheTimeWindow >= AUTH_CHECK_CIRCUIT_BREAKER_COUNT) { - crashReporter?.submitCaughtException( - AccountManagerException.AuthRecoveryCircuitBreakerException(via.operation) - ) - logger.warn("Unable to recover from an auth problem, triggered a circuit breaker.") - - notifyObservers { onAuthenticationProblems() } - - return null - } - - // Since we haven't hit the circuit-breaker above, perform an authorization check. - // We request an access token for a "profile" scope since that's the only - // scope we're guaranteed to have at this point. That is, we don't rely on - // passed-in application-specific scopes. - when (account.checkAuthorizationStatusAsync(SCOPE_PROFILE).await()) { - true -> { - logger.info("Able to recover from an auth problem.") - - // And now we can go back to our regular programming. - return Event.RecoveredFromAuthenticationProblem - } - null, false -> { - // We are either certainly in the scenario (2), or were unable to determine - // our connectivity state. Let's assume we need to re-authenticate. - // This uncertainty about real state means that, hopefully rarely, - // we will disconnect users that hit transient network errors during - // an authorization check. - // See https://github.com/mozilla-mobile/android-components/issues/3347 - logger.info("Unable to recover from an auth problem, notifying observers.") - - // Tell our listeners we're in a bad auth state. - notifyObservers { onAuthenticationProblems() } - } - } - - null + null, false -> { + // We are either certainly in the scenario (2), or were unable to determine + // our connectivity state. Let's assume we need to re-authenticate. + // This uncertainty about real state means that, hopefully rarely, + // we will disconnect users that hit transient network errors during + // an authorization check. + // See https://github.com/mozilla-mobile/android-components/issues/3347 + logger.info("Unable to recover from an auth problem, notifying observers.") + + Event.Progress.FailedToRecoverFromAuthenticationProblem } - else -> null } } } } + /** + * Side-effects matrix. Defines non-pure operations that must take place for state+event combinations. + */ + @Suppress("ComplexMethod", "ReturnCount", "ThrowsCount", "NestedBlockDepth", "LongMethod") + private suspend fun stateActions(forState: State, via: Event): Event? = when (forState) { + // We're about to enter a new state ('forState') via some event ('via'). + // States will have certain side-effects associated with different event transitions. + // In other words, the same state may have different side-effects depending on the event + // which caused a transition. + // For example, a "NotAuthenticated" state may be entered after a logout, and its side-effects + // will include clean-up and re-initialization of an account. Alternatively, it may be entered + // after we've checked local disk, and didn't find a persisted authenticated account. + is State.Idle -> { + accountStateSideEffects(forState, via) + null + } + is State.Active -> internalStateSideEffects(forState, via) + } + + private suspend fun tryToMigrate( + reuseSessionToken: Boolean, + migrationBlock: suspend () -> JSONObject? + ): Event.Progress { + return if (migrationBlock() != null) { + Event.Progress.Migrated(reuseSessionToken) + } else { + // null json means 'migrationBlock' call above failed. We expect account to be still + // in a migrating state, and if it isn't declare this migration a failure. + if (account.isInMigrationState() == null) { + resetAccount() + Event.Progress.FailedToCompleteMigration + } else { + Event.Progress.IncompleteMigration(reuseSessionToken) + } + } + } + + private suspend fun resetAccount() { + // Clean up internal account state and destroy the current FxA device record (if one exists). + // This can fail (network issues, auth problems, etc), but nothing we can do at this point. + account.disconnect() + + // Clean up resources. + profile = null + account.close() + // Delete persisted state. + getAccountStorage().clear() + // Even though we might not have Sync enabled, clear out sync-related storage + // layers as well; if they're already empty (unused), nothing bad will happen + // and extra overhead is quite small. + SyncAuthInfoCache(context).clear() + SyncEnginesStorage(context).clear() + clearSyncState(context) + // Re-initialize account. + // In theory, .disconnect call above should ensure that we don't need to re-initialize the account. + // See https://github.com/mozilla-mobile/android-components/issues/8195 + account = createAccount(serverConfig) + } + private suspend fun maybeUpdateSyncAuthInfoCache() { // Update cached sync auth info only if we have a syncConfig (e.g. sync is enabled)... if (syncConfig == null) { @@ -929,54 +830,90 @@ open class FxaAccountManager( return } - // NB: this call will inform authErrorRegistry in case an auth error is encountered. - account.getAccessTokenAsync(SCOPE_SYNC).await()?.let { + // NB: this call will call into GlobalAccountManager.authError if necessary. + // Network failures are ignored. + account.getAccessToken(SCOPE_SYNC)?.let { SyncAuthInfoCache(context).setToCache( it.asSyncAuthInfo(account.getTokenServerEndpointURL()) ) } } - private suspend fun doAuthenticate(): Event? { - val authFlowUrl = account.beginOAuthFlowAsync(scopes).await() - if (authFlowUrl == null) { - oauthObservers.notifyObservers { onError() } - return Event.FailedToAuthenticate - } - latestAuthState = authFlowUrl.state - oauthObservers.notifyObservers { onBeginOAuthFlow(authFlowUrl) } - return null - } - - private suspend fun postAuthenticated(authType: AuthType, declinedEngines: Set? = null) { + private fun persistDeclinedEngines(declinedEngines: Set) { // Sync may not be configured at all (e.g. won't run), but if we received a // list of declined engines, that indicates user intent, so we preserve it // within SyncEnginesStorage regardless. If sync becomes enabled later on, // we will be respecting user choice around what to sync. - declinedEngines?.let { - val enginesStorage = SyncEnginesStorage(context) - declinedEngines.forEach { declinedEngine -> - enginesStorage.setStatus(declinedEngine, false) + val enginesStorage = SyncEnginesStorage(context) + declinedEngines.forEach { declinedEngine -> + enginesStorage.setStatus(declinedEngine, false) + } + + // Enable all engines that were not explicitly disabled. Only do this in + // the presence of a "declinedEngines" list, since that indicates user + // intent. Absence of that list means that user was not presented with a + // choice during authentication, and so we can't assume an enabled state + // for any of the engines. + syncConfig?.supportedEngines?.forEach { supportedEngine -> + if (!declinedEngines.contains(supportedEngine)) { + enginesStorage.setStatus(supportedEngine, true) } + } + } - // Enable all engines that were not explicitly disabled. Only do this in - // the presence of a "declinedEngines" list, since that indicates user - // intent. Absence of that list means that user was not presented with a - // choice during authentication, and so we can't assume an enabled state - // for any of the engines. - syncConfig?.supportedEngines?.forEach { supportedEngine -> - if (!declinedEngines.contains(supportedEngine)) { - enginesStorage.setStatus(supportedEngine, true) - } + private sealed class RetryResult { + internal data class Success(val value: T) : RetryResult() + internal object Failure : RetryResult() + } + + private suspend fun withRetries(retryCount: Int, block: suspend () -> T): RetryResult { + var attempt = 0 + var res: T? = null + while (attempt < retryCount && (res == null || res == false)) { + attempt += 1 + logger.info("attempt $attempt/$retryCount") + res = block() + } + return if (res == null || res == false) { + logger.warn("all attempts failed") + RetryResult.Failure + } else { + RetryResult.Success(res) + } + } + + private suspend fun withServiceRetries(retryCount: Int, block: suspend () -> ServiceResult): ServiceResult { + var attempt = 0 + do { + attempt += 1 + logger.info("attempt $attempt/$retryCount") + when (val res = block()) { + ServiceResult.Ok, ServiceResult.AuthError -> return res + ServiceResult.OtherError -> {} } + } while (attempt < retryCount) + + logger.warn("all attempts failed") + return ServiceResult.OtherError + } + + private suspend fun String?.asAuthFlowUrl(): AuthFlowUrl? { + return if (this != null) { + account.beginPairingFlow(this, scopes) + } else { + account.beginOAuthFlow(scopes) } + } - // Before any sync workers have a chance to access it, make sure our SyncAuthInfo cache is hot. - maybeUpdateSyncAuthInfoCache() + private suspend fun finalizeDevice(authType: AuthType) = account.deviceConstellation().finalizeDevice( + authType, deviceConfig + ) - // Notify our internal (sync) and external (app logic) observers. - notifyObservers { onAuthenticated(account, authType) } + private suspend fun authenticationSideEffects(authType: AuthType) { + // Make sure our SyncAuthInfo cache is hot, background sync worker needs it to function. + maybeUpdateSyncAuthInfoCache() + // Sync workers also need to know about the current FxA device. FxaDeviceSettingsCache(context).setToCache(DeviceSettings( fxaDeviceId = account.getCurrentDeviceId()!!, name = deviceConfig.name, @@ -985,37 +922,24 @@ open class FxaAccountManager( // If device supports SEND_TAB, and we're not recovering from an auth problem... if (deviceConfig.capabilities.contains(DeviceCapability.SEND_TAB) && authType != AuthType.Recovered) { - // ... update constellation state - account.deviceConstellation().refreshDevicesAsync().await() + // ... update constellation state (fetching info about other devices, our own device). + account.deviceConstellation().refreshDevices() } } - private suspend fun retryMigration(reuseAccount: Boolean): Event { - val resultJson = account.retryMigrateFromSessionTokenAsync().await() - if (resultJson != null) { - return Event.SignedInShareableAccount(reuseAccount) - } - - return when (account.isInMigrationState()) { - InFlightMigrationState.NONE -> Event.FailedToAuthenticate - InFlightMigrationState.COPY_SESSION_TOKEN -> { - if (reuseAccount) { - throw IllegalStateException("Expected 'reuse' in-flight state, saw 'copy'") - } - Event.RetryLaterViaTokenCopy - } - InFlightMigrationState.REUSE_SESSION_TOKEN -> { - if (!reuseAccount) { - throw IllegalStateException("Expected 'copy' in-flight state, saw 'reuse'") - } - Event.RetryLaterViaTokenReuse - } + private fun createAccount(config: ServerConfig): OAuthAccount { + return obtainAccount(config).also { + it.registerPersistenceCallback(statePersistenceCallback) + it.deviceConstellation().register(accountEventsIntegration) } } + /** + * Exists strictly for testing purposes, allowing tests to specify their own implementation of [OAuthAccount]. + */ @VisibleForTesting - open fun createAccount(config: ServerConfig): OAuthAccount { - return FirefoxAccount(config, null, crashReporter) + open fun obtainAccount(config: ServerConfig): OAuthAccount { + return FirefoxAccount(config, crashReporter) } @VisibleForTesting @@ -1061,11 +985,12 @@ open class FxaAccountManager( override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { val reason = when (authType) { - is AuthType.OtherExternal, AuthType.Signin, AuthType.Signup, AuthType.Shared, AuthType.Pairing - -> SyncReason.FirstSync + is AuthType.OtherExternal, AuthType.Signin, AuthType.Signup, AuthType.MigratedReuse, + AuthType.MigratedCopy, AuthType.Pairing -> SyncReason.FirstSync AuthType.Existing, AuthType.Recovered -> SyncReason.Startup } - syncManager.start(reason) + syncManager.start() + syncManager.now(reason) } override fun onProfileUpdated(profile: Profile) { diff --git a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt index add7a31a730..09d390b0991 100644 --- a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt +++ b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt @@ -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 } -/** - * 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 object Init : Event() - - object AccountNotFound : Event() - object AccountRestored : Event() +internal enum class ProgressState { + Initializing, + BeginningAuthentication, + CompletingAuthentication, + MigratingAccount, + RecoveringFromAuthProblem, + LoggingOut, +} - 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 +internal sealed class Event { + 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" + } } - } - /** - * Fired during account init, if an in-flight copy migration was detected. - */ - object InFlightCopyMigration : Event() + data class MigrateFromAccount(val account: ShareableAccount, val reuseSessionToken: Boolean) : Account() { + override fun toString(): String { + return this.javaClass.simpleName + } + } - /** - * Fired during account init, if an in-flight copy migration was detected. - */ - object InFlightReuseMigration : Event() + object RetryMigration : Account() - data class AuthenticationError(val operation: String, val errorCountWithinTheTimeWindow: Int = 1) : Event() { - override fun toString(): String { - return "${this.javaClass.simpleName} - $operation" - } + object Logout : Account() } - 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() + internal sealed class Progress : Event() { + object AccountNotFound : Progress() + object AccountRestored : Progress() + data class IncompleteMigration(val reuseSessionToken: Boolean) : Progress() - /** - * Fired to trigger a migration retry. - */ - object RetryMigration : Event() + data class AuthData(val authData: FxaAuthData) : Progress() + data class Migrated(val reusedSessionToken: Boolean) : Progress() - object RecoveredFromAuthenticationProblem : Event() - object FetchProfile : Event() - object FetchedProfile : Event() + object FailedToCompleteMigration : Progress() + object FailedToBeginAuth : Progress() + object FailedToCompleteAuthRestore : Progress() + object FailedToCompleteAuth : Progress() - object FailedToAuthenticate : Event() - object FailedToFetchProfile : Event() + object FailedToRecoverFromAuthenticationProblem : Progress() + object RecoveredFromAuthenticationProblem : Progress() - object Logout : Event() + object LoggedOut : Progress() - 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 + } + } } diff --git a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sharing/AccountSharing.kt b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sharing/AccountSharing.kt index a43200c5ef5..799401407e8 100644 --- a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sharing/AccountSharing.kt +++ b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sharing/AccountSharing.kt @@ -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 diff --git a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/SyncManager.kt b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/SyncManager.kt index 8d5b8daee3a..0cdfec3c3fc 100644 --- a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/SyncManager.kt +++ b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/SyncManager.kt @@ -97,7 +97,7 @@ object GlobalSyncableStoreProvider { internal interface SyncDispatcher : Closeable, Observable { 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 { * @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 diff --git a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/WorkManagerSyncManager.kt b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/WorkManagerSyncManager.kt index a9a0507eafa..b1f4ccd447a 100644 --- a/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/WorkManagerSyncManager.kt +++ b/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/WorkManagerSyncManager.kt @@ -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 ) : SyncDispatcher, Observable 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(period, unit) + return PeriodicWorkRequestBuilder(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 { diff --git a/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt b/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt index fee45fb9875..383cf86d94c 100644 --- a/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt +++ b/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt @@ -5,35 +5,31 @@ package mozilla.components.service.fxa import android.content.Context -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import mozilla.components.concept.sync.AccessTokenInfo import mozilla.components.concept.sync.AccessType import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.AuthFlowUrl +import mozilla.components.concept.sync.MigratingAccountInfo import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.DeviceCapability +import mozilla.components.concept.sync.DeviceConfig import mozilla.components.concept.sync.DeviceConstellation import mozilla.components.concept.sync.DeviceType import mozilla.components.concept.sync.InFlightMigrationState import mozilla.components.concept.sync.OAuthAccount -import mozilla.components.concept.sync.OAuthScopedKey import mozilla.components.concept.sync.Profile +import mozilla.components.concept.sync.ServiceResult import mozilla.components.concept.sync.StatePersistenceCallback import mozilla.components.service.fxa.manager.FxaAccountManager import mozilla.components.service.fxa.manager.GlobalAccountManager -import mozilla.components.service.fxa.manager.SCOPE_SYNC -import mozilla.components.service.fxa.manager.SignInWithShareableAccountResult +import mozilla.components.service.fxa.manager.MigrationResult import mozilla.components.service.fxa.manager.SyncEnginesStorage import mozilla.components.service.fxa.sharing.ShareableAccount -import mozilla.components.service.fxa.sharing.ShareableAuthInfo import mozilla.components.service.fxa.sync.SyncManager import mozilla.components.service.fxa.sync.SyncDispatcher import mozilla.components.service.fxa.sync.SyncReason @@ -58,8 +54,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyBoolean import org.mockito.ArgumentMatchers.anyString -import org.mockito.ArgumentMatchers.anyLong import org.mockito.Mockito.`when` +import org.mockito.Mockito.doAnswer import org.mockito.Mockito.never import org.mockito.Mockito.reset import org.mockito.Mockito.times @@ -67,7 +63,6 @@ import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.Mockito.verifyZeroInteractions import java.lang.Exception -import java.lang.IllegalArgumentException import java.util.concurrent.TimeUnit import kotlin.coroutines.CoroutineContext @@ -83,9 +78,13 @@ internal open class TestableFxaAccountManager( syncConfig: SyncConfig? = null, coroutineContext: CoroutineContext, crashReporter: CrashReporting? = null, - private val block: () -> OAuthAccount = { mock() } + private val block: () -> OAuthAccount = { + val account: OAuthAccount = mock() + `when`(account.deviceConstellation()).thenReturn(mock()) + account + } ) : FxaAccountManager(context, config, DeviceConfig("test", DeviceType.UNKNOWN, capabilities), syncConfig, emptySet(), crashReporter, coroutineContext) { - override fun createAccount(config: ServerConfig): OAuthAccount { + override fun obtainAccount(config: ServerConfig): OAuthAccount { return block() } @@ -116,8 +115,8 @@ class FxaAccountManagerTest { inner.syncNow(reason, debounce) } - override fun startPeriodicSync(unit: TimeUnit, period: Long) { - inner.startPeriodicSync(unit, period) + override fun startPeriodicSync(unit: TimeUnit, period: Long, initialDelay: Long) { + inner.startPeriodicSync(unit, period, initialDelay) } override fun stopPeriodicSync() { @@ -165,217 +164,6 @@ class FxaAccountManagerTest { } } - @Test - fun `updating sync config, without it at first`() = runBlocking { - val accountStorage: AccountStorage = mock() - val profile = Profile("testUid", "test@example.com", null, "Test Profile") - val constellation: DeviceConstellation = mockDeviceConstellation() - - val syncAccessTokenExpiresAt = System.currentTimeMillis() + 10 * 60 * 1000L - val account = StatePersistenceTestableAccount(profile, constellation, tokenServerEndpointUrl = "https://some.server.com/test") { - AccessTokenInfo( - SCOPE_SYNC, - "tolkien", - OAuthScopedKey("kty-test", SCOPE_SYNC, "kid-test", "k-test"), - syncAccessTokenExpiresAt - ) - } - - var latestSyncManager: TestSyncManager? = null - // Without sync config to begin with. NB: we're pretending that we "have" a sync scope. - val manager = object : TestableFxaAccountManager( - context = testContext, - config = ServerConfig(Server.RELEASE, "dummyId", "http://auth-url/redirect"), - storage = accountStorage, - capabilities = setOf(DeviceCapability.SEND_TAB), - syncConfig = null, - coroutineContext = this@runBlocking.coroutineContext, - block = { account } - ) { - override fun createSyncManager(config: SyncConfig): SyncManager { - return TestSyncManager(config).also { latestSyncManager = it } - } - } - - `when`(constellation.ensureCapabilitiesAsync(any())).thenReturn(CompletableDeferred(true)) - // We have an account at the start. - `when`(accountStorage.read()).thenReturn(account) - - manager.initAsync().await() - - // Can check if sync is running (it's not!), even if we don't have sync configured. - assertFalse(manager.isSyncActive()) - - // No sync engines are supported, since sync isn't configured. - assertNull(manager.supportedSyncEngines()) - - val syncStatusObserver = TestSyncStatusObserver() - val lifecycleOwner: LifecycleOwner = mock() - val lifecycle: Lifecycle = mock() - `when`(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED) - `when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) - manager.registerForSyncEvents(syncStatusObserver, lifecycleOwner, true) - - // Bad configuration: no stores. - try { - manager.setSyncConfigAsync(SyncConfig(setOf())).await() - fail() - } catch (e: IllegalArgumentException) {} - - assertNull(latestSyncManager) - - assertEquals(0, syncStatusObserver.onStartedCount) - assertEquals(0, syncStatusObserver.onIdleCount) - assertEquals(0, syncStatusObserver.onErrorCount) - - // No periodic sync. - manager.setSyncConfigAsync(SyncConfig(setOf(SyncEngine.History))).await() - - assertEquals(setOf(SyncEngine.History), manager.supportedSyncEngines()) - assertNotNull(latestSyncManager) - assertNotNull(latestSyncManager?.dispatcher) - assertNotNull(latestSyncManager?.dispatcher?.inner) - verify(latestSyncManager!!.dispatcher.inner, never()).startPeriodicSync(any(), anyLong()) - verify(latestSyncManager!!.dispatcher.inner, never()).stopPeriodicSync() - verify(latestSyncManager!!.dispatcher.inner, times(1)).syncNow(eq(SyncReason.FirstSync), anyBoolean()) - - // With periodic sync. - manager.setSyncConfigAsync(SyncConfig(setOf(SyncEngine.History, SyncEngine.Passwords), 60 * 1000L)).await() - - assertEquals(setOf(SyncEngine.History, SyncEngine.Passwords), manager.supportedSyncEngines()) - verify(latestSyncManager!!.dispatcher.inner, times(1)).startPeriodicSync(any(), anyLong()) - verify(latestSyncManager!!.dispatcher.inner, never()).stopPeriodicSync() - verify(latestSyncManager!!.dispatcher.inner, times(1)).syncNow(eq(SyncReason.FirstSync), anyBoolean()) - - // Make sure sync status listeners are working. - // TODO fix these tests. -// // Test dispatcher -> sync manager -> account manager -> our test observer. -// latestSyncManager!!.dispatcherRegistry.notifyObservers { onStarted() } -// assertEquals(1, syncStatusObserver.onStartedCount) -// latestSyncManager!!.dispatcherRegistry.notifyObservers { onIdle() } -// assertEquals(1, syncStatusObserver.onIdleCount) -// latestSyncManager!!.dispatcherRegistry.notifyObservers { onError(null) } -// assertEquals(1, syncStatusObserver.onErrorCount) - - // Make sure that sync access token was cached correctly. - assertFalse(SyncAuthInfoCache(testContext).expired()) - val cachedAuthInfo = SyncAuthInfoCache(testContext).getCached() - assertNotNull(cachedAuthInfo) - assertEquals("tolkien", cachedAuthInfo!!.fxaAccessToken) - assertEquals(syncAccessTokenExpiresAt, cachedAuthInfo.fxaAccessTokenExpiresAt) - assertEquals("https://some.server.com/test", cachedAuthInfo.tokenServerUrl) - assertEquals("kid-test", cachedAuthInfo.kid) - assertEquals("k-test", cachedAuthInfo.syncKey) - } - - @Test - fun `updating sync config, with one to begin with`() = runBlocking { - val accountStorage: AccountStorage = mock() - val profile = Profile("testUid", "test@example.com", null, "Test Profile") - val constellation: DeviceConstellation = mockDeviceConstellation() - - val syncAccessTokenExpiresAt = System.currentTimeMillis() + 10 * 60 * 1000L - val account = StatePersistenceTestableAccount(profile, constellation, tokenServerEndpointUrl = "https://some.server.com/test") { - AccessTokenInfo( - SCOPE_SYNC, - "arda", - OAuthScopedKey("kty-test", SCOPE_SYNC, "kid-test", "k-test"), - syncAccessTokenExpiresAt - ) - } - - // With a sync config this time. - var latestSyncManager: TestSyncManager? = null - val syncConfig = SyncConfig(setOf(SyncEngine.History), syncPeriodInMinutes = 120L) - val manager = object : TestableFxaAccountManager( - context = testContext, - config = ServerConfig(Server.RELEASE, "dummyId", "http://auth-url/redirect"), - storage = accountStorage, - capabilities = setOf(DeviceCapability.SEND_TAB), - syncConfig = syncConfig, - coroutineContext = this@runBlocking.coroutineContext, - block = { account } - ) { - override fun createSyncManager(config: SyncConfig): SyncManager { - return TestSyncManager(config).also { latestSyncManager = it } - } - } - - `when`(constellation.ensureCapabilitiesAsync(any())).thenReturn(CompletableDeferred(true)) - // We have an account at the start. - `when`(accountStorage.read()).thenReturn(account) - - val syncStatusObserver = TestSyncStatusObserver() - val lifecycleOwner: LifecycleOwner = mock() - val lifecycle: Lifecycle = mock() - `when`(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED) - `when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) - manager.registerForSyncEvents(syncStatusObserver, lifecycleOwner, true) - - manager.initAsync().await() - - // Make sure that sync access token was cached correctly. - assertFalse(SyncAuthInfoCache(testContext).expired()) - val cachedAuthInfo = SyncAuthInfoCache(testContext).getCached() - assertNotNull(cachedAuthInfo) - assertEquals("arda", cachedAuthInfo!!.fxaAccessToken) - assertEquals(syncAccessTokenExpiresAt, cachedAuthInfo.fxaAccessTokenExpiresAt) - assertEquals("https://some.server.com/test", cachedAuthInfo.tokenServerUrl) - assertEquals("kid-test", cachedAuthInfo.kid) - assertEquals("k-test", cachedAuthInfo.syncKey) - - // Make sure periodic syncing started, and an immediate sync was requested. - assertNotNull(latestSyncManager) - assertNotNull(latestSyncManager!!.dispatcher) - assertNotNull(latestSyncManager!!.dispatcher.inner) - verify(latestSyncManager!!.dispatcher.inner, times(1)).startPeriodicSync(any(), anyLong()) - verify(latestSyncManager!!.dispatcher.inner, never()).stopPeriodicSync() - verify(latestSyncManager!!.dispatcher.inner, times(1)).syncNow(eq(SyncReason.Startup), anyBoolean()) - - // Can trigger syncs. - manager.syncNowAsync(SyncReason.User).await() - verify(latestSyncManager!!.dispatcher.inner, times(1)).syncNow(SyncReason.User, debounce = false) - manager.syncNowAsync(SyncReason.Startup).await() - verify(latestSyncManager!!.dispatcher.inner, times(2)).syncNow(SyncReason.Startup, debounce = false) - manager.syncNowAsync(SyncReason.EngineChange, debounce = true).await() - verify(latestSyncManager!!.dispatcher.inner, times(1)).syncNow(SyncReason.EngineChange, debounce = true) - - // TODO fix these tests -// assertEquals(0, syncStatusObserver.onStartedCount) -// assertEquals(0, syncStatusObserver.onIdleCount) -// assertEquals(0, syncStatusObserver.onErrorCount) - - // Make sure sync status listeners are working. -// // Test dispatcher -> sync manager -> account manager -> our test observer. -// latestSyncManager!!.dispatcherRegistry.notifyObservers { onStarted() } -// assertEquals(1, syncStatusObserver.onStartedCount) -// latestSyncManager!!.dispatcherRegistry.notifyObservers { onIdle() } -// assertEquals(1, syncStatusObserver.onIdleCount) -// latestSyncManager!!.dispatcherRegistry.notifyObservers { onError(null) } -// assertEquals(1, syncStatusObserver.onErrorCount) - - // Turn off periodic syncing. - manager.setSyncConfigAsync(SyncConfig(setOf(SyncEngine.History))).await() - - verify(latestSyncManager!!.dispatcher.inner, never()).startPeriodicSync(any(), anyLong()) - verify(latestSyncManager!!.dispatcher.inner, never()).stopPeriodicSync() - verify(latestSyncManager!!.dispatcher.inner, times(1)).syncNow(SyncReason.FirstSync, debounce = false) - - // Can trigger syncs. - manager.syncNowAsync(SyncReason.User).await() - verify(latestSyncManager!!.dispatcher.inner, times(1)).syncNow(SyncReason.User, debounce = false) - manager.syncNowAsync(SyncReason.Startup).await() - verify(latestSyncManager!!.dispatcher.inner, times(1)).syncNow(SyncReason.Startup, debounce = false) - - // Pretend sync is running. - `when`(latestSyncManager!!.dispatcher.inner.isSyncActive()).thenReturn(true) - assertTrue(manager.isSyncActive()) - - // Pretend sync is not running. - `when`(latestSyncManager!!.dispatcher.inner.isSyncActive()).thenReturn(false) - assertFalse(manager.isSyncActive()) - } - @Test fun `migrating an account via copyAccountAsync - creating a new session token`() = runBlocking { // We'll test three scenarios: @@ -398,55 +186,54 @@ class FxaAccountManagerTest { // We don't have an account at the start. `when`(accountStorage.read()).thenReturn(null) - manager.initAsync().await() + manager.start() // Bad package name. var migratableAccount = ShareableAccount( email = "test@example.com", sourcePackage = "org.mozilla.firefox", - authInfo = ShareableAuthInfo("session", "kSync", "kXCS") + authInfo = MigratingAccountInfo("session", "kSync", "kXCS") ) // TODO Need to mock inputs into - mock a PackageManager, and have it return PackageInfo with the right signature. // AccountSharing.isTrustedPackage // We failed to migrate for some reason. - account.migrationResult = SignInWithShareableAccountResult.Failure + account.migrationResult = MigrationResult.Failure assertEquals( - SignInWithShareableAccountResult.Failure, - manager.signInWithShareableAccountAsync(migratableAccount).await() + MigrationResult.Failure, + manager.migrateFromAccount(migratableAccount) ) - assertEquals("session", account.latestMigrateAuthInfo?.sessionToken) - assertEquals("kSync", account.latestMigrateAuthInfo?.kSync) - assertEquals("kXCS", account.latestMigrateAuthInfo?.kXCS) + assertEquals("session", account.latestMigrateAuthInfo!!.sessionToken) + assertEquals("kSync", account.latestMigrateAuthInfo!!.kSync) + assertEquals("kXCS", account.latestMigrateAuthInfo!!.kXCS) assertNull(manager.authenticatedAccount()) // Prepare for a successful migration. - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(eq(AuthType.MigratedCopy), any())).thenReturn(ServiceResult.Ok) // Success. - account.migrationResult = SignInWithShareableAccountResult.Success + account.migrationResult = MigrationResult.Success migratableAccount = migratableAccount.copy( - authInfo = ShareableAuthInfo("session2", "kSync2", "kXCS2") + authInfo = MigratingAccountInfo("session2", "kSync2", "kXCS2") ) assertEquals( - SignInWithShareableAccountResult.Success, - manager.signInWithShareableAccountAsync(migratableAccount).await() + MigrationResult.Success, + manager.migrateFromAccount(migratableAccount) ) - assertEquals("session2", account.latestMigrateAuthInfo?.sessionToken) - assertEquals("kSync2", account.latestMigrateAuthInfo?.kSync) - assertEquals("kXCS2", account.latestMigrateAuthInfo?.kXCS) + assertEquals("session2", account.latestMigrateAuthInfo!!.sessionToken) + assertEquals("kSync2", account.latestMigrateAuthInfo!!.kSync) + assertEquals("kXCS2", account.latestMigrateAuthInfo!!.kXCS) assertNotNull(manager.authenticatedAccount()) assertEquals(profile, manager.accountProfile()) - verify(constellation, times(1)).initDeviceAsync(any(), any(), any()) - verify(constellation, never()).ensureCapabilitiesAsync(any()) - verify(accountObserver, times(1)).onAuthenticated(account, AuthType.Shared) + verify(constellation, times(1)).finalizeDevice(eq(AuthType.MigratedCopy), any()) + verify(accountObserver, times(1)).onAuthenticated(account, AuthType.MigratedCopy) } @Test @@ -471,55 +258,54 @@ class FxaAccountManagerTest { // We don't have an account at the start. `when`(accountStorage.read()).thenReturn(null) - manager.initAsync().await() + manager.start() // Bad package name. var migratableAccount = ShareableAccount( email = "test@example.com", sourcePackage = "org.mozilla.firefox", - authInfo = ShareableAuthInfo("session", "kSync", "kXCS") + authInfo = MigratingAccountInfo("session", "kSync", "kXCS") ) // TODO Need to mock inputs into - mock a PackageManager, and have it return PackageInfo with the right signature. // AccountSharing.isTrustedPackage // We failed to migrate for some reason. - account.migrationResult = SignInWithShareableAccountResult.Failure + account.migrationResult = MigrationResult.Failure assertEquals( - SignInWithShareableAccountResult.Failure, - manager.signInWithShareableAccountAsync(migratableAccount, reuseSessionToken = true).await() + MigrationResult.Failure, + manager.migrateFromAccount(migratableAccount, reuseSessionToken = true) ) - assertEquals("session", account.latestMigrateAuthInfo?.sessionToken) - assertEquals("kSync", account.latestMigrateAuthInfo?.kSync) - assertEquals("kXCS", account.latestMigrateAuthInfo?.kXCS) + assertEquals("session", account.latestMigrateAuthInfo!!.sessionToken) + assertEquals("kSync", account.latestMigrateAuthInfo!!.kSync) + assertEquals("kXCS", account.latestMigrateAuthInfo!!.kXCS) assertNull(manager.authenticatedAccount()) // Prepare for a successful migration. - `when`(constellation.ensureCapabilitiesAsync(any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(eq(AuthType.MigratedReuse), any())).thenReturn(ServiceResult.Ok) // Success. - account.migrationResult = SignInWithShareableAccountResult.Success + account.migrationResult = MigrationResult.Success migratableAccount = migratableAccount.copy( - authInfo = ShareableAuthInfo("session2", "kSync2", "kXCS2") + authInfo = MigratingAccountInfo("session2", "kSync2", "kXCS2") ) assertEquals( - SignInWithShareableAccountResult.Success, - manager.signInWithShareableAccountAsync(migratableAccount, reuseSessionToken = true).await() + MigrationResult.Success, + manager.migrateFromAccount(migratableAccount, reuseSessionToken = true) ) - assertEquals("session2", account.latestMigrateAuthInfo?.sessionToken) - assertEquals("kSync2", account.latestMigrateAuthInfo?.kSync) - assertEquals("kXCS2", account.latestMigrateAuthInfo?.kXCS) + assertEquals("session2", account.latestMigrateAuthInfo!!.sessionToken) + assertEquals("kSync2", account.latestMigrateAuthInfo!!.kSync) + assertEquals("kXCS2", account.latestMigrateAuthInfo!!.kXCS) assertNotNull(manager.authenticatedAccount()) assertEquals(profile, manager.accountProfile()) - verify(constellation, times(1)).ensureCapabilitiesAsync(any()) - verify(constellation, never()).initDeviceAsync(any(), any(), any()) - verify(accountObserver, times(1)).onAuthenticated(account, AuthType.Shared) + verify(constellation, times(1)).finalizeDevice(eq(AuthType.MigratedReuse), any()) + verify(accountObserver, times(1)).onAuthenticated(account, AuthType.MigratedReuse) } @Test @@ -540,47 +326,45 @@ class FxaAccountManagerTest { // We don't have an account at the start. `when`(accountStorage.read()).thenReturn(null) - manager.initAsync().await() + manager.start() // Bad package name. - var migratableAccount = ShareableAccount( + val migratableAccount = ShareableAccount( email = "test@example.com", sourcePackage = "org.mozilla.firefox", - authInfo = ShareableAuthInfo("session", "kSync", "kXCS") + authInfo = MigratingAccountInfo("session", "kSync", "kXCS") ) // TODO Need to mock inputs into - mock a PackageManager, and have it return PackageInfo with the right signature. // AccountSharing.isTrustedPackage - // We failed to migrate for some reason. - account.migrationResult = SignInWithShareableAccountResult.WillRetry + // We failed to migrate for some reason. 'WillRetry' in this case assumes reuse flow. + account.migrationResult = MigrationResult.WillRetry assertEquals( - SignInWithShareableAccountResult.WillRetry, - manager.signInWithShareableAccountAsync(migratableAccount, reuseSessionToken = true).await() + MigrationResult.WillRetry, + manager.migrateFromAccount(migratableAccount, reuseSessionToken = true) ) - assertEquals("session", account.latestMigrateAuthInfo?.sessionToken) - assertEquals("kSync", account.latestMigrateAuthInfo?.kSync) - assertEquals("kXCS", account.latestMigrateAuthInfo?.kXCS) + assertEquals("session", account.latestMigrateAuthInfo!!.sessionToken) + assertEquals("kSync", account.latestMigrateAuthInfo!!.kSync) + assertEquals("kXCS", account.latestMigrateAuthInfo!!.kXCS) assertNotNull(manager.authenticatedAccount()) - assertTrue(manager.accountMigrationInFlight()) + assertNull(manager.accountProfile()) // Prepare for a successful migration. - `when`(constellation.ensureCapabilitiesAsync(any())).thenReturn(CompletableDeferred(true)) - account.migrationResult = SignInWithShareableAccountResult.Success + `when`(constellation.finalizeDevice(eq(AuthType.MigratedReuse), any())).thenReturn(ServiceResult.Ok) + account.migrationResult = MigrationResult.Success account.migrationRetrySuccess = true // 'sync now' user action will trigger a sign-in retry. - manager.syncNowAsync(SyncReason.User).await() + manager.syncNow(SyncReason.User) assertNotNull(manager.authenticatedAccount()) - assertFalse(manager.accountMigrationInFlight()) assertEquals(profile, manager.accountProfile()) - verify(constellation, times(1)).ensureCapabilitiesAsync(any()) - verify(constellation, never()).initDeviceAsync(any(), any(), any()) - verify(accountObserver, times(1)).onAuthenticated(account, AuthType.Shared) + verify(constellation, times(1)).finalizeDevice(eq(AuthType.MigratedReuse), any()) + verify(accountObserver, times(1)).onAuthenticated(account, AuthType.MigratedReuse) } @Test @@ -597,28 +381,27 @@ class FxaAccountManagerTest { account } - `when`(constellation.ensureCapabilitiesAsync(any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(eq(AuthType.MigratedReuse), any())).thenReturn(ServiceResult.Ok) // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) - account.migrationResult = SignInWithShareableAccountResult.WillRetry + account.migrationResult = MigrationResult.WillRetry account.migrationRetrySuccess = false assertNull(account.persistenceCallback) - manager.initAsync().await() + manager.start() // Make sure a persistence callback was registered while pumping the state machine. assertNotNull(account.persistenceCallback) // Assert that neither ensureCapabilities nor initialization fired. - verify(constellation, never()).ensureCapabilitiesAsync(setOf(DeviceCapability.SEND_TAB)) - verify(constellation, never()).initDeviceAsync(any(), any(), any()) + verify(constellation, never()).finalizeDevice(any(), any()) // Assert that we do not refresh device state. - verify(constellation, never()).refreshDevicesAsync() + verify(constellation, never()).refreshDevices() // Finally, assert that we see an account with an inflight migration. assertNotNull(manager.authenticatedAccount()) - assertTrue(manager.accountMigrationInFlight()) + assertNull(manager.accountProfile()) } @Test @@ -635,24 +418,24 @@ class FxaAccountManagerTest { account } - `when`(constellation.ensureCapabilitiesAsync(any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(eq(AuthType.MigratedReuse), any())).thenReturn(ServiceResult.Ok) // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) - account.migrationResult = SignInWithShareableAccountResult.WillRetry + account.migrationResult = MigrationResult.WillRetry account.migrationRetrySuccess = true assertNull(account.persistenceCallback) - manager.initAsync().await() + manager.start() // Make sure a persistence callback was registered while pumping the state machine. assertNotNull(account.persistenceCallback) - verify(constellation).ensureCapabilitiesAsync(setOf(DeviceCapability.SEND_TAB)) - verify(constellation, never()).initDeviceAsync(any(), any(), any()) + verify(constellation).finalizeDevice(eq(AuthType.MigratedReuse), any()) - // Finally, assert that we see an account with an inflight migration. + // Finally, assert that we see an account in good standing. assertNotNull(manager.authenticatedAccount()) - assertFalse(manager.accountMigrationInFlight()) + assertFalse(manager.accountNeedsReauth()) + assertEquals(profile, manager.accountProfile()) } @Test @@ -669,22 +452,21 @@ class FxaAccountManagerTest { account } - `when`(constellation.ensureCapabilitiesAsync(any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(eq(AuthType.Existing), any())).thenReturn(ServiceResult.Ok) // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) assertNull(account.persistenceCallback) - manager.initAsync().await() + manager.start() // Assert that persistence callback is set. assertNotNull(account.persistenceCallback) // Assert that ensureCapabilities fired, but not the device initialization (since we're restoring). - verify(constellation).ensureCapabilitiesAsync(setOf(DeviceCapability.SEND_TAB)) - verify(constellation, never()).initDeviceAsync(any(), any(), any()) + verify(constellation).finalizeDevice(eq(AuthType.Existing), any()) // Assert that we refresh device state. - verify(constellation).refreshDevicesAsync() + verify(constellation).refreshDevices() // Assert that persistence callback is interacting with the storage layer. account.persistenceCallback!!.persist("test") @@ -692,7 +474,7 @@ class FxaAccountManagerTest { } @Test - fun `restored account state persistence, ensureCapabilities hit an intermittent error`() = runBlocking { + fun `restored account state persistence, finalizeDevice hit an intermittent error`() = runBlocking { val accountStorage: AccountStorage = mock() val profile = Profile("testUid", "test@example.com", null, "Test Profile") val constellation: DeviceConstellation = mockDeviceConstellation() @@ -705,23 +487,25 @@ class FxaAccountManagerTest { account } - `when`(constellation.ensureCapabilitiesAsync(any())).thenReturn(CompletableDeferred(false)) + `when`(constellation.finalizeDevice(eq(AuthType.Existing), any())).thenReturn(ServiceResult.OtherError) // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) assertNull(account.persistenceCallback) - manager.initAsync().await() + manager.start() // Assert that persistence callback is set. assertNotNull(account.persistenceCallback) - // Assert that ensureCapabilities fired, but not the device initialization (since we're restoring). - verify(constellation).ensureCapabilitiesAsync(setOf(DeviceCapability.SEND_TAB)) - verify(constellation, never()).initDeviceAsync(any(), any(), any()) + // Assert that finalizeDevice fired with a correct auth type. 3 times since we re-try. + verify(constellation, times(3)).finalizeDevice(eq(AuthType.Existing), any()) // Assert that persistence callback is interacting with the storage layer. account.persistenceCallback!!.persist("test") verify(accountStorage).write("test") + + // Since we weren't able to finalize the account state, we're no longer authenticated. + assertNull(manager.authenticatedAccount()) } @Test @@ -740,13 +524,7 @@ class FxaAccountManagerTest { } manager.register(accountObserver) - lateinit var toAwait: Deferred - - `when`(constellation.ensureCapabilitiesAsync(any())).then { - // Hit an auth error. - toAwait = CoroutineScope(coroutineContext).async { manager.encounteredAuthError("testing") } - CompletableDeferred(false) - } + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.AuthError) // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) @@ -757,8 +535,7 @@ class FxaAccountManagerTest { assertFalse(account.checkAuthorizationStatusCalled) verify(accountObserver, never()).onAuthenticationProblems() - manager.initAsync().await() - toAwait.await() + manager.start() assertTrue(manager.accountNeedsReauth()) verify(accountObserver, times(1)).onAuthenticationProblems() @@ -784,9 +561,10 @@ class FxaAccountManagerTest { manager.register(accountObserver) // Hit a panic while we're restoring account. - val fxaPanic = CompletableDeferred() - fxaPanic.completeExceptionally(FxaPanicException("panic!")) - `when`(constellation.ensureCapabilitiesAsync(any())).thenReturn(fxaPanic) + doAnswer { + throw FxaPanicException("don't panic!") + }.`when`(constellation).finalizeDevice(any(), any()) + // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) @@ -795,7 +573,7 @@ class FxaAccountManagerTest { assertFalse(manager.accountNeedsReauth()) verify(accountObserver, never()).onAuthenticationProblems() - manager.initAsync().await() + manager.start() } @Test @@ -816,7 +594,7 @@ class FxaAccountManagerTest { account } - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) // There's no account at the start. `when`(accountStorage.read()).thenReturn(null) @@ -824,22 +602,16 @@ class FxaAccountManagerTest { manager.register(accountObserver) // Kick it off, we'll get into a "NotAuthenticated" state. - manager.initAsync().await() - - assertNull(account.persistenceCallback) + manager.start() // Perform authentication. - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) - - // Assert that persistence callback is set. - assertNotNull(account.persistenceCallback) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) // Assert that initDevice fired, but not ensureCapabilities (since we're initing a new account). - verify(constellation).initDeviceAsync(any(), any(), eq(setOf(DeviceCapability.SEND_TAB))) - verify(constellation, never()).ensureCapabilitiesAsync(any()) + verify(constellation).finalizeDevice(eq(AuthType.Signin), any()) // Assert that persistence callback is interacting with the storage layer. account.persistenceCallback!!.persist("test") @@ -869,20 +641,20 @@ class FxaAccountManagerTest { manager.register(accountObserver) // Kick it off, we'll get into a "NotAuthenticated" state. - manager.initAsync().await() + manager.start() // Attempt to finish authentication without starting it first. - assertFalse(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertFalse(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) // Start authentication. StatePersistenceTestableAccount will produce state=EXPECTED_AUTH_STATE. - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) // Attempt to finish authentication with a wrong state. - assertFalse(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", UNEXPECTED_AUTH_STATE)).await()) + assertFalse(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", UNEXPECTED_AUTH_STATE))) // Now attempt to finish it with a correct state. - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + `when`(constellation.finalizeDevice(eq(AuthType.Signin), any())).thenReturn(ServiceResult.Ok) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) // Assert that manager is authenticated. assertEquals(account, manager.authenticatedAccount()) @@ -893,7 +665,7 @@ class FxaAccountManagerTest { private val constellation: DeviceConstellation, val ableToRecoverFromAuthError: Boolean = false, val tokenServerEndpointUrl: String? = null, - var migrationResult: SignInWithShareableAccountResult = SignInWithShareableAccountResult.Failure, + var migrationResult: MigrationResult = MigrationResult.Failure, var migrationRetrySuccess: Boolean = false, val accessToken: (() -> AccessTokenInfo)? = null ) : OAuthAccount { @@ -901,22 +673,22 @@ class FxaAccountManagerTest { var persistenceCallback: StatePersistenceCallback? = null var checkAuthorizationStatusCalled = false var authErrorDetectedCalled = false - var latestMigrateAuthInfo: ShareableAuthInfo? = null + var latestMigrateAuthInfo: MigratingAccountInfo? = null - override fun beginOAuthFlowAsync(scopes: Set): Deferred { - return CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + override suspend fun beginOAuthFlow(scopes: Set): AuthFlowUrl? { + return AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url") } - override fun beginPairingFlowAsync(pairingUrl: String, scopes: Set): Deferred { - return CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + override suspend fun beginPairingFlow(pairingUrl: String, scopes: Set): AuthFlowUrl? { + return AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url") } - override fun getProfileAsync(ignoreCache: Boolean): Deferred { - return CompletableDeferred(profile) + override suspend fun getProfile(ignoreCache: Boolean): Profile? { + return profile } - override fun authorizeOAuthCodeAsync(clientId: String, scopes: Array, state: String, accessType: AccessType): Deferred { - return CompletableDeferred("123abc") + override suspend fun authorizeOAuthCode(clientId: String, scopes: Array, state: String, accessType: AccessType): String? { + return "123abc" } override fun getCurrentDeviceId(): String? { @@ -927,55 +699,48 @@ class FxaAccountManagerTest { return null } - override fun completeOAuthFlowAsync(code: String, state: String): Deferred { - return CompletableDeferred(true) + override suspend fun completeOAuthFlow(code: String, state: String): Boolean { + return true } - override fun migrateFromSessionTokenAsync(sessionToken: String, kSync: String, kXCS: String): Deferred { - latestMigrateAuthInfo = ShareableAuthInfo(sessionToken, kSync, kXCS) - return CompletableDeferred(when (migrationResult) { - SignInWithShareableAccountResult.Failure, SignInWithShareableAccountResult.WillRetry -> null - SignInWithShareableAccountResult.Success -> JSONObject() - }) + override suspend fun migrateFromAccount(authInfo: MigratingAccountInfo, reuseSessionToken: Boolean): JSONObject? { + latestMigrateAuthInfo = authInfo + return when (migrationResult) { + MigrationResult.Failure, MigrationResult.WillRetry -> null + MigrationResult.Success -> JSONObject() + } } - override fun isInMigrationState(): InFlightMigrationState { + override fun isInMigrationState(): InFlightMigrationState? { return when (migrationResult) { - SignInWithShareableAccountResult.WillRetry -> InFlightMigrationState.REUSE_SESSION_TOKEN - else -> InFlightMigrationState.NONE + MigrationResult.Success -> InFlightMigrationState.REUSE_SESSION_TOKEN + MigrationResult.WillRetry -> InFlightMigrationState.REUSE_SESSION_TOKEN + else -> null } } - override fun retryMigrateFromSessionTokenAsync(): Deferred { - return CompletableDeferred(when (migrationRetrySuccess) { + override suspend fun retryMigrateFromSessionToken(): JSONObject? { + return when (migrationRetrySuccess) { true -> JSONObject() false -> null - }) - } - - override fun copyFromSessionTokenAsync(sessionToken: String, kSync: String, kXCS: String): Deferred { - latestMigrateAuthInfo = ShareableAuthInfo(sessionToken, kSync, kXCS) - return CompletableDeferred(when (migrationResult) { - SignInWithShareableAccountResult.Failure, SignInWithShareableAccountResult.WillRetry -> null - SignInWithShareableAccountResult.Success -> JSONObject() - }) + } } - override fun getAccessTokenAsync(singleScope: String): Deferred { + override suspend fun getAccessToken(singleScope: String): AccessTokenInfo? { val token = accessToken?.invoke() - if (token != null) return CompletableDeferred(token) + if (token != null) return token fail() - return CompletableDeferred(null) + return null } override fun authErrorDetected() { authErrorDetectedCalled = true } - override fun checkAuthorizationStatusAsync(singleScope: String): Deferred { + override suspend fun checkAuthorizationStatus(singleScope: String): Boolean? { checkAuthorizationStatusCalled = true - return CompletableDeferred(ableToRecoverFromAuthError) + return ableToRecoverFromAuthError } override fun getTokenServerEndpointURL(): String { @@ -997,9 +762,8 @@ class FxaAccountManagerTest { return constellation } - override fun disconnectAsync(): Deferred { - fail() - return CompletableDeferred(false) + override suspend fun disconnect(): Boolean { + return true } override fun toJSONString(): String { @@ -1046,7 +810,7 @@ class FxaAccountManagerTest { } manager.register(accountObserver) - manager.initAsync().await() + manager.start() } @Test @@ -1064,7 +828,7 @@ class FxaAccountManagerTest { val accountObserver: AccountObserver = mock() manager.register(accountObserver) - manager.initAsync().await() + manager.start() verify(accountObserver, never()).onAuthenticated(any(), any()) verify(accountObserver, never()).onProfileUpdated(any()) @@ -1085,13 +849,13 @@ class FxaAccountManagerTest { val constellation: DeviceConstellation = mock() val profile = Profile( "testUid", "test@example.com", null, "Test Profile") - `when`(mockAccount.getProfileAsync(ignoreCache = false)).thenReturn(CompletableDeferred(profile)) - `when`(mockAccount.isInMigrationState()).thenReturn(InFlightMigrationState.NONE) + `when`(mockAccount.getProfile(ignoreCache = false)).thenReturn(profile) + `when`(mockAccount.isInMigrationState()).thenReturn(null) // We have an account at the start. `when`(accountStorage.read()).thenReturn(mockAccount) `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") `when`(mockAccount.deviceConstellation()).thenReturn(constellation) - `when`(constellation.ensureCapabilitiesAsync(any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(eq(AuthType.Existing), any())).thenReturn(ServiceResult.Ok) val manager = TestableFxaAccountManager( testContext, @@ -1104,7 +868,7 @@ class FxaAccountManagerTest { manager.register(accountObserver) - manager.initAsync().await() + manager.start() // Make sure that account and profile observers are fired exactly once. verify(accountObserver, times(1)).onAuthenticated(mockAccount, AuthType.Existing) @@ -1119,26 +883,26 @@ class FxaAccountManagerTest { assertEquals(profile, manager.accountProfile()) // Assert that we don't refresh device state for non-SEND_TAB enabled devices. - verify(constellation, never()).refreshDevicesAsync() + verify(constellation, never()).refreshDevices() // Make sure 'logoutAsync' clears out state and fires correct observers. reset(accountObserver) reset(accountStorage) - `when`(mockAccount.disconnectAsync()).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.disconnect()).thenReturn(true) // Simulate SyncManager populating SyncEnginesStorage with some state. SyncEnginesStorage(testContext).setStatus(SyncEngine.History, true) SyncEnginesStorage(testContext).setStatus(SyncEngine.Passwords, false) assertTrue(SyncEnginesStorage(testContext).getStatus().isNotEmpty()) - verify(mockAccount, never()).disconnectAsync() - manager.logoutAsync().await() + verify(mockAccount, never()).disconnect() + manager.logout() assertTrue(SyncEnginesStorage(testContext).getStatus().isEmpty()) verify(accountObserver, never()).onAuthenticated(any(), any()) verify(accountObserver, never()).onProfileUpdated(any()) verify(accountObserver, times(1)).onLoggedOut() - verify(mockAccount, times(1)).disconnectAsync() + verify(mockAccount, times(1)).disconnect() verify(accountStorage, never()).read() verify(accountStorage, never()).write(any()) @@ -1152,6 +916,7 @@ class FxaAccountManagerTest { fun `happy authentication and profile flow`() = runBlocking { val mockAccount: OAuthAccount = mock() val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountStorage = mock() val accountObserver: AccountObserver = mock() @@ -1162,15 +927,15 @@ class FxaAccountManagerTest { verify(accountObserver, never()).onAuthenticated(any(), any()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") `when`(mockAccount.deviceConstellation()).thenReturn(constellation) - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) verify(accountStorage, times(1)).read() verify(accountStorage, never()).clear() @@ -1187,6 +952,7 @@ class FxaAccountManagerTest { fun `fxa panic during initDevice flow`() = runBlocking { val mockAccount: OAuthAccount = mock() val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountStorage = mock() val accountObserver: AccountObserver = mock() @@ -1197,30 +963,30 @@ class FxaAccountManagerTest { verify(accountObserver, never()).onAuthenticated(any(), any()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) `when`(mockAccount.deviceConstellation()).thenReturn(constellation) - val fxaPanic = CompletableDeferred() - fxaPanic.completeExceptionally(FxaPanicException("panic!")) - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(fxaPanic) + doAnswer { + throw FxaPanicException("Don't panic!") + }.`when`(constellation).finalizeDevice(any(), any()) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) } @Test(expected = FxaPanicException::class) fun `fxa panic during pairing flow`() = runBlocking { val mockAccount: OAuthAccount = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(mock()) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountStorage = mock() - `when`(mockAccount.getProfileAsync(ignoreCache = false)).thenReturn(CompletableDeferred(profile)) + `when`(mockAccount.getProfile(ignoreCache = false)).thenReturn(profile) - val fxaPanic = CompletableDeferred() - fxaPanic.completeExceptionally(FxaPanicException("panic!")) - - `when`(mockAccount.beginPairingFlowAsync(any(), any())).thenReturn(fxaPanic) - `when`(mockAccount.completeOAuthFlowAsync(anyString(), anyString())).thenReturn(CompletableDeferred(true)) + doAnswer { + throw FxaPanicException("Don't panic!") + }.`when`(mockAccount).beginPairingFlow(any(), any()) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) // There's no account at the start. `when`(accountStorage.read()).thenReturn(null) @@ -1232,8 +998,8 @@ class FxaAccountManagerTest { mockAccount } - manager.initAsync().await() - manager.beginAuthenticationAsync("http://pairing.com").await() + manager.start() + manager.beginAuthentication("http://pairing.com") fail() } @@ -1241,6 +1007,7 @@ class FxaAccountManagerTest { fun `happy pairing authentication and profile flow`() = runBlocking { val mockAccount: OAuthAccount = mock() val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountStorage = mock() val accountObserver: AccountObserver = mock() @@ -1251,15 +1018,15 @@ class FxaAccountManagerTest { verify(accountObserver, never()).onAuthenticated(any(), any()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync(pairingUrl = "auth://pairing").await()) + assertEquals("auth://url", manager.beginAuthentication(pairingUrl = "auth://pairing")) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") `when`(mockAccount.deviceConstellation()).thenReturn(constellation) - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) verify(accountStorage, times(1)).read() verify(accountStorage, never()).clear() @@ -1290,23 +1057,23 @@ class FxaAccountManagerTest { reset(accountObserver) - assertNull(manager.beginAuthenticationAsync().await()) + assertNull(manager.beginAuthentication()) // Confirm that account state observable doesn't receive authentication errors. assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) // Try again, without any network problems this time. - `when`(mockAccount.beginOAuthFlowAsync(any())).thenReturn(CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url"))) - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.beginOAuthFlow(any())).thenReturn(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) verify(accountStorage, times(1)).clear() - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) verify(accountStorage, times(1)).read() verify(accountStorage, times(1)).clear() @@ -1337,23 +1104,23 @@ class FxaAccountManagerTest { reset(accountObserver) - assertNull(manager.beginAuthenticationAsync(pairingUrl = "auth://pairing").await()) + assertNull(manager.beginAuthentication(pairingUrl = "auth://pairing")) // Confirm that account state observable doesn't receive authentication errors. assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) // Try again, without any network problems this time. - `when`(mockAccount.beginPairingFlowAsync(anyString(), any())).thenReturn(CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url"))) - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.beginPairingFlow(anyString(), any())).thenReturn(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) - assertEquals("auth://url", manager.beginAuthenticationAsync(pairingUrl = "auth://pairing").await()) + assertEquals("auth://url", manager.beginAuthentication(pairingUrl = "auth://pairing")) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) verify(accountStorage, times(1)).clear() - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) verify(accountStorage, times(1)).read() verify(accountStorage, times(1)).clear() @@ -1370,6 +1137,7 @@ class FxaAccountManagerTest { fun `authentication issues are propagated via AccountObserver`() = runBlocking { val mockAccount: OAuthAccount = mock() val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountStorage = mock() val accountObserver: AccountObserver = mock() @@ -1380,21 +1148,21 @@ class FxaAccountManagerTest { verify(accountObserver, never()).onAuthenticated(any(), any()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) `when`(mockAccount.deviceConstellation()).thenReturn(constellation) `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) verify(accountObserver, never()).onAuthenticationProblems() assertFalse(manager.accountNeedsReauth()) // Our recovery flow should attempt to hit this API. Model the "can't recover" condition by returning 'false'. - `when`(mockAccount.checkAuthorizationStatusAsync(eq("profile"))).thenReturn(CompletableDeferred(false)) + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(false) // At this point, we're logged in. Trigger a 401. manager.encounteredAuthError("a test") @@ -1408,10 +1176,9 @@ class FxaAccountManagerTest { // Able to re-authenticate. reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) - assertEquals(mockAccount, manager.authenticatedAccount()) + assertEquals("auth://url", manager.beginAuthentication()) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Pairing, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Pairing, "dummyCode", EXPECTED_AUTH_STATE))) verify(accountObserver).onAuthenticated(mockAccount, AuthType.Pairing) verify(accountObserver, never()).onAuthenticationProblems() @@ -1423,6 +1190,7 @@ class FxaAccountManagerTest { fun `authentication issues are recoverable via checkAuthorizationState`() = runBlocking { val mockAccount: OAuthAccount = mock() val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountStorage = mock() val accountObserver: AccountObserver = mock() @@ -1442,22 +1210,22 @@ class FxaAccountManagerTest { verify(accountObserver, never()).onAuthenticated(any(), any()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") `when`(mockAccount.deviceConstellation()).thenReturn(constellation) - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) - `when`(constellation.refreshDevicesAsync()).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + `when`(constellation.refreshDevices()).thenReturn(true) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) verify(accountObserver, never()).onAuthenticationProblems() assertFalse(manager.accountNeedsReauth()) // Recovery flow will hit this API, and will recover if it returns 'true'. - `when`(mockAccount.checkAuthorizationStatusAsync(eq("profile"))).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(true) // At this point, we're logged in. Trigger a 401. manager.encounteredAuthError("a test") @@ -1468,6 +1236,7 @@ class FxaAccountManagerTest { fun `authentication recovery flow has a circuit breaker`() = runBlocking { val mockAccount: OAuthAccount = mock() val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountStorage = mock() val accountObserver: AccountObserver = mock() @@ -1488,22 +1257,22 @@ class FxaAccountManagerTest { verify(accountObserver, never()).onAuthenticated(any(), any()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") `when`(mockAccount.deviceConstellation()).thenReturn(constellation) - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) - `when`(constellation.refreshDevicesAsync()).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + `when`(constellation.refreshDevices()).thenReturn(true) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) verify(accountObserver, never()).onAuthenticationProblems() assertFalse(manager.accountNeedsReauth()) // Recovery flow will hit this API, and will recover if it returns 'true'. - `when`(mockAccount.checkAuthorizationStatusAsync(eq("profile"))).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(true) // At this point, we're logged in. Trigger a 401 for the first time. manager.encounteredAuthError("a test") @@ -1520,7 +1289,7 @@ class FxaAccountManagerTest { assertRecovered(false, "another test", constellation, accountObserver, manager, mockAccount, crashReporter) } - private fun assertRecovered( + private suspend fun assertRecovered( success: Boolean, operation: String, constellation: DeviceConstellation, @@ -1529,10 +1298,10 @@ class FxaAccountManagerTest { mockAccount: OAuthAccount, crashReporter: CrashReporting ) { - // During recovery, neither `init` nor `refresh` nor `ensure` calls should not have been made. - verify(constellation, times(1)).initDeviceAsync(any(), any(), any()) - verify(constellation, times(1)).refreshDevicesAsync() - verify(constellation, never()).ensureCapabilitiesAsync(any()) + // During recovery, only 'sign-in' finalize device call should have been made. + verify(constellation, times(1)).finalizeDevice(eq(AuthType.Signin), any()) + verify(constellation, never()).finalizeDevice(eq(AuthType.Recovered), any()) + verify(constellation, times(1)).refreshDevices() assertEquals(mockAccount, manager.authenticatedAccount()) @@ -1561,10 +1330,10 @@ class FxaAccountManagerTest { `when`(mockAccount.deviceConstellation()).thenReturn(constellation) `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) - `when`(mockAccount.getProfileAsync(ignoreCache = false)).thenReturn(CompletableDeferred(value = null)) - `when`(mockAccount.beginOAuthFlowAsync(any())).thenReturn(CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url"))) - `when`(mockAccount.completeOAuthFlowAsync(anyString(), anyString())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + `when`(mockAccount.getProfile(ignoreCache = false)).thenReturn(null) + `when`(mockAccount.beginOAuthFlow(any())).thenReturn(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) // There's no account at the start. `when`(accountStorage.read()).thenReturn(null) @@ -1579,18 +1348,18 @@ class FxaAccountManagerTest { val accountObserver: AccountObserver = mock() manager.register(accountObserver) - manager.initAsync().await() + manager.start() // We start off as logged-out, but the event won't be called (initial default state is assumed). verify(accountObserver, never()).onLoggedOut() verify(accountObserver, never()).onAuthenticated(any(), any()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) verify(accountStorage, times(1)).read() verify(accountStorage, never()).clear() @@ -1607,14 +1376,13 @@ class FxaAccountManagerTest { val profile = Profile( uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") - `when`(mockAccount.getProfileAsync(ignoreCache = false)).thenReturn(CompletableDeferred(profile)) - - manager.updateProfileAsync().await() + `when`(mockAccount.getProfile(ignoreCache = true)).thenReturn(profile) + assertNull(manager.accountProfile()) + assertEquals(profile, manager.fetchProfile()) verify(accountObserver, times(1)).onProfileUpdated(profile) verify(accountObserver, never()).onAuthenticated(any(), any()) verify(accountObserver, never()).onLoggedOut() - assertEquals(profile, manager.accountProfile()) } @Test @@ -1625,13 +1393,13 @@ class FxaAccountManagerTest { `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") `when`(mockAccount.deviceConstellation()).thenReturn(constellation) - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) // Our recovery flow should attempt to hit this API. Model the "can't recover" condition by returning false. - `when`(mockAccount.checkAuthorizationStatusAsync(eq("profile"))).thenReturn(CompletableDeferred(false)) + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(false) - `when`(mockAccount.beginOAuthFlowAsync(any())).thenReturn(CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url"))) - `when`(mockAccount.completeOAuthFlowAsync(anyString(), anyString())).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.beginOAuthFlow(any())).thenReturn(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) // There's no account at the start. `when`(accountStorage.read()).thenReturn(null) @@ -1643,34 +1411,36 @@ class FxaAccountManagerTest { mockAccount } - `when`(mockAccount.getProfileAsync(ignoreCache = false)).then { + lateinit var waitFor: Job + `when`(mockAccount.getProfile(ignoreCache = false)).then { // Hit an auth error. - CoroutineScope(coroutineContext).launch { manager.encounteredAuthError("a test") } - CompletableDeferred(value = null) + waitFor = CoroutineScope(coroutineContext).launch { manager.encounteredAuthError("a test") } + null } val accountObserver: AccountObserver = mock() manager.register(accountObserver) - manager.initAsync().await() + manager.start() // We start off as logged-out, but the event won't be called (initial default state is assumed). verify(accountObserver, never()).onLoggedOut() verify(accountObserver, never()).onAuthenticated(any(), any()) verify(accountObserver, never()).onAuthenticationProblems() - verify(mockAccount, never()).checkAuthorizationStatusAsync(any()) + verify(mockAccount, never()).checkAuthorizationStatus(any()) assertFalse(manager.accountNeedsReauth()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) - manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await() + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + waitFor.join() assertTrue(manager.accountNeedsReauth()) verify(accountObserver, times(1)).onAuthenticationProblems() - verify(mockAccount, times(1)).checkAuthorizationStatusAsync(eq("profile")) + verify(mockAccount, times(1)).checkAuthorizationStatus(eq("profile")) Unit } @@ -1682,13 +1452,13 @@ class FxaAccountManagerTest { `when`(mockAccount.deviceConstellation()).thenReturn(constellation) `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) // Our recovery flow should attempt to hit this API. Model the "don't know what's up" condition by returning null. - `when`(mockAccount.checkAuthorizationStatusAsync(eq("profile"))).thenReturn(CompletableDeferred(value = null)) + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(null) - `when`(mockAccount.beginOAuthFlowAsync(any())).thenReturn(CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url"))) - `when`(mockAccount.completeOAuthFlowAsync(anyString(), anyString())).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.beginOAuthFlow(any())).thenReturn(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) // There's no account at the start. `when`(accountStorage.read()).thenReturn(null) @@ -1700,34 +1470,36 @@ class FxaAccountManagerTest { mockAccount } - `when`(mockAccount.getProfileAsync(ignoreCache = false)).then { + lateinit var waitFor: Job + `when`(mockAccount.getProfile(ignoreCache = false)).then { // Hit an auth error. - CoroutineScope(coroutineContext).launch { manager.encounteredAuthError("a test") } - CompletableDeferred(value = null) + waitFor = CoroutineScope(coroutineContext).launch { manager.encounteredAuthError("a test") } + null } val accountObserver: AccountObserver = mock() manager.register(accountObserver) - manager.initAsync().await() + manager.start() // We start off as logged-out, but the event won't be called (initial default state is assumed). verify(accountObserver, never()).onLoggedOut() verify(accountObserver, never()).onAuthenticated(any(), any()) verify(accountObserver, never()).onAuthenticationProblems() - verify(mockAccount, never()).checkAuthorizationStatusAsync(any()) + verify(mockAccount, never()).checkAuthorizationStatus(any()) assertFalse(manager.accountNeedsReauth()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) + waitFor.join() assertTrue(manager.accountNeedsReauth()) verify(accountObserver, times(1)).onAuthenticationProblems() - verify(mockAccount, times(1)).checkAuthorizationStatusAsync(eq("profile")) + verify(mockAccount, times(1)).checkAuthorizationStatus(eq("profile")) Unit } @@ -1740,16 +1512,16 @@ class FxaAccountManagerTest { `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") `when`(mockAccount.deviceConstellation()).thenReturn(constellation) - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) val profile = Profile( uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") // Recovery flow will hit this API, return a success. - `when`(mockAccount.checkAuthorizationStatusAsync(eq("profile"))).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(true) - `when`(mockAccount.beginOAuthFlowAsync(any())).thenReturn(CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url"))) - `when`(mockAccount.completeOAuthFlowAsync(anyString(), anyString())).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.beginOAuthFlow(any())).thenReturn(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) // There's no account at the start. `when`(accountStorage.read()).thenReturn(null) @@ -1762,37 +1534,40 @@ class FxaAccountManagerTest { } var didFailProfileFetch = false - `when`(mockAccount.getProfileAsync(ignoreCache = false)).then { + lateinit var waitFor: Job + `when`(mockAccount.getProfile(ignoreCache = false)).then { // Hit an auth error, but only once. As we recover from it, we'll attempt to fetch a profile // again. At that point, we'd like to succeed. if (!didFailProfileFetch) { didFailProfileFetch = true - CoroutineScope(coroutineContext).launch { manager.encounteredAuthError("a test") } - CompletableDeferred(value = null) + waitFor = CoroutineScope(coroutineContext).launch { manager.encounteredAuthError("a test") } + null } else { - CompletableDeferred(profile) + profile } } + // Upon recovery, we'll hit an 'ignore cache' path. + `when`(mockAccount.getProfile(ignoreCache = true)).thenReturn(profile) val accountObserver: AccountObserver = mock() manager.register(accountObserver) - manager.initAsync().await() + manager.start() // We start off as logged-out, but the event won't be called (initial default state is assumed). verify(accountObserver, never()).onLoggedOut() verify(accountObserver, never()).onAuthenticated(any(), any()) verify(accountObserver, never()).onAuthenticationProblems() - verify(mockAccount, never()).checkAuthorizationStatusAsync(any()) + verify(mockAccount, never()).checkAuthorizationStatus(any()) assertFalse(manager.accountNeedsReauth()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signup, "dummyCode", EXPECTED_AUTH_STATE)).await()) - + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signup, "dummyCode", EXPECTED_AUTH_STATE))) + waitFor.join() assertFalse(manager.accountNeedsReauth()) assertEquals(mockAccount, manager.authenticatedAccount()) assertEquals(profile, manager.accountProfile()) @@ -1802,7 +1577,7 @@ class FxaAccountManagerTest { assertEquals(AuthType.Signup, captor.allValues[0]) assertEquals(AuthType.Recovered, captor.allValues[1]) // Verify that we went through the recovery flow. - verify(mockAccount, times(1)).checkAuthorizationStatusAsync(eq("profile")) + verify(mockAccount, times(1)).checkAuthorizationStatus(eq("profile")) Unit } @@ -1812,16 +1587,14 @@ class FxaAccountManagerTest { val mockAccount: OAuthAccount = mock() val constellation: DeviceConstellation = mock() - val exceptionalProfile = CompletableDeferred() - val fxaException = FxaPanicException("500") - exceptionalProfile.completeExceptionally(fxaException) - `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") `when`(mockAccount.deviceConstellation()).thenReturn(constellation) - `when`(constellation.initDeviceAsync(any(), any(), any())).thenReturn(CompletableDeferred(true)) - `when`(mockAccount.getProfileAsync(ignoreCache = false)).thenReturn(exceptionalProfile) - `when`(mockAccount.beginOAuthFlowAsync(any())).thenReturn(CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url"))) - `when`(mockAccount.completeOAuthFlowAsync(anyString(), anyString())).thenReturn(CompletableDeferred(true)) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + doAnswer { + throw FxaPanicException("500") + }.`when`(mockAccount).getProfile(ignoreCache = false) + `when`(mockAccount.beginOAuthFlow(any())).thenReturn(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) // There's no account at the start. `when`(accountStorage.read()).thenReturn(null) @@ -1836,7 +1609,7 @@ class FxaAccountManagerTest { val accountObserver: AccountObserver = mock() manager.register(accountObserver) - manager.initAsync().await() + manager.start() // We start off as logged-out, but the event won't be called (initial default state is assumed). verify(accountObserver, never()).onLoggedOut() @@ -1845,11 +1618,11 @@ class FxaAccountManagerTest { assertFalse(manager.accountNeedsReauth()) reset(accountObserver) - assertEquals("auth://url", manager.beginAuthenticationAsync().await()) + assertEquals("auth://url", manager.beginAuthentication()) assertNull(manager.authenticatedAccount()) assertNull(manager.accountProfile()) - assertTrue(manager.finishAuthenticationAsync(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)).await()) + assertTrue(manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE))) } @Test @@ -1859,19 +1632,26 @@ class FxaAccountManagerTest { // onAuthenticated - mapping of AuthType to SyncReason integration.onAuthenticated(mock(), AuthType.Signin) - verify(syncManager, times(1)).start(SyncReason.FirstSync) + verify(syncManager, times(1)).start() + verify(syncManager, times(1)).now(eq(SyncReason.FirstSync), anyBoolean()) integration.onAuthenticated(mock(), AuthType.Signup) - verify(syncManager, times(2)).start(SyncReason.FirstSync) + verify(syncManager, times(2)).start() + verify(syncManager, times(2)).now(eq(SyncReason.FirstSync), anyBoolean()) integration.onAuthenticated(mock(), AuthType.Pairing) - verify(syncManager, times(3)).start(SyncReason.FirstSync) - integration.onAuthenticated(mock(), AuthType.Shared) - verify(syncManager, times(4)).start(SyncReason.FirstSync) + verify(syncManager, times(3)).start() + verify(syncManager, times(3)).now(eq(SyncReason.FirstSync), anyBoolean()) + integration.onAuthenticated(mock(), AuthType.MigratedReuse) + verify(syncManager, times(4)).start() + verify(syncManager, times(4)).now(eq(SyncReason.FirstSync), anyBoolean()) integration.onAuthenticated(mock(), AuthType.OtherExternal("test")) - verify(syncManager, times(5)).start(SyncReason.FirstSync) + verify(syncManager, times(5)).start() + verify(syncManager, times(5)).now(eq(SyncReason.FirstSync), anyBoolean()) integration.onAuthenticated(mock(), AuthType.Existing) - verify(syncManager, times(1)).start(SyncReason.Startup) + verify(syncManager, times(6)).start() + verify(syncManager, times(1)).now(eq(SyncReason.Startup), anyBoolean()) integration.onAuthenticated(mock(), AuthType.Recovered) - verify(syncManager, times(2)).start(SyncReason.Startup) + verify(syncManager, times(7)).start() + verify(syncManager, times(2)).now(eq(SyncReason.Startup), anyBoolean()) verifyNoMoreInteractions(syncManager) // onProfileUpdated - no-op @@ -1889,7 +1669,7 @@ class FxaAccountManagerTest { verifyNoMoreInteractions(syncManager) } - private fun prepareHappyAuthenticationFlow( + private suspend fun prepareHappyAuthenticationFlow( mockAccount: OAuthAccount, profile: Profile, accountStorage: AccountStorage, @@ -1899,10 +1679,10 @@ class FxaAccountManagerTest { crashReporter: CrashReporting? = null ): FxaAccountManager { - `when`(mockAccount.getProfileAsync(ignoreCache = false)).thenReturn(CompletableDeferred(profile)) - `when`(mockAccount.beginOAuthFlowAsync(any())).thenReturn(CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url"))) - `when`(mockAccount.beginPairingFlowAsync(anyString(), any())).thenReturn(CompletableDeferred(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url"))) - `when`(mockAccount.completeOAuthFlowAsync(anyString(), anyString())).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.getProfile(ignoreCache = false)).thenReturn(profile) + `when`(mockAccount.beginOAuthFlow(any())).thenReturn(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + `when`(mockAccount.beginPairingFlow(anyString(), any())).thenReturn(AuthFlowUrl(EXPECTED_AUTH_STATE, "auth://url")) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) // There's no account at the start. `when`(accountStorage.read()).thenReturn(null) @@ -1915,27 +1695,23 @@ class FxaAccountManagerTest { } manager.register(accountObserver) - - runBlocking(coroutineContext) { - manager.initAsync().await() - } + manager.start() return manager } - private fun prepareUnhappyAuthenticationFlow( + private suspend fun prepareUnhappyAuthenticationFlow( mockAccount: OAuthAccount, profile: Profile, accountStorage: AccountStorage, accountObserver: AccountObserver, coroutineContext: CoroutineContext ): FxaAccountManager { - `when`(mockAccount.getProfileAsync(ignoreCache = false)).thenReturn(CompletableDeferred(profile)) - - `when`(mockAccount.disconnectAsync()).thenReturn(CompletableDeferred(true)) - `when`(mockAccount.beginOAuthFlowAsync(any())).thenReturn(CompletableDeferred(value = null)) - `when`(mockAccount.beginPairingFlowAsync(anyString(), any())).thenReturn(CompletableDeferred(value = null)) - `when`(mockAccount.completeOAuthFlowAsync(anyString(), anyString())).thenReturn(CompletableDeferred(true)) + `when`(mockAccount.getProfile(ignoreCache = false)).thenReturn(profile) + `when`(mockAccount.deviceConstellation()).thenReturn(mock()) + `when`(mockAccount.beginOAuthFlow(any())).thenReturn(null) + `when`(mockAccount.beginPairingFlow(anyString(), any())).thenReturn(null) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) // There's no account at the start. `when`(accountStorage.read()).thenReturn(null) @@ -1950,15 +1726,15 @@ class FxaAccountManagerTest { manager.register(accountObserver) runBlocking(coroutineContext) { - manager.initAsync().await() + manager.start() } return manager } - private fun mockDeviceConstellation(): DeviceConstellation { + private suspend fun mockDeviceConstellation(): DeviceConstellation { val c: DeviceConstellation = mock() - `when`(c.refreshDevicesAsync()).thenReturn(CompletableDeferred(true)) + `when`(c.refreshDevices()).thenReturn(true) return c } } diff --git a/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaDeviceConstellationTest.kt b/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaDeviceConstellationTest.kt index 11d41924c3b..a6d969b36b0 100644 --- a/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaDeviceConstellationTest.kt +++ b/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaDeviceConstellationTest.kt @@ -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(), 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? = null @@ -251,14 +278,14 @@ class FxaDeviceConstellationTest { // No commands, with an observer. constellation.register(eventsObserver) - assertTrue(constellation.pollForCommandsAsync().await()) + assertTrue(constellation.pollForCommands()) assertEquals(listOf(), 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) } diff --git a/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/FxaStateMatrixTest.kt b/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/FxaStateMatrixTest.kt deleted file mode 100644 index 22cecf1d11e..00000000000 --- a/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/FxaStateMatrixTest.kt +++ /dev/null @@ -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)) - } -} diff --git a/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt b/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt new file mode 100644 index 00000000000..bfc4efb3858 --- /dev/null +++ b/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt @@ -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) + ) + } + } + } +} diff --git a/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/sharing/AccountSharingTest.kt b/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/sharing/AccountSharingTest.kt index 1a6c606c031..57d2d1c83ce 100644 --- a/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/sharing/AccountSharingTest.kt +++ b/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/sharing/AccountSharingTest.kt @@ -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" diff --git a/components/support/migration/src/main/java/mozilla/components/support/migration/FennecFxaMigration.kt b/components/support/migration/src/main/java/mozilla/components/support/migration/FennecFxaMigration.kt index a51bf94fc52..f9586d8ee83 100644 --- a/components/support/migration/src/main/java/mozilla/components/support/migration/FennecFxaMigration.kt +++ b/components/support/migration/src/main/java/mozilla/components/support/migration/FennecFxaMigration.kt @@ -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 { 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 diff --git a/components/support/migration/src/test/java/mozilla/components/support/migration/FennecFxaMigrationTest.kt b/components/support/migration/src/test/java/mozilla/components/support/migration/FennecFxaMigrationTest.kt index 3f4ffc6d404..72a962797ee 100644 --- a/components/support/migration/src/test/java/mozilla/components/support/migration/FennecFxaMigrationTest.kt +++ b/components/support/migration/src/test/java/mozilla/components/support/migration/FennecFxaMigrationTest.kt @@ -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() - 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() - 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() - 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() - 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() - 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) diff --git a/components/support/migration/src/test/java/mozilla/components/support/migration/FennecMigratorTest.kt b/components/support/migration/src/test/java/mozilla/components/support/migration/FennecMigratorTest.kt index 3e1d74ed8e5..9e9f8a4f254 100644 --- a/components/support/migration/src/test/java/mozilla/components/support/migration/FennecMigratorTest.kt +++ b/components/support/migration/src/test/java/mozilla/components/support/migration/FennecMigratorTest.kt @@ -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() - 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() - 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() - 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. diff --git a/samples/firefox-accounts/src/main/java/org/mozilla/samples/fxa/MainActivity.kt b/samples/firefox-accounts/src/main/java/org/mozilla/samples/fxa/MainActivity.kt index 5389320bbff..ed297c070ee 100644 --- a/samples/firefox-accounts/src/main/java/org/mozilla/samples/fxa/MainActivity.kt +++ b/samples/firefox-accounts/src/main/java/org/mozilla/samples/fxa/MainActivity.kt @@ -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(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(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 { diff --git a/samples/sync-logins/src/main/java/org/mozilla/samples/sync/logins/MainActivity.kt b/samples/sync-logins/src/main/java/org/mozilla/samples/sync/logins/MainActivity.kt index bffbfd034a2..e16c44de378 100644 --- a/samples/sync-logins/src/main/java/org/mozilla/samples/sync/logins/MainActivity.kt +++ b/samples/sync-logins/src/main/java/org/mozilla/samples/sync/logins/MainActivity.kt @@ -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(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() } } diff --git a/samples/sync/src/main/java/org/mozilla/samples/sync/MainActivity.kt b/samples/sync/src/main/java/org/mozilla/samples/sync/MainActivity.kt index e5e36ac8254..615b4e8021a 100644 --- a/samples/sync/src/main/java/org/mozilla/samples/sync/MainActivity.kt +++ b/samples/sync/src/main/java/org/mozilla/samples/sync/MainActivity.kt @@ -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(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(R.id.buttonLogout).setOnClickListener { - launch { - accountManager.logoutAsync().await() - } + launch { accountManager.logout() } } findViewById(R.id.refreshDevice).setOnClickListener { - launch { accountManager.authenticatedAccount()?.deviceConstellation()?.refreshDevicesAsync()?.await() } + launch { accountManager.authenticatedAccount()?.deviceConstellation()?.refreshDevices() } } findViewById(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(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 - - 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) - } + 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 devicesFragment = supportFragmentManager.findFragmentById(R.id.devices_fragment) as DeviceFragment - devicesFragment.updateDevices(constellation.otherDevices) + val devicesFragment = supportFragmentManager.findFragmentById(R.id.devices_fragment) as DeviceFragment + devicesFragment.updateDevices(constellation.otherDevices) - Toast.makeText(this@MainActivity, "Devices updated", Toast.LENGTH_SHORT).show() + 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 } } }