1 /* 2 * Copyright (C) 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.statusbar; 18 19 import static android.view.InsetsState.ITYPE_NAVIGATION_BAR; 20 import static android.view.InsetsState.ITYPE_STATUS_BAR; 21 import static android.view.WindowInsetsController.APPEARANCE_LOW_PROFILE_BARS; 22 23 import static com.android.internal.jank.InteractionJankMonitor.CUJ_LOCKSCREEN_TRANSITION_FROM_AOD; 24 import static com.android.internal.jank.InteractionJankMonitor.CUJ_LOCKSCREEN_TRANSITION_TO_AOD; 25 26 import android.animation.Animator; 27 import android.animation.AnimatorListenerAdapter; 28 import android.animation.ObjectAnimator; 29 import android.animation.ValueAnimator; 30 import android.os.SystemProperties; 31 import android.os.Trace; 32 import android.text.format.DateFormat; 33 import android.util.FloatProperty; 34 import android.util.Log; 35 import android.view.Choreographer; 36 import android.view.InsetsFlags; 37 import android.view.InsetsVisibilities; 38 import android.view.View; 39 import android.view.ViewDebug; 40 import android.view.WindowInsetsController.Appearance; 41 import android.view.WindowInsetsController.Behavior; 42 import android.view.animation.Interpolator; 43 44 import androidx.annotation.NonNull; 45 46 import com.android.internal.annotations.GuardedBy; 47 import com.android.internal.annotations.VisibleForTesting; 48 import com.android.internal.jank.InteractionJankMonitor; 49 import com.android.internal.jank.InteractionJankMonitor.Configuration; 50 import com.android.internal.logging.UiEventLogger; 51 import com.android.systemui.DejankUtils; 52 import com.android.systemui.Dumpable; 53 import com.android.systemui.animation.Interpolators; 54 import com.android.systemui.dagger.SysUISingleton; 55 import com.android.systemui.dump.DumpManager; 56 import com.android.systemui.plugins.statusbar.StatusBarStateController.StateListener; 57 import com.android.systemui.shade.ShadeExpansionStateManager; 58 import com.android.systemui.statusbar.notification.stack.StackStateAnimator; 59 import com.android.systemui.statusbar.policy.CallbackController; 60 import com.android.systemui.util.Compile; 61 62 import java.io.PrintWriter; 63 import java.util.ArrayList; 64 import java.util.Comparator; 65 66 import javax.inject.Inject; 67 68 /** 69 * Tracks and reports on {@link StatusBarState}. 70 */ 71 @SysUISingleton 72 public class StatusBarStateControllerImpl implements 73 SysuiStatusBarStateController, 74 CallbackController<StateListener>, 75 Dumpable { 76 private static final String TAG = "SbStateController"; 77 private static final boolean DEBUG_IMMERSIVE_APPS = 78 SystemProperties.getBoolean("persist.debug.immersive_apps", false); 79 80 // Must be a power of 2 81 private static final int HISTORY_SIZE = 32; 82 83 private static final int MAX_STATE = StatusBarState.SHADE_LOCKED; 84 private static final int MIN_STATE = StatusBarState.SHADE; 85 86 private static final Comparator<RankedListener> sComparator = 87 Comparator.comparingInt(o -> o.mRank); 88 private static final FloatProperty<StatusBarStateControllerImpl> SET_DARK_AMOUNT_PROPERTY = 89 new FloatProperty<StatusBarStateControllerImpl>("mDozeAmount") { 90 91 @Override 92 public void setValue(StatusBarStateControllerImpl object, float value) { 93 object.setDozeAmountInternal(value); 94 } 95 96 @Override 97 public Float get(StatusBarStateControllerImpl object) { 98 return object.mDozeAmount; 99 } 100 }; 101 102 private final ArrayList<RankedListener> mListeners = new ArrayList<>(); 103 private final UiEventLogger mUiEventLogger; 104 private final InteractionJankMonitor mInteractionJankMonitor; 105 private int mState; 106 private int mLastState; 107 private int mUpcomingState; 108 private boolean mLeaveOpenOnKeyguardHide; 109 private boolean mKeyguardRequested; 110 111 // Record the HISTORY_SIZE most recent states 112 private int mHistoryIndex = 0; 113 private HistoricalState[] mHistoricalRecords = new HistoricalState[HISTORY_SIZE]; 114 // This is used by InteractionJankMonitor to get callback from HWUI. 115 private View mView; 116 117 /** 118 * If any of the system bars is hidden. 119 */ 120 private boolean mIsFullscreen = false; 121 122 /** 123 * If the device is currently pulsing (AOD2). 124 */ 125 private boolean mPulsing; 126 127 /** 128 * If the device is currently dozing or not. 129 */ 130 private boolean mIsDozing; 131 132 /** 133 * If the device is currently dreaming or not. 134 */ 135 private boolean mIsDreaming; 136 137 /** 138 * If the status bar is currently expanded or not. 139 */ 140 private boolean mIsExpanded; 141 142 /** 143 * Current {@link #mDozeAmount} animator. 144 */ 145 private ValueAnimator mDarkAnimator; 146 147 /** 148 * Current doze amount in this frame. 149 */ 150 private float mDozeAmount; 151 152 /** 153 * Where the animator will stop. 154 */ 155 private float mDozeAmountTarget; 156 157 /** 158 * The type of interpolator that should be used to the doze animation. 159 */ 160 private Interpolator mDozeInterpolator = Interpolators.FAST_OUT_SLOW_IN; 161 162 @Inject StatusBarStateControllerImpl( UiEventLogger uiEventLogger, DumpManager dumpManager, InteractionJankMonitor interactionJankMonitor, ShadeExpansionStateManager shadeExpansionStateManager )163 public StatusBarStateControllerImpl( 164 UiEventLogger uiEventLogger, 165 DumpManager dumpManager, 166 InteractionJankMonitor interactionJankMonitor, 167 ShadeExpansionStateManager shadeExpansionStateManager 168 ) { 169 mUiEventLogger = uiEventLogger; 170 mInteractionJankMonitor = interactionJankMonitor; 171 for (int i = 0; i < HISTORY_SIZE; i++) { 172 mHistoricalRecords[i] = new HistoricalState(); 173 } 174 shadeExpansionStateManager.addFullExpansionListener(this::onShadeExpansionFullyChanged); 175 176 dumpManager.registerDumpable(this); 177 } 178 179 @Override getState()180 public int getState() { 181 return mState; 182 } 183 184 @Override setState(int state, boolean force)185 public boolean setState(int state, boolean force) { 186 if (state > MAX_STATE || state < MIN_STATE) { 187 throw new IllegalArgumentException("Invalid state " + state); 188 } 189 190 // Unless we're explicitly asked to force the state change, don't apply the new state if 191 // it's identical to both the current and upcoming states, since that should not be 192 // necessary. 193 if (!force && state == mState && state == mUpcomingState) { 194 return false; 195 } 196 197 if (state != mUpcomingState) { 198 Log.d(TAG, "setState: requested state " + StatusBarState.toString(state) 199 + "!= upcomingState: " + StatusBarState.toString(mUpcomingState) + ". " 200 + "This usually means the status bar state transition was interrupted before " 201 + "the upcoming state could be applied."); 202 } 203 204 // Record the to-be mState and mLastState 205 recordHistoricalState(state /* newState */, mState /* lastState */, false); 206 207 // b/139259891 208 if (mState == StatusBarState.SHADE && state == StatusBarState.SHADE_LOCKED) { 209 Log.e(TAG, "Invalid state transition: SHADE -> SHADE_LOCKED", new Throwable()); 210 } 211 212 synchronized (mListeners) { 213 String tag = getClass().getSimpleName() + "#setState(" + state + ")"; 214 DejankUtils.startDetectingBlockingIpcs(tag); 215 for (RankedListener rl : new ArrayList<>(mListeners)) { 216 rl.mListener.onStatePreChange(mState, state); 217 } 218 mLastState = mState; 219 mState = state; 220 updateUpcomingState(mState); 221 mUiEventLogger.log(StatusBarStateEvent.fromState(mState)); 222 Trace.instantForTrack(Trace.TRACE_TAG_APP, "UI Events", "StatusBarState " + tag); 223 for (RankedListener rl : new ArrayList<>(mListeners)) { 224 rl.mListener.onStateChanged(mState); 225 } 226 227 for (RankedListener rl : new ArrayList<>(mListeners)) { 228 rl.mListener.onStatePostChange(); 229 } 230 DejankUtils.stopDetectingBlockingIpcs(tag); 231 } 232 233 return true; 234 } 235 236 @Override setUpcomingState(int nextState)237 public void setUpcomingState(int nextState) { 238 recordHistoricalState(nextState /* newState */, mState /* lastState */, true); 239 updateUpcomingState(nextState); 240 241 } 242 updateUpcomingState(int upcomingState)243 private void updateUpcomingState(int upcomingState) { 244 if (mUpcomingState != upcomingState) { 245 mUpcomingState = upcomingState; 246 for (RankedListener rl : new ArrayList<>(mListeners)) { 247 rl.mListener.onUpcomingStateChanged(mUpcomingState); 248 } 249 } 250 } 251 252 @Override getCurrentOrUpcomingState()253 public int getCurrentOrUpcomingState() { 254 return mUpcomingState; 255 } 256 257 @Override isDozing()258 public boolean isDozing() { 259 return mIsDozing; 260 } 261 262 @Override isPulsing()263 public boolean isPulsing() { 264 return mPulsing; 265 } 266 267 @Override getDozeAmount()268 public float getDozeAmount() { 269 return mDozeAmount; 270 } 271 272 @Override isExpanded()273 public boolean isExpanded() { 274 return mIsExpanded; 275 } 276 277 @Override getInterpolatedDozeAmount()278 public float getInterpolatedDozeAmount() { 279 return mDozeInterpolator.getInterpolation(mDozeAmount); 280 } 281 282 @Override setIsDozing(boolean isDozing)283 public boolean setIsDozing(boolean isDozing) { 284 if (mIsDozing == isDozing) { 285 return false; 286 } 287 288 mIsDozing = isDozing; 289 290 synchronized (mListeners) { 291 String tag = getClass().getSimpleName() + "#setIsDozing"; 292 DejankUtils.startDetectingBlockingIpcs(tag); 293 for (RankedListener rl : new ArrayList<>(mListeners)) { 294 rl.mListener.onDozingChanged(isDozing); 295 } 296 DejankUtils.stopDetectingBlockingIpcs(tag); 297 } 298 299 return true; 300 } 301 302 @Override setIsDreaming(boolean isDreaming)303 public boolean setIsDreaming(boolean isDreaming) { 304 if (Log.isLoggable(TAG, Log.DEBUG) || Compile.IS_DEBUG) { 305 Log.d(TAG, "setIsDreaming:" + isDreaming); 306 } 307 if (mIsDreaming == isDreaming) { 308 return false; 309 } 310 311 mIsDreaming = isDreaming; 312 313 synchronized (mListeners) { 314 String tag = getClass().getSimpleName() + "#setIsDreaming"; 315 DejankUtils.startDetectingBlockingIpcs(tag); 316 for (RankedListener rl : new ArrayList<>(mListeners)) { 317 rl.mListener.onDreamingChanged(isDreaming); 318 } 319 DejankUtils.stopDetectingBlockingIpcs(tag); 320 } 321 322 return true; 323 } 324 325 @Override isDreaming()326 public boolean isDreaming() { 327 return mIsDreaming; 328 } 329 330 @Override setAndInstrumentDozeAmount(View view, float dozeAmount, boolean animated)331 public void setAndInstrumentDozeAmount(View view, float dozeAmount, boolean animated) { 332 if (mDarkAnimator != null && mDarkAnimator.isRunning()) { 333 if (animated && mDozeAmountTarget == dozeAmount) { 334 return; 335 } else { 336 mDarkAnimator.cancel(); 337 } 338 } 339 340 // We don't need a new attached view if we already have one. 341 if ((mView == null || !mView.isAttachedToWindow()) 342 && (view != null && view.isAttachedToWindow())) { 343 mView = view; 344 } 345 mDozeAmountTarget = dozeAmount; 346 if (animated) { 347 startDozeAnimation(); 348 } else { 349 setDozeAmountInternal(dozeAmount); 350 } 351 } 352 onShadeExpansionFullyChanged(Boolean isExpanded)353 private void onShadeExpansionFullyChanged(Boolean isExpanded) { 354 if (mIsExpanded != isExpanded) { 355 mIsExpanded = isExpanded; 356 String tag = getClass().getSimpleName() + "#setIsExpanded"; 357 DejankUtils.startDetectingBlockingIpcs(tag); 358 for (RankedListener rl : new ArrayList<>(mListeners)) { 359 rl.mListener.onExpandedChanged(mIsExpanded); 360 } 361 DejankUtils.stopDetectingBlockingIpcs(tag); 362 } 363 } 364 startDozeAnimation()365 private void startDozeAnimation() { 366 if (mDozeAmount == 0f || mDozeAmount == 1f) { 367 mDozeInterpolator = mIsDozing 368 ? Interpolators.FAST_OUT_SLOW_IN 369 : Interpolators.TOUCH_RESPONSE_REVERSE; 370 } 371 if (mDozeAmount == 1f && !mIsDozing) { 372 // Workaround to force relayoutWindow to be called a frame earlier. Otherwise, if 373 // mDozeAmount = 1f, then neither start() nor the first frame of the animation will 374 // cause the scrim opacity to change, which ultimately results in an extra relayout and 375 // causes us to miss a frame. By settings the doze amount to be <1f a frame earlier, 376 // we can batch the relayout with the one in NotificationShadeWindowControllerImpl. 377 setDozeAmountInternal(0.99f); 378 } 379 mDarkAnimator = createDarkAnimator(); 380 } 381 382 @VisibleForTesting createDarkAnimator()383 protected ObjectAnimator createDarkAnimator() { 384 ObjectAnimator darkAnimator = ObjectAnimator.ofFloat( 385 this, SET_DARK_AMOUNT_PROPERTY, mDozeAmountTarget); 386 darkAnimator.setInterpolator(Interpolators.LINEAR); 387 darkAnimator.setDuration(StackStateAnimator.ANIMATION_DURATION_WAKEUP); 388 darkAnimator.addListener(new AnimatorListenerAdapter() { 389 @Override 390 public void onAnimationCancel(Animator animation) { 391 cancelInteractionJankMonitor(); 392 } 393 394 @Override 395 public void onAnimationEnd(Animator animation) { 396 endInteractionJankMonitor(); 397 } 398 399 @Override 400 public void onAnimationStart(Animator animation) { 401 beginInteractionJankMonitor(); 402 } 403 }); 404 darkAnimator.start(); 405 return darkAnimator; 406 } 407 setDozeAmountInternal(float dozeAmount)408 private void setDozeAmountInternal(float dozeAmount) { 409 if (Float.compare(dozeAmount, mDozeAmount) == 0) { 410 return; 411 } 412 mDozeAmount = dozeAmount; 413 float interpolatedAmount = mDozeInterpolator.getInterpolation(dozeAmount); 414 synchronized (mListeners) { 415 String tag = getClass().getSimpleName() + "#setDozeAmount"; 416 DejankUtils.startDetectingBlockingIpcs(tag); 417 for (RankedListener rl : new ArrayList<>(mListeners)) { 418 rl.mListener.onDozeAmountChanged(mDozeAmount, interpolatedAmount); 419 } 420 DejankUtils.stopDetectingBlockingIpcs(tag); 421 } 422 } 423 beginInteractionJankMonitor()424 private void beginInteractionJankMonitor() { 425 final boolean shouldPost = 426 (mIsDozing && mDozeAmount == 0) || (!mIsDozing && mDozeAmount == 1); 427 if (mInteractionJankMonitor != null && mView != null && mView.isAttachedToWindow()) { 428 if (shouldPost) { 429 Choreographer.getInstance().postCallback( 430 Choreographer.CALLBACK_ANIMATION, this::beginInteractionJankMonitor, null); 431 } else { 432 Configuration.Builder builder = Configuration.Builder.withView(getCujType(), mView) 433 .setDeferMonitorForAnimationStart(false); 434 mInteractionJankMonitor.begin(builder); 435 } 436 } 437 } 438 endInteractionJankMonitor()439 private void endInteractionJankMonitor() { 440 if (mInteractionJankMonitor == null) { 441 return; 442 } 443 mInteractionJankMonitor.end(getCujType()); 444 } 445 cancelInteractionJankMonitor()446 private void cancelInteractionJankMonitor() { 447 if (mInteractionJankMonitor == null) { 448 return; 449 } 450 mInteractionJankMonitor.cancel(getCujType()); 451 } 452 getCujType()453 private int getCujType() { 454 return mIsDozing ? CUJ_LOCKSCREEN_TRANSITION_TO_AOD : CUJ_LOCKSCREEN_TRANSITION_FROM_AOD; 455 } 456 457 @Override goingToFullShade()458 public boolean goingToFullShade() { 459 return mState == StatusBarState.SHADE && mLeaveOpenOnKeyguardHide; 460 } 461 462 @Override setLeaveOpenOnKeyguardHide(boolean leaveOpen)463 public void setLeaveOpenOnKeyguardHide(boolean leaveOpen) { 464 mLeaveOpenOnKeyguardHide = leaveOpen; 465 } 466 467 @Override leaveOpenOnKeyguardHide()468 public boolean leaveOpenOnKeyguardHide() { 469 return mLeaveOpenOnKeyguardHide; 470 } 471 472 @Override fromShadeLocked()473 public boolean fromShadeLocked() { 474 return mLastState == StatusBarState.SHADE_LOCKED; 475 } 476 477 @Override addCallback(@onNull StateListener listener)478 public void addCallback(@NonNull StateListener listener) { 479 synchronized (mListeners) { 480 addListenerInternalLocked(listener, Integer.MAX_VALUE); 481 } 482 } 483 484 /** 485 * Add a listener and a rank based on the priority of this message 486 * @param listener the listener 487 * @param rank the order in which you'd like to be called. Ranked listeners will be 488 * notified before unranked, and we will sort ranked listeners from low to high 489 * 490 * @deprecated This method exists only to solve latent inter-dependencies from refactoring 491 * StatusBarState out of CentralSurfaces.java. Any new listeners should be built not to need 492 * ranking (i.e., they are non-dependent on the order of operations of StatusBarState 493 * listeners). 494 */ 495 @Deprecated 496 @Override addCallback(StateListener listener, @SbStateListenerRank int rank)497 public void addCallback(StateListener listener, @SbStateListenerRank int rank) { 498 synchronized (mListeners) { 499 addListenerInternalLocked(listener, rank); 500 } 501 } 502 503 @GuardedBy("mListeners") addListenerInternalLocked(StateListener listener, int rank)504 private void addListenerInternalLocked(StateListener listener, int rank) { 505 // Protect against double-subscribe 506 for (RankedListener rl : mListeners) { 507 if (rl.mListener.equals(listener)) { 508 return; 509 } 510 } 511 512 RankedListener rl = new SysuiStatusBarStateController.RankedListener(listener, rank); 513 mListeners.add(rl); 514 mListeners.sort(sComparator); 515 } 516 517 518 @Override removeCallback(@onNull StateListener listener)519 public void removeCallback(@NonNull StateListener listener) { 520 synchronized (mListeners) { 521 mListeners.removeIf((it) -> it.mListener.equals(listener)); 522 } 523 } 524 525 @Override setKeyguardRequested(boolean keyguardRequested)526 public void setKeyguardRequested(boolean keyguardRequested) { 527 mKeyguardRequested = keyguardRequested; 528 } 529 530 @Override isKeyguardRequested()531 public boolean isKeyguardRequested() { 532 return mKeyguardRequested; 533 } 534 535 @Override setSystemBarAttributes(@ppearance int appearance, @Behavior int behavior, InsetsVisibilities requestedVisibilities, String packageName)536 public void setSystemBarAttributes(@Appearance int appearance, @Behavior int behavior, 537 InsetsVisibilities requestedVisibilities, String packageName) { 538 boolean isFullscreen = !requestedVisibilities.getVisibility(ITYPE_STATUS_BAR) 539 || !requestedVisibilities.getVisibility(ITYPE_NAVIGATION_BAR); 540 if (mIsFullscreen != isFullscreen) { 541 mIsFullscreen = isFullscreen; 542 synchronized (mListeners) { 543 for (RankedListener rl : new ArrayList<>(mListeners)) { 544 rl.mListener.onFullscreenStateChanged(isFullscreen); 545 } 546 } 547 } 548 549 // TODO (b/190543382): Finish the logging logic. 550 // This section can be removed if we don't need to print it on logcat. 551 if (DEBUG_IMMERSIVE_APPS) { 552 boolean dim = (appearance & APPEARANCE_LOW_PROFILE_BARS) != 0; 553 String behaviorName = ViewDebug.flagsToString(InsetsFlags.class, "behavior", behavior); 554 String requestedVisibilityString = requestedVisibilities.toString(); 555 if (requestedVisibilityString.isEmpty()) { 556 requestedVisibilityString = "none"; 557 } 558 Log.d(TAG, packageName + " dim=" + dim + " behavior=" + behaviorName 559 + " requested visibilities: " + requestedVisibilityString); 560 } 561 } 562 563 @Override setPulsing(boolean pulsing)564 public void setPulsing(boolean pulsing) { 565 if (mPulsing != pulsing) { 566 mPulsing = pulsing; 567 synchronized (mListeners) { 568 for (RankedListener rl : new ArrayList<>(mListeners)) { 569 rl.mListener.onPulsingChanged(pulsing); 570 } 571 } 572 } 573 } 574 575 /** 576 * Returns String readable state of status bar from {@link StatusBarState} 577 */ describe(int state)578 public static String describe(int state) { 579 return StatusBarState.toString(state); 580 } 581 582 @Override dump(PrintWriter pw, String[] args)583 public void dump(PrintWriter pw, String[] args) { 584 pw.println("StatusBarStateController: "); 585 pw.println(" mState=" + mState + " (" + describe(mState) + ")"); 586 pw.println(" mLastState=" + mLastState + " (" + describe(mLastState) + ")"); 587 pw.println(" mLeaveOpenOnKeyguardHide=" + mLeaveOpenOnKeyguardHide); 588 pw.println(" mKeyguardRequested=" + mKeyguardRequested); 589 pw.println(" mIsDozing=" + mIsDozing); 590 pw.println(" mIsDreaming=" + mIsDreaming); 591 pw.println(" mListeners{" + mListeners.size() + "}="); 592 for (RankedListener rl : mListeners) { 593 pw.println(" " + rl.mListener); 594 } 595 pw.println(" Historical states:"); 596 // Ignore records without a timestamp 597 int size = 0; 598 for (int i = 0; i < HISTORY_SIZE; i++) { 599 if (mHistoricalRecords[i].mTimestamp != 0) size++; 600 } 601 for (int i = mHistoryIndex + HISTORY_SIZE; 602 i >= mHistoryIndex + HISTORY_SIZE - size + 1; i--) { 603 pw.println(" (" + (mHistoryIndex + HISTORY_SIZE - i + 1) + ")" 604 + mHistoricalRecords[i & (HISTORY_SIZE - 1)]); 605 } 606 } 607 recordHistoricalState(int newState, int lastState, boolean upcoming)608 private void recordHistoricalState(int newState, int lastState, boolean upcoming) { 609 Trace.traceCounter(Trace.TRACE_TAG_APP, "statusBarState", newState); 610 mHistoryIndex = (mHistoryIndex + 1) % HISTORY_SIZE; 611 HistoricalState state = mHistoricalRecords[mHistoryIndex]; 612 state.mNewState = newState; 613 state.mLastState = lastState; 614 state.mTimestamp = System.currentTimeMillis(); 615 state.mUpcoming = upcoming; 616 } 617 618 /** 619 * For keeping track of our previous state to help with debugging 620 */ 621 private static class HistoricalState { 622 int mNewState; 623 int mLastState; 624 long mTimestamp; 625 boolean mUpcoming; 626 627 @Override toString()628 public String toString() { 629 if (mTimestamp != 0) { 630 StringBuilder sb = new StringBuilder(); 631 if (mUpcoming) { 632 sb.append("upcoming-"); 633 } 634 sb.append("newState=").append(mNewState) 635 .append("(").append(describe(mNewState)).append(")"); 636 sb.append(" lastState=").append(mLastState).append("(").append(describe(mLastState)) 637 .append(")"); 638 sb.append(" timestamp=") 639 .append(DateFormat.format("MM-dd HH:mm:ss", mTimestamp)); 640 641 return sb.toString(); 642 } 643 return "Empty " + getClass().getSimpleName(); 644 } 645 } 646 } 647