| // 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); |
| } |
| } |
| } |