/*
 * Copyright (C) 2017 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 com.android.systemui.qs.tileimpl;

import static androidx.lifecycle.Lifecycle.State.CREATED;
import static androidx.lifecycle.Lifecycle.State.DESTROYED;
import static androidx.lifecycle.Lifecycle.State.RESUMED;
import static androidx.lifecycle.Lifecycle.State.STARTED;

import static com.android.internal.logging.nano.MetricsProto.MetricsEvent.ACTION_QS_CLICK;
import static com.android.internal.logging.nano.MetricsProto.MetricsEvent.ACTION_QS_LONG_PRESS;
import static com.android.internal.logging.nano.MetricsProto.MetricsEvent.ACTION_QS_SECONDARY_CLICK;
import static com.android.internal.logging.nano.MetricsProto.MetricsEvent.FIELD_IS_FULL_QS;
import static com.android.internal.logging.nano.MetricsProto.MetricsEvent.FIELD_QS_POSITION;
import static com.android.internal.logging.nano.MetricsProto.MetricsEvent.FIELD_QS_VALUE;
import static com.android.internal.logging.nano.MetricsProto.MetricsEvent.FIELD_STATUS_BAR_STATE;
import static com.android.internal.logging.nano.MetricsProto.MetricsEvent.TYPE_ACTION;
import static com.android.settingslib.RestrictedLockUtils.EnforcedAdmin;

import android.annotation.CallSuper;
import android.annotation.NonNull;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.metrics.LogMaker;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.text.format.DateUtils;
import android.util.ArraySet;
import android.util.Log;
import android.util.SparseArray;

import androidx.annotation.Nullable;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LifecycleRegistry;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.jank.InteractionJankMonitor;
import com.android.internal.logging.InstanceId;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.UiEventLogger;
import com.android.settingslib.RestrictedLockUtils;
import com.android.settingslib.RestrictedLockUtilsInternal;
import com.android.settingslib.graph.SignalDrawable;
import com.android.systemui.Dumpable;
import com.android.systemui.animation.ActivityTransitionAnimator;
import com.android.systemui.animation.Expandable;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.plugins.FalsingManager;
import com.android.systemui.plugins.qs.QSTile;
import com.android.systemui.plugins.qs.QSTile.State;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.qs.QSEvent;
import com.android.systemui.qs.QSHost;
import com.android.systemui.qs.QsEventLogger;
import com.android.systemui.qs.SideLabelTileLayout;
import com.android.systemui.qs.logging.QSLogger;
import com.android.systemui.statusbar.policy.KeyguardStateController;

import java.io.PrintWriter;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Base quick-settings tile, extend this to create a new tile.
 *
 * State management done on a looper provided by the host.  Tiles should update state in
 * handleUpdateState.  Callbacks affecting state should use refreshState to trigger another
 * state update pass on tile looper.
 *
 * @param <TState> see above
 */
public abstract class QSTileImpl<TState extends State> implements QSTile, LifecycleOwner, Dumpable {
    protected final String TAG = "Tile." + getClass().getSimpleName();
    protected final boolean DEBUG = Log.isLoggable("Tile", Log.DEBUG);

    private static final long DEFAULT_STALE_TIMEOUT = 10 * DateUtils.MINUTE_IN_MILLIS;
    protected static final Object ARG_SHOW_TRANSIENT_ENABLING = new Object();
    protected static final Object ARG_SHOW_TRANSIENT_DISABLING = new Object();

    private static final int READY_STATE_NOT_READY = 0;
    private static final int READY_STATE_READYING = 1;
    private static final int READY_STATE_READY = 2;

    protected final QSHost mHost;
    protected final Context mContext;
    // @NonFinalForTesting
    protected final H mHandler;
    protected final Handler mUiHandler;
    private final ArraySet<Object> mListeners = new ArraySet<>();
    private final MetricsLogger mMetricsLogger;
    private final StatusBarStateController mStatusBarStateController;
    protected final ActivityStarter mActivityStarter;
    private final UiEventLogger mUiEventLogger;
    private final FalsingManager mFalsingManager;
    protected final QSLogger mQSLogger;
    private volatile int mReadyState;
    // Keeps track of the click event, to match it with the handling in the background thread
    // Only read and modified in main thread (where click events come through).
    private int mClickEventId = 0;

