1 /* <lambda>null2 * Copyright (C) 2023 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.wm.shell.windowdecor 18 19 import android.animation.AnimatorSet 20 import android.animation.ObjectAnimator 21 import android.animation.ValueAnimator 22 import android.annotation.ColorInt 23 import android.app.ActivityManager.RunningTaskInfo 24 import android.content.Context 25 import android.content.res.ColorStateList 26 import android.content.res.Resources 27 import android.graphics.Paint 28 import android.graphics.PixelFormat 29 import android.graphics.Point 30 import android.graphics.Rect 31 import android.graphics.drawable.Drawable 32 import android.graphics.drawable.GradientDrawable 33 import android.graphics.drawable.LayerDrawable 34 import android.graphics.drawable.ShapeDrawable 35 import android.graphics.drawable.StateListDrawable 36 import android.graphics.drawable.shapes.RoundRectShape 37 import android.os.Bundle 38 import android.util.StateSet 39 import android.view.LayoutInflater 40 import android.view.MotionEvent.ACTION_HOVER_ENTER 41 import android.view.MotionEvent.ACTION_HOVER_EXIT 42 import android.view.MotionEvent.ACTION_HOVER_MOVE 43 import android.view.MotionEvent.ACTION_OUTSIDE 44 import android.view.SurfaceControl 45 import android.view.SurfaceControl.Transaction 46 import android.view.SurfaceControlViewHost 47 import android.view.View 48 import android.view.View.SCALE_Y 49 import android.view.View.TRANSLATION_Y 50 import android.view.View.TRANSLATION_Z 51 import android.view.ViewGroup 52 import android.view.WindowManager 53 import android.view.WindowlessWindowManager 54 import android.view.accessibility.AccessibilityEvent 55 import android.view.accessibility.AccessibilityNodeInfo 56 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction 57 import android.widget.Button 58 import android.widget.TextView 59 import android.window.TaskConstants 60 import androidx.compose.material3.ColorScheme 61 import androidx.compose.ui.graphics.toArgb 62 import androidx.core.animation.addListener 63 import androidx.core.view.isGone 64 import androidx.core.view.isVisible 65 import com.android.wm.shell.R 66 import com.android.wm.shell.RootTaskDisplayAreaOrganizer 67 import com.android.wm.shell.common.DisplayController 68 import com.android.wm.shell.common.SyncTransactionQueue 69 import com.android.wm.shell.desktopmode.DesktopModeUiEventLogger 70 import com.android.wm.shell.desktopmode.DesktopModeUiEventLogger.DesktopUiEventEnum.A11Y_MAXIMIZE_MENU_MAXIMIZE 71 import com.android.wm.shell.desktopmode.DesktopModeUiEventLogger.DesktopUiEventEnum.A11Y_MAXIMIZE_MENU_RESIZE_LEFT 72 import com.android.wm.shell.desktopmode.DesktopModeUiEventLogger.DesktopUiEventEnum.A11Y_MAXIMIZE_MENU_RESIZE_RIGHT 73 import com.android.wm.shell.desktopmode.isTaskMaximized 74 import com.android.wm.shell.shared.animation.Interpolators.EMPHASIZED_DECELERATE 75 import com.android.wm.shell.shared.animation.Interpolators.FAST_OUT_LINEAR_IN 76 import com.android.wm.shell.windowdecor.additionalviewcontainer.AdditionalViewHostViewContainer 77 import com.android.wm.shell.windowdecor.common.DecorThemeUtil 78 import com.android.wm.shell.windowdecor.common.OPACITY_12 79 import com.android.wm.shell.windowdecor.common.OPACITY_40 80 import com.android.wm.shell.windowdecor.common.OPACITY_60 81 import com.android.wm.shell.windowdecor.common.withAlpha 82 import java.util.function.Supplier 83 84 /** 85 * Menu that appears when user long clicks the maximize button. Gives the user the option to 86 * maximize the task or restore previous task bounds from the maximized state and to snap the task 87 * to the right or left half of the screen. 88 */ 89 class MaximizeMenu( 90 private val syncQueue: SyncTransactionQueue, 91 private val rootTdaOrganizer: RootTaskDisplayAreaOrganizer, 92 private val displayController: DisplayController, 93 private val taskInfo: RunningTaskInfo, 94 private val decorWindowContext: Context, 95 private val positionSupplier: (Int, Int) -> Point, 96 private val transactionSupplier: Supplier<Transaction> = Supplier { Transaction() }, 97 private val desktopModeUiEventLogger: DesktopModeUiEventLogger 98 ) { 99 private var maximizeMenu: AdditionalViewHostViewContainer? = null 100 private var maximizeMenuView: MaximizeMenuView? = null 101 private lateinit var viewHost: SurfaceControlViewHost 102 private lateinit var leash: SurfaceControl 103 private val cornerRadius = loadDimensionPixelSize( 104 R.dimen.desktop_mode_maximize_menu_corner_radius 105 ).toFloat() 106 private lateinit var menuPosition: Point 107 private val menuPadding = loadDimensionPixelSize(R.dimen.desktop_mode_menu_padding) 108 109 /** Position the menu relative to the caption's position. */ positionMenunull110 fun positionMenu(t: Transaction) { 111 menuPosition = positionSupplier(maximizeMenuView?.measureWidth() ?: 0, 112 maximizeMenuView?.measureHeight() ?: 0) 113 t.setPosition(leash, menuPosition.x.toFloat(), menuPosition.y.toFloat()) 114 } 115 116 /** Creates and shows the maximize window. */ shownull117 fun show( 118 isTaskInImmersiveMode: Boolean, 119 showImmersiveOption: Boolean, 120 showSnapOptions: Boolean, 121 onMaximizeOrRestoreClickListener: () -> Unit, 122 onImmersiveOrRestoreClickListener: () -> Unit, 123 onLeftSnapClickListener: () -> Unit, 124 onRightSnapClickListener: () -> Unit, 125 onHoverListener: (Boolean) -> Unit, 126 onOutsideTouchListener: () -> Unit, 127 ) { 128 if (maximizeMenu != null) return 129 createMaximizeMenu( 130 isTaskInImmersiveMode = isTaskInImmersiveMode, 131 showImmersiveOption = showImmersiveOption, 132 showSnapOptions = showSnapOptions, 133 onMaximizeClickListener = onMaximizeOrRestoreClickListener, 134 onImmersiveOrRestoreClickListener = onImmersiveOrRestoreClickListener, 135 onLeftSnapClickListener = onLeftSnapClickListener, 136 onRightSnapClickListener = onRightSnapClickListener, 137 onHoverListener = onHoverListener, 138 onOutsideTouchListener = onOutsideTouchListener, 139 ) 140 maximizeMenuView?.let { view -> 141 view.animateOpenMenu(onEnd = { 142 view.requestAccessibilityFocus() 143 }) 144 } 145 } 146 147 /** Closes the maximize window and releases its view. */ closenull148 fun close(onEnd: () -> Unit) { 149 val view = maximizeMenuView 150 val menu = maximizeMenu 151 if (view == null) { 152 menu?.releaseView() 153 } else { 154 view.animateCloseMenu(onEnd = { 155 menu?.releaseView() 156 onEnd.invoke() 157 }) 158 } 159 maximizeMenu = null 160 maximizeMenuView = null 161 } 162 163 /** Create a maximize menu that is attached to the display area. */ createMaximizeMenunull164 private fun createMaximizeMenu( 165 isTaskInImmersiveMode: Boolean, 166 showImmersiveOption: Boolean, 167 showSnapOptions: Boolean, 168 onMaximizeClickListener: () -> Unit, 169 onImmersiveOrRestoreClickListener: () -> Unit, 170 onLeftSnapClickListener: () -> Unit, 171 onRightSnapClickListener: () -> Unit, 172 onHoverListener: (Boolean) -> Unit, 173 onOutsideTouchListener: () -> Unit, 174 ) { 175 val t = transactionSupplier.get() 176 val builder = SurfaceControl.Builder() 177 rootTdaOrganizer.attachToDisplayArea(taskInfo.displayId, builder) 178 leash = builder 179 .setName("Maximize Menu") 180 .setContainerLayer() 181 .build() 182 val windowManager = WindowlessWindowManager( 183 taskInfo.configuration, 184 leash, 185 null // HostInputToken 186 ) 187 viewHost = SurfaceControlViewHost(decorWindowContext, 188 displayController.getDisplay(taskInfo.displayId), windowManager, 189 "MaximizeMenu") 190 maximizeMenuView = MaximizeMenuView( 191 context = decorWindowContext, 192 desktopModeUiEventLogger = desktopModeUiEventLogger, 193 sizeToggleDirection = getSizeToggleDirection(), 194 immersiveConfig = if (showImmersiveOption) { 195 MaximizeMenuView.ImmersiveConfig.Visible( 196 getImmersiveToggleDirection(isTaskInImmersiveMode) 197 ) 198 } else { 199 MaximizeMenuView.ImmersiveConfig.Hidden 200 }, 201 showSnapOptions = showSnapOptions, 202 menuPadding = menuPadding, 203 ).also { menuView -> 204 menuView.bind(taskInfo) 205 menuView.onMaximizeClickListener = onMaximizeClickListener 206 menuView.onImmersiveOrRestoreClickListener = onImmersiveOrRestoreClickListener 207 menuView.onLeftSnapClickListener = onLeftSnapClickListener 208 menuView.onRightSnapClickListener = onRightSnapClickListener 209 menuView.onMenuHoverListener = onHoverListener 210 menuView.onOutsideTouchListener = onOutsideTouchListener 211 val menuWidth = menuView.measureWidth() 212 val menuHeight = menuView.measureHeight() 213 menuPosition = positionSupplier(menuWidth, menuHeight) 214 val lp = WindowManager.LayoutParams( 215 menuWidth, 216 menuHeight, 217 WindowManager.LayoutParams.TYPE_APPLICATION, 218 WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE 219 or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, 220 PixelFormat.TRANSPARENT 221 ) 222 lp.title = "Maximize Menu for Task=" + taskInfo.taskId 223 lp.setTrustedOverlay() 224 viewHost.setView(menuView.rootView, lp) 225 } 226 227 // Bring menu to front when open 228 t.setLayer(leash, TaskConstants.TASK_CHILD_LAYER_FLOATING_MENU) 229 .setPosition(leash, menuPosition.x.toFloat(), menuPosition.y.toFloat()) 230 .setCornerRadius(leash, cornerRadius) 231 .show(leash) 232 maximizeMenu = 233 AdditionalViewHostViewContainer(leash, viewHost, transactionSupplier) 234 235 syncQueue.runInSync { transaction -> 236 transaction.merge(t) 237 t.close() 238 } 239 } 240 getSizeToggleDirectionnull241 private fun getSizeToggleDirection(): MaximizeMenuView.SizeToggleDirection { 242 val maximized = isTaskMaximized(taskInfo, displayController) 243 return if (maximized) 244 MaximizeMenuView.SizeToggleDirection.RESTORE 245 else 246 MaximizeMenuView.SizeToggleDirection.MAXIMIZE 247 } 248 getImmersiveToggleDirectionnull249 private fun getImmersiveToggleDirection( 250 isTaskImmersive: Boolean 251 ): MaximizeMenuView.ImmersiveToggleDirection = 252 if (isTaskImmersive) { 253 MaximizeMenuView.ImmersiveToggleDirection.EXIT 254 } else { 255 MaximizeMenuView.ImmersiveToggleDirection.ENTER 256 } 257 loadDimensionPixelSizenull258 private fun loadDimensionPixelSize(resourceId: Int): Int { 259 return if (resourceId == Resources.ID_NULL) { 260 0 261 } else { 262 decorWindowContext.resources.getDimensionPixelSize(resourceId) 263 } 264 } 265 266 /** 267 * The view within the Maximize Menu, presents maximize, restore and snap-to-side options for 268 * resizing a Task. 269 */ 270 class MaximizeMenuView( 271 context: Context, 272 private val desktopModeUiEventLogger: DesktopModeUiEventLogger, 273 private val sizeToggleDirection: SizeToggleDirection, 274 immersiveConfig: ImmersiveConfig, 275 showSnapOptions: Boolean, 276 private val menuPadding: Int 277 ) { 278 val rootView = LayoutInflater.from(context) 279 .inflate(R.layout.desktop_mode_window_decor_maximize_menu, null /* root */) as ViewGroup 280 private val container = requireViewById(R.id.container) 281 private val overlay = requireViewById(R.id.maximize_menu_overlay) 282 private val immersiveToggleContainer = 283 requireViewById(R.id.maximize_menu_immersive_toggle_container) as View 284 private val immersiveToggleButtonText = 285 requireViewById(R.id.maximize_menu_immersive_toggle_button_text) as TextView 286 private val immersiveToggleButton = 287 requireViewById(R.id.maximize_menu_immersive_toggle_button) as Button 288 private val sizeToggleContainer = 289 requireViewById(R.id.maximize_menu_size_toggle_container) as View 290 private val sizeToggleButtonText = 291 requireViewById(R.id.maximize_menu_size_toggle_button_text) as TextView 292 private val sizeToggleButton = 293 requireViewById(R.id.maximize_menu_size_toggle_button) as Button 294 private val snapContainer = 295 requireViewById(R.id.maximize_menu_snap_container) as View 296 private val snapWindowText = 297 requireViewById(R.id.maximize_menu_snap_window_text) as TextView 298 private val snapButtonsLayout = 299 requireViewById(R.id.maximize_menu_snap_menu_layout) 300 301 // If layout direction is RTL, maximize menu will be mirrored, switching the order of the 302 // snap right/left buttons. 303 val isRtl: Boolean = 304 (context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL) 305 private val snapRightButton = if (isRtl) { 306 requireViewById(R.id.maximize_menu_snap_left_button) as Button 307 } else { 308 requireViewById(R.id.maximize_menu_snap_right_button) as Button 309 } 310 private val snapLeftButton = if (isRtl) { 311 requireViewById(R.id.maximize_menu_snap_right_button) as Button 312 } else { 313 requireViewById(R.id.maximize_menu_snap_left_button) as Button 314 } 315 316 private val decorThemeUtil = DecorThemeUtil(context) 317 318 private val outlineRadius = context.resources 319 .getDimensionPixelSize(R.dimen.desktop_mode_maximize_menu_buttons_outline_radius) 320 private val outlineStroke = context.resources 321 .getDimensionPixelSize(R.dimen.desktop_mode_maximize_menu_buttons_outline_stroke) 322 private val fillRadius = context.resources 323 .getDimensionPixelSize(R.dimen.desktop_mode_maximize_menu_buttons_fill_radius) 324 325 private val immersiveFillPadding = context.resources.getDimensionPixelSize(R.dimen 326 .desktop_mode_maximize_menu_immersive_button_fill_padding) 327 private val maximizeFillPaddingDefault = context.resources.getDimensionPixelSize(R.dimen 328 .desktop_mode_maximize_menu_snap_and_maximize_buttons_fill_padding) 329 private val maximizeRestoreFillPaddingVertical = context.resources.getDimensionPixelSize( 330 R.dimen.desktop_mode_maximize_menu_restore_button_fill_vertical_padding) 331 private val maximizeRestoreFillPaddingHorizontal = context.resources.getDimensionPixelSize( 332 R.dimen.desktop_mode_maximize_menu_restore_button_fill_horizontal_padding) 333 private val maximizeFillPaddingRect = Rect( 334 maximizeFillPaddingDefault, 335 maximizeFillPaddingDefault, 336 maximizeFillPaddingDefault, 337 maximizeFillPaddingDefault 338 ) 339 private val maximizeRestoreFillPaddingRect = Rect( 340 maximizeRestoreFillPaddingHorizontal, 341 maximizeRestoreFillPaddingVertical, 342 maximizeRestoreFillPaddingHorizontal, 343 maximizeRestoreFillPaddingVertical, 344 ) 345 private val immersiveFillPaddingRect = Rect( 346 immersiveFillPadding, 347 immersiveFillPadding, 348 immersiveFillPadding, 349 immersiveFillPadding 350 ) 351 352 private val hoverTempRect = Rect() 353 private var menuAnimatorSet: AnimatorSet? = null 354 private lateinit var taskInfo: RunningTaskInfo 355 private lateinit var style: MenuStyle 356 357 /** Invoked when the maximize or restore option is clicked. */ 358 var onMaximizeClickListener: (() -> Unit)? = null 359 /** Invoked when the immersive or restore option is clicked. */ 360 var onImmersiveOrRestoreClickListener: (() -> Unit)? = null 361 /** Invoked when the left snap option is clicked. */ 362 var onLeftSnapClickListener: (() -> Unit)? = null 363 /** Invoked when the right snap option is clicked. */ 364 var onRightSnapClickListener: (() -> Unit)? = null 365 /** Invoked whenever the hover state of the menu changes. */ 366 var onMenuHoverListener: ((Boolean) -> Unit)? = null 367 /** Invoked whenever a click occurs outside the menu */ 368 var onOutsideTouchListener: (() -> Unit)? = null 369 370 init { eventnull371 overlay.setOnHoverListener { _, event -> 372 // The overlay covers the entire menu, so it's a convenient way to monitor whether 373 // the menu is hovered as a whole or not. 374 when (event.action) { 375 ACTION_HOVER_ENTER -> onMenuHoverListener?.invoke(true) 376 ACTION_HOVER_EXIT -> onMenuHoverListener?.invoke(false) 377 } 378 379 // Also check if the hover falls within the snap options layout, to manually 380 // set the left/right state based on the event's position. 381 // TODO(b/346440693): this manual hover tracking is needed for left/right snap 382 // because its view/background(s) don't support selector states. Look into whether 383 // that can be added to avoid manual tracking. Also because these button 384 // colors/state logic is only being applied on hover events, but there's pressed, 385 // focused and selected states that should be responsive too. 386 val snapLayoutBoundsRelToOverlay = hoverTempRect.also { rect -> 387 snapButtonsLayout.getDrawingRect(rect) 388 rootView.offsetDescendantRectToMyCoords(snapButtonsLayout, rect) 389 } 390 if (event.action == ACTION_HOVER_ENTER || event.action == ACTION_HOVER_MOVE) { 391 if (snapLayoutBoundsRelToOverlay.contains(event.x.toInt(), event.y.toInt())) { 392 // Hover is inside the snap layout, anything left of center is the left 393 // snap, and anything right of center is right snap. 394 val layoutCenter = snapLayoutBoundsRelToOverlay.centerX() 395 if (event.x < layoutCenter) { 396 updateSplitSnapSelection(SnapToHalfSelection.LEFT) 397 } else { 398 updateSplitSnapSelection(SnapToHalfSelection.RIGHT) 399 } 400 } else { 401 // Any other hover is outside the snap layout, so neither is selected. 402 updateSplitSnapSelection(SnapToHalfSelection.NONE) 403 } 404 } 405 406 // Don't consume the event to allow child views to receive the event too. 407 return@setOnHoverListener false 408 } 409 410 immersiveToggleContainer.isGone = immersiveConfig is ImmersiveConfig.Hidden 411 sizeToggleContainer.isVisible = true 412 snapContainer.isGone = !showSnapOptions 413 <lambda>null414 immersiveToggleButton.setOnClickListener { onImmersiveOrRestoreClickListener?.invoke() } <lambda>null415 sizeToggleButton.setOnClickListener { onMaximizeClickListener?.invoke() } <lambda>null416 snapRightButton.setOnClickListener { onRightSnapClickListener?.invoke() } <lambda>null417 snapLeftButton.setOnClickListener { onLeftSnapClickListener?.invoke() } eventnull418 rootView.setOnTouchListener { _, event -> 419 if (event.actionMasked == ACTION_OUTSIDE) { 420 onOutsideTouchListener?.invoke() 421 return@setOnTouchListener false 422 } 423 true 424 } 425 426 sizeToggleButton.accessibilityDelegate = object : View.AccessibilityDelegate() { onInitializeAccessibilityNodeInfonull427 override fun onInitializeAccessibilityNodeInfo( 428 host: View, 429 info: AccessibilityNodeInfo 430 ) { 431 432 super.onInitializeAccessibilityNodeInfo(host, info) 433 info.addAction(AccessibilityAction( 434 AccessibilityAction.ACTION_CLICK.id, 435 context.getString(R.string.maximize_menu_talkback_action_maximize_restore_text) 436 )) 437 host.isClickable = true 438 } 439 performAccessibilityActionnull440 override fun performAccessibilityAction( 441 host: View, 442 action: Int, 443 args: Bundle? 444 ): Boolean { 445 if (action == AccessibilityAction.ACTION_CLICK.id) { 446 desktopModeUiEventLogger.log(taskInfo, A11Y_MAXIMIZE_MENU_MAXIMIZE) 447 onMaximizeClickListener?.invoke() 448 } 449 return super.performAccessibilityAction(host, action, args) 450 } 451 } 452 453 snapLeftButton.accessibilityDelegate = object : View.AccessibilityDelegate() { onInitializeAccessibilityNodeInfonull454 override fun onInitializeAccessibilityNodeInfo( 455 host: View, 456 info: AccessibilityNodeInfo 457 ) { 458 super.onInitializeAccessibilityNodeInfo(host, info) 459 info.addAction(AccessibilityAction( 460 AccessibilityAction.ACTION_CLICK.id, 461 context.getString(R.string.maximize_menu_talkback_action_snap_left_text) 462 )) 463 host.isClickable = true 464 } 465 performAccessibilityActionnull466 override fun performAccessibilityAction( 467 host: View, 468 action: Int, 469 args: Bundle? 470 ): Boolean { 471 if (action == AccessibilityAction.ACTION_CLICK.id) { 472 desktopModeUiEventLogger.log(taskInfo, A11Y_MAXIMIZE_MENU_RESIZE_LEFT) 473 onLeftSnapClickListener?.invoke() 474 } 475 return super.performAccessibilityAction(host, action, args) 476 } 477 } 478 479 snapRightButton.accessibilityDelegate = object : View.AccessibilityDelegate() { onInitializeAccessibilityNodeInfonull480 override fun onInitializeAccessibilityNodeInfo( 481 host: View, 482 info: AccessibilityNodeInfo 483 ) { 484 super.onInitializeAccessibilityNodeInfo(host, info) 485 info.addAction(AccessibilityAction( 486 AccessibilityAction.ACTION_CLICK.id, 487 context.getString(R.string.maximize_menu_talkback_action_snap_right_text) 488 )) 489 host.isClickable = true 490 } 491 performAccessibilityActionnull492 override fun performAccessibilityAction( 493 host: View, 494 action: Int, 495 args: Bundle? 496 ): Boolean { 497 if (action == AccessibilityAction.ACTION_CLICK.id) { 498 desktopModeUiEventLogger.log(taskInfo, A11Y_MAXIMIZE_MENU_RESIZE_RIGHT) 499 onRightSnapClickListener?.invoke() 500 } 501 return super.performAccessibilityAction(host, action, args) 502 } 503 } 504 505 // Maximize/restore button. 506 val sizeToggleBtnTextId = if (sizeToggleDirection == SizeToggleDirection.RESTORE) 507 R.string.desktop_mode_maximize_menu_restore_button_text 508 else 509 R.string.desktop_mode_maximize_menu_maximize_button_text 510 val sizeToggleBtnText = context.resources.getText(sizeToggleBtnTextId) 511 sizeToggleButton.contentDescription = sizeToggleBtnText 512 sizeToggleButtonText.text = sizeToggleBtnText 513 514 // Immersive enter/exit button. 515 if (immersiveConfig is ImmersiveConfig.Visible) { 516 val immersiveToggleBtnTextId = when (immersiveConfig.direction) { 517 ImmersiveToggleDirection.ENTER -> { 518 R.string.desktop_mode_maximize_menu_immersive_button_text 519 } 520 521 ImmersiveToggleDirection.EXIT -> { 522 R.string.desktop_mode_maximize_menu_immersive_restore_button_text 523 } 524 } 525 val immersiveToggleBtnText = context.resources.getText(immersiveToggleBtnTextId) 526 immersiveToggleButton.contentDescription = immersiveToggleBtnText 527 immersiveToggleButtonText.text = immersiveToggleBtnText 528 } 529 530 // To prevent aliasing. 531 sizeToggleButton.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 532 sizeToggleButtonText.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 533 immersiveToggleButton.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 534 immersiveToggleButtonText.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 535 } 536 537 /** Bind the menu views to the new [RunningTaskInfo] data. */ bindnull538 fun bind(taskInfo: RunningTaskInfo) { 539 this.taskInfo = taskInfo 540 this.style = calculateMenuStyle(taskInfo) 541 542 rootView.background.setTint(style.backgroundColor) 543 544 // Maximize option. 545 sizeToggleButton.background = style.maximizeOption.drawable 546 sizeToggleButtonText.setTextColor(style.textColor) 547 548 // Immersive option. 549 immersiveToggleButton.background = style.immersiveOption.drawable 550 immersiveToggleButtonText.setTextColor(style.textColor) 551 552 // Snap options. 553 snapWindowText.setTextColor(style.textColor) 554 updateSplitSnapSelection(SnapToHalfSelection.NONE) 555 } 556 557 /** Animate the opening of the menu */ animateOpenMenunull558 fun animateOpenMenu(onEnd: () -> Unit) { 559 sizeToggleButton.setLayerType(View.LAYER_TYPE_HARDWARE, null) 560 sizeToggleButtonText.setLayerType(View.LAYER_TYPE_HARDWARE, null) 561 immersiveToggleButton.setLayerType(View.LAYER_TYPE_HARDWARE, null) 562 immersiveToggleButtonText.setLayerType(View.LAYER_TYPE_HARDWARE, null) 563 menuAnimatorSet = AnimatorSet() 564 menuAnimatorSet?.playTogether( 565 ObjectAnimator.ofFloat(rootView, SCALE_Y, STARTING_MENU_HEIGHT_SCALE, 1f) 566 .apply { 567 duration = OPEN_MENU_HEIGHT_ANIMATION_DURATION_MS 568 interpolator = EMPHASIZED_DECELERATE 569 }, 570 ValueAnimator.ofFloat(STARTING_MENU_HEIGHT_SCALE, 1f) 571 .apply { 572 duration = OPEN_MENU_HEIGHT_ANIMATION_DURATION_MS 573 interpolator = EMPHASIZED_DECELERATE 574 addUpdateListener { 575 // Animate padding so that controls stay pinned to the bottom of 576 // the menu. 577 val value = animatedValue as Float 578 val topPadding = menuPadding - 579 ((1 - value) * measureHeight()).toInt() 580 container.setPadding(menuPadding, topPadding, 581 menuPadding, menuPadding) 582 } 583 }, 584 ValueAnimator.ofFloat(1 / STARTING_MENU_HEIGHT_SCALE, 1f).apply { 585 duration = OPEN_MENU_HEIGHT_ANIMATION_DURATION_MS 586 interpolator = EMPHASIZED_DECELERATE 587 addUpdateListener { 588 // Scale up the children of the maximize menu so that the menu 589 // scale is cancelled out and only the background is scaled. 590 val value = animatedValue as Float 591 sizeToggleButton.scaleY = value 592 immersiveToggleButton.scaleY = value 593 snapButtonsLayout.scaleY = value 594 sizeToggleButtonText.scaleY = value 595 immersiveToggleButtonText.scaleY = value 596 snapWindowText.scaleY = value 597 } 598 }, 599 ObjectAnimator.ofFloat(rootView, TRANSLATION_Y, 600 (STARTING_MENU_HEIGHT_SCALE - 1) * measureHeight(), 0f).apply { 601 duration = OPEN_MENU_HEIGHT_ANIMATION_DURATION_MS 602 interpolator = EMPHASIZED_DECELERATE 603 }, 604 ObjectAnimator.ofInt(rootView.background, "alpha", 605 MAX_DRAWABLE_ALPHA_VALUE).apply { 606 duration = ALPHA_ANIMATION_DURATION_MS 607 }, 608 ValueAnimator.ofFloat(0f, 1f) 609 .apply { 610 duration = ALPHA_ANIMATION_DURATION_MS 611 startDelay = CONTROLS_ALPHA_OPEN_MENU_ANIMATION_DELAY_MS 612 addUpdateListener { 613 val value = animatedValue as Float 614 sizeToggleButton.alpha = value 615 immersiveToggleButton.alpha = value 616 snapButtonsLayout.alpha = value 617 sizeToggleButtonText.alpha = value 618 immersiveToggleButtonText.alpha = value 619 snapWindowText.alpha = value 620 } 621 }, 622 ObjectAnimator.ofFloat(rootView, TRANSLATION_Z, MENU_Z_TRANSLATION) 623 .apply { 624 duration = ELEVATION_ANIMATION_DURATION_MS 625 startDelay = CONTROLS_ALPHA_OPEN_MENU_ANIMATION_DELAY_MS 626 } 627 ) 628 menuAnimatorSet?.addListener( 629 onEnd = { 630 sizeToggleButton.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 631 sizeToggleButtonText.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 632 immersiveToggleButton.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 633 immersiveToggleButtonText.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 634 onEnd.invoke() 635 } 636 ) 637 menuAnimatorSet?.start() 638 } 639 640 /** Animate the closing of the menu */ animateCloseMenunull641 fun animateCloseMenu(onEnd: (() -> Unit)) { 642 sizeToggleButton.setLayerType(View.LAYER_TYPE_HARDWARE, null) 643 sizeToggleButtonText.setLayerType(View.LAYER_TYPE_HARDWARE, null) 644 immersiveToggleButton.setLayerType(View.LAYER_TYPE_HARDWARE, null) 645 immersiveToggleButtonText.setLayerType(View.LAYER_TYPE_HARDWARE, null) 646 cancelAnimation() 647 menuAnimatorSet = AnimatorSet() 648 menuAnimatorSet?.playTogether( 649 ObjectAnimator.ofFloat(rootView, SCALE_Y, 1f, STARTING_MENU_HEIGHT_SCALE) 650 .apply { 651 duration = CLOSE_MENU_HEIGHT_ANIMATION_DURATION_MS 652 interpolator = FAST_OUT_LINEAR_IN 653 }, 654 ValueAnimator.ofFloat(1f, STARTING_MENU_HEIGHT_SCALE) 655 .apply { 656 duration = CLOSE_MENU_HEIGHT_ANIMATION_DURATION_MS 657 interpolator = FAST_OUT_LINEAR_IN 658 addUpdateListener { 659 // Animate padding so that controls stay pinned to the bottom of 660 // the menu. 661 val value = animatedValue as Float 662 val topPadding = menuPadding - 663 ((1 - value) * measureHeight()).toInt() 664 container.setPadding(menuPadding, topPadding, 665 menuPadding, menuPadding) 666 } 667 }, 668 ValueAnimator.ofFloat(1f, 1 / STARTING_MENU_HEIGHT_SCALE).apply { 669 duration = CLOSE_MENU_HEIGHT_ANIMATION_DURATION_MS 670 interpolator = FAST_OUT_LINEAR_IN 671 addUpdateListener { 672 // Scale up the children of the maximize menu so that the menu 673 // scale is cancelled out and only the background is scaled. 674 val value = animatedValue as Float 675 sizeToggleButton.scaleY = value 676 immersiveToggleButton.scaleY = value 677 snapButtonsLayout.scaleY = value 678 sizeToggleButtonText.scaleY = value 679 immersiveToggleButtonText.scaleY = value 680 snapWindowText.scaleY = value 681 } 682 }, 683 ObjectAnimator.ofFloat(rootView, TRANSLATION_Y, 684 0f, (STARTING_MENU_HEIGHT_SCALE - 1) * measureHeight()).apply { 685 duration = CLOSE_MENU_HEIGHT_ANIMATION_DURATION_MS 686 interpolator = FAST_OUT_LINEAR_IN 687 }, 688 ObjectAnimator.ofInt(rootView.background, "alpha", 689 MAX_DRAWABLE_ALPHA_VALUE, 0).apply { 690 startDelay = CONTAINER_ALPHA_CLOSE_MENU_ANIMATION_DELAY_MS 691 duration = ALPHA_ANIMATION_DURATION_MS 692 }, 693 ValueAnimator.ofFloat(1f, 0f) 694 .apply { 695 duration = ALPHA_ANIMATION_DURATION_MS 696 addUpdateListener { 697 val value = animatedValue as Float 698 sizeToggleButton.alpha = value 699 immersiveToggleButton.alpha = value 700 snapButtonsLayout.alpha = value 701 sizeToggleButtonText.alpha = value 702 immersiveToggleButtonText.alpha = value 703 snapWindowText.alpha = value 704 } 705 }, 706 ObjectAnimator.ofFloat(rootView, TRANSLATION_Z, MENU_Z_TRANSLATION, 0f) 707 .apply { 708 duration = ELEVATION_ANIMATION_DURATION_MS 709 } 710 ) 711 menuAnimatorSet?.addListener( 712 onEnd = { 713 sizeToggleButton.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 714 sizeToggleButtonText.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 715 immersiveToggleButton.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 716 immersiveToggleButtonText.setLayerType(View.LAYER_TYPE_SOFTWARE, null) 717 onEnd?.invoke() 718 } 719 ) 720 menuAnimatorSet?.start() 721 } 722 723 /** Request that the accessibility service focus on the menu. */ requestAccessibilityFocusnull724 fun requestAccessibilityFocus() { 725 // Focus the first button in the menu by default. 726 if (immersiveToggleButton.isVisible) { 727 immersiveToggleButton.post { 728 immersiveToggleButton.sendAccessibilityEvent( 729 AccessibilityEvent.TYPE_VIEW_FOCUSED 730 ) 731 } 732 return 733 } 734 sizeToggleButton.post { 735 sizeToggleButton.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) 736 } 737 } 738 739 /** Cancel the menu animation. */ cancelAnimationnull740 private fun cancelAnimation() { 741 menuAnimatorSet?.cancel() 742 } 743 744 /** Update the view state to a new snap to half selection. */ updateSplitSnapSelectionnull745 private fun updateSplitSnapSelection(selection: SnapToHalfSelection) { 746 when (selection) { 747 SnapToHalfSelection.NONE -> deactivateSnapOptions() 748 SnapToHalfSelection.LEFT -> activateSnapOption(activateLeft = true) 749 SnapToHalfSelection.RIGHT -> activateSnapOption(activateLeft = false) 750 } 751 } 752 calculateMenuStylenull753 private fun calculateMenuStyle(taskInfo: RunningTaskInfo): MenuStyle { 754 val colorScheme = decorThemeUtil.getColorScheme(taskInfo) 755 val menuBackgroundColor = colorScheme.surfaceContainerLow.toArgb() 756 return MenuStyle( 757 backgroundColor = menuBackgroundColor, 758 textColor = colorScheme.onSurface.toArgb(), 759 maximizeOption = MenuStyle.MaximizeOption( 760 drawable = createMaximizeOrImmersiveDrawable( 761 menuBackgroundColor, 762 colorScheme, 763 fillPadding = when (sizeToggleDirection) { 764 SizeToggleDirection.MAXIMIZE -> maximizeFillPaddingRect 765 SizeToggleDirection.RESTORE -> maximizeRestoreFillPaddingRect 766 } 767 ) 768 ), 769 immersiveOption = MenuStyle.ImmersiveOption( 770 drawable = createMaximizeOrImmersiveDrawable( 771 menuBackgroundColor, 772 colorScheme, 773 fillPadding = immersiveFillPaddingRect, 774 ), 775 ), 776 snapOptions = MenuStyle.SnapOptions( 777 inactiveSnapSideColor = colorScheme.outlineVariant.toArgb(), 778 semiActiveSnapSideColor = colorScheme.primary.toArgb().withAlpha(OPACITY_40), 779 activeSnapSideColor = colorScheme.primary.toArgb(), 780 inactiveStrokeColor = colorScheme.outlineVariant.toArgb().withAlpha(OPACITY_60), 781 activeStrokeColor = colorScheme.primary.toArgb(), 782 inactiveBackgroundColor = menuBackgroundColor, 783 activeBackgroundColor = colorScheme.primary.toArgb().withAlpha(OPACITY_12) 784 ), 785 ) 786 } 787 788 /** Measure width of the root view of this menu. */ measureWidthnull789 fun measureWidth(): Int { 790 rootView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) 791 return rootView.measuredWidth 792 } 793 794 /** Measure height of the root view of this menu. */ measureHeightnull795 fun measureHeight(): Int { 796 rootView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) 797 return rootView.measuredHeight 798 } 799 deactivateSnapOptionsnull800 private fun deactivateSnapOptions() { 801 // TODO(b/346440693): the background/colorStateList set on these buttons is overridden 802 // to a static resource & color on manually tracked hover events, which defeats the 803 // point of state lists and selector states. Look into whether changing that is 804 // possible, similar to the maximize option. Also to include support for the 805 // semi-active state (when the "other" snap option is selected). 806 val snapSideColorList = ColorStateList( 807 arrayOf( 808 intArrayOf(android.R.attr.state_pressed), 809 intArrayOf(android.R.attr.state_focused), 810 intArrayOf(android.R.attr.state_selected), 811 intArrayOf(), 812 ), 813 intArrayOf( 814 style.snapOptions.activeSnapSideColor, 815 style.snapOptions.activeSnapSideColor, 816 style.snapOptions.activeSnapSideColor, 817 style.snapOptions.inactiveSnapSideColor 818 ) 819 ) 820 snapLeftButton.background?.setTintList(snapSideColorList) 821 snapRightButton.background?.setTintList(snapSideColorList) 822 with (snapButtonsLayout) { 823 setBackgroundResource(R.drawable.desktop_mode_maximize_menu_layout_background) 824 (background as GradientDrawable).apply { 825 setColor(style.snapOptions.inactiveBackgroundColor) 826 setStroke(outlineStroke, style.snapOptions.inactiveStrokeColor) 827 } 828 } 829 } 830 activateSnapOptionnull831 private fun activateSnapOption(activateLeft: Boolean) { 832 // Regardless of which side is active, the background of the snap options layout (that 833 // includes both sides) is considered "active". 834 with (snapButtonsLayout) { 835 setBackgroundResource( 836 R.drawable.desktop_mode_maximize_menu_layout_background_on_hover) 837 (background as GradientDrawable).apply { 838 setColor(style.snapOptions.activeBackgroundColor) 839 setStroke(outlineStroke, style.snapOptions.activeStrokeColor) 840 } 841 } 842 if (activateLeft) { 843 // Highlight snap left button, partially highlight the other side. 844 snapLeftButton.background.setTint(style.snapOptions.activeSnapSideColor) 845 snapRightButton.background.setTint(style.snapOptions.semiActiveSnapSideColor) 846 } else { 847 // Highlight snap right button, partially highlight the other side. 848 snapRightButton.background.setTint(style.snapOptions.activeSnapSideColor) 849 snapLeftButton.background.setTint(style.snapOptions.semiActiveSnapSideColor) 850 } 851 } 852 createMaximizeOrImmersiveDrawablenull853 private fun createMaximizeOrImmersiveDrawable( 854 @ColorInt menuBackgroundColor: Int, 855 colorScheme: ColorScheme, 856 fillPadding: Rect, 857 ): StateListDrawable { 858 val activeStrokeAndFill = colorScheme.primary.toArgb() 859 val activeBackground = colorScheme.primary.toArgb().withAlpha(OPACITY_12) 860 val activeDrawable = createMaximizeOrImmersiveButtonDrawable( 861 strokeColor = activeStrokeAndFill, 862 fillColor = activeStrokeAndFill, 863 backgroundColor = activeBackground, 864 // Add a mask with the menu background's color because the active background color is 865 // semi transparent, otherwise the transparency will reveal the stroke/fill color 866 // behind it. 867 backgroundMask = menuBackgroundColor, 868 fillPadding = fillPadding, 869 ) 870 return StateListDrawable().apply { 871 addState(intArrayOf(android.R.attr.state_pressed), activeDrawable) 872 addState(intArrayOf(android.R.attr.state_focused), activeDrawable) 873 addState(intArrayOf(android.R.attr.state_selected), activeDrawable) 874 addState(intArrayOf(android.R.attr.state_hovered), activeDrawable) 875 // Inactive drawable. 876 addState( 877 StateSet.WILD_CARD, 878 createMaximizeOrImmersiveButtonDrawable( 879 strokeColor = colorScheme.outlineVariant.toArgb().withAlpha(OPACITY_60), 880 fillColor = colorScheme.outlineVariant.toArgb(), 881 backgroundColor = colorScheme.surfaceContainerLow.toArgb(), 882 backgroundMask = null, // not needed because the bg color is fully opaque 883 fillPadding = fillPadding, 884 ) 885 ) 886 } 887 } 888 createMaximizeOrImmersiveButtonDrawablenull889 private fun createMaximizeOrImmersiveButtonDrawable( 890 @ColorInt strokeColor: Int, 891 @ColorInt fillColor: Int, 892 @ColorInt backgroundColor: Int, 893 @ColorInt backgroundMask: Int?, 894 fillPadding: Rect, 895 ): LayerDrawable { 896 val layers = mutableListOf<Drawable>() 897 // First (bottom) layer, effectively the button's border ring once its inner shape is 898 // covered by the next layers. 899 layers.add(ShapeDrawable().apply { 900 shape = RoundRectShape( 901 FloatArray(8) { outlineRadius.toFloat() }, 902 null /* inset */, 903 null /* innerRadii */ 904 ) 905 paint.color = strokeColor 906 paint.style = Paint.Style.FILL 907 }) 908 // Second layer, a mask for the next (background) layer if needed because of 909 // transparency. 910 backgroundMask?.let { color -> 911 layers.add( 912 ShapeDrawable().apply { 913 shape = RoundRectShape( 914 FloatArray(8) { outlineRadius.toFloat() }, 915 null /* inset */, 916 null /* innerRadii */ 917 ) 918 paint.color = color 919 paint.style = Paint.Style.FILL 920 } 921 ) 922 } 923 // Third layer, the "background" padding between the border and the fill. 924 layers.add(ShapeDrawable().apply { 925 shape = RoundRectShape( 926 FloatArray(8) { outlineRadius.toFloat() }, 927 null /* inset */, 928 null /* innerRadii */ 929 ) 930 paint.color = backgroundColor 931 paint.style = Paint.Style.FILL 932 }) 933 // Final layer, the inner most rounded-rect "fill". 934 layers.add(ShapeDrawable().apply { 935 shape = RoundRectShape( 936 FloatArray(8) { fillRadius.toFloat() }, 937 null /* inset */, 938 null /* innerRadii */ 939 ) 940 paint.color = fillColor 941 paint.style = Paint.Style.FILL 942 }) 943 944 return LayerDrawable(layers.toTypedArray()).apply { 945 when (numberOfLayers) { 946 3 -> { 947 setLayerInset(1, outlineStroke) 948 setLayerInset(2, fillPadding.left, fillPadding.top, 949 fillPadding.right, fillPadding.bottom) 950 } 951 4 -> { 952 setLayerInset(intArrayOf(1, 2), outlineStroke) 953 setLayerInset(3, fillPadding.left, fillPadding.top, 954 fillPadding.right, fillPadding.bottom) 955 } 956 else -> error("Unexpected number of layers: $numberOfLayers") 957 } 958 } 959 } 960 LayerDrawablenull961 private fun LayerDrawable.setLayerInset(index: IntArray, inset: Int) { 962 for (i in index) { 963 setLayerInset(i, inset, inset, inset, inset) 964 } 965 } 966 LayerDrawablenull967 private fun LayerDrawable.setLayerInset(index: Int, inset: Int) { 968 setLayerInset(index, inset, inset, inset, inset) 969 } 970 requireViewByIdnull971 private fun requireViewById(id: Int) = rootView.requireViewById<View>(id) 972 973 /** The style to apply to the menu. */ 974 data class MenuStyle( 975 @ColorInt val backgroundColor: Int, 976 @ColorInt val textColor: Int, 977 val maximizeOption: MaximizeOption, 978 val immersiveOption: ImmersiveOption, 979 val snapOptions: SnapOptions, 980 ) { 981 data class MaximizeOption( 982 val drawable: StateListDrawable, 983 ) 984 data class ImmersiveOption( 985 val drawable: StateListDrawable, 986 ) 987 data class SnapOptions( 988 @ColorInt val inactiveSnapSideColor: Int, 989 @ColorInt val semiActiveSnapSideColor: Int, 990 @ColorInt val activeSnapSideColor: Int, 991 @ColorInt val inactiveStrokeColor: Int, 992 @ColorInt val activeStrokeColor: Int, 993 @ColorInt val inactiveBackgroundColor: Int, 994 @ColorInt val activeBackgroundColor: Int, 995 ) 996 } 997 998 /** The possible selection states of the half-snap menu option. */ 999 enum class SnapToHalfSelection { 1000 NONE, LEFT, RIGHT 1001 } 1002 1003 /** The possible immersive configs for this menu instance. */ 1004 sealed class ImmersiveConfig { 1005 data class Visible( 1006 val direction: ImmersiveToggleDirection, 1007 ) : ImmersiveConfig() 1008 data object Hidden : ImmersiveConfig() 1009 } 1010 1011 /** The possible selection states of the size toggle button in the maximize menu. */ 1012 enum class SizeToggleDirection { 1013 MAXIMIZE, RESTORE 1014 } 1015 1016 /** The possible selection states of the immersive toggle button in the maximize menu. */ 1017 enum class ImmersiveToggleDirection { 1018 ENTER, EXIT 1019 } 1020 } 1021 1022 companion object { 1023 // Open menu animation constants 1024 private const val ALPHA_ANIMATION_DURATION_MS = 50L 1025 private const val MAX_DRAWABLE_ALPHA_VALUE = 255 1026 private const val STARTING_MENU_HEIGHT_SCALE = 0.8f 1027 private const val OPEN_MENU_HEIGHT_ANIMATION_DURATION_MS = 300L 1028 private const val CLOSE_MENU_HEIGHT_ANIMATION_DURATION_MS = 200L 1029 private const val ELEVATION_ANIMATION_DURATION_MS = 50L 1030 private const val CONTROLS_ALPHA_OPEN_MENU_ANIMATION_DELAY_MS = 33L 1031 private const val CONTAINER_ALPHA_CLOSE_MENU_ANIMATION_DELAY_MS = 33L 1032 private const val MENU_Z_TRANSLATION = 1f 1033 } 1034 } 1035 1036 /** A factory interface to create a [MaximizeMenu]. */ 1037 interface MaximizeMenuFactory { createnull1038 fun create( 1039 syncQueue: SyncTransactionQueue, 1040 rootTdaOrganizer: RootTaskDisplayAreaOrganizer, 1041 displayController: DisplayController, 1042 taskInfo: RunningTaskInfo, 1043 decorWindowContext: Context, 1044 positionSupplier: (Int, Int) -> Point, 1045 transactionSupplier: Supplier<Transaction>, 1046 desktopModeUiEventLogger: DesktopModeUiEventLogger, 1047 ): MaximizeMenu 1048 } 1049 1050 /** A [MaximizeMenuFactory] implementation that creates a [MaximizeMenu]. */ 1051 object DefaultMaximizeMenuFactory : MaximizeMenuFactory { 1052 override fun create( 1053 syncQueue: SyncTransactionQueue, 1054 rootTdaOrganizer: RootTaskDisplayAreaOrganizer, 1055 displayController: DisplayController, 1056 taskInfo: RunningTaskInfo, 1057 decorWindowContext: Context, 1058 positionSupplier: (Int, Int) -> Point, 1059 transactionSupplier: Supplier<Transaction>, 1060 desktopModeUiEventLogger: DesktopModeUiEventLogger, 1061 ): MaximizeMenu { 1062 return MaximizeMenu( 1063 syncQueue, 1064 rootTdaOrganizer, 1065 displayController, 1066 taskInfo, 1067 decorWindowContext, 1068 positionSupplier, 1069 transactionSupplier, 1070 desktopModeUiEventLogger 1071 ) 1072 } 1073 } 1074