chromium/chrome/android/java/src/org/chromium/chrome/browser/fonts/FontPreloader.java

// Copyright 2021 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.fonts;

import android.content.Context;
import android.graphics.Typeface;
import android.os.SystemClock;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.res.ResourcesCompat;

import org.chromium.base.ThreadUtils.ThreadChecker;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.flags.ChromeFeatureList;

/**
 * Class to load downloadable fonts async and emit histograms related to the availability of these
 * fonts. It should be used by calling {@link FontPreloader#load) early in the app start-up, e.g.
 * {@link Application#onCreate}. The {@link Activity}s should also call the #on* methods to notify
 * this class of the events as they are used to record metrics.
 */
public class FontPreloader {
    private static FontPreloader sInstance;

    private static final Integer[] FONTS = {
        R.font.chrome_google_sans, R.font.chrome_google_sans_medium, R.font.chrome_google_sans_bold
    };

    private static final Integer[] FONTS_V2 = {
        R.font.chrome_google_sans,
        R.font.chrome_google_sans_medium,
        R.font.chrome_google_sans_bold,
        R.font.chrome_google_sans_text,
        R.font.chrome_google_sans_text_medium
    };

    private static final String UMA_PREFIX = "Android.Fonts";

    private static final String UMA_FONTS_RETRIEVED_BEFORE_INFLATION =
            "TimeDownloadableFontsRetrievedBeforePostInflationStartup";
    private static final String UMA_FONTS_RETRIEVED_AFTER_ON_CREATE =
            "TimeToRetrieveDownloadableFontsAfterOnCreate";
    private static final String UMA_FONTS_RETRIEVED_AFTER_INFLATION =
            "TimeDownloadableFontsRetrievedAfterPostInflationStartup";

    private static final String UMA_FONTS_RETRIEVED_BEFORE_FIRST_DRAW =
            "TimeDownloadableFontsRetrievedBeforeFirstDraw";
    private static final String UMA_FONTS_RETRIEVED_AFTER_FIRST_DRAW =
            "TimeDownloadableFontsRetrievedAfterFirstDraw";

    private static final String UMA_FRE = "FirstRunActivity";
    private static final String UMA_TABBED_ACTIVITY = "ChromeTabbedActivity";
    private static final String UMA_CUSTOM_TAB_ACTIVITY = "CustomTabActivity";

    private final ThreadChecker mThreadChecker = new ThreadChecker();

    private final Integer[] mFonts;
    private boolean mInitialized;
    // Time of first event between |#onAllFontsRetrieved()| and |#onPostInflationStartup*()|.
    private Long mTimeOfFirstEventForPostInflation;
    private long mTimeOfLoadCall;
    private String mActivityNameForPostInflation;
    // Time of first event between |#onAllFontsRetrieved()| and |#onFirstDraw*()|.
    private Long mTimeOfFirstEventForFirstDraw;
    private String mActivityNameForFirstDraw;

    @VisibleForTesting
    FontPreloader(Integer[] fonts) {
        mFonts = fonts;
    }

    private FontPreloader() {
        if (ChromeFeatureList.sAndroidGoogleSansText.isEnabled()) {
            mFonts = FONTS_V2;
        } else {
            mFonts = FONTS;
        }
    }

    /**
     * @return The {@link FontPreloader} singleton instance.
     */
    public static FontPreloader getInstance() {
        if (sInstance == null) {
            sInstance = new FontPreloader();
        }
        return sInstance;
    }

    /**
     * Should be called to preload the fonts onCreate. Any subsequent calls will be ignored.
     *
     * @param context A {@link Context} to retrieve the fonts from.
     */
    public void load(Context context) {
        mThreadChecker.assertOnValidThread();
        if (!mInitialized) {
            mInitialized = true;
            context = context.getApplicationContext();
            OnFontCallback callback = new OnFontCallback();
            for (int font : mFonts) {
                ResourcesCompat.getFont(context, font, callback, null);
            }
            mTimeOfLoadCall = SystemClock.elapsedRealtime();
        }
    }

    /** Should be called from FirstRunActivity to notify this class of post-inflation startup. */
    public void onPostInflationStartupFre() {
        mThreadChecker.assertOnValidThread();
        onPostInflationStartup(UMA_FRE);
    }

    /** Should be called from FirstRunActivity to notify this class of the first draw. */
    public void onFirstDrawFre() {
        mThreadChecker.assertOnValidThread();
        onFirstDraw(UMA_FRE);
    }

    /**
     * Should be called from ChromeTabbedActivity to notify this class of post-inflation startup.
     */
    public void onPostInflationStartupTabbedActivity() {
        mThreadChecker.assertOnValidThread();
        onPostInflationStartup(UMA_TABBED_ACTIVITY);
    }

