/*
 * Copyright (C) 2019 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.statusbar;

import static com.android.internal.jank.InteractionJankMonitor.CUJ_LOCKSCREEN_TRANSITION_FROM_AOD;
import static com.android.internal.jank.InteractionJankMonitor.CUJ_LOCKSCREEN_TRANSITION_TO_AOD;
import static com.android.systemui.keyguard.shared.model.KeyguardState.GONE;
import static com.android.systemui.util.kotlin.JavaAdapterKt.combineFlows;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.os.SystemProperties;
import android.os.Trace;
import android.text.format.DateFormat;
import android.util.FloatProperty;
import android.util.Log;
import android.view.Choreographer;
import android.view.View;
import android.view.animation.Interpolator;

import androidx.annotation.NonNull;

import com.android.app.animation.Interpolators;
import com.android.compose.animation.scene.SceneKey;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.jank.InteractionJankMonitor;
import com.android.internal.jank.InteractionJankMonitor.Configuration;
import com.android.internal.logging.UiEventLogger;
import com.android.keyguard.KeyguardClockSwitch;
import com.android.systemui.DejankUtils;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.deviceentry.domain.interactor.DeviceUnlockedInteractor;
import com.android.systemui.deviceentry.shared.model.DeviceUnlockStatus;
import com.android.systemui.keyguard.MigrateClocksToBlueprint;
import com.android.systemui.keyguard.domain.interactor.KeyguardClockInteractor;
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor;
import com.android.systemui.plugins.statusbar.StatusBarStateController.StateListener;
import com.android.systemui.res.R;
import com.android.systemui.scene.domain.interactor.SceneInteractor;
import com.android.systemui.scene.shared.flag.SceneContainerFlag;
import com.android.systemui.scene.shared.model.Scenes;
import com.android.systemui.shade.domain.interactor.ShadeInteractor;
import com.android.systemui.statusbar.notification.stack.StackStateAnimator;
import com.android.systemui.statusbar.policy.CallbackController;
import com.android.systemui.util.Compile;
import com.android.systemui.util.kotlin.JavaAdapter;

import com.google.common.base.Preconditions;

import dagger.Lazy;

import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Map;

import javax.inject.Inject;

/**
 * Tracks and reports on {@link StatusBarState}.
 */
