Skip to content

Commit

Permalink
Fix Android Picker ArrayOutOfBoundsException during Picker.Item updat…
Browse files Browse the repository at this point in the history
…e from a long list (facebook#25276)

Summary:
axe-fb reported this side effect from my previous commit in facebook#24793 (comment)
After revisited the implementation of Android Spinner, it seems the formal way to update existing adapter is mutating it, i.e. `arrayAdapter.clear()` & `arrayAdapter.addAll()` to update a Spinner Adapter.
`setAdapter()` will reset everything including `mDataChanged`.
A race condition may happens between rendering a long picker list and reseting adapter.
Here is a code snippet: https://snack.expo.io/kudochien/80f810
To reproduce the issue, please select large item (e.g. 500) first and click the button right hand side.
Please not to verify this on Expo directly in the meantime, because Expo with RN 0.59 does not include my previous commit.

## Changelog

[Android] [Fixed] - Fix Picker ArrayOutOfBoundsException during Picker.Item update from a long list
Pull Request resolved: facebook#25276

Test Plan:
1. Check the test case https://snack.expo.io/kudochien/80f810 will have exception or not.
2. Regression of https://snack.expo.io/Sy1JClEag from facebook#13351
3. Regression of https://snack.expo.io/kudochien/android-picker-issue from facebook#22821
4. RNTester Picker example

Reviewed By: mdvacca

Differential Revision: D15857426

Pulled By: axe-fb

fbshipit-source-id: 8ef902447fdd1b8aeab50ad061545cd14c735e51
  • Loading branch information
Kudo authored and M-i-k-e-l committed Mar 10, 2020
1 parent d49a8c1 commit b819d93
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 94 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,27 @@
package com.facebook.react.views.picker;

import android.content.Context;
import androidx.appcompat.widget.AppCompatSpinner;
import android.util.AttributeSet;
import android.view.View;
import android.widget.AdapterView;
import android.widget.Spinner;
import android.widget.SpinnerAdapter;

import androidx.appcompat.widget.AppCompatSpinner;

import com.facebook.react.common.annotations.VisibleForTesting;

import java.util.List;

import javax.annotation.Nullable;

public class ReactPicker extends AppCompatSpinner {

private int mMode = Spinner.MODE_DIALOG;
private @Nullable Integer mPrimaryColor;
private @Nullable OnSelectListener mOnSelectListener;
private @Nullable SpinnerAdapter mStagedAdapter;
private @Nullable List<ReactPickerItem> mItems;
private @Nullable List<ReactPickerItem> mStagedItems;
private @Nullable Integer mStagedSelection;
private @Nullable Integer mStagedPrimaryTextColor;

private final OnItemSelectedListener mItemSelectedListener = new OnItemSelectedListener() {
@Override
Expand Down Expand Up @@ -113,8 +116,8 @@ public void setOnSelectListener(@Nullable OnSelectListener onSelectListener) {
return mOnSelectListener;
}

/* package */ void setStagedAdapter(final SpinnerAdapter adapter) {
mStagedAdapter = adapter;
/* package */ void setStagedItems(final @Nullable List<ReactPickerItem> items) {
mStagedItems = items;
}

/**
Expand All @@ -125,6 +128,10 @@ public void setOnSelectListener(@Nullable OnSelectListener onSelectListener) {
mStagedSelection = selection;
}

/* package */ void setStagedPrimaryTextColor(@Nullable Integer primaryColor) {
mStagedPrimaryTextColor = primaryColor;
}

/**
* Used to commit staged data into ReactPicker view.
* During this period, we will disable {@link OnSelectListener#onItemSelected(int)} temporarily,
Expand All @@ -133,30 +140,34 @@ public void setOnSelectListener(@Nullable OnSelectListener onSelectListener) {
/* package */ void commitStagedData() {
setOnItemSelectedListener(null);

ReactPickerAdapter adapter = (ReactPickerAdapter) getAdapter();
final int origSelection = getSelectedItemPosition();
if (mStagedAdapter != null && mStagedAdapter != getAdapter()) {
setAdapter(mStagedAdapter);
// After setAdapter(), Spinner will reset selection and cause unnecessary onValueChange event.
// Explicitly setup selection again to prevent this.
// Ref: https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/AbsSpinner.java#123
setSelection(origSelection, false);
mStagedAdapter = null;
if (mStagedItems != null && mStagedItems != mItems) {
mItems = mStagedItems;
mStagedItems = null;
if (adapter == null) {
adapter = new ReactPickerAdapter(getContext(), mItems);
setAdapter(adapter);
} else {
adapter.clear();
adapter.addAll(mItems);
adapter.notifyDataSetChanged();
}
}

if (mStagedSelection != null && mStagedSelection != origSelection) {
setSelection(mStagedSelection, false);
mStagedSelection = null;
}

setOnItemSelectedListener(mItemSelectedListener);
}

public @Nullable Integer getPrimaryColor() {
return mPrimaryColor;
}
if (mStagedPrimaryTextColor != null &&
adapter != null &&
mStagedPrimaryTextColor != adapter.getPrimaryTextColor()) {
adapter.setPrimaryTextColor(mStagedPrimaryTextColor);
mStagedPrimaryTextColor = null;
}

public void setPrimaryColor(@Nullable Integer primaryColor) {
mPrimaryColor = primaryColor;
setOnItemSelectedListener(mItemSelectedListener);
}

@VisibleForTesting
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.facebook.react.views.picker;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;

import com.facebook.infer.annotation.Assertions;

import java.util.List;

import javax.annotation.Nullable;

/* package */
class ReactPickerAdapter extends ArrayAdapter<ReactPickerItem> {

private final LayoutInflater mInflater;
private @Nullable
Integer mPrimaryTextColor;

public ReactPickerAdapter(Context context, List<ReactPickerItem> data) {
super(context, 0, data);

mInflater = (LayoutInflater) Assertions.assertNotNull(
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE));
}

@Override
public View getView(int position, View convertView, ViewGroup parent) {
return getView(position, convertView, parent, false);
}

@Override
public View getDropDownView(int position, View convertView, ViewGroup parent) {
return getView(position, convertView, parent, true);
}

private View getView(int position, View convertView, ViewGroup parent, boolean isDropdown) {
ReactPickerItem item = getItem(position);
if (convertView == null) {
int layoutResId = isDropdown
? android.R.layout.simple_spinner_dropdown_item
: android.R.layout.simple_spinner_item;
convertView = mInflater.inflate(layoutResId, parent, false);
}

TextView textView = (TextView) convertView;
textView.setText(item.label);
if (!isDropdown && mPrimaryTextColor != null) {
textView.setTextColor(mPrimaryTextColor);
} else if (item.color != null) {
textView.setTextColor(item.color);
}

return convertView;
}

public @Nullable Integer getPrimaryTextColor() {
return mPrimaryTextColor;
}

public void setPrimaryTextColor(@Nullable Integer primaryTextColor) {
mPrimaryTextColor = primaryTextColor;
notifyDataSetChanged();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.facebook.react.views.picker;

import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;

import java.util.ArrayList;
import java.util.List;

import javax.annotation.Nullable;

/* package */
class ReactPickerItem {
public final String label;
@Nullable
public final Integer color;

public ReactPickerItem(final ReadableMap jsMapData) {
label = jsMapData.getString("label");

if (jsMapData.hasKey("color") && !jsMapData.isNull("color")) {
color = jsMapData.getInt("color");
} else {
color = null;
}
}

@Nullable
public static List<ReactPickerItem> createFromJsArrayMap(final ReadableArray jsArrayMap) {
if (jsArrayMap == null) {
return null;
}

final List<ReactPickerItem> items = new ArrayList<>(jsArrayMap.size());
for (int i = 0; i < jsArrayMap.size(); ++i) {
items.add(new ReactPickerItem(jsArrayMap.getMap(i)));
}
return items;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,19 @@

package com.facebook.react.views.picker;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.Spinner;
import android.widget.TextView;
import com.facebook.infer.annotation.Assertions;

import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.uimanager.SimpleViewManager;
import com.facebook.react.uimanager.ThemedReactContext;
import com.facebook.react.uimanager.UIManagerModule;
import com.facebook.react.uimanager.ViewProps;
import com.facebook.react.uimanager.annotations.ReactProp;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.views.picker.events.PickerItemSelectEvent;

import java.util.List;

import javax.annotation.Nullable;

/**
Expand All @@ -37,26 +33,13 @@ public abstract class ReactPickerManager extends SimpleViewManager<ReactPicker>

@ReactProp(name = "items")
public void setItems(ReactPicker view, @Nullable ReadableArray items) {
if (items != null) {
ReadableMap[] data = new ReadableMap[items.size()];
for (int i = 0; i < items.size(); i++) {
data[i] = items.getMap(i);
}
ReactPickerAdapter adapter = new ReactPickerAdapter(view.getContext(), data);
adapter.setPrimaryTextColor(view.getPrimaryColor());
view.setStagedAdapter(adapter);
} else {
view.setStagedAdapter(null);
}
final List<ReactPickerItem> pickerItems = ReactPickerItem.createFromJsArrayMap(items);
view.setStagedItems(pickerItems);
}

@ReactProp(name = ViewProps.COLOR, customType = "Color")
public void setColor(ReactPicker view, @Nullable Integer color) {
view.setPrimaryColor(color);
ReactPickerAdapter adapter = (ReactPickerAdapter) view.getAdapter();
if (adapter != null) {
adapter.setPrimaryTextColor(color);
}
view.setStagedPrimaryTextColor(color);
}

@ReactProp(name = "prompt")
Expand Down Expand Up @@ -90,55 +73,6 @@ protected void addEventEmitters(
reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher()));
}

private static class ReactPickerAdapter extends ArrayAdapter<ReadableMap> {

private final LayoutInflater mInflater;
private @Nullable Integer mPrimaryTextColor;

public ReactPickerAdapter(Context context, ReadableMap[] data) {
super(context, 0, data);

mInflater = (LayoutInflater) Assertions.assertNotNull(
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE));
}

@Override
public View getView(int position, View convertView, ViewGroup parent) {
return getView(position, convertView, parent, false);
}

@Override
public View getDropDownView(int position, View convertView, ViewGroup parent) {
return getView(position, convertView, parent, true);
}

private View getView(int position, View convertView, ViewGroup parent, boolean isDropdown) {
ReadableMap item = getItem(position);

if (convertView == null) {
int layoutResId = isDropdown
? android.R.layout.simple_spinner_dropdown_item
: android.R.layout.simple_spinner_item;
convertView = mInflater.inflate(layoutResId, parent, false);
}

TextView textView = (TextView) convertView;
textView.setText(item.getString("label"));
if (!isDropdown && mPrimaryTextColor != null) {
textView.setTextColor(mPrimaryTextColor);
} else if (item.hasKey("color") && !item.isNull("color")) {
textView.setTextColor(item.getInt("color"));
}

return convertView;
}

public void setPrimaryTextColor(@Nullable Integer primaryTextColor) {
mPrimaryTextColor = primaryTextColor;
notifyDataSetChanged();
}
}

private static class PickerEventEmitter implements ReactPicker.OnSelectListener {

private final ReactPicker mReactPicker;
Expand Down

0 comments on commit b819d93

Please sign in to comment.