diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-06-12 05:35:29 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-06-12 05:35:29 +0000 |
commit | 59203c63bb777a3bacec32fb8830fba33540e809 (patch) | |
tree | 58298e711c0ff0575818c30485b44a2f21bf28a0 /mobile/android/android-components/components/service | |
parent | Adding upstream version 126.0.1. (diff) | |
download | firefox-59203c63bb777a3bacec32fb8830fba33540e809.tar.xz firefox-59203c63bb777a3bacec32fb8830fba33540e809.zip |
Adding upstream version 127.0.upstream/127.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/service')
20 files changed, 454 insertions, 302 deletions
diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt index 37af0f5b76..e5f8a665eb 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt @@ -11,7 +11,6 @@ import mozilla.appservices.fxaclient.FxaRustAuthState import mozilla.components.concept.base.crash.CrashReporting import mozilla.components.concept.sync.AccountEvent import mozilla.components.concept.sync.AccountEventsObserver -import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.StatePersistenceCallback import mozilla.components.lib.dataprotect.SecureAbove22Preferences import mozilla.components.service.fxa.manager.FxaAccountManager @@ -26,16 +25,16 @@ const val FXA_STATE_KEY = "fxaState" * Represents state of our account on disk - is it new, or restored? */ internal sealed class AccountOnDisk : WithAccount { - data class Restored(val account: OAuthAccount) : AccountOnDisk() { + data class Restored(val account: FirefoxAccount) : AccountOnDisk() { override fun account() = account } - data class New(val account: OAuthAccount) : AccountOnDisk() { + data class New(val account: FirefoxAccount) : AccountOnDisk() { override fun account() = account } } internal interface WithAccount { - fun account(): OAuthAccount + fun account(): FirefoxAccount } /** @@ -80,16 +79,16 @@ open class StorageWrapper( } } - private fun watchAccount(account: OAuthAccount) { + private fun watchAccount(account: FirefoxAccount) { account.registerPersistenceCallback(statePersistenceCallback) account.deviceConstellation().register(accountEventsIntegration) } /** - * Exists strictly for testing purposes, allowing tests to specify their own implementation of [OAuthAccount]. + * Exists strictly for testing purposes, allowing tests to specify their own implementation of [FirefoxAccount]. */ @VisibleForTesting - open fun obtainAccount(): OAuthAccount = FirefoxAccount(serverConfig, crashReporter) + open fun obtainAccount(): FirefoxAccount = FirefoxAccount(serverConfig, crashReporter) } /** @@ -110,7 +109,7 @@ internal class AccountEventsIntegration( internal interface AccountStorage { @Throws(Exception::class) - fun read(): OAuthAccount? + fun read(): FirefoxAccount? fun write(accountState: String) fun clear() } @@ -156,7 +155,7 @@ internal class SharedPrefAccountStorage( * @throws FxaException if JSON failed to parse into a [FirefoxAccount]. */ @Throws(FxaException::class) - override fun read(): OAuthAccount? { + override fun read(): FirefoxAccount? { val savedJSON = accountPreferences().getString(FXA_STATE_KEY, null) ?: return null @@ -244,7 +243,7 @@ internal class SecureAbove22AccountStorage( * @throws FxaException if JSON failed to parse into a [FirefoxAccount]. */ @Throws(FxaException::class) - override fun read(): OAuthAccount? { + override fun read(): FirefoxAccount? { return store.getString(KEY_ACCOUNT_STATE).also { // If account state is missing, but we expected it to be present, report an exception. if (it == null && prefs.getBoolean(PREF_KEY_HAS_STATE, false)) { diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt index 7fc31785e3..14e3e7d105 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt @@ -17,6 +17,7 @@ import mozilla.components.concept.sync.DeviceConstellation import mozilla.components.concept.sync.FxAEntryPoint import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.StatePersistenceCallback +import mozilla.components.concept.sync.UserData import mozilla.components.support.base.log.logger.Logger typealias PersistCallback = mozilla.appservices.fxaclient.FxaClient.PersistCallback @@ -96,6 +97,10 @@ class FirefoxAccount internal constructor( internal fun getAuthState() = inner.getAuthState() + internal fun simulateNetworkError() = inner.simulateNetworkError() + internal fun simulateTemporaryAuthTokenIssue() = inner.simulateTemporaryAuthTokenIssue() + internal fun simulatePermanentAuthTokenIssue() = inner.simulatePermanentAuthTokenIssue() + override suspend fun beginOAuthFlow( scopes: Set<String>, entryPoint: FxAEntryPoint, @@ -128,6 +133,12 @@ class FirefoxAccount internal constructor( } } + override suspend fun setUserData(userData: UserData) { + handleFxaExceptions(logger, "setUserData", { null }) { + inner.setUserData(userData.into()) + } + } + override fun getCurrentDeviceId(): 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. diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt index 99c4fb196f..88e4eebd4c 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt @@ -191,6 +191,9 @@ class FxaDeviceConstellation( crashReporter?.submitCaughtException(error) } } + is DeviceCommandOutgoing.CloseTab -> { + account.closeTabs(targetDeviceId, outgoingCommand.urls) + } else -> logger.debug("Skipped sending unsupported command type: $outgoingCommand") } null diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt index 737ff3c273..d489686cdd 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt @@ -18,6 +18,7 @@ import mozilla.components.concept.sync.DeviceCapability import mozilla.components.concept.sync.DeviceType import mozilla.components.concept.sync.OAuthScopedKey import mozilla.components.concept.sync.SyncAuthInfo +import mozilla.components.concept.sync.UserData import mozilla.appservices.fxaclient.DeviceCapability as RustDeviceCapability import mozilla.appservices.fxaclient.DevicePushSubscription as RustDevicePushSubscription import mozilla.appservices.sync15.DeviceType as RustDeviceType @@ -106,6 +107,20 @@ fun Profile.into(): mozilla.components.concept.sync.Profile { ) } +/** + * Converts the android-components defined [UserData] type into + * the application-services one, so consumers of android-components + * do not have to know about application services. + */ +fun UserData.into(): mozilla.appservices.fxaclient.UserData { + return mozilla.appservices.fxaclient.UserData( + sessionToken, + uid, + email, + verified, + ) +} + internal fun RustDeviceType.into(): DeviceType { return when (this) { RustDeviceType.DESKTOP -> DeviceType.DESKTOP @@ -139,6 +154,7 @@ fun DeviceType.into(): RustDeviceType { fun DeviceCapability.into(): RustDeviceCapability { return when (this) { DeviceCapability.SEND_TAB -> RustDeviceCapability.SEND_TAB + DeviceCapability.CLOSE_TABS -> RustDeviceCapability.CLOSE_TABS } } @@ -149,6 +165,7 @@ fun DeviceCapability.into(): RustDeviceCapability { fun RustDeviceCapability.into(): DeviceCapability { return when (this) { RustDeviceCapability.SEND_TAB -> DeviceCapability.SEND_TAB + RustDeviceCapability.CLOSE_TABS -> DeviceCapability.CLOSE_TABS } } @@ -240,6 +257,7 @@ fun AccountEvent.into(): mozilla.components.concept.sync.AccountEvent { fun IncomingDeviceCommand.into(): mozilla.components.concept.sync.DeviceCommandIncoming { return when (this) { is IncomingDeviceCommand.TabReceived -> this.into() + is IncomingDeviceCommand.TabsClosed -> this.into() } } @@ -249,3 +267,10 @@ fun IncomingDeviceCommand.TabReceived.into(): mozilla.components.concept.sync.De entries = this.payload.entries.map { it.into() }, ) } + +fun IncomingDeviceCommand.TabsClosed.into(): mozilla.components.concept.sync.DeviceCommandIncoming.TabsClosed { + return mozilla.components.concept.sync.DeviceCommandIncoming.TabsClosed( + from = this.sender?.into(), + urls = this.payload.urls, + ) +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt index 03a7d25635..71232c0815 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt @@ -8,9 +8,11 @@ import android.content.Context import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import mozilla.appservices.fxaclient.FxaStateCheckerEvent import mozilla.appservices.fxaclient.FxaStateCheckerState @@ -26,6 +28,7 @@ import mozilla.components.concept.sync.FxAEntryPoint import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile import mozilla.components.concept.sync.ServiceResult +import mozilla.components.concept.sync.UserData import mozilla.components.service.fxa.AccessTokenUnexpectedlyWithoutKey import mozilla.components.service.fxa.AccountManagerException import mozilla.components.service.fxa.AccountOnDisk @@ -344,11 +347,14 @@ open class FxaAccountManager( * @param pairingUrl Optional pairing URL in case a pairing flow is being initiated. * @param entrypoint an enum representing the feature entrypoint requesting the URL. * the entrypoint is used in telemetry. + * @param authScopes The oAuth scopes being requested, if none are provided + * we default to the scopes provided when constructing [FxaAccountManager] * @return An authentication url which is to be presented to the user. */ suspend fun beginAuthentication( pairingUrl: String? = null, entrypoint: FxAEntryPoint, + authScopes: Set<String> = scopes, ): String? = withContext(coroutineContext) { // It's possible that at this point authentication is considered to be "in-progress". // For example, if user started authentication flow, but cancelled it (closing a custom tab) @@ -357,9 +363,9 @@ open class FxaAccountManager( processQueue(Event.Progress.CancelAuth) val event = if (pairingUrl != null) { - Event.Account.BeginPairingFlow(pairingUrl, entrypoint) + Event.Account.BeginPairingFlow(pairingUrl, entrypoint, authScopes) } else { - Event.Account.BeginEmailFlow(entrypoint) + Event.Account.BeginEmailFlow(entrypoint, authScopes) } // Process the event, then use the new state to check the result of the operation @@ -375,6 +381,16 @@ open class FxaAccountManager( } /** + * Sets the user's data received from the web content. + * **NOTE**: This is only useful for applications that are user agents, that + * require the user's session token, and thus isn't a part of the state machine + * @param userData: The user's data as given by the web channel, including the session token + */ + suspend fun setUserData(userData: UserData) = withContext(coroutineContext) { + account.setUserData(userData) + } + + /** * Finalize authentication that was started via [beginAuthentication]. * * If authentication wasn't started via this manager we won't accept this authentication attempt, @@ -590,10 +606,10 @@ open class FxaAccountManager( } else { null } - val entrypoint = if (via is Event.Account.BeginEmailFlow) { - via.entrypoint + val (entrypoint, authScopes) = if (via is Event.Account.BeginEmailFlow) { + Pair(via.entrypoint, via.scopes) } else if (via is Event.Account.BeginPairingFlow) { - via.entrypoint + Pair(via.entrypoint, via.scopes) } else { // This should be impossible, both `BeginPairingFlow` and `BeginEmailFlow` // have a required `entrypoint` and we are matching against only instances @@ -601,7 +617,7 @@ open class FxaAccountManager( throw IllegalStateException("BeginningAuthentication with a flow that is neither email nor pairing") } val result = withRetries(logger, MAX_NETWORK_RETRIES) { - pairingUrl.asAuthFlowUrl(account, scopes, entrypoint = entrypoint) + pairingUrl.asAuthFlowUrl(account, authScopes, entrypoint = entrypoint) } when (result) { is Result.Success -> { @@ -935,4 +951,60 @@ open class FxaAccountManager( accountManager.syncStatusObserverRegistry.notifyObservers { onError(error) } } } + + /** + * Hook this up to the secret debug menu to simulate a network error + * + * Typical usage is: + * - `adb logcat | grep fxa_client` + * - Trigger this via the secret debug menu item. + * - Watch the logs. You should see the client perform a call to `get_profile', see a + * network error, then recover. + * - Note: the logs will be more clear once we switch the code to using the app-services state + * machine. + * - Check the UI, it should be in an authenticated state. + */ + public fun simulateNetworkError() { + account.simulateNetworkError() + CoroutineScope(coroutineContext).launch { + refreshProfile(true) + } + } + + /** + * Hook this up to the secret debug menu to simulate a temporary auth error + * + * Typical usage is: + * - `adb logcat | grep fxa_client` + * - Trigger this via the secret debug menu item. + * - Watch the logs. You should see the client perform a call to `get_profile', see an + * auth error, then recover. + * - Check the UI, it should be in an authenticated state. + */ + public fun simulateTemporaryAuthTokenIssue() { + account.simulateTemporaryAuthTokenIssue() + SyncAuthInfoCache(context).clear() + CoroutineScope(coroutineContext).launch { + refreshProfile(true) + } + } + + /** + * Hook this up to the secret debug menu to simulate an unrecoverable auth error + * + * Typical usage is: + * - `adb logcat | grep fxa_client` + * - Trigger this via the secret debug menu item. + * - Initiaite a sync, or perform some other action that requires authentication. + * - Watch the logs. You should see the client perform a call to `get_profile', see an + * auth error, then fail to recover. + * - Check the UI, it should be in an authentication problems state. + */ + public fun simulatePermanentAuthTokenIssue() { + account.simulatePermanentAuthTokenIssue() + SyncAuthInfoCache(context).clear() + CoroutineScope(coroutineContext).launch { + refreshProfile(true) + } + } } diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt index 111715703a..50858c2bbe 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt @@ -52,10 +52,31 @@ import mozilla.components.service.fxa.FxaAuthData * State transitions are described by a transition matrix, which is described in [State.next]. */ -internal sealed class AccountState { +/** + * Represents a [State.Idle] in the accounts state machine detailing the state of the account + * lifecycle. + */ +sealed class AccountState { + /** + * Account is logged in and authenticated. + */ object Authenticated : AccountState() + + /** + * Account is authenticating. + * + * @property oAuthUrl OAuth URL to be loaded to go through the authentication flow. + */ data class Authenticating(val oAuthUrl: String) : AccountState() + + /** + * Account needs to be re-authenticated (e.g. due to a password change). + */ object AuthenticationProblem : AccountState() + + /** + * No authenticated account is available (e.g. account is logged out). + */ object NotAuthenticated : AccountState() } @@ -70,8 +91,12 @@ internal enum class ProgressState { internal sealed class Event { internal sealed class Account : Event() { internal object Start : Account() - data class BeginEmailFlow(val entrypoint: FxAEntryPoint) : Account() - data class BeginPairingFlow(val pairingUrl: String?, val entrypoint: FxAEntryPoint) : Account() + data class BeginEmailFlow(val entrypoint: FxAEntryPoint, val scopes: Set<String>) : Account() + data class BeginPairingFlow( + val pairingUrl: String?, + val entrypoint: FxAEntryPoint, + val scopes: Set<String>, + ) : Account() data class AuthenticationError(val operation: String, val errorCountWithinTheTimeWindow: Int = 1) : Account() { override fun toString(): String { return "${this.javaClass.simpleName} - $operation" diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt index b09e7f3dcc..eb2d7960cf 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt @@ -6,6 +6,7 @@ package mozilla.components.service.fxa.store import mozilla.components.concept.sync.ConstellationState import mozilla.components.lib.state.Action +import mozilla.components.service.fxa.manager.AccountState /** * Actions for updating the global [SyncState] via [SyncStore]. @@ -22,6 +23,11 @@ sealed class SyncAction : Action { data class UpdateAccount(val account: Account?) : SyncAction() /** + * Update the [SyncState.accountState] of the [SyncStore]. + */ + data class UpdateAccountState(val accountState: AccountState) : SyncAction() + + /** * Update the [SyncState.constellationState] of the [SyncStore]. */ data class UpdateDeviceConstellation(val deviceConstellation: ConstellationState) : SyncAction() diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt index 86c9d05d2a..91e3870251 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt @@ -9,18 +9,21 @@ import mozilla.components.concept.sync.ConstellationState import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile import mozilla.components.lib.state.State +import mozilla.components.service.fxa.manager.AccountState import mozilla.components.service.fxa.sync.WorkManagerSyncManager /** * Global state of Sync. * * @property status The current status of Sync. - * @property account The current Sync account, if any. + * @property account The current Sync [Account], if any. + * @property accountState The current [AccountState] of Sync. * @property constellationState The current constellation state, if any. */ data class SyncState( val status: SyncStatus = SyncStatus.NotInitialized, val account: Account? = null, + val accountState: AccountState = AccountState.NotAuthenticated, val constellationState: ConstellationState? = null, ) : State diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt index 50f4b6747e..487d9bd1ea 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt @@ -22,6 +22,7 @@ private fun reduce(syncState: SyncState, syncAction: SyncAction): SyncState { return when (syncAction) { is SyncAction.UpdateSyncStatus -> syncState.copy(status = syncAction.status) is SyncAction.UpdateAccount -> syncState.copy(account = syncAction.account) + is SyncAction.UpdateAccountState -> syncState.copy(accountState = syncAction.accountState) is SyncAction.UpdateDeviceConstellation -> syncState.copy(constellationState = syncAction.deviceConstellation) } diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt index 4feda96e60..7674d8a8fc 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt @@ -10,12 +10,15 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthFlowError import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.ConstellationState import mozilla.components.concept.sync.DeviceConstellationObserver import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile +import mozilla.components.service.fxa.manager.AccountState import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.fxa.manager.SCOPE_PROFILE import mozilla.components.service.fxa.sync.SyncStatusObserver import java.lang.Exception @@ -95,6 +98,24 @@ internal class FxaAccountObserver( private val autoPause: Boolean, private val coroutineScope: CoroutineScope, ) : AccountObserver { + override fun onReady(authenticatedAccount: OAuthAccount?) { + coroutineScope.launch { + if (authenticatedAccount == null) { + return@launch + } + + val syncAccount = + authenticatedAccount.getProfile()?.toAccount(authenticatedAccount) ?: return@launch + store.dispatch(SyncAction.UpdateAccount(account = syncAccount)) + + val accountState = when (authenticatedAccount.checkAuthorizationStatus(SCOPE_PROFILE)) { + true -> AccountState.Authenticated + null, false -> AccountState.AuthenticationProblem + } + store.dispatch(SyncAction.UpdateAccountState(accountState = accountState)) + } + } + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { coroutineScope.launch(Dispatchers.Main) { account.deviceConstellation().registerDeviceObserver( @@ -106,12 +127,23 @@ internal class FxaAccountObserver( coroutineScope.launch { val syncAccount = account.getProfile()?.toAccount(account) ?: return@launch store.dispatch(SyncAction.UpdateAccount(syncAccount)) + store.dispatch(SyncAction.UpdateAccountState(AccountState.Authenticated)) } } + override fun onAuthenticationProblems() { + store.dispatch(SyncAction.UpdateAccountState(accountState = AccountState.AuthenticationProblem)) + } + override fun onLoggedOut() { store.dispatch(SyncAction.UpdateSyncStatus(SyncStatus.LoggedOut)) store.dispatch(SyncAction.UpdateAccount(null)) + store.dispatch(SyncAction.UpdateAccountState(accountState = AccountState.NotAuthenticated)) + } + + override fun onFlowError(error: AuthFlowError) { + store.dispatch(SyncAction.UpdateAccount(account = null)) + store.dispatch(SyncAction.UpdateAccountState(accountState = AccountState.NotAuthenticated)) } } diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt index afc2fc3ee9..3c6a3421f2 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt @@ -74,13 +74,13 @@ internal class TestableStorageWrapper( manager: FxaAccountManager, accountEventObserverRegistry: ObserverRegistry<AccountEventsObserver>, serverConfig: FxaConfig, - private val block: () -> OAuthAccount = { - val account: OAuthAccount = mock() + private val block: () -> FirefoxAccount = { + val account: FirefoxAccount = mock() `when`(account.deviceConstellation()).thenReturn(mock()) account }, ) : StorageWrapper(manager, accountEventObserverRegistry, serverConfig) { - override fun obtainAccount(): OAuthAccount = block() + override fun obtainAccount(): FirefoxAccount = block() } // Same as the actual account manager, except we get to control how FirefoxAccountShaped instances @@ -95,8 +95,8 @@ internal open class TestableFxaAccountManager( syncConfig: SyncConfig? = null, coroutineContext: CoroutineContext, crashReporter: CrashReporting? = null, - block: () -> OAuthAccount = { - val account: OAuthAccount = mock() + block: () -> FirefoxAccount = { + val account: FirefoxAccount = mock() `when`(account.deviceConstellation()).thenReturn(mock()) account }, @@ -201,7 +201,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile("testUid", "test@example.com", null, "Test Profile") val constellation: DeviceConstellation = mockDeviceConstellation() - val account = StatePersistenceTestableAccount(profile, constellation) + val account = statePersistenceTestableAccount(profile, constellation) val manager = TestableFxaAccountManager( testContext, @@ -218,17 +218,18 @@ class FxaAccountManagerTest { // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) - assertNull(account.persistenceCallback) + verify(account, never()).registerPersistenceCallback(any()) manager.start() // Assert that persistence callback is set. - assertNotNull(account.persistenceCallback) + val captor = argumentCaptor<StatePersistenceCallback>() + verify(account).registerPersistenceCallback(captor.capture()) // Assert that ensureCapabilities fired, but not the device initialization (since we're restoring). verify(constellation).finalizeDevice(eq(AuthType.Existing), any()) // Assert that persistence callback is interacting with the storage layer. - account.persistenceCallback!!.persist("test") + captor.value.persist("test") verify(accountStorage).write("test") } @@ -237,7 +238,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile("testUid", "test@example.com", null, "Test Profile") val constellation: DeviceConstellation = mockDeviceConstellation() - val account = StatePersistenceTestableAccount(profile, constellation) + val account = statePersistenceTestableAccount(profile, constellation) val manager = TestableFxaAccountManager( testContext, @@ -254,17 +255,18 @@ class FxaAccountManagerTest { // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) - assertNull(account.persistenceCallback) + verify(account, never()).registerPersistenceCallback(any()) manager.start() // Assert that persistence callback is set. - assertNotNull(account.persistenceCallback) + val captor = argumentCaptor<StatePersistenceCallback>() + verify(account).registerPersistenceCallback(captor.capture()) // 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") + captor.value.persist("test") verify(accountStorage).write("test") // Since we weren't able to finalize the account state, we're no longer authenticated. @@ -276,7 +278,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile("testUid", "test@example.com", null, "Test Profile") val constellation: DeviceConstellation = mockDeviceConstellation() - val account = StatePersistenceTestableAccount(profile, constellation, ableToRecoverFromAuthError = false) + val account = statePersistenceTestableAccount(profile, constellation, ableToRecoverFromAuthError = false) val accountObserver: AccountObserver = mock() val manager = TestableFxaAccountManager( @@ -295,19 +297,19 @@ class FxaAccountManagerTest { // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) - assertNull(account.persistenceCallback) + verify(account, never()).registerPersistenceCallback(any()) assertFalse(manager.accountNeedsReauth()) - assertFalse(account.authErrorDetectedCalled) - assertFalse(account.checkAuthorizationStatusCalled) + verify(account, never()).authErrorDetected() + verify(account, never()).checkAuthorizationStatus(any()) verify(accountObserver, never()).onAuthenticationProblems() manager.start() assertTrue(manager.accountNeedsReauth()) verify(accountObserver, times(1)).onAuthenticationProblems() - assertTrue(account.authErrorDetectedCalled) - assertTrue(account.checkAuthorizationStatusCalled) + verify(account).authErrorDetected() + verify(account).checkAuthorizationStatus(any()) } @Test(expected = FxaPanicException::class) @@ -315,7 +317,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile("testUid", "test@example.com", null, "Test Profile") val constellation: DeviceConstellation = mock() - val account = StatePersistenceTestableAccount(profile, constellation) + val account = statePersistenceTestableAccount(profile, constellation) val accountObserver: AccountObserver = mock() val manager = TestableFxaAccountManager( @@ -339,7 +341,7 @@ class FxaAccountManagerTest { // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) - assertNull(account.persistenceCallback) + verify(account, never()).registerPersistenceCallback(any()) assertFalse(manager.accountNeedsReauth()) verify(accountObserver, never()).onAuthenticationProblems() @@ -352,7 +354,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val constellation: DeviceConstellation = mockDeviceConstellation() - val account = StatePersistenceTestableAccount(profile, constellation) + val account = statePersistenceTestableAccount(profile, constellation) val accountObserver: AccountObserver = mock() // We are not using the "prepareHappy..." helper method here, because our account isn't a mock, // but an actual implementation of the interface. @@ -388,7 +390,9 @@ class FxaAccountManagerTest { verify(constellation).finalizeDevice(eq(AuthType.Signin), any()) // Assert that persistence callback is interacting with the storage layer. - account.persistenceCallback!!.persist("test") + val captor = argumentCaptor<StatePersistenceCallback>() + verify(account).registerPersistenceCallback(captor.capture()) + captor.value.persist("test") verify(accountStorage).write("test") } @@ -397,7 +401,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val constellation: DeviceConstellation = mockDeviceConstellation() - val account = StatePersistenceTestableAccount(profile, constellation) + val account = statePersistenceTestableAccount(profile, constellation) val accountObserver: AccountObserver = mock() // We are not using the "prepareHappy..." helper method here, because our account isn't a mock, // but an actual implementation of the interface. @@ -423,7 +427,7 @@ class FxaAccountManagerTest { manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", UNEXPECTED_AUTH_STATE)) assertTrue(manager.authenticatedAccount() == null) - // Start authentication. StatePersistenceTestableAccount will produce state=EXPECTED_AUTH_STATE. + // Start authentication. statePersistenceTestableAccount will produce state=EXPECTED_AUTH_STATE. assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(entrypoint = entryPoint)) // Now attempt to finish it with a correct state. @@ -435,97 +439,17 @@ class FxaAccountManagerTest { assertEquals(account, manager.authenticatedAccount()) } - class StatePersistenceTestableAccount( - private val profile: Profile, - private val constellation: DeviceConstellation, - val ableToRecoverFromAuthError: Boolean = false, - val tokenServerEndpointUrl: String? = null, - val accessToken: (() -> AccessTokenInfo)? = null, - ) : OAuthAccount { - - var persistenceCallback: StatePersistenceCallback? = null - var checkAuthorizationStatusCalled = false - var authErrorDetectedCalled = false - - override suspend fun beginOAuthFlow(scopes: Set<String>, entryPoint: FxAEntryPoint): AuthFlowUrl? { - return AuthFlowUrl(EXPECTED_AUTH_STATE, testAuthFlowUrl(entrypoint = entryPoint.entryName).url) - } - - override suspend fun beginPairingFlow(pairingUrl: String, scopes: Set<String>, entryPoint: FxAEntryPoint): AuthFlowUrl? { - return AuthFlowUrl(EXPECTED_AUTH_STATE, testAuthFlowUrl(entrypoint = entryPoint.entryName).url) - } - - override suspend fun getProfile(ignoreCache: Boolean): Profile? { - return profile - } - - override fun getCurrentDeviceId(): String? { - return "testFxaDeviceId" - } - - override fun getSessionToken(): String? { - return null - } - - override suspend fun completeOAuthFlow(code: String, state: String): Boolean { - return true - } - - override suspend fun getAccessToken(singleScope: String): AccessTokenInfo? { - val token = accessToken?.invoke() - if (token != null) return token - - fail() - return null - } - - override fun authErrorDetected() { - authErrorDetectedCalled = true - } - - override suspend fun checkAuthorizationStatus(singleScope: String): Boolean? { - checkAuthorizationStatusCalled = true - return ableToRecoverFromAuthError - } - - override suspend fun getTokenServerEndpointURL(): String? { - if (tokenServerEndpointUrl != null) return tokenServerEndpointUrl - - fail() - return "" - } - - override suspend fun getManageAccountURL(entryPoint: FxAEntryPoint): String? { - return "https://firefox.com/settings" - } - - override fun getPairingAuthorityURL(): String { - return "https://firefox.com/pair" - } - - override fun registerPersistenceCallback(callback: StatePersistenceCallback) { - persistenceCallback = callback - } - - override fun deviceConstellation(): DeviceConstellation { - return constellation - } - - override suspend fun disconnect(): Boolean { - return true - } - - override fun toJSONString(): String { - fail() - return "" - } - - override fun close() { - // Only expect 'close' to be called if we can't recover from an auth error. - if (ableToRecoverFromAuthError) { - fail() - } - } + suspend fun statePersistenceTestableAccount(profile: Profile, constellation: DeviceConstellation, ableToRecoverFromAuthError: Boolean = false): FirefoxAccount { + val account = mock<FirefoxAccount>() + `when`(account.getProfile(anyBoolean())).thenReturn(profile) + `when`(account.deviceConstellation()).thenReturn(constellation) + `when`(account.checkAuthorizationStatus(any())).thenReturn(ableToRecoverFromAuthError) + `when`(account.beginOAuthFlow(any(), any())).thenReturn(testAuthFlowUrl(entrypoint = "home-menu")) + `when`(account.beginPairingFlow(any(), any(), any())).thenReturn(testAuthFlowUrl(entrypoint = "home-menu")) + `when`(account.completeOAuthFlow(anyString(), anyString())).thenReturn(true) + `when`(account.getCurrentDeviceId()).thenReturn("testFxaDeviceId") + + return account } @Test @@ -596,7 +520,7 @@ class FxaAccountManagerTest { @Test fun `with persisted account and profile`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() val profile = Profile( "testUid", @@ -667,7 +591,7 @@ class FxaAccountManagerTest { @Test fun `happy authentication and profile flow`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -712,7 +636,7 @@ class FxaAccountManagerTest { @Test(expected = FxaPanicException::class) fun `fxa panic during initDevice flow`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -740,7 +664,7 @@ class FxaAccountManagerTest { @Test(expected = FxaPanicException::class) fun `fxa panic during pairing flow`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() `when`(mockAccount.deviceConstellation()).thenReturn(mock()) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountStorage = mock<AccountStorage>() @@ -769,7 +693,7 @@ class FxaAccountManagerTest { @Test fun `happy pairing authentication and profile flow`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -806,7 +730,7 @@ class FxaAccountManagerTest { @Test fun `repeated unfinished authentication attempts succeed`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -863,7 +787,7 @@ class FxaAccountManagerTest { @Test fun `unhappy authentication flow`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountObserver: AccountObserver = mock() @@ -911,7 +835,7 @@ class FxaAccountManagerTest { @Test fun `unhappy pairing authentication flow`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountObserver: AccountObserver = mock() @@ -970,7 +894,7 @@ class FxaAccountManagerTest { @Test fun `authentication issues are propagated via AccountObserver`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -1025,7 +949,7 @@ class FxaAccountManagerTest { @Test fun `authentication issues are recoverable via checkAuthorizationState`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -1072,7 +996,7 @@ class FxaAccountManagerTest { @Test fun `authentication recovery flow has a circuit breaker`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -1163,7 +1087,7 @@ class FxaAccountManagerTest { @Test fun `unhappy profile fetching flow`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) @@ -1232,7 +1156,7 @@ class FxaAccountManagerTest { @Test fun `profile fetching flow hit an unrecoverable auth problem`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") @@ -1292,7 +1216,7 @@ class FxaAccountManagerTest { @Test fun `profile fetching flow hit an unrecoverable auth problem for which we can't determine a recovery state`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) @@ -1353,7 +1277,7 @@ class FxaAccountManagerTest { @Test fun `profile fetching flow hit a recoverable auth problem`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() val captor = argumentCaptor<AuthType>() @@ -1437,7 +1361,7 @@ class FxaAccountManagerTest { @Test(expected = FxaPanicException::class) fun `profile fetching flow hit an fxa panic, which is re-thrown`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") @@ -1564,7 +1488,7 @@ class FxaAccountManagerTest { } private suspend fun prepareHappyAuthenticationFlow( - mockAccount: OAuthAccount, + mockAccount: FirefoxAccount, profile: Profile, accountStorage: AccountStorage, accountObserver: AccountObserver, @@ -1608,7 +1532,7 @@ class FxaAccountManagerTest { } private suspend fun prepareUnhappyAuthenticationFlow( - mockAccount: OAuthAccount, + mockAccount: FirefoxAccount, profile: Profile, accountStorage: AccountStorage, accountObserver: AccountObserver, diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt index 6323861f0e..df37088ca7 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt @@ -83,8 +83,8 @@ class StateKtTest { private fun instantiateEvent(eventClassSimpleName: String): Event { return when (eventClassSimpleName) { "Start" -> Event.Account.Start - "BeginPairingFlow" -> Event.Account.BeginPairingFlow("http://some.pairing.url.com", mock()) - "BeginEmailFlow" -> Event.Account.BeginEmailFlow(mock()) + "BeginPairingFlow" -> Event.Account.BeginPairingFlow("http://some.pairing.url.com", mock(), mock()) + "BeginEmailFlow" -> Event.Account.BeginEmailFlow(mock(), mock()) "CancelAuth" -> Event.Progress.CancelAuth "StartedOAuthFlow" -> Event.Progress.StartedOAuthFlow("https://example.com/oauth-start") "AuthenticationError" -> Event.Account.AuthenticationError("fxa op") diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt index e0d90118fa..975fd6a481 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt @@ -12,13 +12,16 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import mozilla.components.concept.sync.AuthFlowError import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.Avatar import mozilla.components.concept.sync.ConstellationState import mozilla.components.concept.sync.DeviceConstellation import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile +import mozilla.components.service.fxa.manager.AccountState import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.fxa.manager.SCOPE_PROFILE import mozilla.components.support.test.any import mozilla.components.support.test.coMock import mozilla.components.support.test.eq @@ -26,9 +29,11 @@ import mozilla.components.support.test.libstate.ext.waitUntilIdle import mozilla.components.support.test.mock import mozilla.components.support.test.whenever import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` import java.lang.Exception @OptIn(ExperimentalCoroutinesApi::class) @@ -115,7 +120,7 @@ class SyncStoreSupportTest { } @Test - fun `GIVEN account observer WHEN onAuthenticated observed with profile THEN account state updated`() = coroutineScope.runTest { + fun `GIVEN account observer WHEN onAuthenticated observed with profile THEN account and account state are updated`() = coroutineScope.runTest { val profile = generateProfile() val constellation = mock<DeviceConstellation>() val account = coMock<OAuthAccount> { @@ -125,6 +130,8 @@ class SyncStoreSupportTest { whenever(getProfile()).thenReturn(profile) } + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + accountObserver.onAuthenticated(account, mock<AuthType.Existing>()) runCurrent() @@ -138,10 +145,11 @@ class SyncStoreSupportTest { ) store.waitUntilIdle() assertEquals(expected, store.state.account) + assertEquals(AccountState.Authenticated, store.state.accountState) } @Test - fun `GIVEN account observer WHEN onAuthenticated observed without profile THEN account not updated`() = coroutineScope.runTest { + fun `GIVEN account observer WHEN onAuthenticated observed without profile THEN account and account state are not updated`() = coroutineScope.runTest { val constellation = mock<DeviceConstellation>() val account = coMock<OAuthAccount> { whenever(deviceConstellation()).thenReturn(constellation) @@ -152,11 +160,12 @@ class SyncStoreSupportTest { runCurrent() store.waitUntilIdle() - assertEquals(null, store.state.account) + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) } @Test - fun `GIVEN user is logged in WHEN onLoggedOut observed THEN sync status and account updated`() = coroutineScope.runTest { + fun `GIVEN user is logged in WHEN onLoggedOut observed THEN sync status and account states are updated`() = coroutineScope.runTest { val account = coMock<OAuthAccount> { whenever(deviceConstellation()).thenReturn(mock()) whenever(getProfile()).thenReturn(null) @@ -169,7 +178,94 @@ class SyncStoreSupportTest { store.waitUntilIdle() assertEquals(SyncStatus.LoggedOut, store.state.status) - assertEquals(null, store.state.account) + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + } + + @Test + fun `GIVEN account observer WHEN onAuthenticationProblems observed THEN account state is updated`() = coroutineScope.runTest { + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + + accountObserver.onAuthenticationProblems() + runCurrent() + + store.waitUntilIdle() + assertEquals(AccountState.AuthenticationProblem, store.state.accountState) + } + + @Test + fun `GIVEN account observer WHEN onFlowError observed THEN account state is updated`() = coroutineScope.runTest { + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + + accountObserver.onFlowError(mock<AuthFlowError>()) + runCurrent() + + store.waitUntilIdle() + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + } + + @Test + fun `GIVEN account observer WHEN onReady observed with profile THEN account states are updated`() = coroutineScope.runTest { + val profile = generateProfile() + val currentDeviceId = "id" + val sessionToken = "token" + val constellation = mock<DeviceConstellation>() + val authenticatedAccount = coMock<OAuthAccount> { + whenever(deviceConstellation()).thenReturn(constellation) + whenever(getCurrentDeviceId()).thenReturn(currentDeviceId) + whenever(getSessionToken()).thenReturn(sessionToken) + whenever(getProfile()).thenReturn(profile) + } + val account = Account( + uid = profile.uid, + email = profile.email, + avatar = profile.avatar, + displayName = profile.displayName, + currentDeviceId = currentDeviceId, + sessionToken = sessionToken, + ) + + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + + `when`(authenticatedAccount.checkAuthorizationStatus(eq(SCOPE_PROFILE))).thenReturn(false) + + accountObserver.onReady(authenticatedAccount = authenticatedAccount) + runCurrent() + + store.waitUntilIdle() + assertEquals(account, store.state.account) + assertEquals(AccountState.AuthenticationProblem, store.state.accountState) + + `when`(authenticatedAccount.checkAuthorizationStatus(eq(SCOPE_PROFILE))).thenReturn(true) + + accountObserver.onReady(authenticatedAccount = authenticatedAccount) + runCurrent() + + store.waitUntilIdle() + assertEquals(account, store.state.account) + assertEquals(AccountState.Authenticated, store.state.accountState) + } + + @Test + fun `GIVEN account observer WHEN onReady observed without profile THEN account states are not updated`() = coroutineScope.runTest { + val constellation = mock<DeviceConstellation>() + val account = coMock<OAuthAccount> { + whenever(deviceConstellation()).thenReturn(constellation) + whenever(getProfile()).thenReturn(null) + } + + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + + accountObserver.onReady(account) + runCurrent() + + store.waitUntilIdle() + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) } @Test diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/Glean.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/Glean.kt index 232ac1b83f..2990220b31 100644 --- a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/Glean.kt +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/Glean.kt @@ -116,8 +116,8 @@ object Glean { * * @param enabled Map of metrics' enabled state. */ - fun setMetricsEnabledConfig(enabled: Map<String, Boolean>) { - GleanCore.setMetricsEnabledConfig(JSONObject(enabled).toString()) + fun applyServerKnobsConfig(enabled: Map<String, Boolean>) { + GleanCore.applyServerKnobsConfig(JSONObject(enabled).toString()) } /** diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/config/Configuration.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/config/Configuration.kt index 11911c7046..382078d3c9 100644 --- a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/config/Configuration.kt +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/config/Configuration.kt @@ -19,6 +19,8 @@ import mozilla.telemetry.glean.config.Configuration as GleanCoreConfiguration * @property channel (optional )the release channel the application is on, if known. This will be * sent along with all the pings, in the `client_info` section. * @property maxEvents (optional) the number of events to store before the events ping is sent + * @property enableEventTimestamps (Experimental) Whether to add a wallclock timestamp to all events. + * @property delayPingLifetimeIo Whether Glean should delay persistence of data from metrics with ping lifetime. */ data class Configuration @JvmOverloads constructor( val httpClient: PingUploader, @@ -26,6 +28,7 @@ data class Configuration @JvmOverloads constructor( val channel: String? = null, val maxEvents: Int? = null, val enableEventTimestamps: Boolean = false, + val delayPingLifetimeIo: Boolean = false, ) { // The following is required to support calling our API from Java. companion object { @@ -45,6 +48,7 @@ data class Configuration @JvmOverloads constructor( maxEvents = maxEvents, httpClient = httpClient, enableEventTimestamps = enableEventTimestamps, + delayPingLifetimeIo = delayPingLifetimeIo, ) } } diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/private/MetricAliases.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/private/MetricAliases.kt index e6e0be9424..b98af6a480 100644 --- a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/private/MetricAliases.kt +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/private/MetricAliases.kt @@ -4,8 +4,6 @@ package mozilla.components.service.glean.private -import androidx.annotation.VisibleForTesting - typealias CommonMetricData = mozilla.telemetry.glean.private.CommonMetricData typealias EventExtras = mozilla.telemetry.glean.private.EventExtras typealias Lifetime = mozilla.telemetry.glean.private.Lifetime @@ -18,12 +16,14 @@ typealias CounterMetricType = mozilla.telemetry.glean.private.CounterMetricType typealias CustomDistributionMetricType = mozilla.telemetry.glean.private.CustomDistributionMetricType typealias DatetimeMetricType = mozilla.telemetry.glean.private.DatetimeMetricType typealias DenominatorMetricType = mozilla.telemetry.glean.private.DenominatorMetricType +typealias EventMetricType<T> = mozilla.telemetry.glean.private.EventMetricType<T> typealias HistogramMetricBase = mozilla.telemetry.glean.private.HistogramBase typealias HistogramType = mozilla.telemetry.glean.private.HistogramType typealias LabeledMetricType<T> = mozilla.telemetry.glean.private.LabeledMetricType<T> typealias MemoryDistributionMetricType = mozilla.telemetry.glean.private.MemoryDistributionMetricType typealias MemoryUnit = mozilla.telemetry.glean.private.MemoryUnit typealias NumeratorMetricType = mozilla.telemetry.glean.private.NumeratorMetricType +typealias ObjectSerialize = mozilla.telemetry.glean.private.ObjectSerialize typealias PingType<T> = mozilla.telemetry.glean.private.PingType<T> typealias QuantityMetricType = mozilla.telemetry.glean.private.QuantityMetricType typealias RateMetricType = mozilla.telemetry.glean.private.RateMetricType @@ -31,89 +31,8 @@ typealias RecordedExperiment = mozilla.telemetry.glean.private.RecordedExperimen typealias StringListMetricType = mozilla.telemetry.glean.private.StringListMetricType typealias StringMetricType = mozilla.telemetry.glean.private.StringMetricType typealias TextMetricType = mozilla.telemetry.glean.private.TextMetricType -typealias TimeUnit = mozilla.telemetry.glean.private.TimeUnit typealias TimespanMetricType = mozilla.telemetry.glean.private.TimespanMetricType +typealias TimeUnit = mozilla.telemetry.glean.private.TimeUnit typealias TimingDistributionMetricType = mozilla.telemetry.glean.private.TimingDistributionMetricType typealias UrlMetricType = mozilla.telemetry.glean.private.UrlMetricType typealias UuidMetricType = mozilla.telemetry.glean.private.UuidMetricType - -// FIXME(bug 1885170): Wrap the Glean SDK `EventMetricType` to overwrite the `testGetValue` function. -/** - * This implements the developer facing API for recording events. - * - * Instances of this class type are automatically generated by the parsers at built time, - * allowing developers to record events that were previously registered in the metrics.yaml file. - * - * The Events API only exposes the [record] method, which takes care of validating the input - * data and making sure that limits are enforced. - */ -class EventMetricType<ExtraObject> internal constructor( - private var inner: mozilla.telemetry.glean.private.EventMetricType<ExtraObject>, -) where ExtraObject : EventExtras { - /** - * The public constructor used by automatically generated metrics. - */ - constructor(meta: CommonMetricData, allowedExtraKeys: List<String>) : - this(inner = mozilla.telemetry.glean.private.EventMetricType(meta, allowedExtraKeys)) - - /** - * Record an event by using the information provided by the instance of this class. - * - * @param extra The event extra properties. - * Values are converted to strings automatically - * This is used for events where additional richer context is needed. - * The maximum length for values is 100 bytes. - * - * Note: `extra` is not optional here to avoid overlapping with the above definition of `record`. - * If no `extra` data is passed the above function will be invoked correctly. - */ - fun record(extra: ExtraObject? = null) { - inner.record(extra) - } - - /** - * Returns the stored value for testing purposes only. This function will attempt to await the - * last task (if any) writing to the the metric's storage engine before returning a value. - * - * @param pingName represents the name of the ping to retrieve the metric for. - * Defaults to the first value in `sendInPings`. - * @return value of the stored events - */ - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - @JvmOverloads - fun testGetValue(pingName: String? = null): List<mozilla.telemetry.glean.private.RecordedEvent>? { - var events = inner.testGetValue(pingName) - if (events == null) { - return events - } - - // Remove the `glean_timestamp` extra. - // This is added by Glean and does not need to be exposed to testing. - for (event in events) { - if (event.extra == null) { - continue - } - - // We know it's not null - var map = event.extra!!.toMutableMap() - map.remove("glean_timestamp") - if (map.isEmpty()) { - event.extra = null - } else { - event.extra = map - } - } - - return events - } - - /** - * Returns the number of errors recorded for the given metric. - * - * @param errorType The type of the error recorded. - * @return the number of errors recorded for the metric. - */ - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - fun testGetNumRecordedErrors(errorType: mozilla.components.service.glean.testing.ErrorType) = - inner.testGetNumRecordedErrors(errorType) -} diff --git a/mobile/android/android-components/components/service/nimbus/messaging.fml.yaml b/mobile/android/android-components/components/service/nimbus/messaging.fml.yaml index c53456e31e..a37818f8e8 100644 --- a/mobile/android/android-components/components/service/nimbus/messaging.fml.yaml +++ b/mobile/android/android-components/components/service/nimbus/messaging.fml.yaml @@ -111,16 +111,20 @@ objects: default: {} title: type: Option<Text> - description: "The title text displayed to the user" + description: The title text displayed to the user default: null text: type: Text - description: "The message text displayed to the user" + description: The message text displayed to the user # This should never be defaulted. default: "" + microsurveyConfig: + type: Option<MicrosurveyConfig> + description: Optional configuration data for a microsurvey. + default: null is-control: type: Boolean - description: "Indicates if this message is the control message, if true shouldn't be displayed" + description: Indicates if this message is the control message, if true shouldn't be displayed default: false experiment: type: Option<ExperimentSlug> @@ -183,6 +187,17 @@ objects: How often, in minutes, the notification message worker will wake up and check for new messages. default: 240 # 4 hours + MicrosurveyConfig: + description: Attributes relating to microsurvey content. + fields: + target-feature: + type: MicrosurveyTargetFeature + description: The type of feature the microsurvey is for e.g. Printing. + default: unknown # Should not be defaulted + options: + description: The list of options to present to the user e.g. "Satisfied, Dissatisfied...". + type: List<Text> + default: [] # Should not be defaulted enums: ControlMessageBehavior: @@ -192,3 +207,11 @@ enums: description: The next eligible message should be shown. show-none: description: The surface should show no message. + + MicrosurveyTargetFeature: + description: The specific feature the microsurvey is for e.g Printing. + variants: + printing: + description: The printing feature. + unknown: + description: No target feature set. Only used in an invalid experiment configuration. diff --git a/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/stories_recommendations_response.json b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/stories_recommendations_response.json index da2b9a2953..f410fd7d3d 100644 --- a/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/stories_recommendations_response.json +++ b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/stories_recommendations_response.json @@ -1,44 +1,44 @@ { - "recommendations": [ - { - "category": "general", - "url": "https://getpocket.com/explore/item/how-to-remember-anything-you-really-want-to-remember-backed-by-science", - "title": "How to Remember Anything You Really Want to Remember, Backed by Science", - "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fwww.incimages.com%252Fuploaded_files%252Fimage%252F1920x1080%252Fgetty-862457080_394628.jpg", - "publisher": "Pocket", - "timeToRead": 3 - }, - { - "category": "general", - "url": "https://www.thecut.com/article/i-dont-want-to-be-like-a-family-with-my-co-workers.html", - "title": "‘I Don’t Want to Be Like a Family With My Co-Workers’", - "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpyxis.nymag.com%2Fv1%2Fimgs%2Fac8%2Fd22%2F315cd0cf1e3a43edfe0e0548f2edbcb1a1-ask-a-boss.1x.rsocial.w1200.jpg", - "publisher": "The Cut", - "timeToRead": 5 - }, - { - "category": "general", - "url": "https://www.newyorker.com/news/q-and-a/how-america-failed-in-afghanistan", - "title": "How America Failed in Afghanistan", - "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fmedia.newyorker.com%2Fphotos%2F6119484157b611aec9c99b43%2F16%3A9%2Fw_1280%2Cc_limit%2FChotiner-Afghanistan01.jpg", - "publisher": "The New Yorker", - "timeToRead": 14 - }, - { - "category": "general", - "url": "https://www.technologyreview.com/2021/08/15/1031804/digital-beauty-filters-photoshop-photo-editing-colorism-racism/", - "title": "How digital beauty filters perpetuate colorism", - "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fwp.technologyreview.com%2Fwp-content%2Fuploads%2F2021%2F08%2FBeautyScoreColorism.jpg%3Fresize%3D1200%2C600", - "publisher": "MIT Technology Review", - "timeToRead": 11 - }, - { - "category": "general", - "url": "https://getpocket.com/explore/item/how-to-get-rid-of-black-mold-naturally", - "title": "How to Get Rid of Black Mold Naturally", - "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F6757%252F1628024495_6109ae86db6cc.png", - "publisher": "Pocket", - "timeToRead": 4 - } - ] + "recommendations": [ + { + "category": "general", + "url": "https://getpocket.com/explore/item/how-to-remember-anything-you-really-want-to-remember-backed-by-science", + "title": "How to Remember Anything You Really Want to Remember, Backed by Science", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fwww.incimages.com%252Fuploaded_files%252Fimage%252F1920x1080%252Fgetty-862457080_394628.jpg", + "publisher": "Pocket", + "timeToRead": 3 + }, + { + "category": "general", + "url": "https://www.thecut.com/article/i-dont-want-to-be-like-a-family-with-my-co-workers.html", + "title": "‘I Don’t Want to Be Like a Family With My Co-Workers’", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpyxis.nymag.com%2Fv1%2Fimgs%2Fac8%2Fd22%2F315cd0cf1e3a43edfe0e0548f2edbcb1a1-ask-a-boss.1x.rsocial.w1200.jpg", + "publisher": "The Cut", + "timeToRead": 5 + }, + { + "category": "general", + "url": "https://www.newyorker.com/news/q-and-a/how-america-failed-in-afghanistan", + "title": "How America Failed in Afghanistan", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fmedia.newyorker.com%2Fphotos%2F6119484157b611aec9c99b43%2F16%3A9%2Fw_1280%2Cc_limit%2FChotiner-Afghanistan01.jpg", + "publisher": "The New Yorker", + "timeToRead": 14 + }, + { + "category": "general", + "url": "https://www.technologyreview.com/2021/08/15/1031804/digital-beauty-filters-photoshop-photo-editing-colorism-racism/", + "title": "How digital beauty filters perpetuate colorism", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fwp.technologyreview.com%2Fwp-content%2Fuploads%2F2021%2F08%2FBeautyScoreColorism.jpg%3Fresize%3D1200%2C600", + "publisher": "MIT Technology Review", + "timeToRead": 11 + }, + { + "category": "general", + "url": "https://getpocket.com/explore/item/how-to-get-rid-of-black-mold-naturally", + "title": "How to Get Rid of Black Mold Naturally", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F6757%252F1628024495_6109ae86db6cc.png", + "publisher": "Pocket", + "timeToRead": 4 + } + ] } diff --git a/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_response.json b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_response.json index 8fa6e33ad7..99b6ef44d0 100644 --- a/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_response.json +++ b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_response.json @@ -1,12 +1,12 @@ { - "recommendations": [ - { - "category": "science", - "url": "https://getpocket.com/explore/item/you-think-you-know-what-blue-is-but-you-have-no-idea", - "title": "You Think You Know What Blue Is, But You Have No Idea", - "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F3713%252F1584373694_GettyImages-83522858.jpg", - "publisher": "Pocket", - "timeToRead": 3 - } - ] + "recommendations": [ + { + "category": "science", + "url": "https://getpocket.com/explore/item/you-think-you-know-what-blue-is-but-you-have-no-idea", + "title": "You Think You Know What Blue Is, But You Have No Idea", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F3713%252F1584373694_GettyImages-83522858.jpg", + "publisher": "Pocket", + "timeToRead": 3 + } + ] } diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt index 892930e28d..f66b727cf1 100644 --- a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt @@ -4,6 +4,7 @@ package mozilla.components.service.sync.logins +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers @@ -42,10 +43,15 @@ import mozilla.components.concept.storage.LoginsStorage * what the result of the operation will be: saving a new login, * updating an existing login, or filling in a blank username. * - If the user accepts: GV calls [onLoginSave] with the [LoginEntry] + * + * @param loginStorage The [LoginsStorage] used for looking up saved credentials to autofill. + * @param scope [CoroutineScope] for long running operations. Defaults to using the [Dispatchers.IO]. + * @param isLoginAutofillEnabled callback allowing to limit [loginStorage] operations if autofill is disabled. */ class GeckoLoginStorageDelegate( private val loginStorage: Lazy<LoginsStorage>, private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val isLoginAutofillEnabled: () -> Boolean = { false }, ) : LoginStorageDelegate { override fun onLoginUsed(login: Login) { @@ -55,6 +61,9 @@ class GeckoLoginStorageDelegate( } override fun onLoginFetch(domain: String): Deferred<List<Login>> { + if (!isLoginAutofillEnabled()) { + return CompletableDeferred(listOf()) + } return scope.async { loginStorage.value.getByBaseDomain(domain) } |