    private final ArraySet<Callback> mCallbacks = new ArraySet<>();
    private final Object mStaleListener = new Object();
    protected TState mState;
    private TState mTmpState;
    private final InstanceId mInstanceId;
    private boolean mAnnounceNextStateChange;

    private String mTileSpec;
    @Nullable
    @VisibleForTesting
    protected EnforcedAdmin mEnforcedAdmin;
    private boolean mShowingDetail;
    private int mIsFullQs;

    private final LifecycleRegistry mLifecycle = new LifecycleRegistry(this);
    private final AtomicBoolean mIsDestroyed = new AtomicBoolean(false);
    private final AtomicInteger mCurrentTileUser = new AtomicInteger();

    /**
     * Provides a new {@link TState} of the appropriate type to use between this tile and the
     * corresponding view.
     *
     * @return new state to use by the tile.
     */
    public abstract TState newTileState();

    /**
     * Handles clicks by the user.
     *
     * Calls to the controller should be made here to set the new state of the device.
     *
     * @param expandable {@link Expandable} that was clicked.
     */
    protected abstract void handleClick(@Nullable Expandable expandable);

    /**
     * Update state of the tile based on device state
     *
     * Called whenever the state of the tile needs to be updated, either after user
     * interaction or from callbacks from the controller. It populates {@code state} with the
     * information to display to the user.
     *
     * @param state {@link TState} to populate with information to display
     * @param arg additional arguments needed to populate {@code state}
     */
    abstract protected void handleUpdateState(TState state, Object arg);

    /**
     * Declare the category of this tile.
     *
     * Categories are defined in {@link com.android.internal.logging.nano.MetricsProto.MetricsEvent}
     * by editing frameworks/base/proto/src/metrics_constants.proto.
     *
     * @deprecated Not needed as this logging is deprecated. Logging tiles is done using
     * {@link QSTile#getMetricsSpec}
     */
    @Deprecated
    public int getMetricsCategory() {
        return 0;
    }

    /**
     * Performs initialization of the tile
     *
     * Use this to perform initialization of the tile. Empty by default.
     */
    protected void handleInitialize() {

    }

    protected QSTileImpl(
            QSHost host,
            QsEventLogger uiEventLogger,
            Looper backgroundLooper,
            Handler mainHandler,
            FalsingManager falsingManager,
            MetricsLogger metricsLogger,
            StatusBarStateController statusBarStateController,
            ActivityStarter activityStarter,
            QSLogger qsLogger
    ) {
        mHost = host;
        mContext = host.getContext();
        mInstanceId = uiEventLogger.getNewInstanceId();
        mUiEventLogger = uiEventLogger;

        mUiHandler = mainHandler;
        mHandler = new H(backgroundLooper);
        mFalsingManager = falsingManager;
        mQSLogger = qsLogger;
        mMetricsLogger = metricsLogger;
        mStatusBarStateController = statusBarStateController;
        mActivityStarter = activityStarter;
        mCurrentTileUser.set(host.getUserId());

        resetStates();
        mUiHandler.post(() -> mLifecycle.setCurrentState(CREATED));
    }

    protected final void resetStates() {
        mState = newTileState();
        mTmpState = newTileState();
        mState.spec = mTileSpec;
        mTmpState.spec = mTileSpec;
    }

    @NonNull
    @Override
    public Lifecycle getLifecycle() {
        return mLifecycle;
    }

    @Override
    public InstanceId getInstanceId() {
        return mInstanceId;
    }

    /**
     * Adds or removes a listening client for the tile. If the tile has one or more
     * listening client it will go into the listening state.
     */
    public void setListening(Object listener, boolean listening) {
        mHandler.obtainMessage(H.SET_LISTENING, listening ? 1 : 0, 0, listener).sendToTarget();
    }

