// Copyright 2015 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.app.Dialog;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
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.Button;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.MathUtils;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.widget.TextViewWithClickableSpans;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* A general-purpose dialog for presenting a list of things to pick from.
*
* <p>The dialog is shown by the ItemChooserDialog constructor, and always calls
* ItemSelectedCallback.onItemSelected() as it's closing.
*/
public class ItemChooserDialog implements DeviceItemAdapter.Observer {
/** 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);
}
/** 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. */
@IntDef({
State.INITIALIZING_ADAPTER,
State.STARTING,
State.PROGRESS_UPDATE_AVAILABLE,
State.DISCOVERY_IDLE
})
@Retention(RetentionPolicy.SOURCE)
public @interface State {
int INITIALIZING_ADAPTER = 0;
int STARTING = 1;
int PROGRESS_UPDATE_AVAILABLE = 2;
int DISCOVERY_IDLE = 3;
}
private Context mContext;
private Window mWindow;
// 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 DeviceItemAdapter 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);
// If this variable is false, the window should be closed when it loses focus;
// Otherwise, the window should not be closed when it loses focus.
private boolean mIgnorePendingWindowFocusChangeForClose;
/**
* Creates the ItemChooserDialog and displays it (and starts waiting for data).
*
* @param context The context used for layout inflation and resource loading.
* @param window The window used to determine the list height.
* @param callback The callback used to communicate back what was selected.
* @param labels The labels to show in the dialog.
*/
public ItemChooserDialog(
Context context,
Window window,
ItemSelectedCallback callback,
ItemChooserLabels labels) {
mContext = context;
mWindow = window;
mItemSelectedCallback = callback;
mLabels = labels;
LinearLayout dialogContainer =
(LinearLayout)
LayoutInflater.from(mContext).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);
View.OnClickListener clickListener =
new View.OnClickListener() {
@Override
public void onClick(View v) {
mItemSelectedCallback.onItemSelected(mItemAdapter.getSelectedItemKey());
mDialog.setOnDismissListener(null);
mDialog.dismiss();
}
};
mItemAdapter =
new DeviceItemAdapter(
mContext, /* itemsSelectable= */ true, R.layout.item_chooser_dialog_row);
mItemAdapter.setNotifyOnChange(true);
mItemAdapter.setObserver(this);
mConfirmButton.setOnClickListener(clickListener);
mListView.setOnItemClickListener(mItemAdapter);
mListView.setAdapter(mItemAdapter);
mListView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
mListView.setEmptyView(mEmptyMessage);
mListView.setDivider(null);
setState(State.STARTING);
mIgnorePendingWindowFocusChangeForClose = false;
showDialogForView(dialogContainer);
dialogContainer.addOnLayoutChangeListener(
(View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) -> {
if (l != ol || t != ot || r != or || b != ob) {
// 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(
mWindow.getDecorView().getHeight(),
mContext.getResources()
.getDisplayMetrics()
.density)));
}
});
}
// DeviceItemAdapter.Observer:
@Override
public void onItemSelectionChanged(boolean itemSelected) {
mConfirmButton.setEnabled(itemSelected);
}
/**
* Sets whether the window should be closed when it loses focus.
*
* @param ignorePendingWindowFocusChangeForClose Whether the window should be closed when it
* loses focus.
*/
public void setIgnorePendingWindowFocusChangeForClose(
boolean ignorePendingWindowFocusChangeForClose) {
mIgnorePendingWindowFocusChangeForClose = ignorePendingWindowFocusChangeForClose;
}
// 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(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public 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 Math.round(heightDp * density);
}
private void showDialogForView(View view) {
mDialog =
new Dialog(mContext) {
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (!mIgnorePendingWindowFocusChangeForClose && !hasFocus) super.dismiss();
setIgnorePendingWindowFocusChangeForClose(false);
}
};
mDialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
mDialog.setCanceledOnTouchOutside(true);
mDialog.addContentView(
view,
new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT));
// Use setOnDismissListener() so that the callback is invoked when the
// user taps the "Cancel" button or dismisses the dialog in other ways
// such as opening the notification shade.
mDialog.setOnDismissListener(dialog -> mItemSelectedCallback.onItemSelected(""));
Window window = mDialog.getWindow();
if (!DeviceFormFactor.isNonMultiDisplayContextOnTablet(mContext)) {
// 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();
}
/**
* Adds an item to the end of the list to show in the dialog if the item was not in the chooser.
* Otherwise updates the items description.
*
* @param key Unique identifier for that item.
* @param description Text in the row.
*/
public void addOrUpdateItem(String key, String description) {
addOrUpdateItem(key, description, /* icon= */ null, /* iconDescription= */ null);
}
/**
* Adds an item to the end of the list to show in the dialog if the item was not in the chooser.
* Otherwise updates the items description or icon. Note that as long as at least one item has
* an icon all rows will be inset with the icon dimensions.
*
* @param key Unique identifier for that item.
* @param description Text in the row.
* @param icon Drawable to show left of the description. The drawable provided should be
* stateful and handle the selected state to be rendered correctly.
* @param iconDescription Description of the icon.
*/
public void addOrUpdateItem(
String key,
String description,
@Nullable Drawable icon,
@Nullable String iconDescription) {
mProgressBar.setVisibility(View.GONE);
mItemAdapter.addOrUpdate(key, description, icon, iconDescription);
setState(State.PROGRESS_UPDATE_AVAILABLE);
}
/**
* Removes an item that is shown in the dialog.
*
* @param key Unique identifier for the item.
*/
public void removeItemFromList(String key) {
mItemAdapter.removeItemWithKey(key);
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);
}
/** Indicates the adapter is being initialized. */
public void signalInitializingAdapter() {
setState(State.INITIALIZING_ADAPTER);
}
/** Clear all items from the dialog. */
public void clear() {
mItemAdapter.clear();
setState(State.STARTING);
}
/** Shows an error message in the dialog. */
public void setErrorState(CharSequence errorMessage, CharSequence errorStatus) {
mListView.setVisibility(View.GONE);
mProgressBar.setVisibility(View.GONE);
mEmptyMessage.setText(errorMessage);
mEmptyMessage.setVisibility(View.VISIBLE);
mStatus.setText(errorStatus);
}
private void setState(@State int state) {
switch (state) {
case State.STARTING:
mStatus.setText(mLabels.searching);
// fall through
case State.INITIALIZING_ADAPTER:
mListView.setVisibility(View.GONE);
mProgressBar.setVisibility(View.VISIBLE);
mEmptyMessage.setVisibility(View.GONE);
break;
case State.PROGRESS_UPDATE_AVAILABLE:
mStatus.setText(mLabels.statusActive);
mProgressBar.setVisibility(View.GONE);
mListView.setVisibility(View.VISIBLE);
break;
case State.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. */
public Dialog getDialogForTesting() {
return mDialog;
}
/** Returns the ItemAdapter associated with this class. For use with tests only. */
public DeviceItemAdapter getItemAdapterForTesting() {
return mItemAdapter;
}
}