chromium/chrome/browser/feed/android/java/src/org/chromium/chrome/browser/feed/FeedListContentManager.java

// Copyright 2020 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.chrome.browser.feed;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.widget.FrameLayout;

import androidx.annotation.Nullable;
import androidx.annotation.Px;

import org.chromium.chrome.browser.xsurface.ListContentManager;
import org.chromium.chrome.browser.xsurface.ListContentManagerObserver;
import org.chromium.chrome.browser.xsurface.LoggingParameters;
import org.chromium.ui.UiUtils;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;

/**
 * Implementation of ListContentManager that manages a list of feed contents that are supported by
 * either native view or external surface controlled view.
 */
public class FeedListContentManager implements ListContentManager {
    /** Encapsulates the content of an item stored and managed by ListContentManager. */
    public abstract static class FeedContent {
        private final String mKey;
        private final boolean mIsFullSpan;

        FeedContent(String key) {
            this(key, false);
        }

        FeedContent(String key, boolean isFullSpan) {
            assert key != null && !key.isEmpty();
            mKey = key;
            mIsFullSpan = isFullSpan;
        }

        /** Returns true if the content is supported by the native view. */
        public abstract boolean isNativeView();

        /** Returns the key which should uniquely identify the content in the list. */
        public String getKey() {
            return mKey;
        }

        public boolean isFullSpan() {
            return mIsFullSpan;
        }

        public @Nullable LoggingParameters getLoggingParameters() {
            return null;
        }
    }

    /** For the content that is supported by external surface controlled view. */
    public static class ExternalViewContent extends FeedContent {
        private final byte[] mData;
        private final LoggingParameters mLoggingParameters;

        public ExternalViewContent(String key, byte[] data, LoggingParameters loggingParameters) {
            super(key);
            mData = data;
            mLoggingParameters = loggingParameters;
        }

        /**
         * Returns the raw bytes that are passed to the external surface for rendering if the
         * content is supported by the external surface controlled view.
         */
        public byte[] getBytes() {
            return mData;
        }

        @Override
        public boolean isNativeView() {
            return false;
        }

        @Override
        public @Nullable LoggingParameters getLoggingParameters() {
            return mLoggingParameters;
        }
    }

    /** For the content that is supported by the native view. */
    public static class NativeViewContent extends FeedContent {
        private View mNativeView;
        private int mResId;
        // An unique ID for this NativeViewContent. This is initially 0, and assigned by
        // FeedListContentManager when needed.
        private int mViewType;
        @Px private int mLateralPaddingsPx;

        /** Holds an inflated native view. */
        public NativeViewContent(@Px int lateralPaddingsPx, String key, View nativeView) {
            super(key, true);
            assert nativeView != null;
            mNativeView = nativeView;
            mLateralPaddingsPx = lateralPaddingsPx;
        }

        /** Holds an inflated native view. */
        public NativeViewContent(
                @Px int lateralPaddingsPx, String key, View nativeView, boolean isFullSpan) {
            super(key, isFullSpan);
            assert nativeView != null;
            mNativeView = nativeView;
            mLateralPaddingsPx = lateralPaddingsPx;
        }

        /** Holds a resource ID used to inflate a native view. */
        public NativeViewContent(@Px int lateralPaddingsPx, String key, int resId) {
            super(key, true);
            mResId = resId;
            mLateralPaddingsPx = lateralPaddingsPx;
        }

        /** Returns the native view if the content is supported by it. Null otherwise. */
        public View getNativeView(ViewGroup parent) {
            Context context = parent.getContext();
            if (mNativeView == null) {
                mNativeView = LayoutInflater.from(context).inflate(mResId, parent, false);
            }

            // If there's already a parent, we have already enclosed this view previously.
            // This can happen if a native view is added, removed, and added again.
            // In this case, it is important to make a new view because the RecyclerView
            // may still have a reference to the old one. See crbug.com/1131975.
            UiUtils.removeViewFromParent(mNativeView);

            FrameLayout enclosingLayout = new FrameLayout(parent.getContext());
            FrameLayout.LayoutParams layoutParams =
                    new FrameLayout.LayoutParams(
                            new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
            enclosingLayout.setLayoutParams(layoutParams);

            // Set the left and right paddings.
            enclosingLayout.setPadding(
                    /* left= */ mLateralPaddingsPx, /* top= */ 0,
                    /* right= */ mLateralPaddingsPx, /* bottom= */ 0);

            // Do not clip children. This ensures that the negative margin use in the feed header
            // does not subsequently cause the IPH bubble to be clipped.
            enclosingLayout.setClipToPadding(false);
            enclosingLayout.setClipChildren(false);
            enclosingLayout.addView(mNativeView);
            return enclosingLayout;
        }

        int getViewType() {
            return mViewType;
        }

        void setViewType(int viewType) {
            mViewType = viewType;
        }

        @Override
        public boolean isNativeView() {
            return true;
        }
    }

