// Copyright 2015 The Chromium Authors. All rights reserved.
// 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;
import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.content.DialogInterface;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.text.SpannableString;
import android.text.method.LinkMovementMethod;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.Window;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.util.MathUtils;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.widget.TextViewWithClickableSpans;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* A general-purpose dialog for presenting a list of things to pick from.
*
* The dialog is shown by the ItemChooserDialog constructor, and always calls
* ItemSelectedCallback.onItemSelected() as it's closing.
*/
public class ItemChooserDialog {
/**
* An interface to implement to get a callback when something has been
* selected.
*/
public interface ItemSelectedCallback {
/**
* Returns the user selection.
*
* @param id The id of the item selected. Blank if the dialog was closed
* without selecting anything.
*/
void onItemSelected(String id);
}
/**
* A class representing one data row in the picker.
*/
public static class ItemChooserRow {
private final String mKey;
private String mDescription;
public ItemChooserRow(String key, String description) {
mKey = key;
mDescription = description;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof ItemChooserRow)) return false;
if (this == obj) return true;
ItemChooserRow item = (ItemChooserRow) obj;
return mKey.equals(item.mKey) && mDescription.equals(item.mDescription);
}
@Override
public int hashCode() {
return mKey.hashCode() + mDescription.hashCode();
}
}
/**
* The labels to show in the dialog.
*/
public static class ItemChooserLabels {
// The title at the top of the dialog.
public final CharSequence title;
// The message to show while there are no results.
public final CharSequence searching;
// The message to show when no results were produced.
public final CharSequence noneFound;
// A status message to show above the button row after an item has
// been added and discovery is still ongoing.
public final CharSequence statusActive;
// A status message to show above the button row after discovery has
// stopped and no devices have been found.
public final CharSequence statusIdleNoneFound;
// A status message to show above the button row after an item has
// been added and discovery has stopped.
public final CharSequence statusIdleSomeFound;
// The label for the positive button (e.g. Select/Pair).
public final CharSequence positiveButton;
public ItemChooserLabels(CharSequence title, CharSequence searching, CharSequence noneFound,
CharSequence statusActive, CharSequence statusIdleNoneFound,
CharSequence statusIdleSomeFound, CharSequence positiveButton) {
this.title = title;
this.searching = searching;
this.noneFound = noneFound;
this.statusActive = statusActive;
this.statusIdleNoneFound = statusIdleNoneFound;
this.statusIdleSomeFound = statusIdleSomeFound;
this.positiveButton = positiveButton;
}
}
/**
* The various states the dialog can represent.
*/
private enum State { STARTING, PROGRESS_UPDATE_AVAILABLE, DISCOVERY_IDLE }
/**
* An adapter for keeping track of which items to show in the dialog.
*/
public class ItemAdapter extends ArrayAdapter<ItemChooserRow>
implements AdapterView.OnItemClickListener {
private final LayoutInflater mInflater;
// The background color of the highlighted item.
private final int mBackgroundHighlightColor;
// The color of the non-highlighted text.
private final int mDefaultTextColor;
// 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;
// A set of keys that are marked as disabled in the dialog.
private Set<String> mDisabledEntries = new HashSet<String>();
// 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, ItemChooserRow> mKeyToItemMap = new HashMap<>();
public ItemAdapter(Context context, int resource) {
super(context, resource);
mInflater = LayoutInflater.from(context);
mBackgroundHighlightColor = ApiCompatibilityUtils.getColor(getContext().getResources(),
R.color.light_active_color);
mDefaultTextColor = ApiCompatibilityUtils.getColor(getContext().getResources(),
R.color.default_text_color);
}
@Override
public boolean isEmpty() {
boolean isEmpty = super.isEmpty();
if (isEmpty) {
assert mKeyToItemMap.isEmpty();
assert mDisabledEntries.isEmpty();
assert mItemDescriptionMap.isEmpty();
} else {
assert !mKeyToItemMap.isEmpty();
assert !mItemDescriptionMap.isEmpty();
}
return isEmpty;
}
public void addOrUpdate(ItemChooserRow item) {
ItemChooserRow oldItem = mKeyToItemMap.get(item.mKey);
if (oldItem != null) {
if (oldItem.equals(item)) {
// No need to update anything.
return;
}
if (!oldItem.mDescription.equals(item.mDescription)) {
removeFromDescriptionsMap(oldItem.mDescription);
oldItem.mDescription = item.mDescription;
addToDescriptionsMap(oldItem.mDescription);
}
notifyDataSetChanged();
return;
}
ItemChooserRow result = mKeyToItemMap.put(item.mKey, item);
assert result == null;
addToDescriptionsMap(item.mDescription);
add(item);
}
@Override
public void remove(ItemChooserRow item) {
ItemChooserRow oldItem = mKeyToItemMap.remove(item.mKey);
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) {
mSelectedItem = ListView.INVALID_POSITION;
mConfirmButton.setEnabled(false);
} else if (oldItemPosition < mSelectedItem) {
--mSelectedItem;
}
removeFromDescriptionsMap(oldItem.mDescription);
super.remove(oldItem);
}
@Override
public void clear() {
mSelectedItem = ListView.INVALID_POSITION;
mKeyToItemMap.clear();
mDisabledEntries.clear();
mItemDescriptionMap.clear();
mConfirmButton.setEnabled(false);
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 "";
ItemChooserRow 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) {
ItemChooserRow item = getItem(position);
String description = item.mDescription;
int counter = mItemDescriptionMap.get(description);
return counter == 1 ? description
: mActivity.getString(R.string.item_chooser_item_name_with_id, description,
item.mKey);
}
/**
* Sets whether the item is enabled. Disabled items are grayed out.
* @param id The id of the item to affect.
* @param enabled Whether the item should be enabled or not.
*/
public void setEnabled(String id, boolean enabled) {
if (enabled) {
mDisabledEntries.remove(id);
} else {
mDisabledEntries.add(id);
}
if (mSelectedItem != ListView.INVALID_POSITION) {
ItemChooserRow selectedRow = getItem(mSelectedItem);
if (id.equals(selectedRow.mKey)) {
mConfirmButton.setEnabled(enabled);
}
}
notifyDataSetChanged();
}
@Override
public boolean isEnabled(int position) {
ItemChooserRow item = getItem(position);
return !mDisabledEntries.contains(item.mKey);
}
@Override
public int getViewTypeCount() {
return 1;
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
TextView view;
if (convertView instanceof TextView) {
view = (TextView) convertView;
} else {
view = (TextView) mInflater.inflate(
R.layout.item_chooser_dialog_row, parent, false);
}
// Set highlighting for currently selected item.
if (position == mSelectedItem) {
view.setBackgroundColor(mBackgroundHighlightColor);
view.setTextColor(Color.WHITE);
} else {
view.setBackground(null);
if (!isEnabled(position)) {
view.setTextColor(ApiCompatibilityUtils.getColor(getContext().getResources(),
R.color.primary_text_disabled_material_light));
} else {
view.setTextColor(mDefaultTextColor);
}
}
view.setText(getDisplayText(position));
return view;
}
@Override
public void onItemClick(AdapterView<?> adapter, View view, int position, long id) {
mSelectedItem = position;
mConfirmButton.setEnabled(true);
mItemAdapter.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 Activity mActivity;
// The dialog this class encapsulates.
private Dialog mDialog;
// The callback to notify when the user selected an item.
private ItemSelectedCallback mItemSelectedCallback;
// Individual UI elements.
private TextViewWithClickableSpans mTitle;
private TextViewWithClickableSpans mEmptyMessage;
private ProgressBar mProgressBar;
private ListView mListView;
private TextView mStatus;
private Button mConfirmButton;
// The labels to display in the dialog.
private ItemChooserLabels mLabels;
// The adapter containing the items to show in the dialog.
private ItemAdapter mItemAdapter;
// How much of the height of the screen should be taken up by the listview.
private static final float LISTVIEW_HEIGHT_PERCENT = 0.30f;
// The height of a row of the listview in dp.
private static final int LIST_ROW_HEIGHT_DP = 48;
// The minimum height of the listview in the dialog (in dp).
private static final int MIN_HEIGHT_DP = (int) (LIST_ROW_HEIGHT_DP * 1.5);
// The maximum height of the listview in the dialog (in dp).
private static final int MAX_HEIGHT_DP = (int) (LIST_ROW_HEIGHT_DP * 8.5);
/**
* Creates the ItemChooserPopup and displays it (and starts waiting for data).
*
* @param activity Activity which is used for launching a dialog.
* @param callback The callback used to communicate back what was selected.
* @param labels The labels to show in the dialog.
*/
public ItemChooserDialog(
Activity activity, ItemSelectedCallback callback, ItemChooserLabels labels) {
mActivity = activity;
mItemSelectedCallback = callback;
mLabels = labels;
LinearLayout dialogContainer = (LinearLayout) LayoutInflater.from(mActivity).inflate(
R.layout.item_chooser_dialog, null);
mListView = (ListView) dialogContainer.findViewById(R.id.items);
mProgressBar = (ProgressBar) dialogContainer.findViewById(R.id.progress);
mStatus = (TextView) dialogContainer.findViewById(R.id.status);
mTitle = (TextViewWithClickableSpans) dialogContainer.findViewById(
R.id.dialog_title);
mEmptyMessage =
(TextViewWithClickableSpans) dialogContainer.findViewById(R.id.not_found_message);
mTitle.setText(labels.title);
mTitle.setMovementMethod(LinkMovementMethod.getInstance());
mEmptyMessage.setMovementMethod(LinkMovementMethod.getInstance());
mStatus.setMovementMethod(LinkMovementMethod.getInstance());
mConfirmButton = (Button) dialogContainer.findViewById(R.id.positive);
mConfirmButton.setText(labels.positiveButton);
mConfirmButton.setEnabled(false);
mConfirmButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mItemSelectedCallback.onItemSelected(mItemAdapter.getSelectedItemKey());
mDialog.setOnDismissListener(null);
mDialog.dismiss();
}
});
mItemAdapter = new ItemAdapter(mActivity, R.layout.item_chooser_dialog_row);
mItemAdapter.setNotifyOnChange(true);
mListView.setAdapter(mItemAdapter);
mListView.setEmptyView(mEmptyMessage);
mListView.setOnItemClickListener(mItemAdapter);
mListView.setDivider(null);
setState(State.STARTING);
// The list is the main element in the dialog and it should grow and
// shrink according to the size of the screen available.
View listViewContainer = dialogContainer.findViewById(R.id.container);
listViewContainer.setLayoutParams(new LinearLayout.LayoutParams(
LayoutParams.MATCH_PARENT,
getListHeight(mActivity.getWindow().getDecorView().getHeight(),
mActivity.getResources().getDisplayMetrics().density)));
showDialogForView(dialogContainer);
}
// Computes the height of the device list, bound to half-multiples of the
// row height so that it's obvious if there are more elements to scroll to.
@VisibleForTesting
static int getListHeight(int decorHeight, float density) {
float heightDp = decorHeight / density * LISTVIEW_HEIGHT_PERCENT;
// Round to (an integer + 0.5) times LIST_ROW_HEIGHT.
heightDp = (Math.round(heightDp / LIST_ROW_HEIGHT_DP - 0.5f) + 0.5f) * LIST_ROW_HEIGHT_DP;
heightDp = MathUtils.clamp(heightDp, MIN_HEIGHT_DP, MAX_HEIGHT_DP);
return (int) Math.round(heightDp * density);
}
private void showDialogForView(View view) {
mDialog = new Dialog(mActivity) {
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (!hasFocus) super.dismiss();
}
};
mDialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
mDialog.setCanceledOnTouchOutside(true);
mDialog.addContentView(view,
new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT));
mDialog.setOnDismissListener(new DialogInterface.OnDismissListener() {
@Override
public void onDismiss(DialogInterface dialog) {
mItemSelectedCallback.onItemSelected("");
}
});
Window window = mDialog.getWindow();
if (!DeviceFormFactor.isTablet(mActivity)) {
// On smaller screens, make the dialog fill the width of the screen,
// and appear at the top.
window.setBackgroundDrawable(new ColorDrawable(Color.WHITE));
window.setGravity(Gravity.TOP);
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
mDialog.show();
}
public void dismiss() {
mDialog.dismiss();
}
/**
* Add an item to the end of the list to show in the dialog if the item
* was not in the chooser. Otherwise update the items description.
*
* @param item The item to be added to the end of the chooser or updated.
*/
public void addOrUpdateItem(ItemChooserRow item) {
mProgressBar.setVisibility(View.GONE);
mItemAdapter.addOrUpdate(item);
setState(State.PROGRESS_UPDATE_AVAILABLE);
}
/**
* Remove an item that is shown in the dialog.
*
* @param item The item to be removed in the chooser.
*/
public void removeItemFromList(ItemChooserRow item) {
mItemAdapter.remove(item);
setState(State.DISCOVERY_IDLE);
}
/**
* Indicates the chooser that no more items will be added.
*/
public void setIdleState() {
mProgressBar.setVisibility(View.GONE);
setState(State.DISCOVERY_IDLE);
}
/**
* Sets whether the item is enabled.
* @param id The id of the item to affect.
* @param enabled Whether the item should be enabled or not.
*/
public void setEnabled(String id, boolean enabled) {
mItemAdapter.setEnabled(id, enabled);
}
/**
* Clear all items from the dialog.
*/
public void clear() {
mItemAdapter.clear();
setState(State.STARTING);
}
/**
* Shows an error message in the dialog.
*/
public void setErrorState(SpannableString errorMessage, SpannableString errorStatus) {
mListView.setVisibility(View.GONE);
mProgressBar.setVisibility(View.GONE);
mEmptyMessage.setText(errorMessage);
mEmptyMessage.setVisibility(View.VISIBLE);
mStatus.setText(errorStatus);
}
private void setState(State state) {
switch (state) {
case STARTING:
mStatus.setText(mLabels.searching);
mListView.setVisibility(View.GONE);
mProgressBar.setVisibility(View.VISIBLE);
mEmptyMessage.setVisibility(View.GONE);
break;
case PROGRESS_UPDATE_AVAILABLE:
mStatus.setText(mLabels.statusActive);
mProgressBar.setVisibility(View.GONE);
mListView.setVisibility(View.VISIBLE);
break;
case DISCOVERY_IDLE:
boolean showEmptyMessage = mItemAdapter.isEmpty();
mStatus.setText(showEmptyMessage
? mLabels.statusIdleNoneFound : mLabels.statusIdleSomeFound);
mEmptyMessage.setText(mLabels.noneFound);
mEmptyMessage.setVisibility(showEmptyMessage ? View.VISIBLE : View.GONE);
break;
}
}
/**
* Returns the dialog associated with this class. For use with tests only.
*/
@VisibleForTesting
public Dialog getDialogForTesting() {
return mDialog;
}
/**
* Returns the ItemAdapter associated with this class. For use with tests only.
*/
@VisibleForTesting
public ItemAdapter getItemAdapterForTesting() {
return mItemAdapter;
}
}