chromium/content/public/android/java/src/org/chromium/content/browser/TracingControllerAndroidImpl.java

// Copyright 2013 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.content.browser;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Environment;
import android.text.TextUtils;
import android.util.Pair;

import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;

import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.StrictModeContext;
import org.chromium.content.R;
import org.chromium.content_public.browser.TracingControllerAndroid;
import org.chromium.ui.widget.Toast;

import java.io.File;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;

/**
 * Controller for Chrome's tracing feature.
 *
 * We don't have any UI per se. Just call startTracing() to start and
 * stopTracing() to stop. We'll report progress to the user with Toasts.
 *
 * If the host application registers this class's BroadcastReceiver, you can
 * also start and stop the tracer with a broadcast intent, as follows:
 * <ul>
 * <li>To start tracing: am broadcast -a org.chromium.content_shell_apk.GPU_PROFILER_START
 * <li>Add "-e file /foo/bar/xyzzy" to log trace data to a specific file.
 * <li>To stop tracing: am broadcast -a org.chromium.content_shell_apk.GPU_PROFILER_STOP
 * </ul>
 * Note that the name of these intents change depending on which application
 * is being traced, but the general form is [app package name].GPU_PROFILER_{START,STOP}.
 */
@JNINamespace("content")
public class TracingControllerAndroidImpl implements TracingControllerAndroid {
    private static final String TAG = "TracingController";

    private static final String ACTION_START = "GPU_PROFILER_START";
    private static final String ACTION_STOP = "GPU_PROFILER_STOP";
    private static final String ACTION_LIST_CATEGORIES = "GPU_PROFILER_LIST_CATEGORIES";
    private static final String FILE_EXTRA = "file";
    private static final String CATEGORIES_EXTRA = "categories";
    private static final String RECORD_CONTINUOUSLY_EXTRA = "continuous";
    private static final String DEFAULT_CHROME_CATEGORIES_PLACE_HOLDER =
            "_DEFAULT_CHROME_CATEGORIES";

    // These strings must match the ones expected by adb_profile_chrome.
    private static final String PROFILER_STARTED_FMT = "Profiler started: %s";
    private static final String PROFILER_FINISHED_FMT = "Profiler finished. Results are in %s.";

    private final Context mContext;
    private final TracingBroadcastReceiver mBroadcastReceiver;
    private final TracingIntentFilter mIntentFilter;
    private boolean mIsTracing;

    // We might not want to always show toasts when we start the profiler, especially if
    // showing the toast impacts performance.  This gives us the chance to disable them.
    private boolean mShowToasts = true;

    private String mFilename;
    private boolean mCompressFile;
    private boolean mUseProtobuf;

    public TracingControllerAndroidImpl(Context context) {
        mContext = context;
        mBroadcastReceiver = new TracingBroadcastReceiver();
        mIntentFilter = new TracingIntentFilter(context);
    }

    /** Get a BroadcastReceiver that can handle profiler intents. */
    public BroadcastReceiver getBroadcastReceiver() {
        return mBroadcastReceiver;
    }

    /** Get an IntentFilter for profiler intents. */
    public IntentFilter getIntentFilter() {
        return mIntentFilter;
    }

    /** Register a BroadcastReceiver in the given context. */
    public void registerReceiver(Context context) {
        ContextUtils.registerExportedBroadcastReceiver(
                context, getBroadcastReceiver(), getIntentFilter(), null);
    }

    /**
     * Unregister the GPU BroadcastReceiver in the given context.
     * @param context
     */
    public void unregisterReceiver(Context context) {
        context.unregisterReceiver(getBroadcastReceiver());
    }

    @Override
    public boolean isTracing() {
        return mIsTracing;
    }

    @Override
    public String getOutputPath() {
        return mFilename;
    }

