DO NOT MERGE: Add uxr content limits for the playback queue

The queue needs to use a custom implementation of the filtering because
the uxr lib only supports limiting at the end of the list, whereas the
queue needs to keep the currently playing element visible and therefore
has to show a sliding range of the queue.

Bug: 160737112
Bug: 159766205
Test: manual
Change-Id: Ia7e6b5c61a5b43cac2e8dad87a162b0f7201990a
diff --git a/Android.bp b/Android.bp
index c7536fc..0a772f3 100644
--- a/Android.bp
+++ b/Android.bp
@@ -44,6 +44,7 @@
         "car-apps-common",
         "car-media-common",
         "car-ui-lib",
+        "car-uxr-client-lib",
     ],
 
     product_variables: {
diff --git a/res/xml/uxr_config.xml b/res/xml/uxr_config.xml
new file mode 100644
index 0000000..bc78320
--- /dev/null
+++ b/res/xml/uxr_config.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License
+  -->
+<Mapping xmlns:app="http://schemas.android.com/apk/res-auto">
+    <!-- We use a default value of -1 to denote UNLIMITED -->
+    <!-- When maxLength is > 0, its value is rounded up to the nearest greater odd integer. -->
+    <ListConfig
+        app:id="@+id/playback_fragment_now_playing_list_uxr_config"
+        app:maxLength="-1"
+    />
+</Mapping>
diff --git a/src/com/android/car/media/PlaybackFragment.java b/src/com/android/car/media/PlaybackFragment.java
index dd31ff0..afd0f72 100644
--- a/src/com/android/car/media/PlaybackFragment.java
+++ b/src/com/android/car/media/PlaybackFragment.java
@@ -51,8 +51,12 @@
 import com.android.car.media.common.playback.PlaybackViewModel;
 import com.android.car.media.common.source.MediaSourceViewModel;
 import com.android.car.media.widgets.AppBarController;
+import com.android.car.ui.recyclerview.ContentLimiting;
+import com.android.car.ui.recyclerview.ScrollingLimitedViewHolder;
 import com.android.car.ui.toolbar.MenuItem;
 import com.android.car.ui.toolbar.Toolbar;
+import com.android.car.uxr.LifeCycleObserverUxrContentLimiter;
+import com.android.car.uxr.UxrContentLimiterImpl;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -68,6 +72,7 @@
 public class PlaybackFragment extends Fragment {
     private static final String TAG = "PlaybackFragment";
 
+    private LifeCycleObserverUxrContentLimiter mUxrContentLimiter;
     private ImageBinder<MediaItemMetadata.ArtworkRef> mAlbumArtBinder;
     private AppBarController mAppBarController;
     private BackgroundImageView mAlbumBackground;
@@ -199,13 +204,47 @@
     }
 
 
-    private class QueueItemsAdapter extends RecyclerView.Adapter<QueueViewHolder> {
+    private class QueueItemsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
+            implements ContentLimiting {
 
+        private static final int CLAMPED_MESSAGE_VIEW_TYPE = -1;
+        private static final int QUEUE_ITEM_VIEW_TYPE = 0;
+
+        private UxrPivotFilter mUxrPivotFilter;
         private List<MediaItemMetadata> mQueueItems = Collections.emptyList();
         private String mCurrentTimeText = "";
         private String mMaxTimeText = "";
-        private Integer mActiveItemPos;
+        /** Index in {@link #mQueueItems}. */
+        private Integer mActiveItemIndex;
         private boolean mTimeVisible;
+        private Integer mScrollingLimitedMessageResId;
+
+        QueueItemsAdapter() {
+            mUxrPivotFilter = UxrPivotFilter.PASS_THROUGH;
+        }
+
+        @Override
+        public void setMaxItems(int maxItems) {
+            if (maxItems >= 0) {
+                mUxrPivotFilter = new UxrPivotFilterImpl(this, maxItems);
+            } else {
+                mUxrPivotFilter = UxrPivotFilter.PASS_THROUGH;
+            }
+            applyFilterToQueue();
+        }
+
+        @Override
+        public void setScrollingLimitedMessageResId(int resId) {
+            if (mScrollingLimitedMessageResId == null || mScrollingLimitedMessageResId != resId) {
+                mScrollingLimitedMessageResId = resId;
+                mUxrPivotFilter.invalidateMessagePositions();
+            }
+        }
+
+        @Override
+        public int getConfigurationId() {
+            return R.id.playback_fragment_now_playing_list_uxr_config;
+        }
 
         void setItems(@Nullable List<MediaItemMetadata> items) {
             List<MediaItemMetadata> newQueueItems =
@@ -214,62 +253,97 @@
                 return;
             }
             mQueueItems = newQueueItems;
-            updateActiveItem();
+            updateActiveItem(/* listIsNew */ true);
+        }
+
+        private int getActiveItemIndex() {
+            return mActiveItemIndex != null ? mActiveItemIndex : 0;
+        }
+
+        private int getQueueSize() {
+            return (mQueueItems != null) ? mQueueItems.size() : 0;
+        }
+
+
+        /**
+         * Returns the position of the active item if there is one, otherwise returns
+         * @link UxrPivotFilter#INVALID_POSITION}.
+         */
+        private int getActiveItemPosition() {
+            if (mActiveItemIndex == null) {
+                return UxrPivotFilter.INVALID_POSITION;
+            }
+            return mUxrPivotFilter.indexToPosition(mActiveItemIndex);
+        }
+
+        private void invalidateActiveItemPosition() {
+            int position = getActiveItemPosition();
+            if (position != UxrPivotFilterImpl.INVALID_POSITION) {
+                notifyItemChanged(position);
+            }
+        }
+
+        private void scrollToActiveItemPosition() {
+            int position = getActiveItemPosition();
+            if (position != UxrPivotFilterImpl.INVALID_POSITION) {
+                mQueue.scrollToPosition(position);
+            }
+        }
+
+        private void applyFilterToQueue() {
+            mUxrPivotFilter.recompute(getQueueSize(), getActiveItemIndex());
             notifyDataSetChanged();
         }
 
         // Updates mActiveItemPos, then scrolls the queue to mActiveItemPos.
         // It should be called when the active item (mActiveQueueItemId) changed or
         // the queue items (mQueueItems) changed.
-        void updateActiveItem() {
+        void updateActiveItem(boolean listIsNew) {
             if (mQueueItems == null || mActiveQueueItemId == null) {
-                mActiveItemPos = null;
+                mActiveItemIndex = null;
+                applyFilterToQueue();
                 return;
             }
             Integer activeItemPos = null;
             for (int i = 0; i < mQueueItems.size(); i++) {
-                if (mQueueItems.get(i).getQueueId() == mActiveQueueItemId) {
+                if (Objects.equals(mQueueItems.get(i).getQueueId(), mActiveQueueItemId)) {
                     activeItemPos = i;
                     break;
                 }
             }
 
-            if (mActiveItemPos != activeItemPos) {
-                if (mActiveItemPos != null) {
-                    notifyItemChanged(mActiveItemPos.intValue());
-                }
-                mActiveItemPos = activeItemPos;
-                if (mActiveItemPos != null) {
-                    mQueue.scrollToPosition(mActiveItemPos.intValue());
-                    notifyItemChanged(mActiveItemPos.intValue());
-                }
+            // Invalidate the previous active item so it gets redrawn as a normal one.
+            invalidateActiveItemPosition();
+
+            mActiveItemIndex = activeItemPos;
+            if (listIsNew) {
+                applyFilterToQueue();
+            } else {
+                mUxrPivotFilter.updatePivotIndex(getActiveItemIndex());
             }
+
+            scrollToActiveItemPosition();
+            invalidateActiveItemPosition();
         }
 
         void setCurrentTime(String currentTime) {
             if (!mCurrentTimeText.equals(currentTime)) {
                 mCurrentTimeText = currentTime;
-                if (mActiveItemPos != null) {
-                    notifyItemChanged(mActiveItemPos.intValue());
-                }
+                invalidateActiveItemPosition();
             }
         }
 
         void setMaxTime(String maxTime) {
             if (!mMaxTimeText.equals(maxTime)) {
                 mMaxTimeText = maxTime;
-                if (mActiveItemPos != null) {
-                    notifyItemChanged(mActiveItemPos.intValue());
-                }
+                invalidateActiveItemPosition();
             }
         }
 
         void setTimeVisible(boolean visible) {
             if (mTimeVisible != visible) {
                 mTimeVisible = visible;
-                if (mActiveItemPos != null) {
-                    notifyItemChanged(mActiveItemPos.intValue());
-                }
+                invalidateActiveItemPosition();
             }
         }
 
@@ -286,51 +360,83 @@
         }
 
         @Override
-        public QueueViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+        public final int getItemViewType(int position) {
+            if (mUxrPivotFilter.positionToIndex(position) == UxrPivotFilterImpl.INVALID_INDEX) {
+                return CLAMPED_MESSAGE_VIEW_TYPE;
+            } else {
+                return QUEUE_ITEM_VIEW_TYPE;
+            }
+        }
+
+        @Override
+        public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+            if (viewType == CLAMPED_MESSAGE_VIEW_TYPE) {
+                return ScrollingLimitedViewHolder.create(parent);
+            }
             LayoutInflater inflater = LayoutInflater.from(parent.getContext());
             return new QueueViewHolder(inflater.inflate(R.layout.queue_list_item, parent, false));
         }
 
         @Override
-        public void onBindViewHolder(QueueViewHolder holder, int position) {
-            int size = mQueueItems.size();
-            if (0 <= position && position < size) {
-                holder.bind(mQueueItems.get(position));
+        public void onBindViewHolder(RecyclerView.ViewHolder vh, int position) {
+            if (vh instanceof QueueViewHolder) {
+                int index = mUxrPivotFilter.positionToIndex(position);
+                if (index != UxrPivotFilterImpl.INVALID_INDEX) {
+                    int size = mQueueItems.size();
+                    if (0 <= index && index < size) {
+                        QueueViewHolder holder = (QueueViewHolder) vh;
+                        holder.bind(mQueueItems.get(index));
+                    } else {
+                        Log.e(TAG, "onBindViewHolder pos: " + position + " gave index: " + index +
+                                " out of bounds size: " + size + " " + mUxrPivotFilter.toString());
+                    }
+                } else {
+                    Log.e(TAG, "onBindViewHolder invalid position " + position + " " +
+                            mUxrPivotFilter.toString());
+                }
+            } else if (vh instanceof ScrollingLimitedViewHolder) {
+                ScrollingLimitedViewHolder holder = (ScrollingLimitedViewHolder) vh;
+                holder.bind(mScrollingLimitedMessageResId);
             } else {
-                Log.e(TAG, "onBindViewHolder invalid position " + position + " of " + size);
+                throw new IllegalArgumentException("unknown holder class " + vh.getClass());
             }
         }
 
         @Override
-        public void onViewAttachedToWindow(@NonNull QueueViewHolder holder) {
-            super.onViewAttachedToWindow(holder);
-            holder.onViewAttachedToWindow();
+        public void onViewAttachedToWindow(@NonNull RecyclerView.ViewHolder vh) {
+            super.onViewAttachedToWindow(vh);
+            if (vh instanceof QueueViewHolder) {
+                QueueViewHolder holder = (QueueViewHolder) vh;
+                holder.onViewAttachedToWindow();
+            }
         }
 
         @Override
-        public void onViewDetachedFromWindow(@NonNull QueueViewHolder holder) {
-            super.onViewDetachedFromWindow(holder);
-            holder.onViewDetachedFromWindow();
+        public void onViewDetachedFromWindow(@NonNull RecyclerView.ViewHolder vh) {
+            super.onViewDetachedFromWindow(vh);
+            if (vh instanceof QueueViewHolder) {
+                QueueViewHolder holder = (QueueViewHolder) vh;
+                holder.onViewDetachedFromWindow();
+            }
         }
 
         @Override
         public int getItemCount() {
-            return mQueueItems.size();
-        }
-
-        void refresh() {
-            // TODO: Perform a diff between current and new content and trigger the proper
-            // RecyclerView updates.
-            this.notifyDataSetChanged();
+            return mUxrPivotFilter.getFilteredCount();
         }
 
         @Override
         public long getItemId(int position) {
-            return mQueueItems.get(position).getQueueId();
+            int index = mUxrPivotFilter.positionToIndex(position);
+            if (index != UxrPivotFilterImpl.INVALID_INDEX) {
+                return mQueueItems.get(position).getQueueId();
+            } else {
+                return RecyclerView.NO_ID;
+            }
         }
     }
 
-    private class QueueTopItemDecoration extends RecyclerView.ItemDecoration {
+    private static class QueueTopItemDecoration extends RecyclerView.ItemDecoration {
         int mHeight;
         int mDecorationPosition;
 
@@ -460,6 +566,12 @@
                         item != null ? item.getArtworkKey() : null));
 
         new GuidelinesUpdater(requireActivity(), view);
+
+        mUxrContentLimiter = new LifeCycleObserverUxrContentLimiter(
+                new UxrContentLimiterImpl(getContext(), R.xml.uxr_config));
+        mUxrContentLimiter.setAdapter(mQueueAdapter);
+        getLifecycle().addObserver(mUxrContentLimiter);
+
         return view;
     }
 
@@ -526,7 +638,7 @@
                     Long itemId = (state != null) ? state.getActiveQueueItemId() : null;
                     if (!Objects.equals(mActiveQueueItemId, itemId)) {
                         mActiveQueueItemId = itemId;
-                        mQueueAdapter.updateActiveItem();
+                        mQueueAdapter.updateActiveItem(/* listIsNew */ false);
                     }
                 });
         mQueue.setAdapter(mQueueAdapter);
diff --git a/src/com/android/car/media/UxrPivotFilter.java b/src/com/android/car/media/UxrPivotFilter.java
new file mode 100644
index 0000000..57b1e52
--- /dev/null
+++ b/src/com/android/car/media/UxrPivotFilter.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.car.media;
+
+
+/**
+ * Interface for helper objects that hide elements from lists that are too long. The limiting
+ * happens around a pivot element that can be anywhere in the list. Elements near that pivot will
+ * be visible, while elements at the head and / or tail of the list will be replaced by a message
+ * telling the user about the truncation.
+ * When no restrictions are in effect, the {@link #PASS_THROUGH} instance should be used.
+ */
+public interface UxrPivotFilter {
+
+    int INVALID_INDEX = -1;
+    int INVALID_POSITION = -1;
+
+    /**
+     * Computes new restrictions when the list (and optionally) the pivot have changed.
+     * The implementation doesn't send any notification.
+     */
+    void recompute(int newCount, int pivotIndex);
+
+    /**
+     * Computes new restrictions when only the pivot has changed.
+     * The implementation must send notification changes (ideally incremental ones).
+     */
+    void updatePivotIndex(int pivotIndex);
+
+    /** Returns the number of elements in the resulting list, including the message(s). */
+    int getFilteredCount();
+
+    /**
+     * Converts an index in the unfiltered data set to a RV position in the filtered UI in the
+     * 0 .. {@link #getFilteredCount} range which includes the limits message(s).
+     * Returns INVALID_POSITION if that element has been filtered out.
+     */
+    int indexToPosition(int index);
+
+    /**
+     * Converts a RV position in the filtered UI to an index in the unfiltered data set.
+     * Returns INVALID_INDEX if a message is shown at that position.
+     */
+    int positionToIndex(int position);
+
+    /** Send notification changes for the restriction message(s) if there are any. */
+    void invalidateMessagePositions();
+
+
+    /**
+     * A trivial implementation that doesn't do any filtering (simplifies the filter's code).
+     */
+    UxrPivotFilter PASS_THROUGH = new UxrPivotFilter() {
+        private int mCount;
+
+        @Override
+        public void recompute(int newCount, int pivotIndex) {
+            mCount = newCount;
+        }
+
+        @Override
+        public void updatePivotIndex(int pivotIndex) {
+        }
+
+        @Override
+        public int getFilteredCount() {
+            return mCount;
+        }
+
+        @Override
+        public int indexToPosition(int index) {
+            return index;
+        }
+
+        @Override
+        public int positionToIndex(int position) {
+            return position;
+        }
+
+        @Override
+        public void invalidateMessagePositions() {
+        }
+    };
+}
diff --git a/src/com/android/car/media/UxrPivotFilterImpl.java b/src/com/android/car/media/UxrPivotFilterImpl.java
new file mode 100644
index 0000000..29be569
--- /dev/null
+++ b/src/com/android/car/media/UxrPivotFilterImpl.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.car.media;
+
+import android.util.Log;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+
+public class UxrPivotFilterImpl implements UxrPivotFilter {
+
+    private static final String TAG = "UxrPivotFilterImpl";
+
+    private final RecyclerView.Adapter<?> mAdapter;
+    private final int mMaxItems;
+    private final int mMaxItemsDiv2;
+
+    private int mUnlimitedCount;
+    private int mPivotIndex;
+    private final ListRange mRange = new ListRange();
+    private final ListRange mSavedRange = new ListRange();
+
+
+    /**
+     * Constructor
+     * @param adapter the adapter to notify of changes in {@link #updatePivotIndex}.
+     * @param maxItems the maximum number of items to show. When > 0, its value is rounded up to
+     *                 the nearest greater odd integer in order to show the active element plus or
+     *                 minus maxItems / 2.
+     */
+    public UxrPivotFilterImpl(RecyclerView.Adapter<?> adapter, int maxItems) {
+        mAdapter = adapter;
+        if (maxItems <= 0) {
+            mMaxItemsDiv2 = 0;
+            mMaxItems = 0;
+        } else {
+            mMaxItemsDiv2 = maxItems / 2;
+            mMaxItems = 1 + (mMaxItemsDiv2 * 2);
+        }
+    }
+
+    @Override
+    public String toString() {
+        return "UxrPivotFilterImpl{" +
+                "mMaxItemsDiv2=" + mMaxItemsDiv2 +
+                ", mUnlimitedCount=" + mUnlimitedCount +
+                ", mPivotIndex=" + mPivotIndex +
+                ", mRange=" + mRange.toString() +
+                '}';
+    }
+
+    @Override
+    public int getFilteredCount() {
+        return mRange.mLimitedCount;
+    }
+
+    @Override
+    public void invalidateMessagePositions() {
+        if (mRange.mClampedHead > 0) {
+            mAdapter.notifyItemChanged(0);
+        }
+        if (mRange.mClampedTail > 0) {
+            mAdapter.notifyItemChanged(getFilteredCount() - 1);
+        }
+    }
+
+    @Override
+    public void recompute(int newCount, int pivotIndex) {
+        if (pivotIndex < 0 || newCount <= pivotIndex) {
+            Log.e(TAG, "Invalid pivotIndex: " + pivotIndex + " newCount: " + newCount);
+            pivotIndex = 0;
+        }
+        mUnlimitedCount = newCount;
+        mPivotIndex = pivotIndex;
+        mRange.mClampedHead = 0;
+        mRange.mClampedTail = 0;
+
+        if (newCount <= mMaxItems) {
+            // Under the cap case.
+            mRange.mStartIndex = 0;
+            mRange.mEndIndex = mUnlimitedCount;
+            mRange.mLimitedCount = mUnlimitedCount;
+        } else if (mMaxItems <= 0) {
+            // Zero cap case.
+            mRange.mStartIndex = 0;
+            mRange.mEndIndex = 0;
+            mRange.mLimitedCount = 1; // One limit message
+            mRange.mClampedTail = 1;
+        } else if (mPivotIndex <= mMaxItemsDiv2) {
+            // No need to clamp the head case
+            // For example: P = 2, M/2 = 2 => exactly two items before the pivot.
+            // Tail has to be clamped or we'd be in the "under the cap" case.
+            mRange.mStartIndex = 0;
+            mRange.mEndIndex = mMaxItems;
+            mRange.mLimitedCount = mMaxItems + 1; // One limit message at the end
+            mRange.mClampedTail = 1;
+        } else if ((mUnlimitedCount - 1 - mPivotIndex) <= mMaxItemsDiv2) {
+            // No need to clamp the tail case
+            // For example: C = 5, P = 2 => exactly 2 items after the pivot (count is exclusive).
+            // Head has to be clamped or we'd be in the "under the cap" case.
+            mRange.mEndIndex = mUnlimitedCount;
+            mRange.mStartIndex = mRange.mEndIndex - mMaxItems;
+            mRange.mLimitedCount = mMaxItems + 1; // One limit message at the start
+            mRange.mClampedHead = 1;
+        } else {
+            // Both head and tail need clamping
+            mRange.mStartIndex = mPivotIndex - mMaxItemsDiv2;
+            mRange.mEndIndex = mPivotIndex + mMaxItemsDiv2 + 1;
+            mRange.mLimitedCount = mMaxItems + 2; // One limit message at each end.
+            mRange.mClampedHead = 1;
+            mRange.mClampedTail = 1;
+        }
+    }
+
+    /**
+     * Computes the new restrictions when the pivot changes but the list remains the same.
+     * Notifications are done from the end to the beginning of the list so we don't have to mess
+     * with the indices as we go. Beyond the addition or removal of head and tail messages, the
+     * method boils down to intersecting two segments and determining which elements to remove and
+     * which to add to go from the old one to the new one.<p/>
+     * The diagram below illustrates all the cases with [S1, E1[ the old range and [S2, E2[ the
+     * new one. The =, + and - signs identify identical, inserted and removed elements.<p/>
+     * <pre>
+     *                             S1                      E1
+     *                            |........................|
+     *                            |                        |
+     *             S2             |      E2                |
+     *             +++++++++++++++|======------------------|
+     *                            |                        |
+     *                            |             S2         |           E2
+     *                            |             ===========|+++++++++++
+     *                            |                        |
+     *                            |     S2         E2      |
+     *                            |-----===========--------|
+     *                            |                        |
+     *                  S2        |                        |                E2
+     *                  ++++++++++|========================|++++++++++++++++
+     * <pre/>
+     */
+    @Override
+    public void updatePivotIndex(int pivotIndex) {
+        if (mPivotIndex == pivotIndex) return;
+
+        mSavedRange.copyFrom(mRange);
+
+        recompute(mUnlimitedCount, pivotIndex);
+
+        if (Log.isLoggable(TAG, Log.DEBUG)) {
+            Log.d(TAG, "updatePivotIndex pivot: " + pivotIndex + " saved: " + mSavedRange
+                    + " new: " + mRange);
+        }
+
+        if (mSavedRange.intersects(mRange)) {
+            if (mSavedRange.mClampedTail < mRange.mClampedTail) {
+                // Add a tail message, inserting it after the last element of the restricted list.
+                mAdapter.notifyItemInserted(mSavedRange.mLimitedCount);
+            }
+            if (mSavedRange.mClampedTail > mRange.mClampedTail) {
+                // Remove a tail message which was shown as the last element of the restricted list.
+                mAdapter.notifyItemRemoved(mSavedRange.mLimitedCount - 1);
+            }
+
+            // Add or remove items at the end
+            if (mSavedRange.mEndIndex < mRange.mEndIndex) {
+                int insertPos = mSavedRange.indexToPosition(mSavedRange.mEndIndex);
+                int insertCount = mRange.mEndIndex - mSavedRange.mEndIndex;
+                mAdapter.notifyItemRangeInserted(insertPos, insertCount);
+            }
+            if (mSavedRange.mEndIndex > mRange.mEndIndex) {
+                int delPos = mSavedRange.indexToPosition(mRange.mEndIndex);
+                int delCount = mSavedRange.mEndIndex - mRange.mEndIndex;
+                mAdapter.notifyItemRangeRemoved(delPos, delCount);
+            }
+
+            // Add or remove items at the start
+            if (mSavedRange.mStartIndex > mRange.mStartIndex) {
+                int insertPos = mSavedRange.indexToPosition(mSavedRange.mStartIndex);
+                int insertCount = mSavedRange.mStartIndex - mRange.mStartIndex;
+                mAdapter.notifyItemRangeInserted(insertPos, insertCount);
+            }
+            if (mSavedRange.mStartIndex < mRange.mStartIndex) {
+                int delPos = mSavedRange.indexToPosition(mSavedRange.mStartIndex);
+                int delCount = mRange.mStartIndex - mSavedRange.mStartIndex;
+                mAdapter.notifyItemRangeRemoved(delPos, delCount);
+            }
+
+            // Add or remove the head message
+            if (mSavedRange.mClampedHead < mRange.mClampedHead) {
+                mAdapter.notifyItemInserted(0);
+            }
+            if (mSavedRange.mClampedHead > mRange.mClampedHead) {
+                mAdapter.notifyItemRemoved(0);
+            }
+        } else {
+            // No element is the same, invalidate all.
+            mAdapter.notifyDataSetChanged();
+        }
+    }
+
+    @Override
+    public int indexToPosition(int index) {
+        if ((mRange.mStartIndex <= index) && (index < mRange.mEndIndex)) {
+            return mRange.indexToPosition(index);
+        } else {
+            return INVALID_POSITION;
+        }
+    }
+
+    @Override
+    public int positionToIndex(int position) {
+        return mRange.positionToIndex(position);
+    }
+
+
+    /** A portion of the unfiltered list. */
+    private static class ListRange {
+
+        /** In original data, inclusive. */
+        private int mStartIndex;
+        /** In original data, exclusive. */
+        private int mEndIndex;
+
+        /** 1 when clamped, otherwise 0. */
+        private int mClampedHead;
+        /** 1 when clamped, otherwise 0. */
+        private int mClampedTail;
+
+        /** The count of the resulting elements, including the truncation message(s). */
+        private int mLimitedCount;
+
+        public void copyFrom(ListRange range) {
+            mStartIndex = range.mStartIndex;
+            mEndIndex = range.mEndIndex;
+            mClampedHead = range.mClampedHead;
+            mClampedTail = range.mClampedTail;
+            mLimitedCount = range.mLimitedCount;
+        }
+
+        @Override
+        public String toString() {
+            return "ListRange{" +
+                    "mStartIndex=" + mStartIndex +
+                    ", mEndIndex=" + mEndIndex +
+                    ", mClampedHead=" + mClampedHead +
+                    ", mClampedTail=" + mClampedTail +
+                    ", mLimitedCount=" + mLimitedCount +
+                    '}';
+        }
+
+        public boolean intersects(ListRange range) {
+            return ((range.mEndIndex > mStartIndex) && (mEndIndex > range.mStartIndex));
+        }
+
+        /** Unchecked index needed by {@link #updatePivotIndex}. */
+        public int indexToPosition(int index) {
+            return index - mStartIndex + mClampedHead;
+        }
+
+        public int positionToIndex(int position) {
+            int index = position - mClampedHead + mStartIndex;
+            if ((index < mStartIndex) || (mEndIndex <= index)) {
+                return INVALID_INDEX;
+            } else {
+                return index;
+            }
+        }
+    }
+}