blob: 715152c792e9455411ce1d7bd4eab551bf7beba9 [file] [log] [blame]
// Copyright 2019 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.omaha;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.StatFs;
import android.text.TextUtils;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.gms.common.GooglePlayServicesUtil;
import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.ApplicationStatus.ActivityStateListener;
import org.chromium.base.BuildInfo;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.ObserverList;
import org.chromium.base.ThreadUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.AsyncTask.Status;
import org.chromium.base.task.PostTask;
import org.chromium.chrome.browser.ChromeActivity;
import org.chromium.chrome.browser.omaha.inline.InlineUpdateController;
import org.chromium.chrome.browser.omaha.inline.InlineUpdateControllerFactory;
import org.chromium.chrome.browser.omaha.metrics.UpdateSuccessMetrics;
import org.chromium.chrome.browser.omaha.metrics.UpdateSuccessMetrics.UpdateType;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.SharedPreferencesManager;
import org.chromium.chrome.browser.util.ConversionUtils;
import org.chromium.content_public.browser.UiThreadTaskTraits;
import java.io.File;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Provides the current update state for Chrome. This update state is asynchronously determined and
* can change as Chrome runs.
*
* For manually testing this functionality, see {@link UpdateConfigs}.
*/
public class UpdateStatusProvider implements ActivityStateListener {
/**
* Possible sources of user interaction regarding updates.
* Treat this as append only as it is used by UMA.
*/
@IntDef({UpdateInteractionSource.FROM_MENU, UpdateInteractionSource.FROM_INFOBAR,
UpdateInteractionSource.FROM_NOTIFICATION})
@Retention(RetentionPolicy.SOURCE)
public @interface UpdateInteractionSource {
int FROM_MENU = 0;
int FROM_INFOBAR = 1;
int FROM_NOTIFICATION = 2;
int NUM_ENTRIES = 3;
}
/**
* Possible update states.
* Treat this as append only as it is used by UMA.
*/
@IntDef({UpdateState.NONE, UpdateState.UPDATE_AVAILABLE, UpdateState.UNSUPPORTED_OS_VERSION,
UpdateState.INLINE_UPDATE_AVAILABLE, UpdateState.INLINE_UPDATE_DOWNLOADING,
UpdateState.INLINE_UPDATE_READY, UpdateState.INLINE_UPDATE_FAILED})
@Retention(RetentionPolicy.SOURCE)
public @interface UpdateState {
int NONE = 0;
int UPDATE_AVAILABLE = 1;
int UNSUPPORTED_OS_VERSION = 2;
int INLINE_UPDATE_AVAILABLE = 3;
int INLINE_UPDATE_DOWNLOADING = 4;
int INLINE_UPDATE_READY = 5;
int INLINE_UPDATE_FAILED = 6;
int NUM_ENTRIES = 7;
}
/** A set of properties that represent the current update state for Chrome. */
public static final class UpdateStatus {
/**
* The current state of whether an update is available or whether it ever will be
* (unsupported OS).
*/
public @UpdateState int updateState;
/** URL to direct the user to when Omaha detects a newer version available. */
public String updateUrl;
/**
* The latest Chrome version available if OmahaClient.isNewerVersionAvailable() returns
* true.
*/
public String latestVersion;
/**
* If the current OS version is unsupported, and we show the menu badge, and then the user
* clicks the badge and sees the unsupported message, we store the current version to a
* preference and cache it here. This preference is read on startup to ensure we only show
* the unsupported message once per version.
*/
public String latestUnsupportedVersion;
/**
* Whether or not we are currently trying to simulate the update. Used to ignore other
* update signals.
*/
private boolean mIsSimulated;
/**
* Whether or not we are currently trying to simulate an inline flow. Used to allow
* overriding Omaha update state, which usually supersedes inline update states.
*/
private boolean mIsInlineSimulated;
public UpdateStatus() {}
UpdateStatus(UpdateStatus other) {
updateState = other.updateState;
updateUrl = other.updateUrl;
latestVersion = other.latestVersion;
latestUnsupportedVersion = other.latestUnsupportedVersion;
mIsSimulated = other.mIsSimulated;
mIsInlineSimulated = other.mIsInlineSimulated;
}
}
private final ObserverList<Callback<UpdateStatus>> mObservers = new ObserverList<>();
private final InlineUpdateController mInlineController;
private final UpdateQuery mOmahaQuery;
private final UpdateSuccessMetrics mMetrics;
private @Nullable UpdateStatus mStatus;
/** Whether or not we've recorded the initial update status yet. */
private boolean mRecordedInitialStatus;
/** @return Returns a singleton of {@link UpdateStatusProvider}. */
public static UpdateStatusProvider getInstance() {
return LazyHolder.INSTANCE;
}
/**
* Adds {@code observer} to notify about update state changes. It is safe to call this multiple
* times with the same {@code observer}. This method will always notify {@code observer} of the
* current status. If that status has not been calculated yet this method call will trigger the
* async work to calculate it.
* @param observer The observer to notify about update state changes.
* @return {@code true} if {@code observer} is newly registered. {@code false} if it was
* already registered.
*/
public boolean addObserver(Callback<UpdateStatus> observer) {
if (mObservers.hasObserver(observer)) return false;
mObservers.addObserver(observer);
if (mStatus != null) {
PostTask.postTask(UiThreadTaskTraits.DEFAULT, () -> observer.onResult(mStatus));
} else {
if (mOmahaQuery.getStatus() == Status.PENDING) {
mOmahaQuery.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
}
return true;
}
/**
* No longer notifies {@code observer} about update state changes. It is safe to call this
* multiple times with the same {@code observer}.
* @param observer To no longer notify about update state changes.
*/
public void removeObserver(Callback<UpdateStatus> observer) {
if (!mObservers.hasObserver(observer)) return;
mObservers.removeObserver(observer);
}
/**
* Notes that the user is aware that this version of Chrome is no longer supported and
* potentially updates the update state accordingly.
*/
public void updateLatestUnsupportedVersion() {
if (mStatus == null) return;
// If we have already stored the current version to a preference, no need to store it again,
// unless their Chrome version has changed.
String currentlyUsedVersion = BuildInfo.getInstance().versionName;
if (mStatus.latestUnsupportedVersion != null
&& mStatus.latestUnsupportedVersion.equals(currentlyUsedVersion)) {
return;
}
SharedPreferencesManager.getInstance().writeString(
ChromePreferenceKeys.LATEST_UNSUPPORTED_VERSION, currentlyUsedVersion);
mStatus.latestUnsupportedVersion = currentlyUsedVersion;
pingObservers();
}
/**
* Starts the inline update process, if possible.
* @source The source of the action (the UI that caused it).
* @param activity An {@link Activity} that will be used to interact with Play.
*/
public void startInlineUpdate(@UpdateInteractionSource int source, Activity activity) {
if (mStatus == null || mStatus.updateState != UpdateState.INLINE_UPDATE_AVAILABLE) return;
RecordHistogram.recordEnumeratedHistogram(
"GoogleUpdate.Inline.UI.Start.Source", source, UpdateInteractionSource.NUM_ENTRIES);
mMetrics.startUpdate(UpdateType.INLINE, source);
mInlineController.startUpdate(activity);
}
/**
* Retries the inline update process, if possible.
* @param activity An {@link Activity} that will be used to interact with Play.
*/
public void retryInlineUpdate(@UpdateInteractionSource int source, Activity activity) {
if (mStatus == null || mStatus.updateState != UpdateState.INLINE_UPDATE_AVAILABLE) return;
RecordHistogram.recordEnumeratedHistogram(
"GoogleUpdate.Inline.UI.Retry.Source", source, UpdateInteractionSource.NUM_ENTRIES);
mMetrics.startUpdate(UpdateType.INLINE, source);
mInlineController.startUpdate(activity);
}
/** Finishes the inline update process, which may involve restarting the app. */
public void finishInlineUpdate(@UpdateInteractionSource int source) {
if (mStatus == null || mStatus.updateState != UpdateState.INLINE_UPDATE_READY) return;
RecordHistogram.recordEnumeratedHistogram("GoogleUpdate.Inline.UI.Install.Source", source,
UpdateInteractionSource.NUM_ENTRIES);
mInlineController.completeUpdate();
}
/**
* Starts the intent update process, if possible
* @param context An {@link Context} that will be used to fire off the update intent.
* @param source The source of the action (the UI that caused it).
* @param newTask Whether or not to make the intent a new task.
* @return Whether or not the update intent was sent and had a valid handler.
*/
public boolean startIntentUpdate(
Context context, @UpdateInteractionSource int source, boolean newTask) {
if (mStatus == null || mStatus.updateState != UpdateState.UPDATE_AVAILABLE) return false;
if (TextUtils.isEmpty(mStatus.updateUrl)) return false;
try {
mMetrics.startUpdate(UpdateType.INTENT, source);
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(mStatus.updateUrl));
if (newTask) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
} catch (ActivityNotFoundException e) {
return false;
}
return true;
}
// ApplicationStateListener implementation.
@Override
public void onActivityStateChange(Activity changedActivity, @ActivityState int newState) {
boolean hasActiveActivity = false;
for (Activity activity : ApplicationStatus.getRunningActivities()) {
if (activity == null || !(activity instanceof ChromeActivity)) continue;
hasActiveActivity |=
ApplicationStatus.getStateForActivity(activity) == ActivityState.RESUMED;
if (hasActiveActivity) break;
}
mInlineController.setEnabled(hasActiveActivity);
}
private UpdateStatusProvider() {
mInlineController = InlineUpdateControllerFactory.create(this::resolveStatus);
mOmahaQuery = new UpdateQuery(this::resolveStatus);
mMetrics = new UpdateSuccessMetrics();
// Note that as a singleton this class never unregisters.
ApplicationStatus.registerStateListenerForAllActivities(this);
}
private void pingObservers() {
for (Callback<UpdateStatus> observer : mObservers) observer.onResult(mStatus);
}
private void resolveStatus() {
if (mOmahaQuery.getStatus() != Status.FINISHED || mInlineController.getStatus() == null) {
return;
}
// We pull the Omaha result once as it will never change.
if (mStatus == null) mStatus = new UpdateStatus(mOmahaQuery.getResult());
if (mStatus.mIsSimulated) {
if (mStatus.mIsInlineSimulated) {
@UpdateState
int inlineState = mInlineController.getStatus();
if (inlineState == UpdateState.NONE) {
mStatus.updateState = mOmahaQuery.getResult().updateState;
} else {
mStatus.updateState = inlineState;
}
}
} else {
@UpdateState
int omahaState = mOmahaQuery.getResult().updateState;
@UpdateState
int inlineState = mInlineController.getStatus();
mStatus.updateState = resolveOmahaAndInlineStatus(
UpdateConfigs.getConfiguration(), omahaState, inlineState);
}
if (!mRecordedInitialStatus) {
RecordHistogram.recordEnumeratedHistogram(
"GoogleUpdate.StartUp.State", mStatus.updateState, UpdateState.NUM_ENTRIES);
mMetrics.analyzeFirstStatus(mStatus);
mRecordedInitialStatus = true;
}
pingObservers();
}
@VisibleForTesting
static @UpdateState int resolveOmahaAndInlineStatus(
@UpdateConfigs.UpdateFlowConfiguration int configuration, @UpdateState int omahaState,
@UpdateState int inlineState) {
switch (configuration) {
case UpdateConfigs.UpdateFlowConfiguration.NEVER_SHOW:
return UpdateState.NONE;
case UpdateConfigs.UpdateFlowConfiguration.INLINE_ONLY:
if (omahaState != UpdateState.UPDATE_AVAILABLE) return omahaState;
if (inlineState == UpdateState.NONE) return UpdateState.NONE;
return inlineState;
case UpdateConfigs.UpdateFlowConfiguration.BEST_EFFORT:
if (omahaState != UpdateState.UPDATE_AVAILABLE) return omahaState;
if (inlineState == UpdateState.NONE) return omahaState;
return inlineState;
case UpdateConfigs.UpdateFlowConfiguration.INTENT_ONLY: // Intentional fall through.
default:
// Fall back to use Omaha only and use the old flow.
return omahaState;
}
}
private static final class LazyHolder {
private static final UpdateStatusProvider INSTANCE = new UpdateStatusProvider();
}
private static final class UpdateQuery extends AsyncTask<UpdateStatus> {
private final Context mContext = ContextUtils.getApplicationContext();
private final Runnable mCallback;
private @Nullable UpdateStatus mStatus;
public UpdateQuery(@NonNull Runnable resultReceiver) {
mCallback = resultReceiver;
}
public UpdateStatus getResult() {
return mStatus;
}
@Override
protected UpdateStatus doInBackground() {
UpdateStatus testStatus = getTestStatus();
if (testStatus != null) return testStatus;
return getRealStatus(mContext);
}
@Override
protected void onPostExecute(UpdateStatus result) {
mStatus = result;
PostTask.postTask(UiThreadTaskTraits.DEFAULT, mCallback);
}
private UpdateStatus getTestStatus() {
@UpdateState
Integer forcedUpdateState = UpdateConfigs.getMockUpdateState();
if (forcedUpdateState == null) return null;
UpdateStatus status = new UpdateStatus();
status.mIsSimulated = true;
status.updateState = forcedUpdateState;
status.mIsInlineSimulated = forcedUpdateState == UpdateState.INLINE_UPDATE_AVAILABLE;
// Push custom configurations for certain update states.
switch (forcedUpdateState) {
case UpdateState.UPDATE_AVAILABLE:
String updateUrl = UpdateConfigs.getMockMarketUrl();
if (!TextUtils.isEmpty(updateUrl)) status.updateUrl = updateUrl;
break;
case UpdateState.UNSUPPORTED_OS_VERSION:
status.latestUnsupportedVersion =
SharedPreferencesManager.getInstance().readString(
ChromePreferenceKeys.LATEST_UNSUPPORTED_VERSION, null);
break;
}
return status;
}
private UpdateStatus getRealStatus(Context context) {
UpdateStatus status = new UpdateStatus();
if (VersionNumberGetter.isNewerVersionAvailable(context)) {
status.updateUrl = MarketURLGetter.getMarketUrl();
status.latestVersion =
VersionNumberGetter.getInstance().getLatestKnownVersion(context);
boolean allowedToUpdate =
checkForSufficientStorage() && isGooglePlayStoreAvailable(context);
status.updateState =
allowedToUpdate ? UpdateState.UPDATE_AVAILABLE : UpdateState.NONE;
SharedPreferencesManager.getInstance().removeKey(
ChromePreferenceKeys.LATEST_UNSUPPORTED_VERSION);
} else if (!VersionNumberGetter.isCurrentOsVersionSupported()) {
status.updateState = UpdateState.UNSUPPORTED_OS_VERSION;
status.latestUnsupportedVersion = SharedPreferencesManager.getInstance().readString(
ChromePreferenceKeys.LATEST_UNSUPPORTED_VERSION, null);
} else {
status.updateState = UpdateState.NONE;
}
return status;
}
private boolean checkForSufficientStorage() {
assert !ThreadUtils.runningOnUiThread();
File path = Environment.getDataDirectory();
StatFs statFs = new StatFs(path.getAbsolutePath());
long size;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
size = getSize(statFs);
} else {
size = getSizeUpdatedApi(statFs);
}
RecordHistogram.recordLinearCountHistogram(
"GoogleUpdate.InfoBar.InternalStorageSizeAvailable", (int) size, 1, 200, 100);
RecordHistogram.recordLinearCountHistogram(
"GoogleUpdate.InfoBar.DeviceFreeSpace", (int) size, 1, 1000, 50);
int minRequiredStorage = UpdateConfigs.getMinRequiredStorage();
if (minRequiredStorage == -1) return true;
return size >= minRequiredStorage;
}
private boolean isGooglePlayStoreAvailable(Context context) {
try {
context.getPackageManager().getPackageInfo(
GooglePlayServicesUtil.GOOGLE_PLAY_STORE_PACKAGE, 0);
} catch (PackageManager.NameNotFoundException e) {
return false;
}
return true;
}
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
private long getSizeUpdatedApi(StatFs statFs) {
return ConversionUtils.bytesToMegabytes(statFs.getAvailableBytes());
}
@SuppressWarnings("deprecation")
private long getSize(StatFs statFs) {
int blockSize = statFs.getBlockSize();
int availableBlocks = statFs.getAvailableBlocks();
return ConversionUtils.bytesToMegabytes(blockSize * availableBlocks);
}
}
}