// Copyright 2019 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.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.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.text.Editable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextWatcher;
import android.text.style.BackgroundColorSpan;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.inputmethod.InputMethodManager;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Filter;
import android.widget.ListView;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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.ServiceHelper;
import org.chromium.android_webview.common.services.ServiceNames;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.ThreadUtils;
import org.chromium.base.metrics.RecordHistogram;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
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[] sBaseFeatureStates = {
STATE_DEFAULT, STATE_ENABLED, STATE_DISABLED,
};
private static final String[] sCommandLineStates = {
STATE_DEFAULT, STATE_ENABLED,
};
private boolean mEnabled;
private boolean mShouldReset;
private Map<String, Boolean> mOverriddenFlags = new HashMap<>();
private FlagsListAdapter mListAdapter;
private Context mContext;
private EditText mSearchBar;
private static volatile @Nullable Runnable sFilterListener;
// Must only be accessed on UI thread.
private static @NonNull Flag[] sFlagList = ProductionSupportedFlagList.sFlagList;
public FlagsFragment(boolean enabled, boolean shouldReset) {
mEnabled = enabled;
mShouldReset = shouldReset;
}
public FlagsFragment() {
// All fragments must have a public no-args constructor
// https://developer.android.com/reference/android/app/Fragment
}
@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(sFlagList);
Flag[] flagsAndWarningText = new Flag[sFlagList.length + 1];
flagsAndWarningText[0] = null; // the first entry is the warning text
for (int i = 0; i < sFlagList.length; i++) {
flagsAndWarningText[i + 1] = sortedFlags[i];
}
mListAdapter = new FlagsListAdapter(flagsAndWarningText);
flagsListView.setAdapter(mListAdapter);
if (mShouldReset) {
mShouldReset = false;
resetAllFlags();
}
Button resetFlagsButton = view.findViewById(R.id.reset_flags_button);
resetFlagsButton.setOnClickListener(
(View flagButton) -> {
resetAllFlags();
});
mSearchBar = view.findViewById(R.id.flag_search_bar);
mSearchBar.addTextChangedListener(
new TextWatcher() {
private boolean mPreviouslyHadText;
@Override
public void onTextChanged(CharSequence cs, int start, int before, int count) {
mListAdapter.getFilter().filter(cs);
boolean currentlyHasText = !cs.toString().isEmpty();
// As an optimization, only change the clear text button if the search bar
// just now became empty or non-empty.
if (mPreviouslyHadText != currentlyHasText) {
setClearTextButtonEnabled(mSearchBar, currentlyHasText);
}
mPreviouslyHadText = currentlyHasText;
}
@Override
public void beforeTextChanged(
CharSequence cs, int start, int count, int after) {}
@Override
public void afterTextChanged(Editable e) {}
});
mSearchBar.setOnFocusChangeListener(
(View v, boolean hasFocus) -> {
if (!hasFocus) hideKeyboard(mContext, v);
});
}
private static void hideKeyboard(Context context, View view) {
InputMethodManager inputMethodManager =
(InputMethodManager) context.getSystemService(Activity.INPUT_METHOD_SERVICE);
inputMethodManager.hideSoftInputFromWindow(view.getWindowToken(), 0);
}
private void setClearTextButtonEnabled(EditText editText, boolean enabled) {
int iconColor = getContext().getColor(R.color.navigation_unselected);
Drawable clearTextIcon = getContext().getDrawable(R.drawable.ic_clear_text);
clearTextIcon.mutate();
clearTextIcon.setColorFilter(new PorterDuffColorFilter(iconColor, PorterDuff.Mode.SRC_IN));
// Overwrite only the end drawable (index = 2), since there's already a drawable at the
// start.
Drawable[] compoundDrawables = editText.getCompoundDrawablesRelative();
compoundDrawables[2] = enabled ? clearTextIcon : null;
editText.setCompoundDrawablesRelativeWithIntrinsicBounds(
compoundDrawables[0],
compoundDrawables[1],
compoundDrawables[2],
compoundDrawables[3]);
// Set (or remove) the onTouchListener
if (enabled) {
editText.setOnTouchListener(
(View v, MotionEvent event) -> {
int x = (int) event.getX();
int iconStart = editText.getWidth() - clearTextIcon.getIntrinsicWidth();
int iconEnd = editText.getWidth();
boolean didTapIcon = x >= iconStart && x <= iconEnd;
if (didTapIcon) {
if (event.getAction() == MotionEvent.ACTION_UP) {
editText.setText("");
}
return true;
}
return false;
});
} else {
editText.setOnTouchListener(null);
}
}
/**
* Notifies the caller when ListView filtering is complete, in response to modifying the text in
* {@code R.id.flag_search_bar}.
*/
public static void setFilterListenerForTesting(@Nullable Runnable listener) {
sFilterListener = listener;
ResettersForTesting.register(() -> sFilterListener = null);
}
public static void setFlagListForTesting(@NonNull Flag[] flagList) {
ThreadUtils.assertOnUiThread();
var oldValue = sFlagList;
sFlagList = flagList;
ResettersForTesting.register(() -> sFlagList = oldValue);
}
private void onFilterDone() {
if (sFilterListener != null) sFilterListener.run();
}
/**
* 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 booleanToBaseFeatureState(Boolean b) {
if (b == null) {
return
/* STATE_DEFAULT= */ 0;
} else if (b) {
return
/* STATE_ENABLED= */ 1;
}
return
/* STATE_DISABLED= */ 2;
}
private static int booleanToCommandLineState(Boolean b) {
return Boolean.TRUE.equals(b) ? /* STATE_ENABLED= */ 1 : /* STATE_DEFAULT= */ 0;
}
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;
if (mFlag.isBaseFeature()) {
oldState = booleanToBaseFeatureState(mOverriddenFlags.get(flagName));
} else {
oldState = booleanToCommandLineState(mOverriddenFlags.get(flagName));
}
int newState = position;
if (mFlag.isBaseFeature()) {
switch (sBaseFeatureStates[newState]) {
case STATE_DEFAULT:
mOverriddenFlags.remove(flagName);
break;
case STATE_ENABLED:
mOverriddenFlags.put(flagName, true);
break;
case STATE_DISABLED:
mOverriddenFlags.put(flagName, false);
break;
}
} else {
switch (sCommandLineStates[newState]) {
case STATE_DEFAULT:
mOverriddenFlags.remove(flagName);
break;
case STATE_ENABLED:
mOverriddenFlags.put(flagName, true);
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);
}
boolean hasSearchQuery = !mSearchBar.getText().toString().isEmpty();
RecordHistogram.recordBooleanHistogram(
"Android.WebView.DevUi.FlagsUi.ToggledFromSearch", hasSearchQuery);
}
}
@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;
}
private static class FlagQuery {
// Lower-case words from the query. Never contains empty strings.
String[] mLowerCaseWords;
public FlagQuery(CharSequence chars) {
String lowerCaseTrimmed = chars.toString().toLowerCase(Locale.getDefault()).trim();
if (lowerCaseTrimmed.length() == 0) {
// This needs to be handled as a special case, since calling
// split on an empty string will end up with mLowerCaseWords
// containing a single empty string.
mLowerCaseWords = new String[0];
} else {
mLowerCaseWords = lowerCaseTrimmed.split("\\s+");
}
}
boolean match(Flag flag) {
// If empty query, match every everything (including the warning text)
if (mLowerCaseWords.length == 0) {
return true;
}
// If the user is searching for something and flag represents the warning text, don't
// match the warning text
if (flag == null) {
return false;
}
// Split the query into words, and look for each word in either the name or the
// description, matching case insensitively.
String lowerCaseName = flag.getName().toLowerCase(Locale.getDefault());
String lowerCaseDescription = flag.getDescription().toLowerCase(Locale.getDefault());
for (String word : mLowerCaseWords) {
if (!lowerCaseName.contains(word) && !lowerCaseDescription.contains(word)) {
return false;
}
}
return true;
}
SpannableString highlight(String text) {
SpannableString highlighted = new SpannableString(text);
String lowerCaseText = text.toLowerCase(Locale.getDefault());
for (String word : mLowerCaseWords) {
int fromIndex = 0;
while (true) {
int startIndex = lowerCaseText.indexOf(word, fromIndex);
if (startIndex == -1) break;
int endIndex = startIndex + word.length();
highlighted.setSpan(
new BackgroundColorSpan(Color.YELLOW),
startIndex,
endIndex,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
fromIndex = endIndex;
}
}
return highlighted;
}
}
/** Adapter to create rows of toggleable Flags. */
private class FlagsListAdapter extends ArrayAdapter<Flag> {
private FlagQuery mQuery = new FlagQuery("");
private List<Flag> mItems;
private final Filter mFilter;
public FlagsListAdapter(Flag[] flagsAndWarningText) {
super(mContext, 0);
mItems = Arrays.asList(flagsAndWarningText);
mFilter =
new Filter() {
@Override
protected FilterResults performFiltering(CharSequence constraint) {
List<Flag> matches = new ArrayList<>();
// Do not store in mQuery here, since this is run off the UI
// thread.
FlagQuery query = new FlagQuery(constraint);
for (Flag flag : flagsAndWarningText) {
if (query.match(flag)) matches.add(flag);
}
FilterResults filterResults = new FilterResults();
filterResults.values = matches;
filterResults.count = matches.size();
return filterResults;
}
@Override
protected void publishResults(
CharSequence constraint, FilterResults results) {
mQuery = new FlagQuery(constraint);
mItems = (List<Flag>) results.values;
notifyDataSetChanged();
onFilterDone();
}
};
}
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);
SpannableString highlightedName = mQuery.highlight(flag.getName());
if (flag.getEnabledStateValue() != null) {
flagName.setText(
new SpannableStringBuilder(highlightedName)
.append("=" + flag.getEnabledStateValue()));
} else {
flagName.setText(highlightedName);
}
TextView flagDescription = view.findViewById(R.id.flag_description);
flagDescription.setText(mQuery.highlight(flag.getDescription()));
Spinner flagToggle = view.findViewById(R.id.flag_toggle);
flagToggle.setEnabled(mEnabled);
ArrayAdapter<String> adapter;
if (flag.isBaseFeature()) {
adapter = new ArrayAdapter<>(mContext, R.layout.flag_states, sBaseFeatureStates);
} else {
adapter = new ArrayAdapter<>(mContext, R.layout.flag_states, sCommandLineStates);
}
adapter.setDropDownViewResource(android.R.layout.select_dialog_singlechoice);
flagToggle.setAdapter(adapter);
// Populate spinner state from map and update indicators.
int state;
if (flag.isBaseFeature()) {
state = booleanToBaseFeatureState(mOverriddenFlags.get(flag.getName()));
} else {
state = booleanToCommandLineState(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
public int getCount() {
return mItems.size();
}
@Override
public Flag getItem(int position) {
return mItems.get(position);
}
@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);
}
}
@Override
public Filter getFilter() {
return mFilter;
}
}
/**
* 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.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0);
} else { // STATE_ENABLED or STATE_DISABLED
// Draws a blue circle to the left of the text.
flagName.setCompoundDrawablesRelativeWithIntrinsicBounds(
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 (!ServiceHelper.bindService(mContext, 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();
}
}