    protected long getStaleTimeout() {
        return DEFAULT_STALE_TIMEOUT;
    }

    @VisibleForTesting
    protected void handleStale() {
        if (!mListeners.isEmpty()) {
            // If the tile is already listening (it's been a long time since it refreshed), just
            // force a refresh. Don't add the staleListener because there's already a listener there
            refreshState();
        } else {
            setListening(mStaleListener, true);
        }
    }

    public String getTileSpec() {
        return mTileSpec;
    }

    public void setTileSpec(String tileSpec) {
        mTileSpec = tileSpec;
        mState.spec = tileSpec;
        mTmpState.spec = tileSpec;
    }

    public QSHost getHost() {
        return mHost;
    }

    /**
     * Is a startup check whether this device currently supports this tile.
     * Should not be used to conditionally hide tiles.  Only checked on tile
     * creation or whether should be shown in edit screen.
     */
    public boolean isAvailable() {
        return true;
    }

    public enum Action {
        CLICK,
        SECONDARY_CLICK,
        LONG_CLICK,
    }

    public boolean isAllowedWhenLocked(Action action) {
        return false;
    }

    void handleAction(Action action, Runnable handler) {
        if (isAllowedWhenLocked(action)) {
            handler.run();
            return;
        }

        KeyguardStateController ksc = mHost.getKeyguardStateController();
        boolean hasSecureKeyguard = ksc.isMethodSecure() && ksc.isShowing();

        if (hasSecureKeyguard) {
            mActivityStarter.postQSRunnableDismissingKeyguard(handler);
        } else {
            handler.run();
        }
    }

    // safe to call from any thread

    public void addCallback(Callback callback) {
        mHandler.obtainMessage(H.ADD_CALLBACK, callback).sendToTarget();
    }

    public void removeCallback(Callback callback) {
        mHandler.obtainMessage(H.REMOVE_CALLBACK, callback).sendToTarget();
    }

    public void removeCallbacks() {
        mHandler.sendEmptyMessage(H.REMOVE_CALLBACKS);
    }

    @Override
    public void click(@Nullable Expandable expandable) {
        mMetricsLogger.write(populate(new LogMaker(ACTION_QS_CLICK).setType(TYPE_ACTION)
                .addTaggedData(FIELD_STATUS_BAR_STATE,
                        mStatusBarStateController.getState())));
        mUiEventLogger.logWithInstanceId(QSEvent.QS_ACTION_CLICK, 0, getMetricsSpec(),
                getInstanceId());
        final int eventId = mClickEventId++;
        mQSLogger.logTileClick(mTileSpec, mStatusBarStateController.getState(), mState.state,
                eventId);
        if (!mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) {
            mHandler.obtainMessage(H.CLICK, eventId, 0, expandable).sendToTarget();
        }
    }

    @Override
    public void secondaryClick(@Nullable Expandable expandable) {
        mMetricsLogger.write(populate(new LogMaker(ACTION_QS_SECONDARY_CLICK).setType(TYPE_ACTION)
                .addTaggedData(FIELD_STATUS_BAR_STATE,
                        mStatusBarStateController.getState())));
        mUiEventLogger.logWithInstanceId(QSEvent.QS_ACTION_SECONDARY_CLICK, 0, getMetricsSpec(),
                getInstanceId());
        final int eventId = mClickEventId++;
        mQSLogger.logTileSecondaryClick(mTileSpec, mStatusBarStateController.getState(),
                mState.state, eventId);
        if (!mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) {
            mHandler.obtainMessage(H.SECONDARY_CLICK, eventId, 0, expandable).sendToTarget();
        }
    }

