Action.java

/*
 * Copyright 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 androidx.car.app.model;

import static androidx.annotation.RestrictTo.Scope.LIBRARY;
import static androidx.car.app.model.CarColor.DEFAULT;
import static androidx.car.app.model.constraints.CarColorConstraints.STANDARD_ONLY;

import static java.util.Objects.requireNonNull;

import android.annotation.SuppressLint;
import android.os.Looper;
import android.text.TextUtils;

import androidx.activity.OnBackPressedCallback;
import androidx.activity.OnBackPressedDispatcher;
import androidx.annotation.IntDef;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.car.app.CarContext;
import androidx.car.app.annotations.ExperimentalCarApi;
import androidx.car.app.model.constraints.CarIconConstraints;
import androidx.lifecycle.LifecycleOwner;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Objects;

/**
 * Represents an action with an optional icon and text.
 *
 * <p>Actions may be displayed differently depending on the template or model they are added to. For
 * example, the host may decide to display an action as a floating action button (FAB) when
 * displayed over a map, as a button when displayed in a {@link Pane}, or as a simple icon with no
 * title when displayed within a {@link Row}.
 *
 * <h4>Standard actions</h4>
 *
 * A set of standard, built-in {@link Action} instances is available with a few of the common basic
 * actions car apps may need (for example a {@link #BACK} action).
 *
 * <p>With the exception of {@link #APP_ICON} and {@link #BACK}, an app can provide a custom title
 * and icon for the action. However, depending on the template the action belongs to, the title or
 * icon may be disallowed. If such restrictions apply, the documentation of the APIs that consume
 * the action will note them accordingly.
 */
public final class Action {
    /**
     * The type of action represented by the {@link Action } instance.
     *
     * @hide
     */
    @RestrictTo(LIBRARY)
    @IntDef(
            value = {
                    TYPE_CUSTOM,
                    TYPE_APP_ICON,
                    TYPE_BACK,
            })
    @Retention(RetentionPolicy.SOURCE)
    public @interface ActionType {
    }

    static final int FLAG_STANDARD = 1 << 16;

    /**
     * An app-defined custom action type.
     */
    public static final int TYPE_CUSTOM = 1;

    /**
     * An action representing an app's icon.
     *
     * @see #APP_ICON
     */
    public static final int TYPE_APP_ICON = 2 | FLAG_STANDARD;

    /**
     * An action to navigate back in the user interface.
     *
     * @see #BACK
     */
    public static final int TYPE_BACK = 3 | FLAG_STANDARD;

    /**
     * A standard action to show the app's icon.
     *
     * <p>This action is non-interactive.
     */
    @NonNull
    public static final Action APP_ICON = new Action(TYPE_APP_ICON);

    /**
     * A standard action to navigate back in the user interface.
     *
     * <p>The default behavior for a back press will call
     * {@link androidx.car.app.ScreenManager#pop}.
     *
     * <p>To override the default behavior, register a {@link OnBackPressedCallback} via
     * {@link OnBackPressedDispatcher#addCallback(LifecycleOwner, OnBackPressedCallback)}, which
     * you can retrieve from {@link CarContext#getOnBackPressedDispatcher()}.
     */
    @NonNull
    public static final Action BACK = new Action(TYPE_BACK);

    @Keep
    @Nullable
    private final CarText mTitle;
    @Keep
    @Nullable
    private final CarIcon mIcon;
    @Keep
    private final CarColor mBackgroundColor;
    @Keep
    @Nullable
    private final OnClickDelegate mOnClickDelegate;
    @Keep
    @ActionType
    private final int mType;

    /**
     * Returns the title displayed in the action or {@code null} if the action does not have a
     * title.
     *
     * @see Builder#setTitle(CharSequence)
     */
    @Nullable
    public CarText getTitle() {
        return mTitle;
    }

