// Copyright 2020 The Chromium Authors
// 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.app.metrics;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.view.Display;
import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.Log;
import org.chromium.base.ThreadUtils;
import org.chromium.base.TraceEvent;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.build.annotations.CheckDiscard;
import org.chromium.chrome.browser.app.ChromeActivity;
import org.chromium.ui.display.DisplayAndroidManager;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** Computes and records metrics for what caused Chrome to be launched. */
public abstract class LaunchCauseMetrics
implements ApplicationStatus.ApplicationStateListener,
ApplicationStatus.ActivityStateListener {
private static final boolean DEBUG = false;
private static final String TAG = "LaunchCauseMetrics";
// Static to avoid recording launch metrics when transitioning between Activities without
// Chrome leaving the foreground.
private static boolean sRecordedLaunchCause;
@VisibleForTesting
public static final String LAUNCH_CAUSE_HISTOGRAM = "MobileStartup.LaunchCause";
private PerLaunchState mPerLaunchState = new PerLaunchState();
private BetweenLaunchState mBetweenLaunchState = new BetweenLaunchState();
private final Activity mActivity;
private long mActivityId;
@SuppressLint("StaticFieldLeak")
private static Activity sLastResumedActivity;
private static ApplicationStatus.ActivityStateListener sAppActivityListener;
// State pertaining to the current launch, reset when Chrome is backgrounded,
// and after computing LaunchCause.
private static class PerLaunchState {
boolean mReceivedIntent;
// Whether a ChromeActivity other than |mActivity| was last focused, used to track
// intentional transitions between different types of ChromeActivity.
boolean mOtherChromeActivityLastFocused;
boolean mLaunchedFromRecents;
}
// State that persists through Chrome being backgrounded (but not destroyed), reset after
// computing LaunchCause.
private static class BetweenLaunchState {
boolean mReceivedLeaveHint;
boolean mScreenOffWhenPaused;
}
// These values are persisted in histograms. Please do not renumber. Append only.
// These values are also recorded in chrome_track_event.proto in Startup.LaunchCauseType.
// Keep values in sync between the two files.
@IntDef({
LaunchCause.OTHER,
LaunchCause.CUSTOM_TAB,
LaunchCause.TWA,
LaunchCause.RECENTS,
LaunchCause.RECENTS_OR_BACK,
LaunchCause.FOREGROUND_WHEN_LOCKED,
LaunchCause.MAIN_LAUNCHER_ICON,
LaunchCause.MAIN_LAUNCHER_ICON_SHORTCUT,
LaunchCause.HOME_SCREEN_WIDGET,
LaunchCause.OPEN_IN_BROWSER_FROM_MENU,
LaunchCause.EXTERNAL_SEARCH_ACTION_INTENT,
LaunchCause.NOTIFICATION,
LaunchCause.EXTERNAL_VIEW_INTENT,
LaunchCause.OTHER_CHROME,
LaunchCause.WEBAPK_CHROME_DISTRIBUTOR,
LaunchCause.WEBAPK_OTHER_DISTRIBUTOR,
LaunchCause.HOME_SCREEN_SHORTCUT,
LaunchCause.SHARE_INTENT,
LaunchCause.NFC,
LaunchCause.AUTH_TAB,
})
@Retention(RetentionPolicy.SOURCE)
public @interface LaunchCause {
int OTHER = 0;
int CUSTOM_TAB = 1;
int TWA = 2;
int RECENTS = 3;
int RECENTS_OR_BACK = 4;
int FOREGROUND_WHEN_LOCKED = 5;
int MAIN_LAUNCHER_ICON = 6;
int MAIN_LAUNCHER_ICON_SHORTCUT = 7;
int HOME_SCREEN_WIDGET = 8;
int OPEN_IN_BROWSER_FROM_MENU = 9;
int EXTERNAL_SEARCH_ACTION_INTENT = 10;
int NOTIFICATION = 11;
int EXTERNAL_VIEW_INTENT = 12;
int OTHER_CHROME = 13;
int WEBAPK_CHROME_DISTRIBUTOR = 14;
int WEBAPK_OTHER_DISTRIBUTOR = 15;
int HOME_SCREEN_SHORTCUT = 16;
int SHARE_INTENT = 17;
int NFC = 18;
int AUTH_TAB = 19;
int NUM_ENTRIES = 20;
}
/**
* @param activity The Activity context to compute LaunchCause for, used for getting the correct
* Display, etc.
*/
public LaunchCauseMetrics(final Activity activity) {
mActivity = activity;
if (sAppActivityListener == null) {
sAppActivityListener =
new ApplicationStatus.ActivityStateListener() {
@Override
public void onActivityStateChange(Activity activity, int newState) {
if (newState == ActivityState.RESUMED) sLastResumedActivity = activity;
if (newState == ActivityState.DESTROYED) {
if (activity == sLastResumedActivity) sLastResumedActivity = null;
}
}
};
ApplicationStatus.registerStateListenerForAllActivities(sAppActivityListener);
if (ApplicationStatus.getStateForApplication()
== ApplicationState.HAS_RUNNING_ACTIVITIES) {
sLastResumedActivity = ApplicationStatus.getLastTrackedFocusedActivity();
}
}
ApplicationStatus.registerApplicationStateListener(this);
ApplicationStatus.registerStateListenerForActivity(this, activity);
}
@Override
public void onActivityStateChange(Activity activity, @ActivityState int newState) {
assert activity == mActivity;
if (newState == ActivityState.DESTROYED) {
ApplicationStatus.unregisterApplicationStateListener(this);
ApplicationStatus.unregisterActivityStateListener(this);
}
if (newState == ActivityState.PAUSED) {
mBetweenLaunchState.mScreenOffWhenPaused = isDisplayOff(mActivity);
}
}
@Override
public void onApplicationStateChange(@ApplicationState int newState) {
if (newState == ApplicationState.HAS_STOPPED_ACTIVITIES) {
sRecordedLaunchCause = false;
resetPerLaunchState();
}
}
private void resetPerLaunchState() {
mPerLaunchState = new PerLaunchState();
}
private void resetBetweenLaunchState() {
mBetweenLaunchState = new BetweenLaunchState();
}
/** Computes and returns what the cause of the Chrome launch was. */
protected abstract @LaunchCause int computeIntentLaunchCause();
/**
* Computes and returns the cause of an Intentional transition between Chrome Activity
* types, or other if the transition wasn't Intentional.
*
* Intentional here means that the user knew they were transitioning between Chrome Activities,
* and made an explicit choice to do so.
*/
protected @LaunchCause int getIntentionalTransitionCauseOrOther() {
return LaunchCause.OTHER;
}
/** Returns true if an intent has been received since the last launch of Chrome. */
protected boolean didReceiveIntent() {
return mPerLaunchState.mReceivedIntent;
}
public void setActivityId(long activityId) {
mActivityId = activityId;
}
/**
* Called after Chrome has launched and all information necessary to compute why Chrome was
* launched is available.
*
* <p>Records UMA metrics for what caused Chrome to launch, and returns the launch cause.
*/
public @LaunchCause int recordLaunchCause() {
@LaunchCause int launchCause = LaunchCause.OTHER;
if (!sRecordedLaunchCause) {
sRecordedLaunchCause = true;
if (mPerLaunchState.mReceivedIntent) {
launchCause = computeIntentLaunchCause();
} else {
launchCause = computeNonIntentLaunchCause();
}
if (DEBUG) logLaunchCause(launchCause);
RecordHistogram.recordEnumeratedHistogram(
LAUNCH_CAUSE_HISTOGRAM, launchCause, LaunchCause.NUM_ENTRIES);
TraceEvent.startupLaunchCause(mActivityId, launchCause);
} else if (mPerLaunchState.mOtherChromeActivityLastFocused) {
// Handle the case where we're intentionally transitioning between two Chrome
// Activities while Chrome is in the foreground, and want to count that as a Launch.
launchCause = getIntentionalTransitionCauseOrOther();
if (launchCause != LaunchCause.OTHER) {
if (DEBUG) logLaunchCause(launchCause);
RecordHistogram.recordEnumeratedHistogram(
LAUNCH_CAUSE_HISTOGRAM, launchCause, LaunchCause.NUM_ENTRIES);
TraceEvent.startupLaunchCause(mActivityId, launchCause);
}
}
resetPerLaunchState();
resetBetweenLaunchState();
return launchCause;
}
// If Chrome wasn't launched via an intent, it was either launched from Recents, Back button,
// or through Screen ON.
//
// For posterity: If you're testing this by switching between the Android Settings app, and
// Chrome, with Chrome set as the debug app, it won't work because Android clears app state and
// resuming through Recents will instead send a MAIN intent.
private @LaunchCause int computeNonIntentLaunchCause() {
if (mPerLaunchState.mLaunchedFromRecents) {
return LaunchCause.RECENTS;
}
if (mBetweenLaunchState.mScreenOffWhenPaused) {
// It's possible we got here through Recents, if the user tapped a non-Chrome
// notification after locking their screen with Chrome in the foreground, then
// returned to Chrome through Recents, and there's no reliable way to detect this.
// The most likely explanation for arriving here is Chrome was resumed through
// unlocking their phone.
return LaunchCause.FOREGROUND_WHEN_LOCKED;
}
// If we don't get a UserLeaveHint when leaving Chrome, then back can't return us to Chrome.
if (!mBetweenLaunchState.mReceivedLeaveHint) {
return LaunchCause.RECENTS;
}
// There's no way to distinguish between Recents and Back when we've received a
// UserLeaveHint.
return LaunchCause.RECENTS_OR_BACK;
}
/**
* Called when Chrome receives a new Intent (including both when Chrome is launched, or
* resumed, through an intent). The Intent may be any Intent, including MAIN, VIEW, and
* arbitrary explicit intents targeting Chrome.
*/
public void onReceivedIntent() {
mPerLaunchState.mOtherChromeActivityLastFocused =
sLastResumedActivity != mActivity && sLastResumedActivity instanceof ChromeActivity;
mPerLaunchState.mReceivedIntent = true;
}
/** See {@link Activity#onUserLeaveHint()} */
public void onUserLeaveHint() {
mBetweenLaunchState.mReceivedLeaveHint = true;
}
/** Called when the Activity is launched from Android Recets (aka App Overview) */
public void onLaunchFromRecents() {
mPerLaunchState.mLaunchedFromRecents = true;
}
@VisibleForTesting
protected boolean isDisplayOff(Activity activity) {
final Display display = DisplayAndroidManager.getDefaultDisplayForContext(activity);
return display.getState() != Display.STATE_ON;
}
public static void resetForTests() {
ThreadUtils.assertOnUiThread();
sRecordedLaunchCause = false;
if (sAppActivityListener != null) {
ApplicationStatus.unregisterActivityStateListener(sAppActivityListener);
sAppActivityListener = null;
}
sLastResumedActivity = null;
}
@CheckDiscard("")
private static void logLaunchCause(@LaunchCause int cause) {
String launchCause = "";
switch (cause) {
case LaunchCause.OTHER:
launchCause = "OTHER";
break;
case LaunchCause.CUSTOM_TAB:
launchCause = "CUSTOM_TAB";
break;
case LaunchCause.TWA:
launchCause = "TWA";
break;
case LaunchCause.RECENTS:
launchCause = "RECENTS";
break;
case LaunchCause.RECENTS_OR_BACK:
launchCause = "RECENTS_OR_BACK";
break;
case LaunchCause.FOREGROUND_WHEN_LOCKED:
launchCause = "FOREGROUND_WHEN_LOCKED";
break;
case LaunchCause.MAIN_LAUNCHER_ICON:
launchCause = "MAIN_LAUNCHER_ICON";
break;
case LaunchCause.MAIN_LAUNCHER_ICON_SHORTCUT:
launchCause = "MAIN_LAUNCHER_ICON_SHORTCUT";
break;
case LaunchCause.HOME_SCREEN_WIDGET:
launchCause = "HOME_SCREEN_WIDGET";
break;
case LaunchCause.OPEN_IN_BROWSER_FROM_MENU:
launchCause = "OPEN_IN_BROWSER_FROM_MENU";
break;
case LaunchCause.EXTERNAL_SEARCH_ACTION_INTENT:
launchCause = "EXTERNAL_SEARCH_ACTION_INTENT";
break;
case LaunchCause.NOTIFICATION:
launchCause = "NOTIFICATION";
break;
case LaunchCause.EXTERNAL_VIEW_INTENT:
launchCause = "EXTERNAL_VIEW_INTENT";
break;
case LaunchCause.OTHER_CHROME:
launchCause = "OTHER_CHROME";
break;
case LaunchCause.WEBAPK_CHROME_DISTRIBUTOR:
launchCause = "WEBAPK_CHROME_DISTRIBUTOR";
break;
case LaunchCause.WEBAPK_OTHER_DISTRIBUTOR:
launchCause = "WEBAPK_OTHER_DISTRIBUTOR";
break;
case LaunchCause.HOME_SCREEN_SHORTCUT:
launchCause = "HOME_SCREEN_SHORTCUT";
break;
}
Log.d(TAG, "Launch Cause: " + launchCause);
}
}