    @Override
    public void longClick(@Nullable Expandable expandable) {
        mMetricsLogger.write(populate(new LogMaker(ACTION_QS_LONG_PRESS).setType(TYPE_ACTION)
                .addTaggedData(FIELD_STATUS_BAR_STATE,
                        mStatusBarStateController.getState())));
        mUiEventLogger.logWithInstanceId(QSEvent.QS_ACTION_LONG_PRESS, 0, getMetricsSpec(),
                getInstanceId());
        final int eventId = mClickEventId++;
        mQSLogger.logTileLongClick(mTileSpec, mStatusBarStateController.getState(), mState.state,
                eventId);
        if (!mFalsingManager.isFalseLongTap(FalsingManager.LOW_PENALTY)) {
            mHandler.obtainMessage(H.LONG_CLICK, eventId, 0, expandable).sendToTarget();
        }
    }

    public LogMaker populate(LogMaker logMaker) {
        if (mState instanceof BooleanState) {
            logMaker.addTaggedData(FIELD_QS_VALUE, ((BooleanState) mState).value ? 1 : 0);
        }
        return logMaker.setSubtype(getMetricsCategory())
                .addTaggedData(FIELD_IS_FULL_QS, mIsFullQs)
                .addTaggedData(FIELD_QS_POSITION, mHost.indexOf(mTileSpec));
    }

    public void refreshState() {
        refreshState(null);
    }

    @Override
    public final boolean isListening() {
        return getLifecycle().getCurrentState().isAtLeast(RESUMED);
    }

    protected final void refreshState(@Nullable Object arg) {
        mHandler.obtainMessage(H.REFRESH_STATE, arg).sendToTarget();
    }

    public void userSwitch(int newUserId) {
        mCurrentTileUser.set(newUserId);
        mHandler.obtainMessage(H.USER_SWITCH, newUserId, 0).sendToTarget();
        postStale();
    }

    @Override
    public int getCurrentTileUser() {
        return mCurrentTileUser.get();
    }

    public void destroy() {
        // We mark it as soon as we start the destroy process, as nothing can interrupt it.
        mIsDestroyed.set(true);
        mHandler.sendEmptyMessage(H.DESTROY);
    }

    /**
     * Schedules initialization of the tile.
     *
     * Should be called upon creation of the tile, before performing other operations
     */
    public final void initialize() {
        mHandler.sendEmptyMessage(H.INITIALIZE);
    }

    @androidx.annotation.NonNull
    public TState getState() {
        return mState;
    }

    public void setDetailListening(boolean listening) {
        // optional
    }

    // call only on tile worker looper

    private void handleAddCallback(Callback callback) {
        mCallbacks.add(callback);
        callback.onStateChanged(mState);
    }

    private void handleRemoveCallback(Callback callback) {
        mCallbacks.remove(callback);
    }

    private void handleRemoveCallbacks() {
        mCallbacks.clear();
    }

    /**
     * Posts a stale message to the background thread.
     */
    public void postStale() {
        mHandler.sendEmptyMessage(H.STALE);
    }

    /**
     * Handles secondary click on the tile.
     *
     * Defaults to {@link QSTileImpl#handleClick}
     *
     * @param expandable {@link Expandable} that was clicked.
     */
    protected void handleSecondaryClick(@Nullable Expandable expandable) {
        // Default to normal click.
        handleClick(expandable);
    }

    /**
     * Handles long click on the tile by launching the {@link Intent} defined in
     * {@link QSTileImpl#getLongClickIntent}.
     *
     * @param expandable {@link Expandable} from which the opening window will be animated.
     */
    protected void handleLongClick(@Nullable Expandable expandable) {
        ActivityTransitionAnimator.Controller animationController =
                expandable != null ? expandable.activityTransitionController(
                        InteractionJankMonitor.CUJ_SHADE_APP_LAUNCH_FROM_QS_TILE) : null;
        mActivityStarter.postStartActivityDismissingKeyguard(getLongClickIntent(), 0,
                animationController);
    }

    /**
     * Returns an intent to be launched when the tile is long pressed.
     *
     * @return the intent to launch
     */
    @Nullable
    public abstract Intent getLongClickIntent();