    /**
     * Returns the {@link CarIcon} to display in the action or {@code null} if the action does
     * not have an icon.
     *
     * @see Builder#setIcon(CarIcon)
     */
    @Nullable
    public CarIcon getIcon() {
        return mIcon;
    }

    /**
     * Returns the {@link CarColor} used for the background color of the action.
     *
     * @see Builder#setBackgroundColor(CarColor)
     */
    @Nullable
    public CarColor getBackgroundColor() {
        return mBackgroundColor;
    }

    /** Returns the type of the action. */
    @ActionType
    public int getType() {
        return mType;
    }

    /** Returns whether the action is a standard action such as {@link #BACK}. */
    public boolean isStandard() {
        return isStandardActionType(mType);
    }

    /**
     * Returns the {@link OnClickDelegate} that should be used for this action.
     */
    @Nullable
    public OnClickDelegate getOnClickDelegate() {
        return mOnClickDelegate;
    }

    @Override
    @NonNull
    public String toString() {
        return "[type: " + typeToString(mType) + ", icon: " + mIcon + ", bkg: " + mBackgroundColor
                + "]";
    }

    /**
     * Converts the given {@code type} into a string representation.
     */
    @NonNull
    public static String typeToString(@ActionType int type) {
        switch (type) {
            case TYPE_CUSTOM:
                return "CUSTOM";
            case TYPE_APP_ICON:
                return "APP_ICON";
            case TYPE_BACK:
                return "BACK";
            default:
                return "<unknown>";
        }
    }

    /** Convenience constructor for standard action singletons. */
    private Action(@ActionType int type) {
        if (!isStandardActionType(type)) {
            throw new IllegalArgumentException(
                    "Standard action constructor used with non standard type");
        }

        mTitle = null;
        mIcon = null;
        mBackgroundColor = DEFAULT;
        mOnClickDelegate = null;
        mType = type;
    }

    Action(Builder builder) {
        mTitle = builder.mTitle;
        mIcon = builder.mIcon;
        mBackgroundColor = builder.mBackgroundColor;
        mOnClickDelegate = builder.mOnClickDelegate;
        mType = builder.mType;
    }

    /** Constructs an empty instance, used by serialization code. */
    private Action() {
        mTitle = null;
        mIcon = null;
        mBackgroundColor = DEFAULT;
        mOnClickDelegate = null;
        mType = TYPE_CUSTOM;
    }

    @Override
    public int hashCode() {
        return Objects.hash(mTitle, mType, mOnClickDelegate == null, mIcon == null);
    }

    @Override
    public boolean equals(@Nullable Object other) {
        if (this == other) {
            return true;
        }
        if (!(other instanceof Action)) {
            return false;
        }
        Action otherAction = (Action) other;

        // Don't compare callback, only ensure if it is present in one, it is also present in
        // the other.
        return Objects.equals(mTitle, otherAction.mTitle)
                && mType == otherAction.mType
                && Objects.equals(mIcon, otherAction.mIcon)
                && Objects.equals(mOnClickDelegate == null, otherAction.mOnClickDelegate == null);
    }

    static boolean isStandardActionType(@ActionType int type) {
        return 0 != (type & FLAG_STANDARD);
    }

    /** A builder of {@link Action}. */
    public static final class Builder {
        @Nullable
        CarText mTitle;
        @Nullable
        CarIcon mIcon;
        @Nullable
        OnClickDelegate mOnClickDelegate;
        CarColor mBackgroundColor = DEFAULT;
        @ActionType
        int mType = TYPE_CUSTOM;

        /**
         * Sets the title to display in the action.
         *
         * <p>Spans are not supported in the input string.
         *
         * @throws NullPointerException if {@code title} is {@code null}
         * @see CarText
         */
        @NonNull
        public Builder setTitle(@NonNull CharSequence title) {
            mTitle = CarText.create(requireNonNull(title));
            return this;
        }

