chromium/android_webview/tools/system_webview_shell/apk/src/org/chromium/webview_shell/WebViewBrowserActivity.java

// Copyright 2015 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.webview_shell;

import android.app.Activity;
import android.app.AlertDialog;
import android.app.UiModeManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.os.Build;
import android.os.Bundle;
import android.os.StrictMode;
import android.print.PrintAttributes;
import android.print.PrintDocumentAdapter;
import android.print.PrintManager;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup.LayoutParams;
import android.webkit.CookieManager;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.widget.Toast;

import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.appcompat.widget.Toolbar;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.webkit.TracingConfig;
import androidx.webkit.TracingController;
import androidx.webkit.WebSettingsCompat;
import androidx.webkit.WebViewCompat;
import androidx.webkit.WebViewFeature;

import org.chromium.base.BuildInfo;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.PackageManagerUtils;
import org.chromium.base.StrictModeContext;

import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.concurrent.Executors;

/**
 * This activity is designed for starting a "mini-browser" for manual testing of WebView.
 * It takes an optional URL as an argument, and displays the page. There is a URL bar
 * on top of the webview for manually specifying URLs to load.
 */
public class WebViewBrowserActivity extends AppCompatActivity {
    private static final String TAG = "WebViewShell";

    private WebViewBrowserFragment mFragment;
    private String mWebViewVersion;
    private boolean mEnableTracing;
    private boolean mIsStoppingTracing;
    private WebView mWebView;

    // This set of models will always bypass strict mode.
    // Google pre-release hardware models do not belong here.
    private static final HashSet<String> STRICT_MODE_BYPASS_MODELS =
            new HashSet<>(
                    Arrays.asList(
                            "humuhumu titan" // See https://crbug.com/1090841#c76
                            ));

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        EdgeToEdge.enable(this);
        super.onCreate(savedInstanceState);
        ContextUtils.initApplicationContext(getApplicationContext());

        setupEdgeToEdge();
        setContentView(R.layout.activity_webview_browser);
        setSupportActionBar((Toolbar) findViewById(R.id.browser_toolbar));
        mWebViewVersion = WebViewCompat.getCurrentWebViewPackage(this).versionName;
        getSupportActionBar().setTitle(getResources().getString(R.string.title_activity_browser));
        getSupportActionBar().setSubtitle(mWebViewVersion);