    protected final void handleRefreshState(@Nullable Object arg) {
        handleUpdateState(mTmpState, arg);
        boolean changed = mTmpState.copyTo(mState);
        if (mReadyState == READY_STATE_READYING) {
            mReadyState = READY_STATE_READY;
            changed = true;
        }
        if (changed) {
            mQSLogger.logTileUpdated(mTileSpec, mState);
            handleStateChanged();
        }
        mHandler.removeMessages(H.STALE);
        mHandler.sendEmptyMessageDelayed(H.STALE, getStaleTimeout());
        setListening(mStaleListener, false);
    }

    private void handleStateChanged() {
        if (!mCallbacks.isEmpty()) {
            for (int i = 0; i < mCallbacks.size(); i++) {
                mCallbacks.valueAt(i).onStateChanged(mState);
            }
        }
    }

    protected void handleUserSwitch(int newUserId) {
        handleRefreshState(null);
    }

    private void handleSetListeningInternal(Object listener, boolean listening) {
        // This should be used to go from resumed to paused. Listening for ON_RESUME and ON_PAUSE
        // in this lifecycle will determine the listening window.
        if (listening) {
            if (mListeners.add(listener) && mListeners.size() == 1) {
                if (DEBUG) Log.d(TAG, "handleSetListening true");
                handleSetListening(listening);
                mUiHandler.post(() -> {
                    // This tile has been destroyed, the state should not change anymore and we
                    // should not refresh it anymore.
                    if (mLifecycle.getCurrentState().equals(DESTROYED)) return;
                    mLifecycle.setCurrentState(RESUMED);
                    if (mReadyState == READY_STATE_NOT_READY) {
                        mReadyState = READY_STATE_READYING;
                    }
                    refreshState(); // Ensure we get at least one refresh after listening.
                });
            }
        } else {
            if (mListeners.remove(listener) && mListeners.size() == 0) {
                if (DEBUG) Log.d(TAG, "handleSetListening false");
                handleSetListening(listening);
                mUiHandler.post(() -> {
                    // This tile has been destroyed, the state should not change anymore.
                    if (mLifecycle.getCurrentState().equals(DESTROYED)) return;
                    mLifecycle.setCurrentState(STARTED);
                });
            }
        }
        updateIsFullQs();
    }

    private void updateIsFullQs() {
        for (Object listener : mListeners) {
            if (SideLabelTileLayout.class.equals(listener.getClass())) {
                mIsFullQs = 1;
                return;
            }
        }
        mIsFullQs = 0;
    }

    @CallSuper
    protected void handleSetListening(boolean listening) {
        if (mTileSpec != null) {
            mQSLogger.logTileChangeListening(mTileSpec, listening);
        }
    }

    protected void handleDestroy() {
        mQSLogger.logTileDestroyed(mTileSpec, "Handle destroy");
        if (mListeners.size() != 0) {
            handleSetListening(false);
            mListeners.clear();
        }
        mCallbacks.clear();
        mHandler.removeCallbacksAndMessages(null);
        // This will force it to be removed from all controllers that may have it registered.
        mUiHandler.post(() -> {
            mLifecycle.setCurrentState(DESTROYED);
        });
    }

    @Override
    public final boolean isDestroyed() {
        return mIsDestroyed.get();
    }

    protected void checkIfRestrictionEnforcedByAdminOnly(State state, String userRestriction) {
        EnforcedAdmin admin = RestrictedLockUtilsInternal.checkIfRestrictionEnforced(mContext,
                userRestriction, mHost.getUserId());
        if (admin != null && !RestrictedLockUtilsInternal.hasBaseUserRestriction(mContext,
                userRestriction, mHost.getUserId())) {
            state.disabledByPolicy = true;
            mEnforcedAdmin = admin;
        } else {
            state.disabledByPolicy = false;
            mEnforcedAdmin = null;
        }
    }

    protected Icon maybeLoadResourceIcon(int id) {
        return maybeLoadResourceIcon(id, mContext);
    }

