/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.fenix.components import android.content.Context import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.OAuthAccount import mozilla.components.lib.crash.CrashReporter import mozilla.components.service.fxa.manager.FxaAccountManager import mozilla.components.support.base.log.logger.Logger import java.lang.Exception import kotlin.coroutines.CoroutineContext /** * Miscellaneous FxA-related abnormalities. */ @VisibleForTesting internal abstract class AbnormalFxaEvent : Exception() { /** * Indicates an overlapping sign-out request. */ class OverlappingFxaLogoutRequest : AbnormalFxaEvent() /** * Indicates an onLogout callback which was received without a preceding onAuthenticated callback. */ class LogoutWithoutAuth : AbnormalFxaEvent() /** * Indicates an unexpected sign-out event. All events must be user-triggered; this exception is * logged when a sign-out event was detected without a corresponding user action. */ class UnexpectedFxaLogout : AbnormalFxaEvent() /** * Indicates an account that's missing after startup, while it was expected to be present. */ class MissingExpectedAccountAfterStartup : AbnormalFxaEvent() } /** * Observes account-related events, and reports any detected abnormalities via [crashReporter]. * * See [AbnormalFxaEvent] for types of abnormal events this class detects. * * @param crashReporter An instance of [CrashReporter] used for reporting detected abnormalities. * @param coroutineContext A [CoroutineContext] used for executing async tasks. Defaults to [Dispatchers.IO]. */ class AccountAbnormalities( context: Context, private val crashReporter: CrashReporter, private val coroutineContext: CoroutineContext = Dispatchers.IO ) : AccountObserver { companion object { private const val PREF_FXA_ABNORMALITIES = "fxa_abnormalities" private const val KEY_HAS_ACCOUNT = "has_account" } @GuardedBy("this") private var isLoggingOut = false @Volatile private var accountManagerConfigured = false @Volatile private var onAuthenticatedCalled = false private val logger = Logger("AccountAbnormalities") private val prefs = context.getSharedPreferences(PREF_FXA_ABNORMALITIES, Context.MODE_PRIVATE) /** * Once [accountManager] is initialized, queries it to detect abnormal account states. * Call this right after running [FxaAccountManager.initAsync]. * * @param accountManager An instance of [FxaAccountManager]. * @param initResult A deferred result of initializing [accountManager]. * @return A [Unit] deferred, resolved once [initResult] is resolved and state is processed for abnormalities. */ fun accountManagerInitializedAsync( accountManager: FxaAccountManager, initResult: Deferred ): Deferred { accountManagerConfigured = true return CoroutineScope(coroutineContext).async { // Wait for the account manager to finish initializing. If it's queried before the // "init" deferred returns, we'll get inaccurate results. initResult.await() // Account manager finished initialization, we can now query it for the account state // and see if it doesn't match our expectations. // Behaviour considered abnormal: // - we had an account before, and it's no longer present during startup // We use a flag in prefs to keep track of the fact that we have an authenticated // account. This works because our account state is persisted in the application's // directory, same as SharedPreferences. If user clears application data, both the // fxa state and our flag will be removed. val hadAccountBefore = prefs.getBoolean(KEY_HAS_ACCOUNT, false) val hasAccountNow = accountManager.authenticatedAccount() != null if (hadAccountBefore && !hasAccountNow) { prefs.edit().putBoolean(KEY_HAS_ACCOUNT, false).apply() logger.warn("Missing expected account on startup") crashReporter.submitCaughtException( AbnormalFxaEvent.MissingExpectedAccountAfterStartup() ) } } } /** * Keeps track of user requests to logout. */ fun userRequestedLogout() { check(accountManagerConfigured) { "userRequestedLogout before account manager was configured" } // Expecting to have seen an onAuthenticated callback before a logout can be triggered. if (!onAuthenticatedCalled) { crashReporter.submitCaughtException(AbnormalFxaEvent.LogoutWithoutAuth()) } // If we're not already in the process of logging out, do nothing. synchronized(this) { if (!isLoggingOut) { isLoggingOut = true return } } logger.warn("Overlapping logout request") // Otherwise, this is an unexpected logout request - there shouldn't be a legitimate way for // the user to request multiple overlapping logouts. Log an exception. crashReporter.submitCaughtException(AbnormalFxaEvent.OverlappingFxaLogoutRequest()) } override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { check(accountManagerConfigured) { "onAuthenticated before account manager was configured" } onAuthenticatedCalled = true // We don't check if KEY_HAS_ACCOUNT was already true: we will see onAuthenticated on every // startup, so any combination of "new value" and "previous value" for this flag is normal. prefs.edit().putBoolean(KEY_HAS_ACCOUNT, true).apply() } override fun onLoggedOut() { check(accountManagerConfigured) { "onLoggedOut before account manager was configured" } onAuthenticatedCalled = false prefs.edit().putBoolean(KEY_HAS_ACCOUNT, false).apply() // If we're in the process of logging out (via userRequestedLogout), do nothing. synchronized(this) { if (isLoggingOut) { isLoggingOut = false return } } logger.warn("Unexpected sign-out") // Otherwise, this is an unexpected logout event - all logout events are expected to be // user-triggered. Log an exception. crashReporter.submitCaughtException(AbnormalFxaEvent.UnexpectedFxaLogout()) } }