    /**
     * Generates a filepath to be used for tracing in the Downloads directory.
     * @param basename The basename to be used, if empty a unique one will be generated.
     */
    @CalledByNative
    private static String generateTracingFilePath(String basename) {
        try (StrictModeContext ignored = StrictModeContext.allowDiskWrites()) {
            String state = Environment.getExternalStorageState();
            if (!Environment.MEDIA_MOUNTED.equals(state)) {
                return null;
            }

            if (basename.isEmpty()) {
                // Generate a hopefully-unique filename using the UTC timestamp.
                // (Not a huge problem if it isn't unique, we'll just append more data.)
                SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.US);
                formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
                basename = "chrome-profile-results-" + formatter.format(new Date());
            }

            Context context = ContextUtils.getApplicationContext();
            File dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
            File file = new File(dir, basename);
            return file.getPath();
        }
    }

    /**
     * Start profiling to a new file in the Downloads directory.
     *
     * Calls #startTracing(String, boolean, String, String, boolean) with a new timestamped
     * filename. Doesn't compress the file or generate protobuf trace data.
     *
     * @see #startTracing(String, boolean, String, String, boolean, boolean)
     */
    public boolean startTracing(boolean showToasts, String categories, String traceOptions) {
        return startTracing(
                null,
                showToasts,
                categories,
                traceOptions,
                /* compressFile= */ false,
                /* useProtobuf= */ false);
    }

    private void initializeNativeControllerIfNeeded() {
        if (mNativeTracingControllerAndroid == 0) {
            mNativeTracingControllerAndroid =
                    TracingControllerAndroidImplJni.get().init(TracingControllerAndroidImpl.this);
        }
    }

    @Override
    public boolean startTracing(
            String filename,
            boolean showToasts,
            String categories,
            String traceOptions,
            boolean compressFile,
            boolean useProtobuf) {
        mShowToasts = showToasts;

        if (filename == null) {
            filename = generateTracingFilePath("");
            if (filename == null) {
                logAndToastError(mContext.getString(R.string.profiler_no_storage_toast));
                return false;
            }
        }

        if (isTracing()) {
            // Don't need a toast because this shouldn't happen via the UI.
            Log.e(TAG, "Received startTracing, but we're already tracing");
            return false;
        }

        // Lazy initialize the native side, to allow construction before the library is loaded.
        initializeNativeControllerIfNeeded();
        if (!TracingControllerAndroidImplJni.get()
                .startTracing(
                        mNativeTracingControllerAndroid,
                        TracingControllerAndroidImpl.this,
                        categories,
                        traceOptions,
                        useProtobuf)) {
            logAndToastError(mContext.getString(R.string.profiler_error_toast));
            return false;
        }

        logForProfiler(String.format(PROFILER_STARTED_FMT, categories));
        showToast(mContext.getString(R.string.profiler_started_toast) + ": " + categories);
        mFilename = filename;
        mCompressFile = compressFile;
        mUseProtobuf = useProtobuf;
        mIsTracing = true;
        return true;
    }

    @Override
    public void stopTracing(Callback<Void> callback) {
        if (isTracing()) {
            TracingControllerAndroidImplJni.get()
                    .stopTracing(
                            mNativeTracingControllerAndroid,
                            TracingControllerAndroidImpl.this,
                            mFilename,
                            mCompressFile,
                            mUseProtobuf,
                            callback);
        }
    }

    /** Called by native code when the profiler's output file is closed. */
    @CalledByNative
    @SuppressWarnings("unchecked")
    protected void onTracingStopped(Object callback) {
        if (!isTracing()) {
            // Don't need a toast because this shouldn't happen via the UI.
            Log.e(TAG, "Received onTracingStopped, but we aren't tracing");
            return;
        }

        logForProfiler(String.format(PROFILER_FINISHED_FMT, mFilename));
        showToast(mContext.getString(R.string.profiler_stopped_toast, mFilename));
        mIsTracing = false;
        mFilename = null;
        mCompressFile = false;

        if (callback != null) ((Callback<Void>) callback).onResult(null);
    }

    /** Get known categories and log them for the profiler. */
    public void getKnownCategories() {
        if (!getKnownCategories(null)) {
            Log.e(TAG, "Unable to fetch tracing category list.");
        }
    }

    @Override
    public boolean getKnownCategories(Callback<String[]> callback) {
        // Lazy initialize the native side, to allow construction before the library is loaded.
        initializeNativeControllerIfNeeded();
        return TracingControllerAndroidImplJni.get()
                .getKnownCategoriesAsync(
                        mNativeTracingControllerAndroid,
                        TracingControllerAndroidImpl.this,
                        callback);
    }

    /**
     * Called by native when the categories requested by getKnownCategories were obtained.
     *
     * @param categories The set of category names.
     * @param callback The callback that was provided to
     *         TracingControllerAndroidImplJni.get().getKnownCategoriesAsync.
     */
    @CalledByNative
    @SuppressWarnings("unchecked")
    public void onKnownCategoriesReceived(String[] categories, Object callback) {
        if (callback != null) {
            ((Callback<String[]>) callback).onResult(categories);
        }
    }

    @Override
    public boolean getTraceBufferUsage(Callback<Pair<Float, Long>> callback) {
        assert callback != null;
        // Lazy initialize the native side, to allow construction before the library is loaded.
        initializeNativeControllerIfNeeded();
        return TracingControllerAndroidImplJni.get()
                .getTraceBufferUsageAsync(
                        mNativeTracingControllerAndroid,
                        TracingControllerAndroidImpl.this,
                        callback);
    }

    @CalledByNative
    @SuppressWarnings("unchecked")
    public void onTraceBufferUsageReceived(
            float percentFull, long approximateEventCount, Object callback) {
        ((Callback<Pair<Float, Long>>) callback)
                .onResult(new Pair<>(percentFull, approximateEventCount));
    }

    @Override
    public void destroy() {
        if (mNativeTracingControllerAndroid != 0) {
            TracingControllerAndroidImplJni.get()
                    .destroy(mNativeTracingControllerAndroid, TracingControllerAndroidImpl.this);
            mNativeTracingControllerAndroid = 0;
        }
    }

    private void logAndToastError(String str) {
        Log.e(TAG, str);
        if (mShowToasts) Toast.makeText(mContext, str, Toast.LENGTH_SHORT).show();
    }

    // The |str| string needs to match the ones that adb_chrome_profiler looks for.
    // TODO(crbug.com/40092856): Replace (users of) this with DevTools' Tracing API.
    private void logForProfiler(String str) {
        Log.i(TAG, str);
    }

    private void showToast(String str) {
        if (mShowToasts) Toast.makeText(mContext, str, Toast.LENGTH_SHORT).show();
    }

    private static class TracingIntentFilter extends IntentFilter {
        TracingIntentFilter(Context context) {
            addAction(context.getPackageName() + "." + ACTION_START);
            addAction(context.getPackageName() + "." + ACTION_STOP);
            addAction(context.getPackageName() + "." + ACTION_LIST_CATEGORIES);
        }
    }

    // TODO(crbug.com/40092856): Replace (users of) this with DevTools' Tracing API.
    class TracingBroadcastReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            if (intent.getAction().endsWith(ACTION_START)) {
                String categories = intent.getStringExtra(CATEGORIES_EXTRA);
                if (TextUtils.isEmpty(categories)) {
                    categories =
                            TracingControllerAndroidImplJni.get()
                                    .getDefaultCategories(TracingControllerAndroidImpl.this);
                } else {
                    categories =
                            categories.replaceFirst(
                                    DEFAULT_CHROME_CATEGORIES_PLACE_HOLDER,
                                    TracingControllerAndroidImplJni.get()
                                            .getDefaultCategories(
                                                    TracingControllerAndroidImpl.this));
                }
                String traceOptions =
                        intent.getStringExtra(RECORD_CONTINUOUSLY_EXTRA) == null
                                ? "record-until-full"
                                : "record-continuously";
                String filename = intent.getStringExtra(FILE_EXTRA);
                if (filename != null) {
                    startTracing(
                            filename,
                            true,
                            categories,
                            traceOptions,
                            /* compressFile= */ false,
                            /* useProtobuf= */ false);
                } else {
                    startTracing(true, categories, traceOptions);
                }
            } else if (intent.getAction().endsWith(ACTION_STOP)) {
                stopTracing(null);
            } else if (intent.getAction().endsWith(ACTION_LIST_CATEGORIES)) {
                getKnownCategories();
            } else {
                Log.e(TAG, "Unexpected intent: %s", intent);
            }
        }
    }

    private long mNativeTracingControllerAndroid;

    @NativeMethods
    interface Natives {
        long init(TracingControllerAndroidImpl caller);

        void destroy(long nativeTracingControllerAndroid, TracingControllerAndroidImpl caller);

        boolean startTracing(
                long nativeTracingControllerAndroid,
                TracingControllerAndroidImpl caller,
                String categories,
                String traceOptions,
                boolean useProtobuf);

        void stopTracing(
                long nativeTracingControllerAndroid,
                TracingControllerAndroidImpl caller,
                String filename,
                boolean compressFile,
                boolean useProtobuf,
                Callback<Void> callback);

        boolean getKnownCategoriesAsync(
                long nativeTracingControllerAndroid,
                TracingControllerAndroidImpl caller,
                Callback<String[]> callback);

        String getDefaultCategories(TracingControllerAndroidImpl caller);

        boolean getTraceBufferUsageAsync(
                long nativeTracingControllerAndroid,
                TracingControllerAndroidImpl caller,
                Callback<Pair<Float, Long>> callback);
    }
}