    private final ArrayList<FeedContent> mFeedContentList = new ArrayList<>();
    private final ArrayList<ListContentManagerObserver> mObservers = new ArrayList<>();
    private final Map<String, Object> mHandlers = new HashMap<>();
    private int mPreviousViewType;

    /**
     * Clears existing handlers and sets current handlers to newHandlers.
     * @param newHandlers handlers to set.
     */
    public void setHandlers(Map<String, Object> newHandlers) {
        mHandlers.clear();
        mHandlers.putAll(newHandlers);
    }

    /**
     * Returns the content at the specified position.
     *
     * @param index The index at which to get the content.
     * @return The content.
     */
    public FeedContent getContent(int index) {
        return mFeedContentList.get(index);
    }

    /** Returns a list of all contents */
    public List<FeedContent> getContentList() {
        return mFeedContentList;
    }

    /**
     * Finds the position of the content with the specified key in the list.
     *
     * @param key The key of the content to search for.
     * @return The position if found, -1 otherwise.
     */
    public int findContentPositionByKey(String key) {
        for (int i = 0; i < mFeedContentList.size(); ++i) {
            if (mFeedContentList.get(i).getKey().equals(key)) {
                return i;
            }
        }
        return -1;
    }

    /**
     * Adds a list of the contents, starting at the specified position.
     *
     * @param index The index at which to insert the first content from the specified collection.
     * @param contents The collection containing contents to be added.
     */
    public void addContents(int index, List<FeedContent> contents) {
        assert index >= 0 && index <= mFeedContentList.size();
        mFeedContentList.addAll(index, contents);
        for (ListContentManagerObserver observer : mObservers) {
            observer.onItemRangeInserted(index, contents.size());
        }
    }

    /**
     * Removes the specified count of contents starting from the speicified position.
     *
     * @param index The index of the first content to be removed.
     * @param count The number of contents to be removed.
     */
    public void removeContents(int index, int count) {
        assert index >= 0 && index < mFeedContentList.size();
        assert index + count <= mFeedContentList.size();
        mFeedContentList.subList(index, index + count).clear();
        for (ListContentManagerObserver observer : mObservers) {
            observer.onItemRangeRemoved(index, count);
        }
    }

    /**
     * Updates a list of the contents, starting at the specified position.
     *
     * @param index The index at which to update the first content from the specified collection.
     * @param contents The collection containing contents to be updated.
     */
    public void updateContents(int index, List<FeedContent> contents) {
        assert index >= 0 && index < mFeedContentList.size();
        assert index + contents.size() <= mFeedContentList.size();
        int pos = index;
        for (FeedContent content : contents) {
            mFeedContentList.set(pos++, content);
        }
        for (ListContentManagerObserver observer : mObservers) {
            observer.onItemRangeChanged(index, contents.size());
        }
    }

    /**
     * Moves the content to a different position.
     *
     * @param curIndex The index of the content to be moved.
     * @param newIndex The new index where the content is being moved to.
     */
    public void moveContent(int curIndex, int newIndex) {
        assert curIndex >= 0 && curIndex < mFeedContentList.size();
        assert newIndex >= 0 && newIndex < mFeedContentList.size();
        int lowIndex;
        int highIndex;
        int distance;
        if (curIndex < newIndex) {
            lowIndex = curIndex;
            highIndex = newIndex;
            distance = -1;
        } else if (curIndex > newIndex) {
            lowIndex = newIndex;
            highIndex = curIndex;
            distance = 1;
        } else {
            return;
        }
        Collections.rotate(mFeedContentList.subList(lowIndex, highIndex + 1), distance);
        for (ListContentManagerObserver observer : mObservers) {
            observer.onItemMoved(curIndex, newIndex);
        }
    }

