blob: db5f1187fffa52abd3b53e1505d3fe2a033c3e28 [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.safety_check;
import android.content.Intent;
import android.net.Uri;
import android.os.Handler;
import android.os.SystemClock;
import android.text.format.DateUtils;
import android.view.View;
import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;
import androidx.preference.Preference;
import org.chromium.base.BuildConfig;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.password_check.CompromisedCredential;
import org.chromium.chrome.browser.password_check.PasswordCheck;
import org.chromium.chrome.browser.password_check.PasswordCheckFactory;
import org.chromium.chrome.browser.password_check.PasswordCheckReferrer;
import org.chromium.chrome.browser.password_check.PasswordCheckUIStatus;
import org.chromium.chrome.browser.password_manager.ManagePasswordsReferrer;
import org.chromium.chrome.browser.password_manager.PasswordManagerHelper;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.SharedPreferencesManager;
import org.chromium.chrome.browser.safe_browsing.metrics.SettingsAccessPoint;
import org.chromium.chrome.browser.safe_browsing.settings.SafeBrowsingSettingsFragment;
import org.chromium.chrome.browser.safety_check.SafetyCheckBridge.SafetyCheckCommonObserver;
import org.chromium.chrome.browser.safety_check.SafetyCheckProperties.PasswordsState;
import org.chromium.chrome.browser.safety_check.SafetyCheckProperties.SafeBrowsingState;
import org.chromium.chrome.browser.safety_check.SafetyCheckProperties.UpdatesState;
import org.chromium.chrome.browser.signin.ui.SigninActivityLauncher;
import org.chromium.components.browser_ui.settings.SettingsLauncher;
import org.chromium.components.signin.metrics.SigninAccessPoint;
import org.chromium.content_public.common.ContentUrlConstants;
import org.chromium.ui.modelutil.PropertyModel;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.ref.WeakReference;
class SafetyCheckMediator implements PasswordCheck.Observer, SafetyCheckCommonObserver {
/**
* The minimal amount of time to show the checking state.
* This is different from the standard guideline of 500ms because of the UX guidance for
* Safety check on mobile and to be consistent with the Desktop counterpart (also 1s there).
*/
private static final int CHECKING_MIN_DURATION_MS = 1000;
/** Time after which the null-states will be shown: 10 minutes. */
private static final long RESET_TO_NULL_AFTER_MS = 10 * DateUtils.MINUTE_IN_MILLIS;
private static final String SAFETY_CHECK_INTERACTIONS = "Settings.SafetyCheck.Interactions";
/** Bridge to the C++ side for the Safe Browsing and passwords checks. */
private SafetyCheckBridge mSafetyCheckBridge;
/** Model representing the current state of the checks. */
private PropertyModel mModel;
/** Client to interact with Omaha for the updates check. */
private SafetyCheckUpdatesDelegate mUpdatesClient;
/** An instance of SettingsLauncher to start other activities. */
private SettingsLauncher mSettingsLauncher;
/** Client to launch a SigninActivity. */
private SigninActivityLauncher mSigninLauncher;
/** Async logic for password check. */
private boolean mShowSafePasswordState;
private boolean mPasswordsLoaded;
private boolean mLeaksLoaded;
// Indicates that the password check results are blocked on disk load at different stages.
@IntDef({PasswordCheckLoadStage.IDLE, PasswordCheckLoadStage.INITIAL_WAIT_FOR_LOAD,
PasswordCheckLoadStage.COMPLETED_WAIT_FOR_LOAD})
@Retention(RetentionPolicy.SOURCE)
private @interface PasswordCheckLoadStage {
/** No need for action - nothing is blocked on data load. */
int IDLE = 1;
/** Apply the data from disk once available since this is initial load. */
int INITIAL_WAIT_FOR_LOAD = 2;
/** Apply the data from the latest run once available since this is a current check. */
int COMPLETED_WAIT_FOR_LOAD = 3;
}
private @PasswordCheckLoadStage int mLoadStage;
/** Callbacks and related objects to show the checking state for at least 1 second. */
private Handler mHandler;
private Runnable mRunnablePasswords;
private Runnable mRunnableSafeBrowsing;
private Runnable mRunnableUpdates;
private long mCheckStartTime = -1;
/**
* UMA histogram values for Safety check interactions. Some value don't apply to Android.
* Note: this should stay in sync with SettingsSafetyCheckInteractions in enums.xml.
*/
@IntDef({SafetyCheckInteractions.STARTED, SafetyCheckInteractions.UPDATES_RELAUNCH,
SafetyCheckInteractions.PASSWORDS_MANAGE, SafetyCheckInteractions.SAFE_BROWSING_MANAGE,
SafetyCheckInteractions.EXTENSIONS_REVIEW,
SafetyCheckInteractions.CHROME_CLEANER_REBOOT,
SafetyCheckInteractions.CHROME_CLEANER_REVIEW,
SafetyCheckInteractions.PASSWORDS_MANAGE_THROUGH_CARET_NAVIGATION,
SafetyCheckInteractions.SAFE_BROWSING_MANAGE_THROUGH_CARET_NAVIGATION,
SafetyCheckInteractions.EXTENSIONS_REVIEW_THROUGH_CARET_NAVIGATION,
SafetyCheckInteractions.MAX_VALUE})
@Retention(RetentionPolicy.SOURCE)
public @interface SafetyCheckInteractions {
int STARTED = 0;
int UPDATES_RELAUNCH = 1;
int PASSWORDS_MANAGE = 2;
int SAFE_BROWSING_MANAGE = 3;
int EXTENSIONS_REVIEW = 4;
int CHROME_CLEANER_REBOOT = 5;
int CHROME_CLEANER_REVIEW = 6;
int PASSWORDS_MANAGE_THROUGH_CARET_NAVIGATION = 7;
int SAFE_BROWSING_MANAGE_THROUGH_CARET_NAVIGATION = 8;
int EXTENSIONS_REVIEW_THROUGH_CARET_NAVIGATION = 9;
// New elements go above.
int MAX_VALUE = EXTENSIONS_REVIEW_THROUGH_CARET_NAVIGATION;
}
private final SharedPreferencesManager mPreferenceManager;
/**
* Callback that gets invoked once the result of the updates check is available. Not inlined
* because a {@link WeakReference} needs to be passed (the check is asynchronous).
*/
private final Callback<Integer> mUpdatesCheckCallback = (status) -> {
mRunnableUpdates = () -> {
if (mModel != null) {
RecordHistogram.recordEnumeratedHistogram("Settings.SafetyCheck.UpdatesResult",
SafetyCheckProperties.updatesStateToNative(status),
UpdateStatus.MAX_VALUE + 1);
mModel.set(SafetyCheckProperties.UPDATES_STATE, status);
}
};
if (mHandler != null) {
// Show the checking state for at least 1 second for a smoother UX.
mHandler.postDelayed(mRunnableUpdates, getModelUpdateDelay());
}
};
/**
* Creates a new instance given a model, an updates client, and a settings launcher.
*
* @param model A model instance.
* @param client An updates client.
* @param settingsLauncher An instance of the {@link SettingsLauncher} implementation.
* @param signinLauncher An instance implementing {@SigninActivityLauncher}.
*/
public SafetyCheckMediator(PropertyModel model, SafetyCheckUpdatesDelegate client,
SettingsLauncher settingsLauncher, SigninActivityLauncher signinLauncher) {
this(model, client, settingsLauncher, signinLauncher, null, new Handler());
// Have to initialize this after the constructor call, since a "this" instance is needed.
mSafetyCheckBridge = new SafetyCheckBridge(SafetyCheckMediator.this);
}
@VisibleForTesting
SafetyCheckMediator(PropertyModel model, SafetyCheckUpdatesDelegate client,
SettingsLauncher settingsLauncher, SigninActivityLauncher signinLauncher,
SafetyCheckBridge bridge, Handler handler) {
mModel = model;
mUpdatesClient = client;
mSettingsLauncher = settingsLauncher;
mSigninLauncher = signinLauncher;
mSafetyCheckBridge = bridge;
mHandler = handler;
mPreferenceManager = SharedPreferencesManager.getInstance();
// Set the listener for clicking the updates element.
mModel.set(SafetyCheckProperties.UPDATES_CLICK_LISTENER,
(Preference.OnPreferenceClickListener) (p) -> {
if (!BuildConfig.IS_CHROME_BRANDED) {
return true;
}
String chromeAppId = ContextUtils.getApplicationContext().getPackageName();
// Open the Play Store page for the installed Chrome channel.
p.getContext().startActivity(new Intent(Intent.ACTION_VIEW,
Uri.parse(ContentUrlConstants.PLAY_STORE_URL_PREFIX + chromeAppId)));
return true;
});
// Set the listener for clicking the Safe Browsing element.
mModel.set(SafetyCheckProperties.SAFE_BROWSING_CLICK_LISTENER,
(Preference.OnPreferenceClickListener) (p) -> {
// Record UMA metrics.
RecordUserAction.record("Settings.SafetyCheck.ManageSafeBrowsing");
RecordHistogram.recordEnumeratedHistogram(SAFETY_CHECK_INTERACTIONS,
SafetyCheckInteractions.SAFE_BROWSING_MANAGE,
SafetyCheckInteractions.MAX_VALUE + 1);
String safeBrowsingSettingsClassName;
if (ChromeFeatureList.isEnabled(ChromeFeatureList.SAFE_BROWSING_SECTION_UI)) {
// Open the Safe Browsing settings since the flag for them is enabled.
safeBrowsingSettingsClassName =
SafeBrowsingSettingsFragment.class.getName();
p.getContext().startActivity(settingsLauncher.createSettingsActivityIntent(
p.getContext(), safeBrowsingSettingsClassName,
SafeBrowsingSettingsFragment.createArguments(
SettingsAccessPoint.SAFETY_CHECK)));
} else {
// Open the Sync and Services settings.
// TODO(crbug.com/1070620): replace the hardcoded class name with an import
// and ".class.getName()" once SyncAndServicesSettings is moved out of
// //chrome/android.
safeBrowsingSettingsClassName =
"org.chromium.chrome.browser.sync.settings.SyncAndServicesSettings";
p.getContext().startActivity(settingsLauncher.createSettingsActivityIntent(
p.getContext(), safeBrowsingSettingsClassName));
}
return true;
});
// Set the listener for clicking the passwords element.
updatePasswordElementClickDestination();
// Set the listener for clicking the Check button.
mModel.set(SafetyCheckProperties.SAFETY_CHECK_BUTTON_CLICK_LISTENER,
(View.OnClickListener) (v) -> performSafetyCheck());
// Get the timestamp of the last run.
mModel.set(SafetyCheckProperties.LAST_RUN_TIMESTAMP,
mPreferenceManager.readLong(
ChromePreferenceKeys.SETTINGS_SAFETY_CHECK_LAST_RUN_TIMESTAMP, 0));
}
/**
* Determines the initial state to show, triggering any fast checks if necessary based on the
* last run timestamp.
*/
public void setInitialState() {
long currentTime = System.currentTimeMillis();
long lastRun = mPreferenceManager.readLong(
ChromePreferenceKeys.SETTINGS_SAFETY_CHECK_LAST_RUN_TIMESTAMP, 0);
if (currentTime - lastRun < RESET_TO_NULL_AFTER_MS) {
mShowSafePasswordState = true;
// Rerun the updates and Safe Browsing checks.
mModel.set(SafetyCheckProperties.SAFE_BROWSING_STATE, SafeBrowsingState.CHECKING);
mModel.set(SafetyCheckProperties.UPDATES_STATE, UpdatesState.CHECKING);
mSafetyCheckBridge.checkSafeBrowsing();
mUpdatesClient.checkForUpdates(new WeakReference(mUpdatesCheckCallback));
} else {
mShowSafePasswordState = false;
mModel.set(SafetyCheckProperties.SAFE_BROWSING_STATE, SafeBrowsingState.UNCHECKED);
mModel.set(SafetyCheckProperties.UPDATES_STATE, UpdatesState.UNCHECKED);
}
mModel.set(SafetyCheckProperties.PASSWORDS_STATE, PasswordsState.CHECKING);
mLoadStage = PasswordCheckLoadStage.INITIAL_WAIT_FOR_LOAD;
// Reset the status of the password disk loads. If it's loaded, PasswordCheck will invoke
// the callbacks again (the |callImmediatelyIfReady| argument to |addObserver| is true).
mPasswordsLoaded = false;
mLeaksLoaded = false;
// If the user is not signed in, immediately set the state and do not block on disk loads.
if (!mSafetyCheckBridge.userSignedIn()) {
mLoadStage = PasswordCheckLoadStage.IDLE;
mModel.set(SafetyCheckProperties.PASSWORDS_STATE, PasswordsState.SIGNED_OUT);
// Record the value in UMA.
RecordHistogram.recordEnumeratedHistogram("Settings.SafetyCheck.PasswordsResult",
PasswordsStatus.SIGNED_OUT, PasswordsStatus.MAX_VALUE + 1);
updatePasswordElementClickDestination();
}
PasswordCheckFactory.getOrCreate(mSettingsLauncher).addObserver(this, true);
if (mPasswordsLoaded && mLeaksLoaded) {
determinePasswordStateOnLoadComplete();
}
}
/** Triggers all safety check child checks. */
public void performSafetyCheck() {
// Cancel pending delayed show callbacks if a new check is starting while any existing
// elements are pending.
cancelCallbacks();
// Record the start action in UMA.
RecordUserAction.record("Settings.SafetyCheck.Start");
// Record the start interaction in the histogram.
RecordHistogram.recordEnumeratedHistogram(SAFETY_CHECK_INTERACTIONS,
SafetyCheckInteractions.STARTED, SafetyCheckInteractions.MAX_VALUE + 1);
// Record the start time for tracking 1 second checking delay in the UI.
mCheckStartTime = SystemClock.elapsedRealtime();
// Record the absolute start time for showing when the last Safety check was performed.
long currentTime = System.currentTimeMillis();
mModel.set(SafetyCheckProperties.LAST_RUN_TIMESTAMP, currentTime);
mPreferenceManager.writeLong(
ChromePreferenceKeys.SETTINGS_SAFETY_CHECK_LAST_RUN_TIMESTAMP, currentTime);
// Increment the stored number of Safety check starts.
mPreferenceManager.incrementInt(ChromePreferenceKeys.SETTINGS_SAFETY_CHECK_RUN_COUNTER);
// Set the checking state for all elements.
mModel.set(SafetyCheckProperties.PASSWORDS_STATE, PasswordsState.CHECKING);
mModel.set(SafetyCheckProperties.SAFE_BROWSING_STATE, SafeBrowsingState.CHECKING);
mModel.set(SafetyCheckProperties.UPDATES_STATE, UpdatesState.CHECKING);
// Start all the checks.
mSafetyCheckBridge.checkSafeBrowsing();
// Start observing the password check events (including data loads).
PasswordCheckFactory.getOrCreate(mSettingsLauncher).addObserver(this, false);
// This indicates that the results of the initial data load should not be applied even if
// they become available during the check.
mLoadStage = PasswordCheckLoadStage.IDLE;
PasswordCheckFactory.getOrCreate(mSettingsLauncher).startCheck();
mUpdatesClient.checkForUpdates(new WeakReference(mUpdatesCheckCallback));
}
/**
* Gets invoked once the Safe Browsing check is completed.
*
* @param status SafetyCheck::SafeBrowsingStatus enum value representing the Safe Browsing state
* (see //components/safety_check/safety_check.h).
*/
@Override
public void onSafeBrowsingCheckResult(@SafeBrowsingStatus int status) {
mRunnableSafeBrowsing = () -> {
if (mModel != null) {
RecordHistogram.recordEnumeratedHistogram("Settings.SafetyCheck.SafeBrowsingResult",
status, SafeBrowsingStatus.MAX_VALUE + 1);
mModel.set(SafetyCheckProperties.SAFE_BROWSING_STATE,
SafetyCheckProperties.safeBrowsingStateFromNative(status));
}
};
// Show the checking state for at least 1 second for a smoother UX.
mHandler.postDelayed(mRunnableSafeBrowsing, getModelUpdateDelay());
}
/**
* Gets invoked when the compromised credentials are fetched from the disk.
* After this call, {@link PasswordCheck#getCompromisedCredentialsCount} returns a valid value.
*/
@Override
public void onCompromisedCredentialsFetchCompleted() {
mLeaksLoaded = true;
if (mPasswordsLoaded) {
determinePasswordStateOnLoadComplete();
}
}
/**
* Gets invoked when the saved passwords are fetched from the disk.
* After this call, {@link PasswordCheck#getSavedPasswordsCount} returns a valid value.
*/
@Override
public void onSavedPasswordsFetchCompleted() {
mPasswordsLoaded = true;
if (mLeaksLoaded) {
determinePasswordStateOnLoadComplete();
}
}
/**
  * Gets invoked once the password check stops running.
* @param status A {@link PasswordCheckUIStatus} enum value.
*/
@Override
public void onPasswordCheckStatusChanged(@PasswordCheckUIStatus int status) {
if (status == PasswordCheckUIStatus.RUNNING || mLoadStage != PasswordCheckLoadStage.IDLE) {
return;
}
// Handle error state.
if (status != PasswordCheckUIStatus.IDLE) {
mRunnablePasswords = () -> {
if (mModel != null) {
@SafetyCheckProperties.PasswordsState
int state = SafetyCheckProperties.passwordsStatefromErrorState(status);
RecordHistogram.recordEnumeratedHistogram(
"Settings.SafetyCheck.PasswordsResult",
SafetyCheckProperties.passwordsStateToNative(state),
PasswordsStatus.MAX_VALUE + 1);
mModel.set(SafetyCheckProperties.PASSWORDS_STATE, state);
updatePasswordElementClickDestination();
}
};
// Show the checking state for at least 1 second for a smoother UX.
mHandler.postDelayed(mRunnablePasswords, getModelUpdateDelay());
return;
}
// Hand off the completed state to the method for handling loaded passwords data.
mLoadStage = PasswordCheckLoadStage.COMPLETED_WAIT_FOR_LOAD;
// If it's loaded already, should invoke the data handling method manually.
if (mPasswordsLoaded && mLeaksLoaded) {
determinePasswordStateOnLoadComplete();
}
}
@Override
public void onCompromisedCredentialFound(CompromisedCredential leakedCredential) {}
@Override
public void onPasswordCheckProgressChanged(int alreadyProcessed, int remainingInQueue) {}
/** Cancels any pending callbacks and registered observers. */
public void destroy() {
cancelCallbacks();
// Refresh the ref without creating a new one.
PasswordCheck passwordCheck = PasswordCheckFactory.getPasswordCheckInstance();
if (passwordCheck != null) {
passwordCheck.stopCheck();
passwordCheck.removeObserver(this);
}
mSafetyCheckBridge.destroy();
mSafetyCheckBridge = null;
mUpdatesClient = null;
mModel = null;
mHandler = null;
}
/** Cancels any delayed show callbacks. */
private void cancelCallbacks() {
if (mRunnablePasswords != null) {
mHandler.removeCallbacks(mRunnablePasswords);
mRunnablePasswords = null;
}
if (mRunnableSafeBrowsing != null) {
mHandler.removeCallbacks(mRunnableSafeBrowsing);
mRunnableSafeBrowsing = null;
}
if (mRunnableUpdates != null) {
mHandler.removeCallbacks(mRunnableUpdates);
mRunnableUpdates = null;
}
}
/** Called when all data is loaded. Determines if it needs to update the model. */
private void determinePasswordStateOnLoadComplete() {
// Nothing is blocked on data load, so ignore the load.
if (mLoadStage == PasswordCheckLoadStage.IDLE) return;
// If something is blocked, that means the passwords check is being observed. At this point,
// no further events need to be observed.
PasswordCheckFactory.getOrCreate(mSettingsLauncher).removeObserver(this);
// Only delay updating the UI on the user-triggered check and not initially.
if (mLoadStage == PasswordCheckLoadStage.INITIAL_WAIT_FOR_LOAD) {
updatePasswordsStateOnDataLoaded();
} else {
// Show the checking state for at least 1 second for a smoother UX.
mRunnablePasswords = this::updatePasswordsStateOnDataLoaded;
mHandler.postDelayed(mRunnablePasswords, getModelUpdateDelay());
}
}
/** Applies the results of the password check to the model. Only called when data is loaded. */
private void updatePasswordsStateOnDataLoaded() {
// Always display the compromised state.
int compromised = PasswordCheckFactory.getOrCreate(mSettingsLauncher)
.getCompromisedCredentialsCount();
if (compromised != 0) {
mModel.set(SafetyCheckProperties.COMPROMISED_PASSWORDS, compromised);
mModel.set(SafetyCheckProperties.PASSWORDS_STATE, PasswordsState.COMPROMISED_EXIST);
// Record the value in UMA.
RecordHistogram.recordEnumeratedHistogram("Settings.SafetyCheck.PasswordsResult",
PasswordsStatus.COMPROMISED_EXIST, PasswordsStatus.MAX_VALUE + 1);
} else if (mLoadStage == PasswordCheckLoadStage.INITIAL_WAIT_FOR_LOAD
&& !mShowSafePasswordState) {
// Cannot show the safe state at the initial load if last run is older than 10 mins.
mModel.set(SafetyCheckProperties.PASSWORDS_STATE, PasswordsState.UNCHECKED);
} else if (PasswordCheckFactory.getOrCreate(mSettingsLauncher).getSavedPasswordsCount()
== 0) {
// Can show safe state: display no passwords.
mModel.set(SafetyCheckProperties.PASSWORDS_STATE, PasswordsState.NO_PASSWORDS);
// Record the value in UMA.
RecordHistogram.recordEnumeratedHistogram("Settings.SafetyCheck.PasswordsResult",
PasswordsStatus.NO_PASSWORDS, PasswordsStatus.MAX_VALUE + 1);
} else {
// Can show safe state: display no compromises.
mModel.set(SafetyCheckProperties.PASSWORDS_STATE, PasswordsState.SAFE);
// Record the value in UMA.
RecordHistogram.recordEnumeratedHistogram("Settings.SafetyCheck.PasswordsResult",
PasswordsStatus.SAFE, PasswordsStatus.MAX_VALUE + 1);
}
// Nothing is blocked on this any longer.
mLoadStage = PasswordCheckLoadStage.IDLE;
updatePasswordElementClickDestination();
}
/**
* @return The delay in ms for updating the model in the running state.
*/
private long getModelUpdateDelay() {
return Math.max(
0, mCheckStartTime + CHECKING_MIN_DURATION_MS - SystemClock.elapsedRealtime());
}
/** Sets the destination of the click on the passwords element based on the current state. */
private void updatePasswordElementClickDestination() {
@PasswordsState
int state = mModel.get(SafetyCheckProperties.PASSWORDS_STATE);
Preference.OnPreferenceClickListener listener = null;
if (state == PasswordsState.SIGNED_OUT) {
listener = (p) -> {
// Open the sign in page.
mSigninLauncher.launchActivity(p.getContext(), SigninAccessPoint.SAFETY_CHECK);
return true;
};
} else if (state == PasswordsState.COMPROMISED_EXIST || state == PasswordsState.SAFE) {
listener = (p) -> {
// Record UMA metrics.
RecordUserAction.record("Settings.SafetyCheck.ManagePasswords");
RecordHistogram.recordEnumeratedHistogram(SAFETY_CHECK_INTERACTIONS,
SafetyCheckInteractions.PASSWORDS_MANAGE,
SafetyCheckInteractions.MAX_VALUE + 1);
// Open the Password Check UI.
PasswordCheckFactory.getOrCreate(mSettingsLauncher)
.showUi(p.getContext(), PasswordCheckReferrer.SAFETY_CHECK);
return true;
};
} else {
listener = (p) -> {
// Open the Passwords settings.
PasswordManagerHelper.showPasswordSettings(
p.getContext(), ManagePasswordsReferrer.CHROME_SETTINGS, mSettingsLauncher);
return true;
};
}
mModel.set(SafetyCheckProperties.PASSWORDS_CLICK_LISTENER, listener);
}
}