    /**
     * Returns the {@link QSTile.Icon} for the resource ID, loading the drawable.
     */
    @SuppressLint("UseCompatLoadingForDrawables")
    public static Icon maybeLoadResourceIcon(int id, Context context) {
        return new DrawableIconWithRes(context.getDrawable(id), id);
    }

    @Override
    public String getMetricsSpec() {
        return mTileSpec;
    }

    /**
     * Provides a default label for the tile.
     * @return default label for the tile.
     */
    public abstract CharSequence getTileLabel();

    /**
     * @return {@code true} if the tile has refreshed state at least once after having set its
     *         lifecycle to {@link Lifecycle.State#RESUMED}.
     */
    @Override
    public boolean isTileReady() {
        return mReadyState == READY_STATE_READY;
    }

    protected final class H extends Handler {
        private static final int ADD_CALLBACK = 1;
        private static final int CLICK = 2;
        private static final int SECONDARY_CLICK = 3;
        private static final int LONG_CLICK = 4;
        private static final int REFRESH_STATE = 5;
        private static final int USER_SWITCH = 6;
        private static final int DESTROY = 7;
        private static final int REMOVE_CALLBACKS = 8;
        private static final int REMOVE_CALLBACK = 9;
        private static final int SET_LISTENING = 10;
        @VisibleForTesting
        protected static final int STALE = 11;
        private static final int INITIALIZE = 12;

        @VisibleForTesting
        protected H(Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(Message msg) {
            String name = null;
            try {
                if (msg.what == ADD_CALLBACK) {
                    name = "handleAddCallback";
                    handleAddCallback((QSTile.Callback) msg.obj);
                } else if (msg.what == REMOVE_CALLBACKS) {
                    name = "handleRemoveCallbacks";
                    handleRemoveCallbacks();
                } else if (msg.what == REMOVE_CALLBACK) {
                    name = "handleRemoveCallback";
                    handleRemoveCallback((QSTile.Callback) msg.obj);
                } else if (msg.what == CLICK) {
                    name = "handleClick";
                    if (mState.disabledByPolicy) {
                        Intent intent = RestrictedLockUtils.getShowAdminSupportDetailsIntent(
                                mEnforcedAdmin);
                        mActivityStarter.postStartActivityDismissingKeyguard(intent, 0);
                    } else {
                        mQSLogger.logHandleClick(mTileSpec, msg.arg1);
                        Expandable view = (Expandable) msg.obj;
                        handleAction(Action.CLICK, () -> handleClick(view));
                    }
                } else if (msg.what == SECONDARY_CLICK) {
                    name = "handleSecondaryClick";
                    mQSLogger.logHandleSecondaryClick(mTileSpec, msg.arg1);
                    Expandable view = (Expandable) msg.obj;
                    handleAction(Action.SECONDARY_CLICK, () -> handleSecondaryClick(view));
                } else if (msg.what == LONG_CLICK) {
                    name = "handleLongClick";
                    mQSLogger.logHandleLongClick(mTileSpec, msg.arg1);
                    Expandable view = (Expandable) msg.obj;
                    handleAction(Action.LONG_CLICK, () -> handleLongClick(view));
                } else if (msg.what == REFRESH_STATE) {
                    name = "handleRefreshState";
                    handleRefreshState(msg.obj);
                } else if (msg.what == USER_SWITCH) {
                    name = "handleUserSwitch";
                    handleUserSwitch(msg.arg1);
                } else if (msg.what == DESTROY) {
                    name = "handleDestroy";
                    handleDestroy();
                } else if (msg.what == SET_LISTENING) {
                    name = "handleSetListeningInternal";
                    handleSetListeningInternal(msg.obj, msg.arg1 != 0);
                } else if (msg.what == STALE) {
                    name = "handleStale";
                    handleStale();
                } else if (msg.what == INITIALIZE) {
                    name = "initialize";
                    handleInitialize();
                } else {
                    throw new IllegalArgumentException("Unknown msg: " + msg.what);
                }
            } catch (Throwable t) {
                final String error = "Error in " + name;
                Log.w(TAG, error, t);
            }
        }
    }