@SysUISingleton
public class StatusBarStateControllerImpl implements
        SysuiStatusBarStateController,
        CallbackController<StateListener> {
    private static final String TAG = "SbStateController";
    private static final boolean DEBUG_IMMERSIVE_APPS =
            SystemProperties.getBoolean("persist.debug.immersive_apps", false);

    // Must be a power of 2
    private static final int HISTORY_SIZE = 32;

    private static final int MAX_STATE = StatusBarState.SHADE_LOCKED;
    private static final int MIN_STATE = StatusBarState.SHADE;

    private static final Comparator<RankedListener> sComparator =
            Comparator.comparingInt(o -> o.mRank);
    private static final FloatProperty<StatusBarStateControllerImpl> SET_DARK_AMOUNT_PROPERTY =
            new FloatProperty<StatusBarStateControllerImpl>("mDozeAmount") {

                @Override
                public void setValue(StatusBarStateControllerImpl object, float value) {
                    object.setDozeAmountInternal(value);
                }

                @Override
                public Float get(StatusBarStateControllerImpl object) {
                    return object.mDozeAmount;
                }
            };

    private final ArrayList<RankedListener> mListeners = new ArrayList<>();
    private final UiEventLogger mUiEventLogger;
    private final Lazy<InteractionJankMonitor> mInteractionJankMonitorLazy;
    private final JavaAdapter mJavaAdapter;
    private final Lazy<KeyguardTransitionInteractor> mKeyguardTransitionInteractorLazy;
    private final Lazy<ShadeInteractor> mShadeInteractorLazy;
    private final Lazy<DeviceUnlockedInteractor> mDeviceUnlockedInteractorLazy;
    private final Lazy<SceneInteractor> mSceneInteractorLazy;
    private final Lazy<KeyguardClockInteractor> mKeyguardClockInteractorLazy;
    private int mState;
    private int mLastState;
    private int mUpcomingState;
    private boolean mLeaveOpenOnKeyguardHide;
    private boolean mKeyguardRequested;

    // Record the HISTORY_SIZE most recent states
    private int mHistoryIndex = 0;
    private HistoricalState[] mHistoricalRecords = new HistoricalState[HISTORY_SIZE];
    // These views are used by InteractionJankMonitor to get callback from HWUI.
    private View mView;
    private KeyguardClockSwitch mClockSwitchView;

    /**
     * If any of the system bars is hidden.
     */
    private boolean mIsFullscreen = false;

    /**
     * If the device is currently pulsing (AOD2).
     */
    private boolean mPulsing;

    /**
     * If the device is currently dozing or not.
     */
    private boolean mIsDozing;

    /**
     * If the device is currently dreaming or not.
     */
    private boolean mIsDreaming;

    /**
     * If the status bar is currently expanded or not.
     */
    private boolean mIsExpanded;

    /**
     * Current {@link #mDozeAmount} animator.
     */
    private ValueAnimator mDarkAnimator;

    /**
     * Current doze amount in this frame.
     */
    private float mDozeAmount;

    /**
     * Where the animator will stop.
     */
    private float mDozeAmountTarget;

    /**
     * The type of interpolator that should be used to the doze animation.
     */
    private Interpolator mDozeInterpolator = Interpolators.FAST_OUT_SLOW_IN;

    @Inject
    public StatusBarStateControllerImpl(
            UiEventLogger uiEventLogger,
            Lazy<InteractionJankMonitor> interactionJankMonitorLazy,
            JavaAdapter javaAdapter,
            Lazy<KeyguardTransitionInteractor> keyguardTransitionInteractor,
            Lazy<ShadeInteractor> shadeInteractorLazy,
            Lazy<DeviceUnlockedInteractor> deviceUnlockedInteractorLazy,
            Lazy<SceneInteractor> sceneInteractorLazy,
            Lazy<KeyguardClockInteractor> keyguardClockInteractorLazy) {
        mUiEventLogger = uiEventLogger;
        mInteractionJankMonitorLazy = interactionJankMonitorLazy;
        mJavaAdapter = javaAdapter;
        mKeyguardTransitionInteractorLazy = keyguardTransitionInteractor;
        mShadeInteractorLazy = shadeInteractorLazy;
        mDeviceUnlockedInteractorLazy = deviceUnlockedInteractorLazy;
        mSceneInteractorLazy = sceneInteractorLazy;
        mKeyguardClockInteractorLazy = keyguardClockInteractorLazy;
        for (int i = 0; i < HISTORY_SIZE; i++) {
            mHistoricalRecords[i] = new HistoricalState();
        }
    }

    @Override
    public void start() {
        mJavaAdapter.alwaysCollectFlow(
                mKeyguardTransitionInteractorLazy.get().isFinishedInState(GONE),
                (Boolean isFinishedInState) -> {
                    if (isFinishedInState) {
                        setLeaveOpenOnKeyguardHide(false);
                    }
                });

        mJavaAdapter.alwaysCollectFlow(mShadeInteractorLazy.get().isAnyExpanded(),
                this::onShadeOrQsExpanded);

        if (SceneContainerFlag.isEnabled()) {
            mJavaAdapter.alwaysCollectFlow(
                    combineFlows(
                        mDeviceUnlockedInteractorLazy.get().getDeviceUnlockStatus(),
                        mSceneInteractorLazy.get().getCurrentScene(),
                        this::calculateStateFromSceneFramework),
                    this::onStatusBarStateChanged);
        }
    }

    @Override
    public int getState() {
        return mState;
    }

    @Override
    public boolean setState(int state, boolean force) {
        if (SceneContainerFlag.isEnabled()) {
            return false;
        }

        if (state > MAX_STATE || state < MIN_STATE) {
            throw new IllegalArgumentException("Invalid state " + state);
        }

        // Unless we're explicitly asked to force the state change, don't apply the new state if
        // it's identical to both the current and upcoming states, since that should not be
        // necessary.
        if (!force && state == mState && state == mUpcomingState) {
            return false;
        }

        updateStateAndNotifyListeners(state);
        return true;
    }

    /**
     * Updates the {@link StatusBarState} and notifies registered listeners, if needed.
     */
    private void updateStateAndNotifyListeners(int state) {
        if (state != mUpcomingState) {
            Log.d(TAG, "setState: requested state " + StatusBarState.toString(state)
                    + "!= upcomingState: " + StatusBarState.toString(mUpcomingState) + ". "
                    + "This usually means the status bar state transition was interrupted before "
                    + "the upcoming state could be applied.");
        }

        // Record the to-be mState and mLastState
        recordHistoricalState(state /* newState */, mState /* lastState */, false);

        // b/139259891
        if (mState == StatusBarState.SHADE && state == StatusBarState.SHADE_LOCKED) {
            Log.e(TAG, "Invalid state transition: SHADE -> SHADE_LOCKED", new Throwable());
        }

        synchronized (mListeners) {
            String tag = getClass().getSimpleName() + "#setState(" + state + ")";
            DejankUtils.startDetectingBlockingIpcs(tag);
            for (RankedListener rl : new ArrayList<>(mListeners)) {
                rl.mListener.onStatePreChange(mState, state);
            }
            mLastState = mState;
            mState = state;
            updateUpcomingState(mState);
            mUiEventLogger.log(StatusBarStateEvent.fromState(mState));
            Trace.instantForTrack(Trace.TRACE_TAG_APP, "UI Events", "StatusBarState " + tag);
            for (RankedListener rl : new ArrayList<>(mListeners)) {
                rl.mListener.onStateChanged(mState);
            }

            for (RankedListener rl : new ArrayList<>(mListeners)) {
                rl.mListener.onStatePostChange();
            }
            DejankUtils.stopDetectingBlockingIpcs(tag);
        }
    }

    @Override
    public void setUpcomingState(int nextState) {
        if (SceneContainerFlag.isEnabled()) {
            return;
        }

        recordHistoricalState(nextState /* newState */, mState /* lastState */, true);
        updateUpcomingState(nextState);
    }

    private void updateUpcomingState(int upcomingState) {
        if (mUpcomingState != upcomingState) {
            mUpcomingState = upcomingState;
            for (RankedListener rl : new ArrayList<>(mListeners)) {
                rl.mListener.onUpcomingStateChanged(mUpcomingState);
            }
        }
    }

    @Override
    public int getCurrentOrUpcomingState() {
        return mUpcomingState;
    }

    @Override
    public boolean isDozing() {
        return mIsDozing;
    }

    @Override
    public boolean isPulsing() {
        return mPulsing;
    }

    @Override
    public float getDozeAmount() {
        return mDozeAmount;
    }

    @Override
    public boolean isExpanded() {
        return mIsExpanded;
    }

    @Override
    public float getInterpolatedDozeAmount() {
        return mDozeInterpolator.getInterpolation(mDozeAmount);
    }

    @Override
    public boolean setIsDozing(boolean isDozing) {
        if (mIsDozing == isDozing) {
            return false;
        }

        mIsDozing = isDozing;

        synchronized (mListeners) {
            String tag = getClass().getSimpleName() + "#setIsDozing";
            DejankUtils.startDetectingBlockingIpcs(tag);
            for (RankedListener rl : new ArrayList<>(mListeners)) {
                rl.mListener.onDozingChanged(isDozing);
            }
            DejankUtils.stopDetectingBlockingIpcs(tag);
        }

        return true;
    }

    @Override
    public boolean setIsDreaming(boolean isDreaming) {
        if (Log.isLoggable(TAG, Log.DEBUG) || Compile.IS_DEBUG) {
            Log.d(TAG, "setIsDreaming:" + isDreaming);
        }
        if (mIsDreaming == isDreaming) {
            return false;
        }

        mIsDreaming = isDreaming;

        synchronized (mListeners) {
            String tag = getClass().getSimpleName() + "#setIsDreaming";
            DejankUtils.startDetectingBlockingIpcs(tag);
            for (RankedListener rl : new ArrayList<>(mListeners)) {
                rl.mListener.onDreamingChanged(isDreaming);
            }
            DejankUtils.stopDetectingBlockingIpcs(tag);
        }

        return true;
    }

    @Override
    public boolean isDreaming() {
        return mIsDreaming;
    }

    @Override
    public void setAndInstrumentDozeAmount(View view, float dozeAmount, boolean animated) {
        if (mDarkAnimator != null && mDarkAnimator.isRunning()) {
            if (animated && mDozeAmountTarget == dozeAmount) {
                return;
            } else {
                mDarkAnimator.cancel();
            }
        }

        // We don't need a new attached view if we already have one.
        if ((mView == null || !mView.isAttachedToWindow())
                && (view != null && view.isAttachedToWindow())) {
            mView = view;
            mClockSwitchView = view.findViewById(R.id.keyguard_clock_container);
        }
        mDozeAmountTarget = dozeAmount;
        if (animated) {
            startDozeAnimation();
        } else {
            setDozeAmountInternal(dozeAmount);
        }
    }

    private void onShadeOrQsExpanded(Boolean isExpanded) {
        if (mIsExpanded != isExpanded) {
            mIsExpanded = isExpanded;
            String tag = getClass().getSimpleName() + "#setIsExpanded";
            DejankUtils.startDetectingBlockingIpcs(tag);
            for (RankedListener rl : new ArrayList<>(mListeners)) {
                rl.mListener.onExpandedChanged(mIsExpanded);
            }
            DejankUtils.stopDetectingBlockingIpcs(tag);
        }
    }

    private void startDozeAnimation() {
        if (mDozeAmount == 0f || mDozeAmount == 1f) {
            mDozeInterpolator = mIsDozing
                    ? Interpolators.FAST_OUT_SLOW_IN
                    : Interpolators.TOUCH_RESPONSE_REVERSE;
        }
        if (mDozeAmount == 1f && !mIsDozing) {
            // Workaround to force relayoutWindow to be called a frame earlier. Otherwise, if
            // mDozeAmount = 1f, then neither start() nor the first frame of the animation will
            // cause the scrim opacity to change, which ultimately results in an extra relayout and
            // causes us to miss a frame. By settings the doze amount to be <1f a frame earlier,
            // we can batch the relayout with the one in NotificationShadeWindowControllerImpl.
            setDozeAmountInternal(0.99f);
        }
        mDarkAnimator = createDarkAnimator();
    }

    @VisibleForTesting
    protected ObjectAnimator createDarkAnimator() {
        ObjectAnimator darkAnimator = ObjectAnimator.ofFloat(
                this, SET_DARK_AMOUNT_PROPERTY, mDozeAmountTarget);
        darkAnimator.setInterpolator(Interpolators.LINEAR);
        darkAnimator.setDuration(StackStateAnimator.ANIMATION_DURATION_WAKEUP);
        darkAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationCancel(Animator animation) {
                cancelInteractionJankMonitor();
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                endInteractionJankMonitor();
            }

            @Override
            public void onAnimationStart(Animator animation) {
                beginInteractionJankMonitor();
            }
        });
        darkAnimator.start();
        return darkAnimator;
    }

    private void setDozeAmountInternal(float dozeAmount) {
        if (Float.compare(dozeAmount, mDozeAmount) == 0) {
            return;
        }
        mDozeAmount = dozeAmount;
        float interpolatedAmount = mDozeInterpolator.getInterpolation(dozeAmount);
        synchronized (mListeners) {
            String tag = getClass().getSimpleName() + "#setDozeAmount";
            DejankUtils.startDetectingBlockingIpcs(tag);
            for (RankedListener rl : new ArrayList<>(mListeners)) {
                rl.mListener.onDozeAmountChanged(mDozeAmount, interpolatedAmount);
            }
            DejankUtils.stopDetectingBlockingIpcs(tag);
        }
    }

    /** Returns the id of the currently rendering clock */
    public String getClockId() {
        if (MigrateClocksToBlueprint.isEnabled()) {
            return mKeyguardClockInteractorLazy.get().getRenderedClockId();
        }

        if (mClockSwitchView == null) {
            Log.e(TAG, "Clock container was missing");
            return KeyguardClockSwitch.MISSING_CLOCK_ID;
        }

        return mClockSwitchView.getClockId();
    }

    private void beginInteractionJankMonitor() {
        final boolean shouldPost =
                (mIsDozing && mDozeAmount == 0) || (!mIsDozing && mDozeAmount == 1);
        InteractionJankMonitor monitor = mInteractionJankMonitorLazy.get();
        if (monitor != null && mView != null && mView.isAttachedToWindow()) {
            if (shouldPost) {
                Choreographer.getInstance().postCallback(
                        Choreographer.CALLBACK_ANIMATION, this::beginInteractionJankMonitor, null);
            } else {
                Configuration.Builder builder = Configuration.Builder.withView(getCujType(), mView)
                        .setTag(getClockId())
                        .setDeferMonitorForAnimationStart(false);
                monitor.begin(builder);
            }
        }
    }

    private void endInteractionJankMonitor() {
        InteractionJankMonitor monitor = mInteractionJankMonitorLazy.get();
        if (monitor == null) {
            return;
        }
        monitor.end(getCujType());
    }

    private void cancelInteractionJankMonitor() {
        InteractionJankMonitor monitor = mInteractionJankMonitorLazy.get();
        if (monitor == null) {
            return;
        }
        monitor.cancel(getCujType());
    }

    private int getCujType() {
        return mIsDozing ? CUJ_LOCKSCREEN_TRANSITION_TO_AOD : CUJ_LOCKSCREEN_TRANSITION_FROM_AOD;
    }

    @Override
    public boolean goingToFullShade() {
        return getState() == StatusBarState.SHADE && mLeaveOpenOnKeyguardHide;
    }

    @Override
    public void setLeaveOpenOnKeyguardHide(boolean leaveOpen) {
        mLeaveOpenOnKeyguardHide = leaveOpen;
    }

    @Override
    public boolean leaveOpenOnKeyguardHide() {
        return mLeaveOpenOnKeyguardHide;
    }

    @Override
    public boolean fromShadeLocked() {
        return mLastState == StatusBarState.SHADE_LOCKED;
    }

    @Override
    public void addCallback(@NonNull StateListener listener) {
        synchronized (mListeners) {
            addListenerInternalLocked(listener, Integer.MAX_VALUE);
        }
    }

    /**
     * Add a listener and a rank based on the priority of this message
     * @param listener the listener
     * @param rank the order in which you'd like to be called. Ranked listeners will be
     * notified before unranked, and we will sort ranked listeners from low to high
     *
     * @deprecated This method exists only to solve latent inter-dependencies from refactoring
     * StatusBarState out of CentralSurfaces.java. Any new listeners should be built not to need
     * ranking (i.e., they are non-dependent on the order of operations of StatusBarState
     * listeners).
     */
    @Deprecated
    @Override
    public void addCallback(StateListener listener, @SbStateListenerRank int rank) {
        synchronized (mListeners) {
            addListenerInternalLocked(listener, rank);
        }
    }

    @GuardedBy("mListeners")
    private void addListenerInternalLocked(StateListener listener, int rank) {
        // Protect against double-subscribe
        for (RankedListener rl : mListeners) {
            if (rl.mListener.equals(listener)) {
                return;
            }
        }

        RankedListener rl = new SysuiStatusBarStateController.RankedListener(listener, rank);
        mListeners.add(rl);
        mListeners.sort(sComparator);
    }


    @Override
    public void removeCallback(@NonNull StateListener listener) {
        synchronized (mListeners) {
            mListeners.removeIf((it) -> it.mListener.equals(listener));
        }
    }

    @Override
    public void setKeyguardRequested(boolean keyguardRequested) {
        mKeyguardRequested = keyguardRequested;
    }

    @Override
    public boolean isKeyguardRequested() {
        return mKeyguardRequested;
    }

    @Override
    public void setPulsing(boolean pulsing) {
        if (mPulsing != pulsing) {
            mPulsing = pulsing;
            synchronized (mListeners) {
                for (RankedListener rl : new ArrayList<>(mListeners)) {
                    rl.mListener.onPulsingChanged(pulsing);
                }
            }
        }
    }

    /**
     * Returns String readable state of status bar from {@link StatusBarState}
     */
    public static String describe(int state) {
        return StatusBarState.toString(state);
    }

    @Override
    public void dump(PrintWriter pw, String[] args) {
        pw.println("StatusBarStateController: ");
        pw.println(" mState=" + mState + " (" + describe(mState) + ")");
        pw.println(" mLastState=" + mLastState + " (" + describe(mLastState) + ")");
        pw.println(" mLeaveOpenOnKeyguardHide=" + mLeaveOpenOnKeyguardHide);
        pw.println(" mKeyguardRequested=" + mKeyguardRequested);
        pw.println(" mIsDozing=" + mIsDozing);
        pw.println(" mIsDreaming=" + mIsDreaming);
        pw.println(" mListeners{" + mListeners.size() + "}=");
        for (RankedListener rl : mListeners) {
            pw.println("    " + rl.mListener);
        }
        pw.println(" Historical states:");
        // Ignore records without a timestamp
        int size = 0;
        for (int i = 0; i < HISTORY_SIZE; i++) {
            if (mHistoricalRecords[i].mTimestamp != 0) size++;
        }
        for (int i = mHistoryIndex + HISTORY_SIZE;
                i >= mHistoryIndex + HISTORY_SIZE - size + 1; i--) {
            pw.println("  (" + (mHistoryIndex + HISTORY_SIZE - i + 1) + ")"
                    + mHistoricalRecords[i & (HISTORY_SIZE - 1)]);
        }
    }

    private void recordHistoricalState(int newState, int lastState, boolean upcoming) {
        Trace.traceCounter(Trace.TRACE_TAG_APP, "statusBarState", newState);
        mHistoryIndex = (mHistoryIndex + 1) % HISTORY_SIZE;
        HistoricalState state = mHistoricalRecords[mHistoryIndex];
        state.mNewState = newState;
        state.mLastState = lastState;
        state.mTimestamp = System.currentTimeMillis();
        state.mUpcoming = upcoming;
    }

    private int calculateStateFromSceneFramework(
            DeviceUnlockStatus deviceUnlockStatus,
            SceneKey currentScene) {
        SceneContainerFlag.isUnexpectedlyInLegacyMode();

        if (deviceUnlockStatus.isUnlocked()) {
            return StatusBarState.SHADE;
        } else {
            return Preconditions.checkNotNull(sStatusBarStateByLockedSceneKey.get(currentScene));
        }
    }

    /** Notifies that the {@link StatusBarState} has changed to the given new state. */
    private void onStatusBarStateChanged(int newState) {
        SceneContainerFlag.isUnexpectedlyInLegacyMode();

        if (newState == mState) {
            return;
        }

        updateStateAndNotifyListeners(newState);
    }

    private static final Map<SceneKey, Integer> sStatusBarStateByLockedSceneKey = Map.of(
            Scenes.Lockscreen, StatusBarState.KEYGUARD,
            Scenes.Bouncer, StatusBarState.KEYGUARD,
            Scenes.Communal, StatusBarState.KEYGUARD,
            Scenes.Shade, StatusBarState.SHADE_LOCKED,
            Scenes.NotificationsShade, StatusBarState.SHADE_LOCKED,
            Scenes.QuickSettings, StatusBarState.SHADE_LOCKED,
            Scenes.QuickSettingsShade, StatusBarState.SHADE_LOCKED,
            Scenes.Gone, StatusBarState.SHADE
    );

    /**
     * For keeping track of our previous state to help with debugging
     */
    private static class HistoricalState {
        int mNewState;
        int mLastState;
        long mTimestamp;
        boolean mUpcoming;

        @Override
        public String toString() {
            if (mTimestamp != 0) {
                StringBuilder sb = new StringBuilder();
                if (mUpcoming) {
                    sb.append("upcoming-");
                }
                sb.append("newState=").append(mNewState)
                        .append("(").append(describe(mNewState)).append(")");
                sb.append(" lastState=").append(mLastState).append("(").append(describe(mLastState))
                        .append(")");
                sb.append(" timestamp=")
                        .append(DateFormat.format("MM-dd HH:mm:ss", mTimestamp));

                return sb.toString();
            }
            return "Empty " + getClass().getSimpleName();
        }
    }
}