    /**
     * Should be called from ChromeTabbedActivity to notify this class of the first draw. The first
     * draw of ChromeTabbedActivity may be blocked by AppLaunchDrawBlocker, so the caller should
     * account for that.
     */
    public void onFirstDrawTabbedActivity() {
        mThreadChecker.assertOnValidThread();
        onFirstDraw(UMA_TABBED_ACTIVITY);
    }

    /** Should be called from CustomTabActivity to notify this class of post-inflation startup. */
    public void onPostInflationStartupCustomTabActivity() {
        mThreadChecker.assertOnValidThread();
        onPostInflationStartup(UMA_CUSTOM_TAB_ACTIVITY);
    }

    /** Should be called from CustomTabActivity to notify this class of the first draw. */
    public void onFirstDrawCustomTabActivity() {
        mThreadChecker.assertOnValidThread();
        onFirstDraw(UMA_CUSTOM_TAB_ACTIVITY);
    }

    private void onPostInflationStartup(String activityName) {
        // Multiple activities will notify us when they are post inflation, but only the first one
        // matters. It is the one we're racing against to load fonts before they're needed.
        if (mActivityNameForPostInflation != null) return;
        mActivityNameForPostInflation = activityName;

        final long time = SystemClock.elapsedRealtime();
        if (mTimeOfFirstEventForPostInflation == null) {
            mTimeOfFirstEventForPostInflation = time;
        } else {
            RecordHistogram.recordTimesHistogram(
                    String.format(
                            "%s.%s.%s",
                            UMA_PREFIX, UMA_FONTS_RETRIEVED_BEFORE_INFLATION, activityName),
                    time - mTimeOfFirstEventForPostInflation);
        }
    }

    private void onFirstDraw(String activityName) {
        // Multiple activities will notify us when they do their first draw, but only the first one
        // matters.
        if (mActivityNameForFirstDraw != null) return;
        mActivityNameForFirstDraw = activityName;

        long time = SystemClock.elapsedRealtime();
        if (mTimeOfFirstEventForFirstDraw == null) {
            mTimeOfFirstEventForFirstDraw = time;
        } else {
            RecordHistogram.recordTimesHistogram(
                    String.format(
                            "%s.%s.%s",
                            UMA_PREFIX,
                            UMA_FONTS_RETRIEVED_BEFORE_FIRST_DRAW,
                            mActivityNameForFirstDraw),
                    time - mTimeOfFirstEventForFirstDraw);
            // Also record one without the activity name for aggregation across all activities.
            RecordHistogram.recordTimesHistogram(
                    String.format("%s.%s", UMA_PREFIX, UMA_FONTS_RETRIEVED_BEFORE_FIRST_DRAW),
                    time - mTimeOfFirstEventForFirstDraw);
        }
    }

    private void onAllFontsRetrieved() {
        final long time = SystemClock.elapsedRealtime();
        RecordHistogram.recordTimesHistogram(
                String.format("%s.%s", UMA_PREFIX, UMA_FONTS_RETRIEVED_AFTER_ON_CREATE),
                time - mTimeOfLoadCall);

        if (mTimeOfFirstEventForPostInflation == null) {
            mTimeOfFirstEventForPostInflation = time;
        } else {
            RecordHistogram.recordTimesHistogram(
                    String.format(
                            "%s.%s.%s",
                            UMA_PREFIX,
                            UMA_FONTS_RETRIEVED_AFTER_INFLATION,
                            mActivityNameForPostInflation),
                    time - mTimeOfFirstEventForPostInflation);
        }

        if (mTimeOfFirstEventForFirstDraw == null) {
            mTimeOfFirstEventForFirstDraw = time;
        } else {
            RecordHistogram.recordTimesHistogram(
                    String.format(
                            "%s.%s.%s",
                            UMA_PREFIX,
                            UMA_FONTS_RETRIEVED_AFTER_FIRST_DRAW,
                            mActivityNameForFirstDraw),
                    time - mTimeOfFirstEventForFirstDraw);
            // Also record one without the activity name for aggregation across all activities.
            RecordHistogram.recordTimesHistogram(
                    String.format("%s.%s", UMA_PREFIX, UMA_FONTS_RETRIEVED_AFTER_FIRST_DRAW),
                    time - mTimeOfFirstEventForFirstDraw);
        }
    }

    private class OnFontCallback extends ResourcesCompat.FontCallback {
        private int mNumberOfFontsRetrieved;

        @Override
        public void onFontRetrieved(@NonNull Typeface typeface) {
            mThreadChecker.assertOnValidThread();
            if (++mNumberOfFontsRetrieved == mFonts.length) {
                onAllFontsRetrieved();
            }
        }

        @Override
        public void onFontRetrievalFailed(int reason) {}
    }
}