    /**
     * Replaces content in the range [index, index+count) with the content in {@code
     * newContentList}. For content that already exists in the range, it is moved rather than
     * removed and then inserted.
     * @param index Index of first item to replace.
     * @param count Number of items to replace.
     * @param newContentList List of content to insert.
     * @return Whether content has changed. Returns false if the new content matches the replaced
     *         content.
     */
    public boolean replaceRange(int rangeStart, int count, List<FeedContent> newContentList) {
        boolean hasContentChange = false;
        // 1) Builds the hash set based on keys of new contents.
        HashSet<String> newContentKeySet = new HashSet<>();
        for (int i = 0; i < newContentList.size(); ++i) {
            newContentKeySet.add(newContentList.get(i).getKey());
        }

        // 2) Builds the hash map of existing content list for fast look up by key. Ignores headers.
        HashMap<String, FeedContent> existingContentMap = new HashMap<>();
        for (int i = rangeStart; i < rangeStart + count; ++i) {
            FeedContent content = getContent(i);
            existingContentMap.put(content.getKey(), content);
        }

        // 3) Removes those existing contents that do not appear in the new list.
        for (int i = rangeStart + count - 1; i >= rangeStart; ) {
            // Find out how many contiguous items need to be removed, and then remove them in one
            // call.
            int rmIndex = i;
            while (rmIndex >= rangeStart) {
                String key = getContent(rmIndex).getKey();
                if (newContentKeySet.contains(key)) {
                    break;
                }
                existingContentMap.remove(key);
                --rmIndex;
            }

            if (rmIndex != i) {
                hasContentChange = true;
                removeContents(rmIndex + 1, i - rmIndex);
                i = rmIndex;
            } else {
                --i;
            }
        }

        // 4) Iterates through the new list to add the new content or move the existing content
        //    if needed.
        for (int i = 0; i < newContentList.size(); ) {
            FeedContent content = newContentList.get(i);

            // If this is an existing content, moves it to new position, offset by header count.
            if (existingContentMap.containsKey(content.getKey())) {
                hasContentChange = true;
                int oldIndex = findContentPositionByKey(content.getKey());
                int newIndex = i + rangeStart;
                if (oldIndex != newIndex) {
                    hasContentChange = true;
                    moveContent(oldIndex, i + rangeStart);
                }

                ++i;
                continue;
            }

            // Otherwise, this is new content. Add it together with all adjacent new contents.
            int startIndex = i++;
            while (i < newContentList.size()
                    && !existingContentMap.containsKey(newContentList.get(i).getKey())) {
                ++i;
            }
            hasContentChange = true;
            // Account for headers when inserting contents.
            addContents(startIndex + rangeStart, newContentList.subList(startIndex, i));
        }

        return hasContentChange;
    }

    @Override
    public boolean isNativeView(int index) {
        return mFeedContentList.get(index).isNativeView();
    }

    @Override
    public boolean isFullSpan(int index) {
        return mFeedContentList.get(index).isFullSpan();
    }

    @Override
    public byte[] getExternalViewBytes(int index) {
        assert !mFeedContentList.get(index).isNativeView();
        ExternalViewContent externalViewContent = (ExternalViewContent) mFeedContentList.get(index);
        return externalViewContent.getBytes();
    }

    @Override
    public Map<String, Object> getContextValues(int index) {
        // We just return mHandlers for items unless they need logging parameters added.
        if (index >= 0 && index < mFeedContentList.size()) {
            LoggingParameters loggingParameters =
                    mFeedContentList.get(index).getLoggingParameters();
            if (loggingParameters != null) {
                // It might be a good idea to cache this value, but it adds complexity because
                // setHandlers() can be called after items are added.
                Map<String, Object> contextValues = new HashMap<>(mHandlers);
                contextValues.put(LoggingParameters.KEY, loggingParameters);
                return contextValues;
            }
        }
        return mHandlers;
    }

    @Override
    public int getViewType(int position) {
        assert mFeedContentList.get(position).isNativeView();
        NativeViewContent content = (NativeViewContent) mFeedContentList.get(position);
        if (content.getViewType() == 0) content.setViewType(++mPreviousViewType);
        return content.getViewType();
    }

    @Override
    public View getNativeView(int viewType, ViewGroup parent) {
        NativeViewContent viewContent = findNativeViewByType(viewType);
        assert viewContent != null;
        return viewContent.getNativeView(parent);
    }

    @Override
    public void bindNativeView(int index, View v) {
        // Nothing to do.
    }

    @Override
    public int getItemCount() {
        return mFeedContentList.size();
    }

    @Override
    public void addObserver(ListContentManagerObserver observer) {
        mObservers.add(observer);
    }

    @Override
    public void removeObserver(ListContentManagerObserver observer) {
        mObservers.remove(observer);
    }

    private @Nullable NativeViewContent findNativeViewByType(int viewType) {
        // Note: since there's relatively few native views, they're mostly at the front, a linear
        // search isn't terrible. This function is also called infrequently.
        for (int i = 0; i < mFeedContentList.size(); i++) {
            FeedContent item = mFeedContentList.get(i);
            if (!item.isNativeView()) continue;
            NativeViewContent nativeContent = (NativeViewContent) item;
            if (nativeContent.getViewType() == viewType) return nativeContent;
        }
        return null;
    }
}