1 /* <lambda>null2 * Copyright (C) 2025 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.quickstep.views 18 19 import android.os.VibrationAttributes 20 import androidx.dynamicanimation.animation.FloatPropertyCompat 21 import androidx.dynamicanimation.animation.FloatValueHolder 22 import androidx.dynamicanimation.animation.SpringAnimation 23 import androidx.dynamicanimation.animation.SpringForce 24 import com.android.launcher3.Flags.enableGridOnlyOverview 25 import com.android.launcher3.R 26 import com.android.launcher3.Utilities.boundToRange 27 import com.android.launcher3.util.DynamicResource 28 import com.android.launcher3.util.MSDLPlayerWrapper 29 import com.android.quickstep.util.TaskGridNavHelper 30 import com.android.quickstep.views.RecentsView.RECENTS_SCALE_PROPERTY 31 import com.google.android.msdl.data.model.MSDLToken 32 import com.google.android.msdl.domain.InteractionProperties 33 import kotlin.math.abs 34 import kotlin.math.roundToInt 35 import kotlin.math.sign 36 37 /** 38 * Helper class for [RecentsView]. This util class contains refactored and extracted functions from 39 * RecentsView related to TaskView dismissal. 40 */ 41 class RecentsDismissUtils(private val recentsView: RecentsView<*, *>) { 42 43 /** 44 * Creates the spring animations which run when a dragged task view in overview is released. 45 * 46 * <p>When a task dismiss is cancelled, the task will return to its original position via a 47 * spring animation. As it passes the threshold of its settling state, its neighbors will spring 48 * in response to the perceived impact of the settling task. 49 */ 50 fun createTaskDismissSettlingSpringAnimation( 51 draggedTaskView: TaskView?, 52 velocity: Float, 53 isDismissing: Boolean, 54 dismissLength: Int, 55 onEndRunnable: () -> Unit, 56 ): SpringAnimation? { 57 draggedTaskView ?: return null 58 val taskDismissFloatProperty = 59 FloatPropertyCompat.createFloatPropertyCompat( 60 draggedTaskView.secondaryDismissTranslationProperty 61 ) 62 val minVelocity = 63 recentsView.pagedOrientationHandler.getSecondaryDimension(draggedTaskView).toFloat() 64 val startVelocity = abs(velocity).coerceAtLeast(minVelocity) * velocity.sign 65 // Animate dragged task towards dismissal or rest state. 66 val draggedTaskViewSpringAnimation = 67 SpringAnimation(draggedTaskView, taskDismissFloatProperty) 68 .setSpring(createExpressiveDismissSpringForce()) 69 .setStartVelocity(startVelocity) 70 .addUpdateListener { animation, value, _ -> 71 if (isDismissing && abs(value) >= abs(dismissLength)) { 72 animation.cancel() 73 } else if (draggedTaskView.isRunningTask && recentsView.enableDrawingLiveTile) { 74 recentsView.runActionOnRemoteHandles { remoteTargetHandle -> 75 remoteTargetHandle.taskViewSimulator.taskSecondaryTranslation.value = 76 taskDismissFloatProperty.getValue(draggedTaskView) 77 } 78 recentsView.redrawLiveTile() 79 } 80 } 81 .addEndListener { _, _, _, _ -> 82 if (isDismissing) { 83 if (!recentsView.showAsGrid() || enableGridOnlyOverview()) { 84 runTaskGridReflowSpringAnimation( 85 draggedTaskView, 86 getDismissedTaskGapForReflow(draggedTaskView), 87 onEndRunnable, 88 ) 89 } else { 90 recentsView.dismissTaskView( 91 draggedTaskView, 92 /* animateTaskView = */ false, 93 /* removeTask = */ true, 94 ) 95 onEndRunnable() 96 } 97 } else { 98 recentsView.onDismissAnimationEnds() 99 onEndRunnable() 100 } 101 } 102 if (!isDismissing) { 103 addNeighborSettlingSpringAnimations( 104 draggedTaskView, 105 draggedTaskViewSpringAnimation, 106 driverProgressThreshold = 0f, 107 isSpringDirectionVertical = true, 108 minVelocity = startVelocity, 109 ) 110 } 111 return draggedTaskViewSpringAnimation 112 } 113 114 private fun addNeighborSettlingSpringAnimations( 115 draggedTaskView: TaskView, 116 springAnimationDriver: SpringAnimation, 117 tasksToExclude: List<TaskView> = emptyList(), 118 driverProgressThreshold: Float, 119 isSpringDirectionVertical: Boolean, 120 minVelocity: Float, 121 ) { 122 // Empty spring animation exists for conditional start, and to drive neighboring springs. 123 val neighborsToSettle = 124 SpringAnimation(FloatValueHolder()).setSpring(createExpressiveDismissSpringForce()) 125 126 // Add tasks before dragged index, fanning out from the dragged task. 127 // The order they are added matters, as each spring drives the next. 128 var previousNeighbor = neighborsToSettle 129 getTasksOffsetPairAdjacentToDraggedTask(draggedTaskView, towardsStart = true) 130 .filter { (taskView, _) -> !tasksToExclude.contains(taskView) } 131 .forEach { (taskView, offset) -> 132 previousNeighbor = 133 createNeighboringTaskViewSpringAnimation( 134 taskView, 135 offset * ADDITIONAL_DISMISS_DAMPING_RATIO, 136 previousNeighbor, 137 isSpringDirectionVertical, 138 ) 139 } 140 // Add tasks after dragged index, fanning out from the dragged task. 141 // The order they are added matters, as each spring drives the next. 142 previousNeighbor = neighborsToSettle 143 getTasksOffsetPairAdjacentToDraggedTask(draggedTaskView, towardsStart = false) 144 .filter { (taskView, _) -> !tasksToExclude.contains(taskView) } 145 .forEach { (taskView, offset) -> 146 previousNeighbor = 147 createNeighboringTaskViewSpringAnimation( 148 taskView, 149 offset * ADDITIONAL_DISMISS_DAMPING_RATIO, 150 previousNeighbor, 151 isSpringDirectionVertical, 152 ) 153 } 154 155 val isCurrentDisplacementAboveOrigin = 156 recentsView.pagedOrientationHandler.isGoingUp( 157 draggedTaskView.secondaryDismissTranslationProperty.get(draggedTaskView), 158 recentsView.isRtl, 159 ) 160 addThresholdSpringAnimationTrigger( 161 springAnimationDriver, 162 progressThreshold = driverProgressThreshold, 163 neighborsToSettle, 164 isCurrentDisplacementAboveOrigin, 165 minVelocity, 166 ) 167 } 168 169 /** As spring passes threshold for the first time, run conditional spring with velocity. */ 170 private fun addThresholdSpringAnimationTrigger( 171 springAnimationDriver: SpringAnimation, 172 progressThreshold: Float, 173 conditionalSpring: SpringAnimation, 174 isCurrentDisplacementAboveOrigin: Boolean, 175 minVelocity: Float, 176 ) { 177 val runSettlingAtVelocity = { velocity: Float -> 178 conditionalSpring.setStartVelocity(velocity).animateToFinalPosition(0f) 179 playDismissSettlingHaptic(velocity) 180 } 181 if (isCurrentDisplacementAboveOrigin) { 182 var lastPosition = 0f 183 var startSettling = false 184 springAnimationDriver.addUpdateListener { _, value, velocity -> 185 // We do not compare to the threshold directly, as the update listener 186 // does not necessarily hit every value. Do not check again once it has started 187 // settling, as a spring can bounce past the end value multiple times. 188 if (startSettling) return@addUpdateListener 189 if ( 190 lastPosition < progressThreshold && value >= progressThreshold || 191 lastPosition > progressThreshold && value <= progressThreshold 192 ) { 193 startSettling = true 194 } 195 lastPosition = value 196 if (startSettling) { 197 runSettlingAtVelocity(velocity) 198 } 199 } 200 } else { 201 // Run settling animations immediately when displacement is already below settled state. 202 runSettlingAtVelocity(minVelocity) 203 } 204 } 205 206 /** 207 * Gets pairs of (TaskView, offset) adjacent the dragged task in visual order. 208 * 209 * <p>Gets tasks either before or after the dragged task along with their offset from it. The 210 * offset is the distance between indices for carousels, or distance between columns for grids. 211 */ 212 private fun getTasksOffsetPairAdjacentToDraggedTask( 213 draggedTaskView: TaskView, 214 towardsStart: Boolean, 215 ): Sequence<Pair<TaskView, Int>> { 216 if (recentsView.showAsGrid()) { 217 val taskGridNavHelper = 218 TaskGridNavHelper( 219 recentsView.mUtils.getTopRowIdArray(), 220 recentsView.mUtils.getBottomRowIdArray(), 221 recentsView.mUtils.getLargeTaskViewIds(), 222 hasAddDesktopButton = false, 223 ) 224 return taskGridNavHelper 225 .gridTaskViewIdOffsetPairInTabOrderSequence( 226 draggedTaskView.taskViewId, 227 towardsStart, 228 ) 229 .mapNotNull { (taskViewId, columnOffset) -> 230 recentsView.getTaskViewFromTaskViewId(taskViewId)?.let { taskView -> 231 Pair(taskView, columnOffset) 232 } 233 } 234 } else { 235 val taskViewList = recentsView.mUtils.taskViews.toList() 236 val draggedTaskViewIndex = taskViewList.indexOf(draggedTaskView) 237 238 return if (towardsStart) { 239 taskViewList 240 .take(draggedTaskViewIndex) 241 .reversed() 242 .mapIndexed { index, taskView -> Pair(taskView, index + 1) } 243 .asSequence() 244 } else { 245 taskViewList 246 .takeLast(taskViewList.size - draggedTaskViewIndex - 1) 247 .mapIndexed { index, taskView -> Pair(taskView, index + 1) } 248 .asSequence() 249 } 250 } 251 } 252 253 /** Creates a neighboring task view spring, driven by the spring of its neighbor. */ 254 private fun createNeighboringTaskViewSpringAnimation( 255 taskView: TaskView, 256 dampingOffsetRatio: Float, 257 previousNeighborSpringAnimation: SpringAnimation, 258 springingDirectionVertical: Boolean, 259 ): SpringAnimation { 260 val springProperty = 261 if (springingDirectionVertical) taskView.secondaryDismissTranslationProperty 262 else taskView.primaryDismissTranslationProperty 263 val neighboringTaskViewSpringAnimation = 264 SpringAnimation(taskView, FloatPropertyCompat.createFloatPropertyCompat(springProperty)) 265 .setSpring(createExpressiveDismissSpringForce(dampingOffsetRatio)) 266 // Update live tile on spring animation. 267 if (taskView.isRunningTask && recentsView.enableDrawingLiveTile) { 268 neighboringTaskViewSpringAnimation.addUpdateListener { _, _, _ -> 269 recentsView.runActionOnRemoteHandles { remoteTargetHandle -> 270 val taskTranslation = 271 if (springingDirectionVertical) { 272 remoteTargetHandle.taskViewSimulator.taskSecondaryTranslation 273 } else { 274 remoteTargetHandle.taskViewSimulator.taskPrimaryTranslation 275 } 276 taskTranslation.value = springProperty.get(taskView) 277 } 278 recentsView.redrawLiveTile() 279 } 280 } 281 // Drive current neighbor's spring with the previous neighbor's. 282 previousNeighborSpringAnimation.addUpdateListener { _, value, _ -> 283 neighboringTaskViewSpringAnimation.animateToFinalPosition(value) 284 } 285 return neighboringTaskViewSpringAnimation 286 } 287 288 private fun createExpressiveDismissSpringForce(dampingRatioOffset: Float = 0f): SpringForce { 289 val resourceProvider = DynamicResource.provider(recentsView.mContainer) 290 return SpringForce() 291 .setDampingRatio( 292 resourceProvider.getFloat(R.dimen.expressive_dismiss_task_trans_y_damping_ratio) + 293 dampingRatioOffset 294 ) 295 .setStiffness( 296 resourceProvider.getFloat(R.dimen.expressive_dismiss_task_trans_y_stiffness) 297 ) 298 } 299 300 private fun createExpressiveGridReflowSpringForce( 301 finalPosition: Float = Float.MAX_VALUE 302 ): SpringForce { 303 val resourceProvider = DynamicResource.provider(recentsView.mContainer) 304 return SpringForce(finalPosition) 305 .setDampingRatio( 306 resourceProvider.getFloat(R.dimen.expressive_dismiss_task_trans_x_damping_ratio) 307 ) 308 .setStiffness( 309 resourceProvider.getFloat(R.dimen.expressive_dismiss_task_trans_x_stiffness) 310 ) 311 } 312 313 /** 314 * Plays a haptic as the dragged task view settles back into its rest state. 315 * 316 * <p>Haptic intensity is proportional to velocity. 317 */ 318 private fun playDismissSettlingHaptic(velocity: Float) { 319 val maxDismissSettlingVelocity = 320 recentsView.pagedOrientationHandler.getSecondaryDimension(recentsView) 321 MSDLPlayerWrapper.INSTANCE.get(recentsView.context) 322 ?.playToken( 323 MSDLToken.CANCEL, 324 InteractionProperties.DynamicVibrationScale( 325 boundToRange(abs(velocity) / maxDismissSettlingVelocity, 0f, 1f), 326 VibrationAttributes.Builder() 327 .setUsage(VibrationAttributes.USAGE_TOUCH) 328 .setFlags(VibrationAttributes.FLAG_PIPELINED_EFFECT) 329 .build(), 330 ), 331 ) 332 } 333 334 /** Animates RecentsView's scale to the provided value, using spring animations. */ 335 fun animateRecentsScale(scale: Float): SpringAnimation { 336 val resourceProvider = DynamicResource.provider(recentsView.mContainer) 337 val dampingRatio = resourceProvider.getFloat(R.dimen.swipe_up_rect_scale_damping_ratio) 338 val stiffness = resourceProvider.getFloat(R.dimen.swipe_up_rect_scale_stiffness) 339 340 // Spring which sets the Recents scale on update. This is needed, as the SpringAnimation 341 // struggles to animate small values like changing recents scale from 0.9 to 1. So 342 // we animate over a larger range (e.g. 900 to 1000) and convert back to the required value. 343 // (This is instead of converting RECENTS_SCALE_PROPERTY to a FloatPropertyCompat and 344 // animating it directly via springs.) 345 val initialRecentsScaleSpringValue = 346 RECENTS_SCALE_SPRING_MULTIPLIER * RECENTS_SCALE_PROPERTY.get(recentsView) 347 return SpringAnimation(FloatValueHolder(initialRecentsScaleSpringValue)) 348 .setSpring( 349 SpringForce(initialRecentsScaleSpringValue) 350 .setDampingRatio(dampingRatio) 351 .setStiffness(stiffness) 352 ) 353 .addUpdateListener { _, value, _ -> 354 RECENTS_SCALE_PROPERTY.setValue( 355 recentsView, 356 value / RECENTS_SCALE_SPRING_MULTIPLIER, 357 ) 358 } 359 .apply { animateToFinalPosition(RECENTS_SCALE_SPRING_MULTIPLIER * scale) } 360 } 361 362 /** Animates with springs the TaskViews beyond the dismissed task to fill the gap it left. */ 363 private fun runTaskGridReflowSpringAnimation( 364 dismissedTaskView: TaskView, 365 dismissedTaskGap: Float, 366 onEndRunnable: () -> Unit, 367 ) { 368 // Empty spring animation exists for conditional start, and to drive neighboring springs. 369 val springAnimationDriver = 370 SpringAnimation(FloatValueHolder()) 371 .setSpring(createExpressiveGridReflowSpringForce(finalPosition = dismissedTaskGap)) 372 val towardsStart = if (recentsView.isRtl) dismissedTaskGap < 0 else dismissedTaskGap > 0 373 374 var tasksToReflow: List<TaskView> 375 // Build the chains of Spring Animations 376 when { 377 !recentsView.showAsGrid() -> { 378 tasksToReflow = 379 getTasksToReflow( 380 recentsView.mUtils.taskViews.toList(), 381 dismissedTaskView, 382 towardsStart, 383 ) 384 buildDismissReflowSpringAnimationChain( 385 tasksToReflow, 386 dismissedTaskGap, 387 previousSpring = springAnimationDriver, 388 ) 389 } 390 dismissedTaskView.isLargeTile -> { 391 tasksToReflow = 392 getTasksToReflow( 393 recentsView.mUtils.getLargeTaskViews(), 394 dismissedTaskView, 395 towardsStart, 396 ) 397 val lastSpringAnimation = 398 buildDismissReflowSpringAnimationChain( 399 tasksToReflow, 400 dismissedTaskGap, 401 previousSpring = springAnimationDriver, 402 ) 403 // Add all top and bottom grid tasks when animating towards the end of the grid. 404 if (!towardsStart) { 405 tasksToReflow += recentsView.mUtils.getTopRowTaskViews() 406 tasksToReflow += recentsView.mUtils.getBottomRowTaskViews() 407 buildDismissReflowSpringAnimationChain( 408 recentsView.mUtils.getTopRowTaskViews(), 409 dismissedTaskGap, 410 previousSpring = lastSpringAnimation, 411 ) 412 buildDismissReflowSpringAnimationChain( 413 recentsView.mUtils.getBottomRowTaskViews(), 414 dismissedTaskGap, 415 previousSpring = lastSpringAnimation, 416 ) 417 } 418 } 419 recentsView.isOnGridBottomRow(dismissedTaskView) -> { 420 tasksToReflow = 421 getTasksToReflow( 422 recentsView.mUtils.getBottomRowTaskViews(), 423 dismissedTaskView, 424 towardsStart, 425 ) 426 buildDismissReflowSpringAnimationChain( 427 tasksToReflow, 428 dismissedTaskGap, 429 previousSpring = springAnimationDriver, 430 ) 431 } 432 else -> { 433 tasksToReflow = 434 getTasksToReflow( 435 recentsView.mUtils.getTopRowTaskViews(), 436 dismissedTaskView, 437 towardsStart, 438 ) 439 buildDismissReflowSpringAnimationChain( 440 tasksToReflow, 441 dismissedTaskGap, 442 previousSpring = springAnimationDriver, 443 ) 444 } 445 } 446 447 if (tasksToReflow.isNotEmpty()) { 448 addNeighborSettlingSpringAnimations( 449 dismissedTaskView, 450 springAnimationDriver, 451 tasksToExclude = tasksToReflow, 452 driverProgressThreshold = dismissedTaskGap, 453 isSpringDirectionVertical = false, 454 minVelocity = 0f, 455 ) 456 } else { 457 springAnimationDriver.addEndListener { _, _, _, _ -> 458 // Play the same haptic as when neighbors spring into place. 459 MSDLPlayerWrapper.INSTANCE.get(recentsView.context)?.playToken(MSDLToken.CANCEL) 460 } 461 } 462 463 // Start animations and remove the dismissed task at the end, dismiss immediately if no 464 // neighboring tasks exist. 465 val runGridEndAnimationAndRelayout = { 466 recentsView.expressiveDismissTaskView(dismissedTaskView, onEndRunnable) 467 } 468 springAnimationDriver?.apply { 469 addEndListener { _, _, _, _ -> runGridEndAnimationAndRelayout() } 470 animateToFinalPosition(dismissedTaskGap) 471 } ?: runGridEndAnimationAndRelayout() 472 } 473 474 private fun getDismissedTaskGapForReflow(dismissedTaskView: TaskView): Float { 475 val screenStart = recentsView.pagedOrientationHandler.getPrimaryScroll(recentsView) 476 val screenEnd = 477 screenStart + recentsView.pagedOrientationHandler.getMeasuredSize(recentsView) 478 val taskStart = 479 recentsView.pagedOrientationHandler.getChildStart(dismissedTaskView) + 480 dismissedTaskView.getOffsetAdjustment(recentsView.showAsGrid()) 481 val taskSize = 482 recentsView.pagedOrientationHandler.getMeasuredSize(dismissedTaskView) * 483 dismissedTaskView.getSizeAdjustment(recentsView.showAsFullscreen()) 484 val taskEnd = taskStart + taskSize 485 486 val isDismissedTaskBeyondEndOfScreen = 487 if (recentsView.isRtl) taskEnd > screenEnd else taskStart < screenStart 488 if ( 489 dismissedTaskView.isLargeTile && 490 isDismissedTaskBeyondEndOfScreen && 491 recentsView.mUtils.getLargeTileCount() == 1 492 ) { 493 return with(recentsView) { 494 pagedOrientationHandler.getPrimaryScroll(this) - 495 getScrollForPage(indexOfChild(mUtils.getFirstNonDesktopTaskView())) 496 } 497 .toFloat() 498 } 499 500 // If current page is beyond last TaskView's index, use last TaskView to calculate offset. 501 val lastTaskViewIndex = recentsView.indexOfChild(recentsView.mUtils.getLastTaskView()) 502 val currentPage = recentsView.currentPage.coerceAtMost(lastTaskViewIndex) 503 val dismissHorizontalFactor = 504 when { 505 dismissedTaskView.isGridTask -> 1f 506 currentPage == lastTaskViewIndex -> -1f 507 recentsView.indexOfChild(dismissedTaskView) < currentPage -> -1f 508 else -> 1f 509 } * (if (recentsView.isRtl) 1f else -1f) 510 511 return (recentsView.pagedOrientationHandler.getPrimarySize(dismissedTaskView) + 512 recentsView.pageSpacing) * dismissHorizontalFactor 513 } 514 515 private fun getTasksToReflow( 516 taskViews: List<TaskView>, 517 dismissedTaskView: TaskView, 518 towardsStart: Boolean, 519 ): List<TaskView> { 520 val dismissedTaskViewIndex = taskViews.indexOf(dismissedTaskView) 521 if (dismissedTaskViewIndex == -1) { 522 return emptyList() 523 } 524 return if (towardsStart) { 525 taskViews.take(dismissedTaskViewIndex).reversed() 526 } else { 527 taskViews.takeLast(taskViews.size - dismissedTaskViewIndex - 1) 528 } 529 } 530 531 private fun willTaskBeVisibleAfterDismiss(taskView: TaskView, taskTranslation: Int): Boolean { 532 val screenStart = recentsView.pagedOrientationHandler.getPrimaryScroll(recentsView) 533 val screenEnd = 534 screenStart + recentsView.pagedOrientationHandler.getMeasuredSize(recentsView) 535 return recentsView.isTaskViewWithinBounds( 536 taskView, 537 screenStart, 538 screenEnd, 539 /* taskViewTranslation = */ taskTranslation, 540 ) 541 } 542 543 /** Builds a chain of spring animations for task reflow after dismissal */ 544 private fun buildDismissReflowSpringAnimationChain( 545 taskViews: Iterable<TaskView>, 546 dismissedTaskGap: Float, 547 previousSpring: SpringAnimation, 548 ): SpringAnimation { 549 var lastTaskViewSpring = previousSpring 550 taskViews 551 .filter { taskView -> 552 willTaskBeVisibleAfterDismiss(taskView, dismissedTaskGap.roundToInt()) 553 } 554 .forEach { taskView -> 555 val taskViewSpringAnimation = 556 SpringAnimation( 557 taskView, 558 FloatPropertyCompat.createFloatPropertyCompat( 559 taskView.primaryDismissTranslationProperty 560 ), 561 ) 562 .setSpring(createExpressiveGridReflowSpringForce(dismissedTaskGap)) 563 // Update live tile on spring animation. 564 if (taskView.isRunningTask && recentsView.enableDrawingLiveTile) { 565 taskViewSpringAnimation.addUpdateListener { _, _, _ -> 566 recentsView.runActionOnRemoteHandles { remoteTargetHandle -> 567 remoteTargetHandle.taskViewSimulator.taskPrimaryTranslation.value = 568 taskView.primaryDismissTranslationProperty.get(taskView) 569 } 570 recentsView.redrawLiveTile() 571 } 572 } 573 lastTaskViewSpring.addUpdateListener { _, value, _ -> 574 taskViewSpringAnimation.animateToFinalPosition(value) 575 } 576 lastTaskViewSpring = taskViewSpringAnimation 577 } 578 return lastTaskViewSpring 579 } 580 581 private companion object { 582 // The additional damping to apply to tasks further from the dismissed task. 583 private const val ADDITIONAL_DISMISS_DAMPING_RATIO = 0.15f 584 private const val RECENTS_SCALE_SPRING_MULTIPLIER = 1000f 585 } 586 } 587