    public static class DrawableIcon extends Icon {

        protected final Drawable mDrawable;
        protected final Drawable mInvisibleDrawable;
        private static final String TAG = "QSTileImpl";

        public DrawableIcon(Drawable drawable) {
            mDrawable = drawable;
            Drawable.ConstantState nullableConstantState = drawable.getConstantState();
            if (nullableConstantState == null) {
                if (!(drawable instanceof SignalDrawable)) {
                    Log.w(TAG, "DrawableIcon: drawable has null ConstantState"
                            + " and is not a SignalDrawable");
                }
                mInvisibleDrawable = drawable;
            } else {
                mInvisibleDrawable = nullableConstantState.newDrawable();
            }
        }

        @Override
        public Drawable getDrawable(Context context) {
            return mDrawable;
        }

        @Override
        public Drawable getInvisibleDrawable(Context context) {
            return mInvisibleDrawable;
        }

        @Override
        @NonNull
        public String toString() {
            return "DrawableIcon";
        }

        @Override
        public boolean equals(@Nullable Object other) {
            // No need to compare equality of the mInvisibleDrawable as that's generated from
            // mDrawable's constant state.
            return other instanceof DrawableIcon && ((DrawableIcon) other).mDrawable == mDrawable;
        }

        @Override
        public int hashCode() {
            return Objects.hash(mDrawable);
        }
    }

    public static class DrawableIconWithRes extends DrawableIcon {
        private final int mId;

        public DrawableIconWithRes(Drawable drawable, int id) {
            super(drawable);
            mId = id;
        }

        public int getResourceId() {
            return mId;
        }

        @Override
        public boolean equals(Object o) {
            return o instanceof DrawableIconWithRes && ((DrawableIconWithRes) o).mId == mId;
        }

        @Override
        @NonNull
        public String toString() {
            return String.format("DrawableIconWithRes[resId=0x%08x]", mId);
        }
    }

    public static class ResourceIcon extends Icon {
        private static final SparseArray<Icon> ICONS = new SparseArray<Icon>();

        protected final int mResId;

        private ResourceIcon(int resId) {
            mResId = resId;
        }

        public static synchronized Icon get(int resId) {
            Icon icon = ICONS.get(resId);
            if (icon == null) {
                icon = new ResourceIcon(resId);
                ICONS.put(resId, icon);
            }
            return icon;
        }

        @Override
        public Drawable getDrawable(Context context) {
            return context.getDrawable(mResId);
        }

        @Override
        public Drawable getInvisibleDrawable(Context context) {
            return context.getDrawable(mResId);
        }

        public int getResId() {
            return mResId;
        }

        @Override
        public boolean equals(Object o) {
            return o instanceof ResourceIcon && ((ResourceIcon) o).mResId == mResId;
        }

        @Override
        @NonNull
        public String toString() {
            return String.format("ResourceIcon[resId=0x%08x]", mResId);
        }
    }

    protected static class AnimationIcon extends ResourceIcon {
        private final int mAnimatedResId;

        public AnimationIcon(int resId, int staticResId) {
            super(staticResId);
            mAnimatedResId = resId;
        }

        @Override
        public Drawable getDrawable(Context context) {
            // workaround: get a clean state for every new AVD
            return context.getDrawable(mAnimatedResId).getConstantState().newDrawable();
        }

        @Override
        @NonNull
        public String toString() {
            return String.format("AnimationIcon[resId=0x%08x]", mResId);
        }
    }

    /**
     * Dumps the state of this tile along with its name.
     *
     * This may be used for CTS testing of tiles.
     */
    @Override
    public void dump(PrintWriter pw, String[] args) {
        pw.print(this.getClass().getSimpleName() + ":");
        pw.print("    "); pw.println(getState().toString());
    }
}
