chromium/components/permissions/android/java/src/org/chromium/components/permissions/DeviceItemAdapter.java

// 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.components.permissions;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;

import androidx.annotation.LayoutRes;
import androidx.annotation.Nullable;
import androidx.core.util.ObjectsCompat;

import java.util.HashMap;
import java.util.Map;

/** An adapter for keeping track of which items to show in the dialog. */
public class DeviceItemAdapter extends ArrayAdapter<DeviceItemRow>
        implements AdapterView.OnItemClickListener {
    /** Item holder for performance boost. */
    private static class ViewHolder {
        private TextView mTextView;
        private @Nullable ImageView mImageView;

        public ViewHolder(View view) {
            mImageView = (ImageView) view.findViewById(R.id.icon);
            mTextView = (TextView) view.findViewById(R.id.description);
        }
    }

    /** An observer interface for item selection change in the adapter. */
    public interface Observer {
        /**
         * Called when item selection changed in the adapter.
         *
         * @param itemSelected Whether there is item selected in the adapter.
         */
        default void onItemSelectionChanged(boolean itemSelected) {}
    }

    private final LayoutInflater mInflater;

    private final Resources mResources;

    // True when there is need to select an item; false otherwise.
    private final boolean mItemsSelectable;

    private final @LayoutRes int mRowLayoutResource;

    // The zero-based index of the item currently selected in the dialog,
    // or -1 (INVALID_POSITION) if nothing is selected.
    private int mSelectedItem = ListView.INVALID_POSITION;

    // Item descriptions are counted in a map.
    private Map<String, Integer> mItemDescriptionMap = new HashMap<>();

    // Map of keys to items so that we can access the items in O(1).
    private Map<String, DeviceItemRow> mKeyToItemMap = new HashMap<>();

    // True when there is at least one row with an icon.
    private boolean mHasIcon;

    private @Nullable Observer mObserver;

    /**
     * Creates a device item adapter which can show a list of items.
     *
     * @param context The context used for layout inflation and resource loading.
     * @param rowLayoutResource The resource identifier for the item row.
     */
    public DeviceItemAdapter(
            Context context, boolean itemsSelectable, @LayoutRes int rowLayoutResource) {
        super(context, rowLayoutResource);

        mInflater = LayoutInflater.from(context);
        mResources = context.getResources();
        mItemsSelectable = itemsSelectable;
        mRowLayoutResource = rowLayoutResource;
    }

    @Override
    public boolean isEmpty() {
        boolean isEmpty = super.isEmpty();
        if (isEmpty) {
            assert mKeyToItemMap.isEmpty();
            assert mItemDescriptionMap.isEmpty();
        } else {
            assert !mKeyToItemMap.isEmpty();
            assert !mItemDescriptionMap.isEmpty();
        }
        return isEmpty;
    }

    /**
     * Adds an item to the list to show in the dialog if the item
     * was not in the chooser. Otherwise updates the items description, icon
     * and icon description.
     * @param key Unique identifier for that item.
     * @param description Text in the row.
     * @param icon Drawable to show next to the item.
     * @param iconDescription Description of the icon.
     */
    public void addOrUpdate(
            String key,
            String description,
            @Nullable Drawable icon,
            @Nullable String iconDescription) {
        DeviceItemRow oldItem = mKeyToItemMap.get(key);
        if (oldItem != null) {
            if (oldItem.hasSameContents(key, description, icon, iconDescription)) {
                // No need to update anything.
                return;
            }

            if (!TextUtils.equals(oldItem.mDescription, description)) {
                removeFromDescriptionsMap(oldItem.mDescription);
                oldItem.mDescription = description;
                addToDescriptionsMap(oldItem.mDescription);
            }

            if (!ObjectsCompat.equals(icon, oldItem.mIcon)) {
                oldItem.mIcon = icon;
                oldItem.mIconDescription = iconDescription;
            }

            notifyDataSetChanged();
            return;
        }

        assert !mKeyToItemMap.containsKey(key);
        DeviceItemRow newItem = new DeviceItemRow(key, description, icon, iconDescription);
        mKeyToItemMap.put(key, newItem);

        addToDescriptionsMap(newItem.mDescription);
        add(newItem);
    }

    public void removeItemWithKey(String key) {
        DeviceItemRow oldItem = mKeyToItemMap.remove(key);
        if (oldItem == null) return;
        int oldItemPosition = getPosition(oldItem);
        // If the removed item is the item that is currently selected, deselect it
        // and disable the confirm button. Otherwise if the removed item is before
        // the currently selected item, the currently selected item's index needs
        // to be adjusted by one.
        if (oldItemPosition == mSelectedItem) {
            updateSelectedItemPosition(ListView.INVALID_POSITION);
        } else if (oldItemPosition < mSelectedItem) {
            --mSelectedItem;
        }
        removeFromDescriptionsMap(oldItem.mDescription);
        super.remove(oldItem);
    }

    @Override
    public void clear() {
        mKeyToItemMap.clear();
        mItemDescriptionMap.clear();
        updateSelectedItemPosition(ListView.INVALID_POSITION);
        super.clear();
    }

    /**
     * Returns the key of the currently selected item or blank if nothing is
     * selected.
     */
    public String getSelectedItemKey() {
        if (mSelectedItem == ListView.INVALID_POSITION) return "";
        DeviceItemRow row = getItem(mSelectedItem);
        if (row == null) return "";
        return row.mKey;
    }

    /**
     * Returns the text to be displayed on the chooser for an item. For items with the same
     * description, their unique keys are appended to distinguish them.
     * @param position The index of the item.
     */
    public String getDisplayText(int position) {
        DeviceItemRow item = getItem(position);
        String description = item.mDescription;
        int counter = mItemDescriptionMap.get(description);
        return counter == 1
                ? description
                : mResources.getString(
                        R.string.item_chooser_item_name_with_id, description, item.mKey);
    }

    /**
     * Sets the observer to be notified of the item selection change in the adapter.
     * @param observer The observer to be notified.
     */
    public void setObserver(Observer observer) {
        mObserver = observer;
    }

    @Override
    public boolean isEnabled(int position) {
        return mItemsSelectable;
    }

    @Override
    public int getViewTypeCount() {
        return 1;
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        ViewHolder row;
        if (convertView == null) {
            convertView = mInflater.inflate(mRowLayoutResource, parent, false);
            row = new ViewHolder(convertView);
            convertView.setTag(row);
        } else {
            row = (ViewHolder) convertView.getTag();
        }

        row.mTextView.setSelected(position == mSelectedItem);
        row.mTextView.setEnabled(isEnabled(position));
        row.mTextView.setText(getDisplayText(position));

        if (row.mImageView != null) {
            // If there is at least one item with an icon then we set mImageView's
            // visibility to INVISIBLE for all items with no icons. We do this
            // so that all items' descriptions are aligned.
            if (!mHasIcon) {
                row.mImageView.setVisibility(View.GONE);
            } else {
                DeviceItemRow item = getItem(position);
                if (item.mIcon != null) {
                    row.mImageView.setContentDescription(item.mIconDescription);
                    row.mImageView.setImageDrawable(item.mIcon);
                    row.mImageView.setVisibility(View.VISIBLE);
                } else {
                    row.mImageView.setVisibility(View.INVISIBLE);
                    row.mImageView.setImageDrawable(null);
                    row.mImageView.setContentDescription(null);
                }
                row.mImageView.setSelected(position == mSelectedItem);
            }
        }

        return convertView;
    }

    @Override
    public void notifyDataSetChanged() {
        mHasIcon = false;
        for (DeviceItemRow row : mKeyToItemMap.values()) {
            if (row.mIcon != null) mHasIcon = true;
        }
        super.notifyDataSetChanged();
    }

    @Override
    public void onItemClick(AdapterView<?> adapter, View view, int position, long id) {
        assert mItemsSelectable;
        updateSelectedItemPosition(position);
        notifyDataSetChanged();
    }

    private void addToDescriptionsMap(String description) {
        int count =
                mItemDescriptionMap.containsKey(description)
                        ? mItemDescriptionMap.get(description)
                        : 0;
        mItemDescriptionMap.put(description, count + 1);
    }

    private void removeFromDescriptionsMap(String description) {
        if (!mItemDescriptionMap.containsKey(description)) {
            return;
        }
        int count = mItemDescriptionMap.get(description);
        if (count == 1) {
            mItemDescriptionMap.remove(description);
        } else {
            mItemDescriptionMap.put(description, count - 1);
        }
    }

    private void updateSelectedItemPosition(int position) {
        mSelectedItem = position;
        if (mObserver != null) {
            boolean itemSelected = (mSelectedItem != ListView.INVALID_POSITION);
            mObserver.onItemSelectionChanged(itemSelected);
        }
    }
}