        mFragment =
                (WebViewBrowserFragment)
                        getSupportFragmentManager().findFragmentById(R.id.container);
        assert mFragment != null;
        mFragment.setActivityResultRegistry(getActivityResultRegistry());
        enableStrictMode();
    }

    @Override
    protected void onResume() {
        super.onResume();
        mWebView = mFragment.getWebView();
    }

    @Override
    public void onBackPressed() {
        if (mWebView != null && mWebView.canGoBack()) {
            mWebView.goBack();
        } else {
            super.onBackPressed();
        }
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.main_menu, menu);
        if (!WebViewFeature.isFeatureSupported(WebViewFeature.TRACING_CONTROLLER_BASIC_USAGE)) {
            menu.findItem(R.id.menu_enable_tracing).setEnabled(false);
        }
        if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)
                || BuildInfo.targetsAtLeastT()) {
            menu.findItem(R.id.menu_force_dark_off).setEnabled(false);
            menu.findItem(R.id.menu_force_dark_auto).setEnabled(false);
            menu.findItem(R.id.menu_force_dark_on).setEnabled(false);
        }
        if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) {
            menu.findItem(R.id.menu_multi_profile).setEnabled(false);
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            menu.findItem(R.id.menu_night_mode_on).setEnabled(false);
        }
        if (!BuildInfo.targetsAtLeastT()
                || !WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
            menu.findItem(R.id.menu_algorithmic_darkening_on).setEnabled(false);
        }
        return super.onCreateOptionsMenu(menu);
    }

    @Override
    public boolean onPrepareOptionsMenu(Menu menu) {
        if (WebViewFeature.isFeatureSupported(WebViewFeature.TRACING_CONTROLLER_BASIC_USAGE)
                && !mIsStoppingTracing) {
            menu.findItem(R.id.menu_enable_tracing).setEnabled(true);
            menu.findItem(R.id.menu_enable_tracing).setChecked(mEnableTracing);
        } else {
            menu.findItem(R.id.menu_enable_tracing).setEnabled(false);
        }
        if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)
                && !BuildInfo.targetsAtLeastT()) {
            int forceDarkState = WebSettingsCompat.getForceDark(mWebView.getSettings());
            switch (forceDarkState) {
                case WebSettingsCompat.FORCE_DARK_OFF:
                    menu.findItem(R.id.menu_force_dark_off).setChecked(true);
                    break;
                case WebSettingsCompat.FORCE_DARK_AUTO:
                    menu.findItem(R.id.menu_force_dark_auto).setChecked(true);
                    break;
                case WebSettingsCompat.FORCE_DARK_ON:
                    menu.findItem(R.id.menu_force_dark_on).setChecked(true);
                    break;
            }
        }

        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            boolean checked =
                    AppCompatDelegate.MODE_NIGHT_YES == AppCompatDelegate.getDefaultNightMode();
            int defaultNightMode = AppCompatDelegate.getDefaultNightMode();
            if (defaultNightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
                    || defaultNightMode == AppCompatDelegate.MODE_NIGHT_UNSPECIFIED) {
                UiModeManager uiModeManager =
                        (UiModeManager)
                                this.getApplicationContext().getSystemService(UI_MODE_SERVICE);
                checked = UiModeManager.MODE_NIGHT_YES == uiModeManager.getNightMode();
            }
            menu.findItem(R.id.menu_night_mode_on).setChecked(checked);
        }
        if (BuildInfo.targetsAtLeastT()
                && WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
            menu.findItem(R.id.menu_algorithmic_darkening_on)
                    .setChecked(
                            WebSettingsCompat.isAlgorithmicDarkeningAllowed(
                                    mWebView.getSettings()));
        }

        menu.findItem(R.id.menu_enable_third_party_cookies).setEnabled(mWebView != null);
        if (mWebView != null) {
            menu.findItem(R.id.menu_enable_third_party_cookies)
                    .setChecked(CookieManager.getInstance().acceptThirdPartyCookies(mWebView));
        }
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        int itemId = item.getItemId();
        if (itemId == R.id.menu_reload_webview) {
            if (mWebView != null) mWebView.reload();
        } else if (itemId == R.id.menu_reset_webview) {
            mFragment.resetWebView();
            mWebView = mFragment.getWebView();
            return true;
        } else if (itemId == R.id.menu_clear_cache) {
            if (mWebView != null) {
                mWebView.clearCache(true);
            }
            return true;
        } else if (itemId == R.id.menu_get_cookie) {
            String url = mWebView.getUrl();
            if (url != null) {
                String cookie = CookieManager.getInstance().getCookie(url);
                Log.w(TAG, "GetCookie: " + cookie);
            } else {
                Toast.makeText(this, "Error: Url is not set", Toast.LENGTH_SHORT).show();
            }
            return true;
        } else if (itemId == R.id.menu_enable_tracing) {
            // This menu item is disabled when mIsStoppingTracing is true, but this
            // is only updated if the menu is closed and reopened. This check is for when
            // this menu item is triggered multiple times while the menu is open which
            // can cause tracing to start when it is already started and throw an error.
            if (!mIsStoppingTracing) {
                mEnableTracing = !mEnableTracing;
                item.setChecked(mEnableTracing);

                TracingController tracingController = TracingController.getInstance();
                if (mEnableTracing) {
                    tracingController.start(
                            new TracingConfig.Builder()
                                    .addCategories(TracingConfig.CATEGORIES_WEB_DEVELOPER)
                                    .setTracingMode(TracingConfig.RECORD_CONTINUOUSLY)
                                    .build());
                } else {
                    try (StrictModeContext ignored = StrictModeContext.allowDiskWrites()) {
                        String outFileName = getFilesDir() + "/webview_tracing.json";
                        try {
                            tracingController.stop(
                                    new TracingLogger(outFileName, this),
                                    Executors.newSingleThreadExecutor());
                            mIsStoppingTracing = true;
                        } catch (FileNotFoundException e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
            }
            return true;
        } else if (itemId == R.id.menu_force_dark_off) {
            WebSettingsCompat.setForceDark(
                    mWebView.getSettings(), WebSettingsCompat.FORCE_DARK_OFF);
            item.setChecked(true);
            return true;
        } else if (itemId == R.id.menu_force_dark_auto) {
            WebSettingsCompat.setForceDark(
                    mWebView.getSettings(), WebSettingsCompat.FORCE_DARK_AUTO);
            item.setChecked(true);
            return true;
        } else if (itemId == R.id.menu_force_dark_on) {
            WebSettingsCompat.setForceDark(mWebView.getSettings(), WebSettingsCompat.FORCE_DARK_ON);
            item.setChecked(true);
            return true;
        } else if (itemId == R.id.menu_night_mode_on) {
            AppCompatDelegate.setDefaultNightMode(
                    item.isChecked()
                            ? AppCompatDelegate.MODE_NIGHT_NO
                            : AppCompatDelegate.MODE_NIGHT_YES);
            return true;
        } else if (itemId == R.id.menu_algorithmic_darkening_on) {
            if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
                WebSettingsCompat.setAlgorithmicDarkeningAllowed(
                        mWebView.getSettings(),
                        !WebSettingsCompat.isAlgorithmicDarkeningAllowed(mWebView.getSettings()));
            }
            return true;
        } else if (itemId == R.id.menu_enable_third_party_cookies) {
            if (mWebView != null) {
                boolean enable = !item.isChecked();
                CookieManager.getInstance().setAcceptThirdPartyCookies(mWebView, enable);
                item.setChecked(enable);
                mWebView.reload(); // Reload to apply the settings.
            }
        } else if (itemId == R.id.menu_multi_profile) {
            startActivity(new Intent(this, WebViewMultiProfileBrowserActivity.class));
            return true;
        } else if (itemId == R.id.start_animation_activity) {
            startActivity(new Intent(this, WebViewAnimationTestActivity.class));
            return true;
        } else if (itemId == R.id.menu_print) {
            PrintManager printManager = (PrintManager) getSystemService(Context.PRINT_SERVICE);
            String jobName = "WebViewShell document";
            PrintDocumentAdapter printAdapter = mWebView.createPrintDocumentAdapter(jobName);
            printManager.print(jobName, printAdapter, new PrintAttributes.Builder().build());
            return true;
        } else if (itemId == R.id.menu_about) {
            about();
            mFragment.hideKeyboard();
            return true;
        } else if (itemId == R.id.menu_devui) {
            launchWebViewDevUI();
            return true;
        } else if (itemId == R.id.menu_hide) {
            if (mWebView.getVisibility() == View.VISIBLE) {
                mWebView.setVisibility(View.INVISIBLE);
            } else {
                mWebView.setVisibility(View.VISIBLE);
            }
            return true;
        }
        return super.onOptionsItemSelected(item);
    }

    private void about() {
        WebSettings settings = mWebView.getSettings();
        StringBuilder summary = new StringBuilder();
        summary.append("WebView version : " + mWebViewVersion + "\n");

        for (Method method : settings.getClass().getMethods()) {
            if (!methodIsSimpleInspector(method)) {
                continue;
            }
            try {
                summary.append(method.getName() + " : " + method.invoke(settings) + "\n");
            } catch (IllegalAccessException e) {
            } catch (InvocationTargetException e) {
            }
        }

        AlertDialog dialog =
                new AlertDialog.Builder(this)
                        .setTitle(getResources().getString(R.string.menu_about))
                        .setMessage(summary)
                        .setPositiveButton("OK", null)
                        .create();
        dialog.show();
        dialog.getWindow().setLayout(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
    }

    // Returns true is a method has no arguments and returns either a boolean or a String.
    private boolean methodIsSimpleInspector(Method method) {
        Class<?> returnType = method.getReturnType();
        return ((returnType.equals(boolean.class) || returnType.equals(String.class))
                && method.getParameterTypes().length == 0);
    }

    private void launchWebViewDevUI() {
        PackageInfo currentWebViewPackage = WebViewCompat.getCurrentWebViewPackage(this);
        if (currentWebViewPackage == null) {
            Log.e(TAG, "Couldn't find current WebView package");
            Toast.makeText(this, "WebView package isn't found", Toast.LENGTH_LONG).show();
            return;
        }
        String currentWebViewPackageName = currentWebViewPackage.packageName;
        Intent intent = new Intent("com.android.webview.SHOW_DEV_UI");
        intent.setPackage(currentWebViewPackageName);

        // Check if the intent is resolved, i.e current WebView package has a developer UI that
        // responds to "com.android.webview.SHOW_DEV_UI" action.
        if (PackageManagerUtils.canResolveActivity(intent)) {
            startActivity(intent);
        } else {
            Log.e(
                    TAG,
                    "Couldn't launch developer UI from current WebView package: "
                            + currentWebViewPackage);
            Toast.makeText(this, "No DevTools in " + currentWebViewPackageName, Toast.LENGTH_LONG)
                    .show();
        }
    }

    /**
     * Enables StrictMode to catch as much as reasonable. This selectively disables some StrictMode
     * policies for some devices, as some manufacturers modify the Android framework in such a way
     * as to unavoidably violate StrictMode (ex. the platform code which opens the 3-dots menu is
     * not controlled by WebView or by WebView shell browser).
     */
    private static void enableStrictMode() {
        String manufacturer = Build.MANUFACTURER.toLowerCase(Locale.US);
        String model = Build.MODEL.toLowerCase(Locale.US);

        StrictMode.ThreadPolicy.Builder threadPolicyBuilder =
                new StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().penaltyDeath();

        if (!manufacturer.equalsIgnoreCase("google") || STRICT_MODE_BYPASS_MODELS.contains(model)) {
            threadPolicyBuilder.permitDiskReads();
            threadPolicyBuilder.permitDiskWrites();
        }

        StrictMode.setThreadPolicy(threadPolicyBuilder.build());

        // Omissions:
        // * detectCleartextNetwork() to permit testing http:// URLs
        // * detectFileUriExposure() to permit testing file:// URLs
        // * detectLeakedClosableObjects() because of drag and drop (https://crbug.com/1090841#c40)
        StrictMode.VmPolicy.Builder builder = new StrictMode.VmPolicy.Builder();
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            // WebViewBrowserActivity will have two instances when switching night mode back and
            // forth for the 3rd times. Don't know the reason, this probably needs the investigation
            // to rule out WebView holding the instance. (crbug.com/1348615)
            builder = builder.detectActivityLeaks();
        }
        StrictMode.setVmPolicy(
                builder.detectLeakedRegistrationObjects()
                        .detectLeakedSqlLiteObjects()
                        .penaltyLog()
                        .penaltyDeath()
                        .build());
    }

    private class TracingLogger extends FileOutputStream {
        private long mByteCount;
        private long mChunkCount;
        private final Activity mActivity;

        public TracingLogger(String fileName, Activity activity) throws FileNotFoundException {
            super(fileName);
            mActivity = activity;
        }

        @Override
        public void write(byte[] chunk) throws IOException {
            mByteCount += chunk.length;
            mChunkCount++;
            super.write(chunk);
        }

        @Override
        public void close() throws IOException {
            super.close();
            showDialog(mByteCount);
            mIsStoppingTracing = false;
        }

        private void showDialog(long nbBytes) {
            StringBuilder info = new StringBuilder();
            info.append("Tracing data written to file\n");
            info.append("number of bytes: " + nbBytes);

            mActivity.runOnUiThread(
                    new Runnable() {
                        @Override
                        public void run() {
                            AlertDialog dialog =
                                    new AlertDialog.Builder(mActivity)
                                            .setTitle("Tracing API")
                                            .setMessage(info)
                                            .setNeutralButton(" OK ", null)
                                            .create();
                            dialog.show();
                        }
                    });
        }
    }

    private void setupEdgeToEdge() {
        ViewCompat.setOnApplyWindowInsetsListener(
                findViewById(android.R.id.content),
                (v, windowInsets) -> {
                    Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
                    // Apply the insets paddings to the view.
                    v.setPadding(insets.left, insets.top, insets.right, insets.bottom);

                    // Return CONSUMED to indicate we have handled the insets for this view
                    // and don't want them to be passed down to descendant views.
                    return WindowInsetsCompat.CONSUMED;
                });
    }
}