        /**
         * Sets the title to display in the action.
         *
         * <p>Spans are not supported in the input string.
         *
         * @throws NullPointerException if {@code title} is {@code null}
         * @see CarText
         */
        @ExperimentalCarApi
        @NonNull
        public Builder setTitle(@NonNull CarText title) {
            mTitle = requireNonNull(title);
            return this;
        }

        /**
         * Sets the icon to display in the action.
         *
         * <p>Unless set with this method, the action will not have an icon.
         *
         * <h4>Icon Sizing Guidance</h4>
         *
         * The provided icon should have a maximum size of 36 x 36 dp. If the icon exceeds this
         * maximum size in either one of the dimensions, it will be scaled down to be centered
         * inside the bounding box while preserving the aspect ratio.
         *
         * <p>See {@link CarIcon} for more details related to providing icon and image resources
         * that work with different car screen pixel densities.
         *
         * @throws NullPointerException if {@code icon} is {@code null}
         */
        @NonNull
        public Builder setIcon(@NonNull CarIcon icon) {
            CarIconConstraints.DEFAULT.validateOrThrow(requireNonNull(icon));
            mIcon = icon;
            return this;
        }

        /**
         * Sets the {@link OnClickListener} to call when the action is clicked.
         *
         * <p>Unless set with this method, the action will not have a click listener.
         *
         * <p>Note that the listener relates to UI events and will be executed on the main thread
         * using {@link Looper#getMainLooper()}.
         *
         * @throws NullPointerException if {@code listener} is {@code null}
         */
        @NonNull
        @SuppressLint({"MissingGetterMatchingBuilder", "ExecutorRegistration"})
        public Builder setOnClickListener(@NonNull OnClickListener listener) {
            mOnClickDelegate = OnClickDelegateImpl.create(listener);
            return this;
        }

        /**
         * Sets the background color to be used for the action.
         *
         * <h4>Requirements</h4>
         *
         * <p>The host may ignore this color and use the default instead if the color does not
         * pass the contrast requirements.
         *
         * <p>Note the color of the text cannot be specified. Host implementations may pick the
         * dark or light versions of the given background color as needed.
         *
         * @param backgroundColor the {@link CarColor} to set as background. Use {@link
         *                        CarColor#DEFAULT} to let the host pick a default
         * @throws IllegalArgumentException if {@code backgroundColor} is not a standard color
         * @throws NullPointerException     if {@code backgroundColor} is {@code null}
         */
        @NonNull
        public Builder setBackgroundColor(@NonNull CarColor backgroundColor) {
            STANDARD_ONLY.validateOrThrow(requireNonNull(backgroundColor));
            mBackgroundColor = backgroundColor;
            return this;
        }

        /**
         * Constructs the {@link Action} defined by this builder.
         *
         * @throws IllegalStateException if the action is not a standard action and does not have an
         *                               icon or a title, if a listener is set on either
         *                               {@link #APP_ICON} or {@link #BACK}, or if an icon or
         *                               title is set on either {@link #APP_ICON} or {@link #BACK}
         */
        @NonNull
        public Action build() {
            boolean isStandard = isStandardActionType(mType);
            if (!isStandard && mIcon == null && (mTitle == null || TextUtils.isEmpty(
                    mTitle.toString()))) {
                throw new IllegalStateException("An action must have either an icon or a title");
            }

            if ((mType == TYPE_APP_ICON || mType == TYPE_BACK)) {
                if (mOnClickDelegate != null) {
                    throw new IllegalStateException(
                            "An on-click listener can't be set on the standard back or app-icon "
                                    + "action");
                }

                if (mIcon != null || (mTitle != null && !TextUtils.isEmpty(mTitle.toString()))) {
                    throw new IllegalStateException(
                            "An icon or title can't be set on the standard back or app-icon "
                                    + "action");
                }
            }

            return new Action(this);
        }

        /** Creates an empty {@link Builder} instance. */
        public Builder() {
        }
    }
}