chromium/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsOpenTimeRecorder.java

// Copyright 2022 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.customtabs;

import android.os.SystemClock;
import android.text.TextUtils;
import android.text.format.DateUtils;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.jni_zero.NativeMethods;

import org.chromium.base.ContextUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
import org.chromium.chrome.browser.customtabs.content.CustomTabActivityNavigationController;
import org.chromium.chrome.browser.customtabs.content.CustomTabActivityNavigationController.FinishReason;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.StartStopWithNativeObserver;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.function.BooleanSupplier;

/**
 * Records how long CCTs are open and the cause of closing. The open time is logged only if CCTs
 * are closed automatically without user intervention.
 * <p>CCTs can be closed automatically when the client app closes the CCT by launching another
 * activity with a flag CLEAR_TOP. We can't detect this exactly, but we use following heuristics:
 *
 *   <li> {@link Activity#onUserLeaveHint()} occurs when user presses home button to put CCT
 *        to background.
 *   <li> {@link Activity#isFinishing()} is _likely_ {@code false} at {@link Activity#onStop}
 *        when user enters Recent screen on Android P/Q/R in system gesture navigation mode.
 *
 * <p>The above signals help eliminate some cases that look like autoclosing but are actually
 * user-intervened ones.
 */
class CustomTabsOpenTimeRecorder implements StartStopWithNativeObserver {
    @VisibleForTesting static final String PACKAGE_NAME_EMPTY_1P = "1p";
    private final CustomTabActivityNavigationController mNavigationController;
    private final BooleanSupplier mIsCctFinishing;
    private final BrowserServicesIntentDataProvider mIntent;

    // Getting the package name from the Intent only works when the client is still connected.
    @Nullable private final String mCachedPackageName;

    private long mOnStartTimestampMs;

    // This should be kept in sync with the definition |CustomTabsCloseCause|
    // in tools/metrics/histograms/enums.xml.
    @IntDef({CloseCause.USER_ACTION_CHROME, CloseCause.USER_ACTION_ANDROID, CloseCause.AUTOCLOSE})
    @Retention(RetentionPolicy.SOURCE)
    public @interface CloseCause {
        int USER_ACTION_CHROME = 0;
        int USER_ACTION_ANDROID = 1;
        int AUTOCLOSE = 2;
        int COUNT = 3;
    }

    private @CloseCause int mCloseCause;

    public CustomTabsOpenTimeRecorder(
            ActivityLifecycleDispatcher lifecycleDispatcher,
            CustomTabActivityNavigationController navigationController,
            BooleanSupplier isCctFinishing,
            BrowserServicesIntentDataProvider intent) {
        lifecycleDispatcher.register(this);
        mNavigationController = navigationController;
        mIsCctFinishing = isCctFinishing;
        mIntent = intent;
        mCachedPackageName = mIntent.getClientPackageName();
    }

    @Override
    public void onStartWithNative() {
        mOnStartTimestampMs = SystemClock.elapsedRealtime();
        mCloseCause = CloseCause.AUTOCLOSE;
    }

    @Override
    public void onStopWithNative() {
        assert mOnStartTimestampMs != 0;
        RecordHistogram.recordEnumeratedHistogram(
                "CustomTabs.CloseCause", mCloseCause, CloseCause.COUNT);

        long duration = SystemClock.elapsedRealtime() - mOnStartTimestampMs;
        // Additional check with |mIsCctFinishing| can eliminate some false positives.
        // See Javadoc for more details.
        if (mCloseCause == CloseCause.AUTOCLOSE && mIsCctFinishing.getAsBoolean()) {
            RecordHistogram.recordLongTimesHistogram(
                    "CustomTabs.AutoclosedSessionDuration", duration);
        }

        if (mIsCctFinishing.getAsBoolean()) {
            long time = System.currentTimeMillis() / DateUtils.SECOND_IN_MILLIS;
            boolean wasUserClose = mCloseCause != CloseCause.AUTOCLOSE;
            boolean isPartial = mIntent.isPartialCustomTab();

            long recordDuration = Math.min(duration / DateUtils.SECOND_IN_MILLIS, 300);
            // For the real implementation, there'll be a native method on this class or a new
            // class entirely. Just for the proof-of-concept I tacked the native method onto another
            // class that already have natives.
            CustomTabsOpenTimeRecorderJni.get()
                    .recordCustomTabSession(
                            time,
                            getPackageName(isPartial),
                            recordDuration,
                            wasUserClose,
                            isPartial);
        }

        mOnStartTimestampMs = 0;
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    String getPackageName(boolean isPartial) {
        boolean isEmpty = TextUtils.isEmpty(mCachedPackageName);
        if (isPartial && isEmpty) {
            // Return a non-empty name for trusted intents.
            if (mIntent.isOpenedByChrome()) {
                return ContextUtils.getApplicationContext().getPackageName();
            }
            if (mIntent.isTrustedIntent()) return PACKAGE_NAME_EMPTY_1P;
        }
        return isEmpty ? "" : mCachedPackageName;
    }

    void updateCloseCause() {
        @FinishReason int finishReason = mNavigationController.getFinishReason();
        if (finishReason == FinishReason.USER_NAVIGATION
                || finishReason == FinishReason.REPARENTING
                || finishReason == FinishReason.OPEN_IN_BROWSER) {
            mCloseCause = CloseCause.USER_ACTION_CHROME;
        }
    }

    void onUserLeaveHint() {
        mCloseCause = CloseCause.USER_ACTION_ANDROID;
    }

    @NativeMethods
    interface Natives {
        void recordCustomTabSession(
                long time,
                String packageName,
                long sessionDuration,
                boolean wasAutomaticallyClosed,
                boolean isPartialCct);
    }
}