1 /* 2 * Copyright (C) 2014 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.notification.stack; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.animation.ValueAnimator; 22 import android.util.Property; 23 import android.view.View; 24 25 import com.android.keyguard.KeyguardSliceView; 26 import com.android.systemui.R; 27 import com.android.systemui.animation.Interpolators; 28 import com.android.systemui.statusbar.NotificationShelf; 29 import com.android.systemui.statusbar.StatusBarIconView; 30 import com.android.systemui.statusbar.notification.collection.NotificationEntry; 31 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow; 32 import com.android.systemui.statusbar.notification.row.ExpandableView; 33 import com.android.systemui.statusbar.notification.row.StackScrollerDecorView; 34 35 import java.util.ArrayList; 36 import java.util.HashSet; 37 import java.util.Stack; 38 39 /** 40 * An stack state animator which handles animations to new StackScrollStates 41 */ 42 public class StackStateAnimator { 43 44 public static final int ANIMATION_DURATION_STANDARD = 360; 45 public static final int ANIMATION_DURATION_CORNER_RADIUS = 200; 46 public static final int ANIMATION_DURATION_WAKEUP = 500; 47 public static final int ANIMATION_DURATION_GO_TO_FULL_SHADE = 448; 48 public static final int ANIMATION_DURATION_APPEAR_DISAPPEAR = 464; 49 public static final int ANIMATION_DURATION_SWIPE = 260; 50 public static final int ANIMATION_DURATION_DIMMED_ACTIVATED = 220; 51 public static final int ANIMATION_DURATION_CLOSE_REMOTE_INPUT = 150; 52 public static final int ANIMATION_DURATION_HEADS_UP_APPEAR = 400; 53 public static final int ANIMATION_DURATION_HEADS_UP_DISAPPEAR = 400; 54 public static final int ANIMATION_DURATION_PULSE_APPEAR = 55 KeyguardSliceView.DEFAULT_ANIM_DURATION; 56 public static final int ANIMATION_DURATION_BLOCKING_HELPER_FADE = 240; 57 public static final int ANIMATION_DURATION_PRIORITY_CHANGE = 500; 58 public static final int ANIMATION_DELAY_PER_ELEMENT_INTERRUPTING = 80; 59 public static final int ANIMATION_DELAY_PER_ELEMENT_MANUAL = 32; 60 public static final int ANIMATION_DELAY_PER_ELEMENT_GO_TO_FULL_SHADE = 48; 61 public static final int DELAY_EFFECT_MAX_INDEX_DIFFERENCE = 2; 62 private static final int MAX_STAGGER_COUNT = 5; 63 64 private final int mGoToFullShadeAppearingTranslation; 65 private final int mPulsingAppearingTranslation; 66 private final ExpandableViewState mTmpState = new ExpandableViewState(); 67 private final AnimationProperties mAnimationProperties; 68 public NotificationStackScrollLayout mHostLayout; 69 private ArrayList<NotificationStackScrollLayout.AnimationEvent> mNewEvents = 70 new ArrayList<>(); 71 private ArrayList<View> mNewAddChildren = new ArrayList<>(); 72 private HashSet<View> mHeadsUpAppearChildren = new HashSet<>(); 73 private HashSet<View> mHeadsUpDisappearChildren = new HashSet<>(); 74 private HashSet<Animator> mAnimatorSet = new HashSet<>(); 75 private Stack<AnimatorListenerAdapter> mAnimationListenerPool = new Stack<>(); 76 private AnimationFilter mAnimationFilter = new AnimationFilter(); 77 private long mCurrentLength; 78 private long mCurrentAdditionalDelay; 79 80 private ValueAnimator mTopOverScrollAnimator; 81 private ValueAnimator mBottomOverScrollAnimator; 82 private int mHeadsUpAppearHeightBottom; 83 private boolean mShadeExpanded; 84 private ArrayList<ExpandableView> mTransientViewsToRemove = new ArrayList<>(); 85 private NotificationShelf mShelf; 86 private float mStatusBarIconLocation; 87 private int[] mTmpLocation = new int[2]; 88 StackStateAnimator(NotificationStackScrollLayout hostLayout)89 public StackStateAnimator(NotificationStackScrollLayout hostLayout) { 90 mHostLayout = hostLayout; 91 mGoToFullShadeAppearingTranslation = 92 hostLayout.getContext().getResources().getDimensionPixelSize( 93 R.dimen.go_to_full_shade_appearing_translation); 94 mPulsingAppearingTranslation = 95 hostLayout.getContext().getResources().getDimensionPixelSize( 96 R.dimen.pulsing_notification_appear_translation); 97 mAnimationProperties = new AnimationProperties() { 98 @Override 99 public AnimationFilter getAnimationFilter() { 100 return mAnimationFilter; 101 } 102 103 @Override 104 public AnimatorListenerAdapter getAnimationFinishListener(Property property) { 105 return getGlobalAnimationFinishedListener(); 106 } 107 108 @Override 109 public boolean wasAdded(View view) { 110 return mNewAddChildren.contains(view); 111 } 112 }; 113 } 114 isRunning()115 public boolean isRunning() { 116 return !mAnimatorSet.isEmpty(); 117 } 118 startAnimationForEvents( ArrayList<NotificationStackScrollLayout.AnimationEvent> mAnimationEvents, long additionalDelay)119 public void startAnimationForEvents( 120 ArrayList<NotificationStackScrollLayout.AnimationEvent> mAnimationEvents, 121 long additionalDelay) { 122 123 processAnimationEvents(mAnimationEvents); 124 125 int childCount = mHostLayout.getChildCount(); 126 mAnimationFilter.applyCombination(mNewEvents); 127 mCurrentAdditionalDelay = additionalDelay; 128 mCurrentLength = NotificationStackScrollLayout.AnimationEvent.combineLength(mNewEvents); 129 // Used to stagger concurrent animations' delays and durations for visual effect 130 int animationStaggerCount = 0; 131 for (int i = 0; i < childCount; i++) { 132 final ExpandableView child = (ExpandableView) mHostLayout.getChildAt(i); 133 134 ExpandableViewState viewState = child.getViewState(); 135 if (viewState == null || child.getVisibility() == View.GONE 136 || applyWithoutAnimation(child, viewState)) { 137 continue; 138 } 139 140 if (mAnimationProperties.wasAdded(child) && animationStaggerCount < MAX_STAGGER_COUNT) { 141 animationStaggerCount++; 142 } 143 initAnimationProperties(child, viewState, animationStaggerCount); 144 viewState.animateTo(child, mAnimationProperties); 145 } 146 if (!isRunning()) { 147 // no child has preformed any animation, lets finish 148 onAnimationFinished(); 149 } 150 mHeadsUpAppearChildren.clear(); 151 mHeadsUpDisappearChildren.clear(); 152 mNewEvents.clear(); 153 mNewAddChildren.clear(); 154 } 155 initAnimationProperties(ExpandableView child, ExpandableViewState viewState, int animationStaggerCount)156 private void initAnimationProperties(ExpandableView child, 157 ExpandableViewState viewState, int animationStaggerCount) { 158 boolean wasAdded = mAnimationProperties.wasAdded(child); 159 mAnimationProperties.duration = mCurrentLength; 160 adaptDurationWhenGoingToFullShade(child, viewState, wasAdded, animationStaggerCount); 161 mAnimationProperties.delay = 0; 162 if (wasAdded || mAnimationFilter.hasDelays 163 && (viewState.yTranslation != child.getTranslationY() 164 || viewState.zTranslation != child.getTranslationZ() 165 || viewState.alpha != child.getAlpha() 166 || viewState.height != child.getActualHeight() 167 || viewState.clipTopAmount != child.getClipTopAmount())) { 168 mAnimationProperties.delay = mCurrentAdditionalDelay 169 + calculateChildAnimationDelay(viewState, animationStaggerCount); 170 } 171 } 172 adaptDurationWhenGoingToFullShade(ExpandableView child, ExpandableViewState viewState, boolean wasAdded, int animationStaggerCount)173 private void adaptDurationWhenGoingToFullShade(ExpandableView child, 174 ExpandableViewState viewState, boolean wasAdded, int animationStaggerCount) { 175 boolean isDecorView = child instanceof StackScrollerDecorView; 176 boolean needsAdjustment = wasAdded || isDecorView; 177 if (needsAdjustment && mAnimationFilter.hasGoToFullShadeEvent) { 178 int startOffset = 0; 179 if (!isDecorView) { 180 startOffset = mGoToFullShadeAppearingTranslation; 181 float longerDurationFactor = (float) Math.pow(animationStaggerCount, 0.7f); 182 mAnimationProperties.duration = ANIMATION_DURATION_APPEAR_DISAPPEAR + 50 183 + (long) (100 * longerDurationFactor); 184 } 185 child.setTranslationY(viewState.yTranslation + startOffset); 186 } 187 } 188 189 /** 190 * Determines if a view should not perform an animation and applies it directly. 191 * 192 * @return true if no animation should be performed 193 */ applyWithoutAnimation(ExpandableView child, ExpandableViewState viewState)194 private boolean applyWithoutAnimation(ExpandableView child, ExpandableViewState viewState) { 195 if (mShadeExpanded) { 196 return false; 197 } 198 if (ViewState.isAnimatingY(child)) { 199 // A Y translation animation is running 200 return false; 201 } 202 if (mHeadsUpDisappearChildren.contains(child) || mHeadsUpAppearChildren.contains(child)) { 203 // This is a heads up animation 204 return false; 205 } 206 if (NotificationStackScrollLayout.isPinnedHeadsUp(child)) { 207 // This is another headsUp which might move. Let's animate! 208 return false; 209 } 210 viewState.applyToView(child); 211 return true; 212 } 213 calculateChildAnimationDelay(ExpandableViewState viewState, int animationStaggerCount)214 private long calculateChildAnimationDelay(ExpandableViewState viewState, 215 int animationStaggerCount) { 216 if (mAnimationFilter.hasGoToFullShadeEvent) { 217 return calculateDelayGoToFullShade(viewState, animationStaggerCount); 218 } 219 if (mAnimationFilter.customDelay != AnimationFilter.NO_DELAY) { 220 return mAnimationFilter.customDelay; 221 } 222 long minDelay = 0; 223 for (NotificationStackScrollLayout.AnimationEvent event : mNewEvents) { 224 long delayPerElement = ANIMATION_DELAY_PER_ELEMENT_INTERRUPTING; 225 switch (event.animationType) { 226 case NotificationStackScrollLayout.AnimationEvent.ANIMATION_TYPE_ADD: { 227 int ownIndex = viewState.notGoneIndex; 228 int changingIndex = 229 ((ExpandableView) (event.mChangingView)).getViewState().notGoneIndex; 230 int difference = Math.abs(ownIndex - changingIndex); 231 difference = Math.max(0, Math.min(DELAY_EFFECT_MAX_INDEX_DIFFERENCE, 232 difference - 1)); 233 long delay = (DELAY_EFFECT_MAX_INDEX_DIFFERENCE - difference) * delayPerElement; 234 minDelay = Math.max(delay, minDelay); 235 break; 236 } 237 case NotificationStackScrollLayout.AnimationEvent.ANIMATION_TYPE_REMOVE_SWIPED_OUT: 238 delayPerElement = ANIMATION_DELAY_PER_ELEMENT_MANUAL; 239 case NotificationStackScrollLayout.AnimationEvent.ANIMATION_TYPE_REMOVE: { 240 int ownIndex = viewState.notGoneIndex; 241 boolean noNextView = event.viewAfterChangingView == null; 242 ExpandableView viewAfterChangingView = noNextView 243 ? mHostLayout.getLastChildNotGone() 244 : (ExpandableView) event.viewAfterChangingView; 245 if (viewAfterChangingView == null) { 246 // This can happen when the last view in the list is removed. 247 // Since the shelf is still around and the only view, the code still goes 248 // in here and tries to calculate the delay for it when case its properties 249 // have changed. 250 continue; 251 } 252 int nextIndex = viewAfterChangingView.getViewState().notGoneIndex; 253 if (ownIndex >= nextIndex) { 254 // we only have the view afterwards 255 ownIndex++; 256 } 257 int difference = Math.abs(ownIndex - nextIndex); 258 difference = Math.max(0, Math.min(DELAY_EFFECT_MAX_INDEX_DIFFERENCE, 259 difference - 1)); 260 long delay = difference * delayPerElement; 261 minDelay = Math.max(delay, minDelay); 262 break; 263 } 264 default: 265 break; 266 } 267 } 268 return minDelay; 269 } 270 calculateDelayGoToFullShade(ExpandableViewState viewState, int animationStaggerCount)271 private long calculateDelayGoToFullShade(ExpandableViewState viewState, 272 int animationStaggerCount) { 273 int shelfIndex = mShelf.getNotGoneIndex(); 274 float index = viewState.notGoneIndex; 275 long result = 0; 276 if (index > shelfIndex) { 277 float diff = (float) Math.pow(animationStaggerCount, 0.7f); 278 result += (long) (diff * ANIMATION_DELAY_PER_ELEMENT_GO_TO_FULL_SHADE * 0.25); 279 index = shelfIndex; 280 } 281 index = (float) Math.pow(index, 0.7f); 282 result += (long) (index * ANIMATION_DELAY_PER_ELEMENT_GO_TO_FULL_SHADE); 283 return result; 284 } 285 286 /** 287 * @return an adapter which ensures that onAnimationFinished is called once no animation is 288 * running anymore 289 */ getGlobalAnimationFinishedListener()290 private AnimatorListenerAdapter getGlobalAnimationFinishedListener() { 291 if (!mAnimationListenerPool.empty()) { 292 return mAnimationListenerPool.pop(); 293 } 294 295 // We need to create a new one, no reusable ones found 296 return new AnimatorListenerAdapter() { 297 private boolean mWasCancelled; 298 299 @Override 300 public void onAnimationEnd(Animator animation) { 301 mAnimatorSet.remove(animation); 302 if (mAnimatorSet.isEmpty() && !mWasCancelled) { 303 onAnimationFinished(); 304 } 305 mAnimationListenerPool.push(this); 306 } 307 308 @Override 309 public void onAnimationCancel(Animator animation) { 310 mWasCancelled = true; 311 } 312 313 @Override 314 public void onAnimationStart(Animator animation) { 315 mWasCancelled = false; 316 mAnimatorSet.add(animation); 317 } 318 }; 319 } 320 onAnimationFinished()321 private void onAnimationFinished() { 322 mHostLayout.onChildAnimationFinished(); 323 324 for (ExpandableView transientViewsToRemove : mTransientViewsToRemove) { 325 transientViewsToRemove.getTransientContainer() 326 .removeTransientView(transientViewsToRemove); 327 } 328 mTransientViewsToRemove.clear(); 329 } 330 331 /** 332 * Process the animationEvents for a new animation 333 * 334 * @param animationEvents the animation events for the animation to perform 335 */ processAnimationEvents( ArrayList<NotificationStackScrollLayout.AnimationEvent> animationEvents)336 private void processAnimationEvents( 337 ArrayList<NotificationStackScrollLayout.AnimationEvent> animationEvents) { 338 for (NotificationStackScrollLayout.AnimationEvent event : animationEvents) { 339 final ExpandableView changingView = (ExpandableView) event.mChangingView; 340 if (event.animationType == 341 NotificationStackScrollLayout.AnimationEvent.ANIMATION_TYPE_ADD) { 342 343 // This item is added, initialize it's properties. 344 ExpandableViewState viewState = changingView.getViewState(); 345 if (viewState == null || viewState.gone) { 346 // The position for this child was never generated, let's continue. 347 continue; 348 } 349 viewState.applyToView(changingView); 350 mNewAddChildren.add(changingView); 351 352 } else if (event.animationType == 353 NotificationStackScrollLayout.AnimationEvent.ANIMATION_TYPE_REMOVE) { 354 if (changingView.getVisibility() != View.VISIBLE) { 355 removeTransientView(changingView); 356 continue; 357 } 358 359 // Find the amount to translate up. This is needed in order to understand the 360 // direction of the remove animation (either downwards or upwards) 361 // upwards by default 362 float translationDirection = -1.0f; 363 if (event.viewAfterChangingView != null) { 364 float ownPosition = changingView.getTranslationY(); 365 if (changingView instanceof ExpandableNotificationRow 366 && event.viewAfterChangingView instanceof ExpandableNotificationRow) { 367 ExpandableNotificationRow changingRow = 368 (ExpandableNotificationRow) changingView; 369 ExpandableNotificationRow nextRow = 370 (ExpandableNotificationRow) event.viewAfterChangingView; 371 if (changingRow.isRemoved() 372 && changingRow.wasChildInGroupWhenRemoved() 373 && !nextRow.isChildInGroup()) { 374 // the next row isn't actually a child from a group! Let's 375 // compare absolute positions! 376 ownPosition = changingRow.getTranslationWhenRemoved(); 377 } 378 } 379 int actualHeight = changingView.getActualHeight(); 380 // there was a view after this one, Approximate the distance the next child 381 // travelled 382 ExpandableViewState viewState = 383 ((ExpandableView) event.viewAfterChangingView).getViewState(); 384 translationDirection = ((viewState.yTranslation 385 - (ownPosition + actualHeight / 2.0f)) * 2 / 386 actualHeight); 387 translationDirection = Math.max(Math.min(translationDirection, 1.0f),-1.0f); 388 389 } 390 changingView.performRemoveAnimation(ANIMATION_DURATION_APPEAR_DISAPPEAR, 391 0 /* delay */, translationDirection, false /* isHeadsUpAppear */, 392 0, () -> removeTransientView(changingView), null); 393 } else if (event.animationType == 394 NotificationStackScrollLayout.AnimationEvent.ANIMATION_TYPE_REMOVE_SWIPED_OUT) { 395 if (mHostLayout.isFullySwipedOut(changingView) 396 && changingView.getTransientContainer() != null) { 397 changingView.getTransientContainer().removeTransientView(changingView); 398 } 399 } else if (event.animationType == NotificationStackScrollLayout 400 .AnimationEvent.ANIMATION_TYPE_GROUP_EXPANSION_CHANGED) { 401 ExpandableNotificationRow row = (ExpandableNotificationRow) event.mChangingView; 402 row.prepareExpansionChanged(); 403 } else if (event.animationType == NotificationStackScrollLayout 404 .AnimationEvent.ANIMATION_TYPE_HEADS_UP_APPEAR) { 405 // This item is added, initialize it's properties. 406 ExpandableViewState viewState = changingView.getViewState(); 407 mTmpState.copyFrom(viewState); 408 if (event.headsUpFromBottom) { 409 mTmpState.yTranslation = mHeadsUpAppearHeightBottom; 410 } else { 411 changingView.performAddAnimation(0, ANIMATION_DURATION_HEADS_UP_APPEAR, 412 true /* isHeadsUpAppear */); 413 } 414 mHeadsUpAppearChildren.add(changingView); 415 mTmpState.applyToView(changingView); 416 } else if (event.animationType == NotificationStackScrollLayout 417 .AnimationEvent.ANIMATION_TYPE_HEADS_UP_DISAPPEAR || 418 event.animationType == NotificationStackScrollLayout 419 .AnimationEvent.ANIMATION_TYPE_HEADS_UP_DISAPPEAR_CLICK) { 420 mHeadsUpDisappearChildren.add(changingView); 421 Runnable endRunnable = null; 422 if (changingView.getParent() == null) { 423 // This notification was actually removed, so we need to add it transiently 424 mHostLayout.addTransientView(changingView, 0); 425 changingView.setTransientContainer(mHostLayout); 426 mTmpState.initFrom(changingView); 427 endRunnable = () -> removeTransientView(changingView); 428 } 429 float targetLocation = 0; 430 boolean needsAnimation = true; 431 if (changingView instanceof ExpandableNotificationRow) { 432 ExpandableNotificationRow row = (ExpandableNotificationRow) changingView; 433 if (row.isDismissed()) { 434 needsAnimation = false; 435 } 436 NotificationEntry entry = row.getEntry(); 437 StatusBarIconView icon = entry.getIcons().getStatusBarIcon(); 438 final StatusBarIconView centeredIcon = entry.getIcons().getCenteredIcon(); 439 if (centeredIcon != null && centeredIcon.getParent() != null) { 440 icon = centeredIcon; 441 } 442 if (icon.getParent() != null) { 443 icon.getLocationOnScreen(mTmpLocation); 444 float iconPosition = mTmpLocation[0] - icon.getTranslationX() 445 + ViewState.getFinalTranslationX(icon) + icon.getWidth() * 0.25f; 446 mHostLayout.getLocationOnScreen(mTmpLocation); 447 targetLocation = iconPosition - mTmpLocation[0]; 448 } 449 } 450 451 if (needsAnimation) { 452 // We need to add the global animation listener, since once no animations are 453 // running anymore, the panel will instantly hide itself. We need to wait until 454 // the animation is fully finished for this though. 455 long removeAnimationDelay = changingView.performRemoveAnimation( 456 ANIMATION_DURATION_HEADS_UP_DISAPPEAR, 457 0, 0.0f, true /* isHeadsUpAppear */, targetLocation, 458 endRunnable, getGlobalAnimationFinishedListener()); 459 mAnimationProperties.delay += removeAnimationDelay; 460 } else if (endRunnable != null) { 461 endRunnable.run(); 462 } 463 } 464 mNewEvents.add(event); 465 } 466 } 467 removeTransientView(ExpandableView viewToRemove)468 public static void removeTransientView(ExpandableView viewToRemove) { 469 if (viewToRemove.getTransientContainer() != null) { 470 viewToRemove.getTransientContainer().removeTransientView(viewToRemove); 471 } 472 } 473 animateOverScrollToAmount(float targetAmount, final boolean onTop, final boolean isRubberbanded)474 public void animateOverScrollToAmount(float targetAmount, final boolean onTop, 475 final boolean isRubberbanded) { 476 final float startOverScrollAmount = mHostLayout.getCurrentOverScrollAmount(onTop); 477 if (targetAmount == startOverScrollAmount) { 478 return; 479 } 480 cancelOverScrollAnimators(onTop); 481 ValueAnimator overScrollAnimator = ValueAnimator.ofFloat(startOverScrollAmount, 482 targetAmount); 483 overScrollAnimator.setDuration(ANIMATION_DURATION_STANDARD); 484 overScrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 485 @Override 486 public void onAnimationUpdate(ValueAnimator animation) { 487 float currentOverScroll = (float) animation.getAnimatedValue(); 488 mHostLayout.setOverScrollAmount( 489 currentOverScroll, onTop, false /* animate */, false /* cancelAnimators */, 490 isRubberbanded); 491 } 492 }); 493 overScrollAnimator.setInterpolator(Interpolators.FAST_OUT_SLOW_IN); 494 overScrollAnimator.addListener(new AnimatorListenerAdapter() { 495 @Override 496 public void onAnimationEnd(Animator animation) { 497 if (onTop) { 498 mTopOverScrollAnimator = null; 499 } else { 500 mBottomOverScrollAnimator = null; 501 } 502 } 503 }); 504 overScrollAnimator.start(); 505 if (onTop) { 506 mTopOverScrollAnimator = overScrollAnimator; 507 } else { 508 mBottomOverScrollAnimator = overScrollAnimator; 509 } 510 } 511 cancelOverScrollAnimators(boolean onTop)512 public void cancelOverScrollAnimators(boolean onTop) { 513 ValueAnimator currentAnimator = onTop ? mTopOverScrollAnimator : mBottomOverScrollAnimator; 514 if (currentAnimator != null) { 515 currentAnimator.cancel(); 516 } 517 } 518 setHeadsUpAppearHeightBottom(int headsUpAppearHeightBottom)519 public void setHeadsUpAppearHeightBottom(int headsUpAppearHeightBottom) { 520 mHeadsUpAppearHeightBottom = headsUpAppearHeightBottom; 521 } 522 setShadeExpanded(boolean shadeExpanded)523 public void setShadeExpanded(boolean shadeExpanded) { 524 mShadeExpanded = shadeExpanded; 525 } 526 setShelf(NotificationShelf shelf)527 public void setShelf(NotificationShelf shelf) { 528 mShelf = shelf; 529 } 530 } 531