blob: cb7b40f98bf8ee712fde2423b178de117c729e5c [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.android_webview.devui;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ListView;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import org.chromium.android_webview.common.DeveloperModeUtils;
import org.chromium.android_webview.common.Flag;
import org.chromium.android_webview.common.ProductionSupportedFlagList;
import org.chromium.android_webview.common.services.IDeveloperUiService;
import org.chromium.android_webview.common.services.ServiceNames;
import org.chromium.base.Log;
import java.util.HashMap;
import java.util.Map;
/**
* A fragment to toggle experimental WebView flags/features.
*/
@SuppressLint("SetTextI18n")
public class FlagsFragment extends DevUiBaseFragment {
private static final String TAG = "WebViewDevTools";
private static final String STATE_DEFAULT = "Default";
private static final String STATE_ENABLED = "Enabled";
private static final String STATE_DISABLED = "Disabled";
private static final String[] sFlagStates = {
STATE_DEFAULT,
STATE_ENABLED,
STATE_DISABLED,
};
private Map<String, Boolean> mOverriddenFlags = new HashMap<>();
private FlagsListAdapter mListAdapter;
private Context mContext;
@Override
public void onAttach(Context context) {
super.onAttach(context);
mContext = context;
}
@Override
public View onCreateView(
LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_flags, null);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
Activity activity = (Activity) mContext;
activity.setTitle("WebView Flags");
ListView flagsListView = view.findViewById(R.id.flags_list);
// Restore flag overrides from the service process to repopulate the UI, if developer mode
// is enabled.
if (DeveloperModeUtils.isDeveloperModeEnabled(mContext.getPackageName())) {
mOverriddenFlags = DeveloperModeUtils.getFlagOverrides(mContext.getPackageName());
}
Flag[] sortedFlags = sortFlagList(ProductionSupportedFlagList.sFlagList);
Flag[] flagsAndWarningText = new Flag[ProductionSupportedFlagList.sFlagList.length + 1];
flagsAndWarningText[0] = null; // the first entry is the warning text
for (int i = 0; i < ProductionSupportedFlagList.sFlagList.length; i++) {
flagsAndWarningText[i + 1] = sortedFlags[i];
}
mListAdapter = new FlagsListAdapter(flagsAndWarningText);
flagsListView.setAdapter(mListAdapter);
Button resetFlagsButton = view.findViewById(R.id.reset_flags_button);
resetFlagsButton.setOnClickListener((View flagButton) -> { resetAllFlags(); });
}
/**
* Sorts the flag list so enabled/disabled flags are at the beginning and default flags are at
* the end.
*/
private Flag[] sortFlagList(Flag[] unsorted) {
Flag[] sortedFlags = new Flag[unsorted.length];
int i = 0;
for (Flag flag : unsorted) {
if (mOverriddenFlags.containsKey(flag.getName())) {
sortedFlags[i++] = flag;
}
}
for (Flag flag : unsorted) {
if (!mOverriddenFlags.containsKey(flag.getName())) {
sortedFlags[i++] = flag;
}
}
assert sortedFlags.length == unsorted.length : "arrays should be same length";
return sortedFlags;
}
private static int booleanToState(Boolean b) {
if (b == null) {
return /* STATE_DEFAULT */ 0;
} else if (b) {
return /* STATE_ENABLED */ 1;
}
return /* STATE_DISABLED */ 2;
}
private class FlagStateSpinnerSelectedListener implements AdapterView.OnItemSelectedListener {
private Flag mFlag;
FlagStateSpinnerSelectedListener(Flag flag) {
mFlag = flag;
}
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
String flagName = mFlag.getName();
int oldState = booleanToState(mOverriddenFlags.get(flagName));
int newState = position;
switch (sFlagStates[newState]) {
case STATE_DEFAULT:
mOverriddenFlags.remove(flagName);
break;
case STATE_ENABLED:
mOverriddenFlags.put(flagName, true);
break;
case STATE_DISABLED:
mOverriddenFlags.put(flagName, false);
break;
}
// Update UI and Service. Only communicate with the service if the map actually updated.
// This optimizes the number of IPCs we make, but this also allows for atomic batch
// updates by updating mOverriddenFlags prior to updating the Spinner state.
if (oldState != newState) {
sendFlagsToService();
ViewParent grandparent = parent.getParent();
if (grandparent instanceof View) {
formatListEntry((View) grandparent, newState);
}
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) {}
}
@IntDef({LayoutType.WARNING_MESSAGE, LayoutType.TOGGLEABLE_FLAG})
private @interface LayoutType {
int WARNING_MESSAGE = 0;
int TOGGLEABLE_FLAG = 1;
int COUNT = 2;
}
/**
* Adapter to create rows of toggleable Flags.
*/
private class FlagsListAdapter extends ArrayAdapter<Flag> {
public FlagsListAdapter(Flag[] flagsAndWarningText) {
super(mContext, 0, flagsAndWarningText);
}
private View getToggleableFlag(@NonNull Flag flag, View view, ViewGroup parent) {
// If the the old view is already created then reuse it, else create a new one by layout
// inflation.
if (view == null) {
view = getLayoutInflater().inflate(R.layout.toggleable_flag, null);
}
TextView flagName = view.findViewById(R.id.flag_name);
TextView flagDescription = view.findViewById(R.id.flag_description);
Spinner flagToggle = view.findViewById(R.id.flag_toggle);
String label = flag.getName();
if (flag.getEnabledStateValue() != null) {
label += "=" + flag.getEnabledStateValue();
}
flagName.setText(label);
flagDescription.setText(flag.getDescription());
ArrayAdapter<String> adapter =
new ArrayAdapter<>(mContext, R.layout.flag_states, sFlagStates);
adapter.setDropDownViewResource(android.R.layout.select_dialog_singlechoice);
flagToggle.setAdapter(adapter);
// Populate spinner state from map and update indicators.
int state = booleanToState(mOverriddenFlags.get(flag.getName()));
flagToggle.setSelection(state);
flagToggle.setOnItemSelectedListener(new FlagStateSpinnerSelectedListener(flag));
formatListEntry(view, state);
return view;
}
private View getWarningMessage(View view, ViewGroup parent) {
// If the the old view is already created then reuse it, else create a new one by layout
// inflation.
if (view == null) {
view = getLayoutInflater().inflate(R.layout.flag_ui_warning, null);
}
TextView flagsDescriptionView = view.findViewById(R.id.flags_description);
flagsDescriptionView.setText("By enabling these features, you could "
+ "lose app data or compromise your security or privacy. Enabled features "
+ "apply to WebViews across all apps on the device.");
return view;
}
@Override
@LayoutType
public int getItemViewType(int position) {
if (getItem(position) == null) return LayoutType.WARNING_MESSAGE;
return LayoutType.TOGGLEABLE_FLAG;
}
@Override
public int getViewTypeCount() {
return LayoutType.COUNT;
}
@Override
public View getView(int position, View view, ViewGroup parent) {
Flag flag = getItem(position);
if (getItemViewType(position) == LayoutType.WARNING_MESSAGE) {
return getWarningMessage(view, parent);
} else {
return getToggleableFlag(flag, view, parent);
}
}
}
/**
* Formats a flag list entry. {@code toggleableFlag} should be the View which holds the {@link
* Spinner}, flag title, flag description, etc. as children.
*
* @param toggleableFlag a View representing an entire flag entry.
* @param state the state of the flag.
*/
private void formatListEntry(View toggleableFlag, int state) {
TextView flagName = toggleableFlag.findViewById(R.id.flag_name);
if (state == /* STATE_DEFAULT */ 0) {
// Unset the compound drawable.
flagName.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
} else { // STATE_ENABLED or STATE_DISABLED
// Draws a blue circle to the left of the text.
flagName.setCompoundDrawablesWithIntrinsicBounds(R.drawable.blue_circle, 0, 0, 0);
}
}
private class FlagsServiceConnection implements ServiceConnection {
public void start() {
Intent intent = new Intent();
intent.setClassName(mContext.getPackageName(), ServiceNames.DEVELOPER_UI_SERVICE);
if (!mContext.bindService(intent, this, Context.BIND_AUTO_CREATE)) {
Log.e(TAG, "Failed to bind to Developer UI service");
}
}
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
try {
IDeveloperUiService.Stub.asInterface(service).setFlagOverrides(mOverriddenFlags);
} catch (RemoteException e) {
Log.e(TAG, "Failed to send flag overrides to service", e);
} finally {
// Unbind when we've sent the flags overrides, since we can always rebind later. The
// service will manage its own lifetime.
mContext.unbindService(this);
}
}
@Override
public void onServiceDisconnected(ComponentName name) {}
}
private void sendFlagsToService() {
FlagsServiceConnection connection = new FlagsServiceConnection();
connection.start();
}
private void resetAllFlags() {
// Clear the map, then update the Spinners from the map value.
mOverriddenFlags.clear();
mListAdapter.notifyDataSetChanged();
sendFlagsToService();
}
}