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 package com.android.wm.shell.windowdecor 17 18 import android.annotation.ColorInt 19 import android.annotation.DimenRes 20 import android.annotation.SuppressLint 21 import android.app.ActivityManager.RunningTaskInfo 22 import android.app.WindowConfiguration 23 import android.content.Context 24 import android.content.Intent 25 import android.content.res.ColorStateList 26 import android.content.res.Resources 27 import android.graphics.Bitmap 28 import android.graphics.Point 29 import android.graphics.PointF 30 import android.graphics.Rect 31 import android.os.Bundle 32 import android.view.LayoutInflater 33 import android.view.MotionEvent 34 import android.view.MotionEvent.ACTION_OUTSIDE 35 import android.view.SurfaceControl 36 import android.view.View 37 import android.view.WindowInsets.Type.systemBars 38 import android.view.WindowManager 39 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction 40 import android.widget.ImageButton 41 import android.widget.ImageView 42 import android.widget.Space 43 import android.window.DesktopModeFlags 44 import android.window.SurfaceSyncGroup 45 import androidx.annotation.StringRes 46 import androidx.annotation.VisibleForTesting 47 import androidx.compose.ui.graphics.toArgb 48 import androidx.core.view.ViewCompat 49 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK 50 import androidx.core.view.isGone 51 import com.android.window.flags.Flags 52 import com.android.wm.shell.R 53 import com.android.wm.shell.desktopmode.DesktopModeUiEventLogger 54 import com.android.wm.shell.desktopmode.DesktopModeUiEventLogger.DesktopUiEventEnum.A11Y_APP_HANDLE_MENU_DESKTOP_VIEW 55 import com.android.wm.shell.desktopmode.DesktopModeUiEventLogger.DesktopUiEventEnum.A11Y_APP_HANDLE_MENU_FULLSCREEN 56 import com.android.wm.shell.desktopmode.DesktopModeUiEventLogger.DesktopUiEventEnum.A11Y_APP_HANDLE_MENU_SPLIT_SCREEN 57 import com.android.wm.shell.shared.annotations.ShellBackgroundThread 58 import com.android.wm.shell.shared.annotations.ShellMainThread 59 import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper 60 import com.android.wm.shell.shared.bubbles.ContextUtils.isRtl 61 import com.android.wm.shell.shared.split.SplitScreenConstants 62 import com.android.wm.shell.splitscreen.SplitScreenController 63 import com.android.wm.shell.windowdecor.additionalviewcontainer.AdditionalSystemViewContainer 64 import com.android.wm.shell.windowdecor.additionalviewcontainer.AdditionalViewContainer 65 import com.android.wm.shell.windowdecor.common.DecorThemeUtil 66 import com.android.wm.shell.windowdecor.common.DrawableInsets 67 import com.android.wm.shell.windowdecor.common.WindowDecorTaskResourceLoader 68 import com.android.wm.shell.windowdecor.common.calculateMenuPosition 69 import com.android.wm.shell.windowdecor.common.createBackgroundDrawable 70 import com.android.wm.shell.windowdecor.extension.isFullscreen 71 import com.android.wm.shell.windowdecor.extension.isMultiWindow 72 import com.android.wm.shell.windowdecor.extension.isPinned 73 import kotlinx.coroutines.CoroutineDispatcher 74 import kotlinx.coroutines.CoroutineScope 75 import kotlinx.coroutines.Job 76 import kotlinx.coroutines.MainCoroutineDispatcher 77 import kotlinx.coroutines.isActive 78 import kotlinx.coroutines.launch 79 import kotlinx.coroutines.withContext 80 81 82 /** 83 * Handle menu opened when the appropriate button is clicked on. 84 * 85 * Displays up to 3 pills that show the following: 86 * App Info: App name, app icon, and collapse button to close the menu. 87 * Windowing Options(Proto 2 only): Buttons to change windowing modes. 88 * Additional Options: Miscellaneous functions including screenshot and closing task. 89 */ 90 class HandleMenu( 91 @ShellMainThread private val mainDispatcher: CoroutineDispatcher, 92 @ShellBackgroundThread private val bgScope: CoroutineScope, 93 private val parentDecor: DesktopModeWindowDecoration, 94 private val windowManagerWrapper: WindowManagerWrapper, 95 private val taskResourceLoader: WindowDecorTaskResourceLoader, 96 private val layoutResId: Int, 97 private val splitScreenController: SplitScreenController, 98 private val shouldShowWindowingPill: Boolean, 99 private val shouldShowNewWindowButton: Boolean, 100 private val shouldShowManageWindowsButton: Boolean, 101 private val shouldShowChangeAspectRatioButton: Boolean, 102 private val shouldShowDesktopModeButton: Boolean, 103 private val shouldShowRestartButton: Boolean, 104 private val isBrowserApp: Boolean, 105 private val openInAppOrBrowserIntent: Intent?, 106 private val desktopModeUiEventLogger: DesktopModeUiEventLogger, 107 private val captionWidth: Int, 108 private val captionHeight: Int, 109 captionX: Int, 110 captionY: Int 111 ) { 112 private val context: Context = parentDecor.mDecorWindowContext 113 private val taskInfo: RunningTaskInfo = parentDecor.mTaskInfo 114 115 private val isViewAboveStatusBar: Boolean 116 get() = (DesktopModeFlags.ENABLE_HANDLE_INPUT_FIX.isTrue() && !taskInfo.isFreeform) 117 118 private val pillTopMargin: Int = loadDimensionPixelSize( 119 R.dimen.desktop_mode_handle_menu_pill_spacing_margin 120 ) 121 private val menuWidth = loadDimensionPixelSize(R.dimen.desktop_mode_handle_menu_width) 122 private val menuHeight = getHandleMenuHeight() 123 private val marginMenuTop = loadDimensionPixelSize(R.dimen.desktop_mode_handle_menu_margin_top) 124 private val marginMenuStart = loadDimensionPixelSize( 125 R.dimen.desktop_mode_handle_menu_margin_start 126 ) 127 128 @VisibleForTesting 129 var handleMenuViewContainer: AdditionalViewContainer? = null 130 131 @VisibleForTesting 132 var handleMenuView: HandleMenuView? = null 133 134 // Position of the handle menu used for laying out the handle view. 135 @VisibleForTesting 136 val handleMenuPosition: PointF = PointF() 137 138 // With the introduction of {@link AdditionalSystemViewContainer}, {@link mHandleMenuPosition} 139 // may be in a different coordinate space than the input coordinates. Therefore, we still care 140 // about the menu's coordinates relative to the display as a whole, so we need to maintain 141 // those as well. 142 private val globalMenuPosition: Point = Point() 143 144 private val shouldShowBrowserPill: Boolean 145 get() = openInAppOrBrowserIntent != null 146 147 private val shouldShowMoreActionsPill: Boolean 148 get() = SHOULD_SHOW_SCREENSHOT_BUTTON || shouldShowNewWindowButton || 149 shouldShowManageWindowsButton || shouldShowChangeAspectRatioButton || 150 shouldShowRestartButton 151 152 private var loadAppInfoJob: Job? = null 153 154 init { 155 updateHandleMenuPillPositions(captionX, captionY) 156 } 157 158 fun show( 159 onToDesktopClickListener: () -> Unit, 160 onToFullscreenClickListener: () -> Unit, 161 onToSplitScreenClickListener: () -> Unit, 162 onToFloatClickListener: () -> Unit, 163 onNewWindowClickListener: () -> Unit, 164 onManageWindowsClickListener: () -> Unit, 165 onChangeAspectRatioClickListener: () -> Unit, 166 openInAppOrBrowserClickListener: (Intent) -> Unit, 167 onOpenByDefaultClickListener: () -> Unit, 168 onRestartClickListener: () -> Unit, 169 onCloseMenuClickListener: () -> Unit, 170 onOutsideTouchListener: () -> Unit, 171 forceShowSystemBars: Boolean = false, 172 ) { 173 val ssg = SurfaceSyncGroup(TAG) 174 val t = SurfaceControl.Transaction() 175 176 createHandleMenu( 177 t = t, 178 ssg = ssg, 179 onToDesktopClickListener = onToDesktopClickListener, 180 onToFullscreenClickListener = onToFullscreenClickListener, 181 onToSplitScreenClickListener = onToSplitScreenClickListener, 182 onToFloatClickListener = onToFloatClickListener, 183 onNewWindowClickListener = onNewWindowClickListener, 184 onManageWindowsClickListener = onManageWindowsClickListener, 185 onChangeAspectRatioClickListener = onChangeAspectRatioClickListener, 186 openInAppOrBrowserClickListener = openInAppOrBrowserClickListener, 187 onOpenByDefaultClickListener = onOpenByDefaultClickListener, 188 onRestartClickListener = onRestartClickListener, 189 onCloseMenuClickListener = onCloseMenuClickListener, 190 onOutsideTouchListener = onOutsideTouchListener, 191 forceShowSystemBars = forceShowSystemBars, 192 ) 193 ssg.addTransaction(t) 194 ssg.markSyncReady() 195 196 handleMenuView?.animateOpenMenu() 197 } 198 199 private fun createHandleMenu( 200 t: SurfaceControl.Transaction, 201 ssg: SurfaceSyncGroup, 202 onToDesktopClickListener: () -> Unit, 203 onToFullscreenClickListener: () -> Unit, 204 onToSplitScreenClickListener: () -> Unit, 205 onToFloatClickListener: () -> Unit, 206 onNewWindowClickListener: () -> Unit, 207 onManageWindowsClickListener: () -> Unit, 208 onChangeAspectRatioClickListener: () -> Unit, 209 openInAppOrBrowserClickListener: (Intent) -> Unit, 210 onOpenByDefaultClickListener: () -> Unit, 211 onRestartClickListener: () -> Unit, 212 onCloseMenuClickListener: () -> Unit, 213 onOutsideTouchListener: () -> Unit, 214 forceShowSystemBars: Boolean = false, 215 ) { 216 val handleMenuView = HandleMenuView( 217 context = context, 218 desktopModeUiEventLogger = desktopModeUiEventLogger, 219 menuWidth = menuWidth, 220 captionHeight = captionHeight, 221 shouldShowWindowingPill = shouldShowWindowingPill, 222 shouldShowBrowserPill = shouldShowBrowserPill, 223 shouldShowNewWindowButton = shouldShowNewWindowButton, 224 shouldShowManageWindowsButton = shouldShowManageWindowsButton, 225 shouldShowChangeAspectRatioButton = shouldShowChangeAspectRatioButton, 226 shouldShowDesktopModeButton = shouldShowDesktopModeButton, 227 shouldShowRestartButton = shouldShowRestartButton, 228 isBrowserApp = isBrowserApp 229 ).apply { 230 bind(taskInfo, shouldShowMoreActionsPill) 231 this.onToDesktopClickListener = onToDesktopClickListener 232 this.onToFullscreenClickListener = onToFullscreenClickListener 233 this.onToSplitScreenClickListener = onToSplitScreenClickListener 234 this.onToFloatClickListener = onToFloatClickListener 235 this.onNewWindowClickListener = onNewWindowClickListener 236 this.onManageWindowsClickListener = onManageWindowsClickListener 237 this.onChangeAspectRatioClickListener = onChangeAspectRatioClickListener 238 this.onOpenInAppOrBrowserClickListener = { 239 openInAppOrBrowserClickListener.invoke(openInAppOrBrowserIntent!!) 240 } 241 this.onRestartClickListener = onRestartClickListener 242 this.onOpenByDefaultClickListener = onOpenByDefaultClickListener 243 this.onCloseMenuClickListener = onCloseMenuClickListener 244 this.onOutsideTouchListener = onOutsideTouchListener 245 } 246 loadAppInfoJob = bgScope.launch { 247 if (!isActive) return@launch 248 val name = taskResourceLoader.getName(taskInfo) 249 val icon = taskResourceLoader.getHeaderIcon(taskInfo) 250 withContext(mainDispatcher) { 251 if (!isActive) return@withContext 252 handleMenuView.setAppName(name) 253 handleMenuView.setAppIcon(icon) 254 } 255 } 256 val x = handleMenuPosition.x.toInt() 257 val y = handleMenuPosition.y.toInt() 258 handleMenuViewContainer = 259 if ((!taskInfo.isFreeform && DesktopModeFlags.ENABLE_HANDLE_INPUT_FIX.isTrue()) 260 || forceShowSystemBars 261 ) { 262 AdditionalSystemViewContainer( 263 windowManagerWrapper = windowManagerWrapper, 264 taskId = taskInfo.taskId, 265 x = x, 266 y = y, 267 width = menuWidth, 268 height = menuHeight, 269 flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or 270 WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, 271 view = handleMenuView.rootView, 272 forciblyShownTypes = if (forceShowSystemBars) { 273 systemBars() 274 } else { 275 0 276 }, 277 ignoreCutouts = Flags.showAppHandleLargeScreens() 278 || BubbleAnythingFlagHelper.enableBubbleToFullscreen() 279 ) 280 } else { 281 parentDecor.addWindow( 282 handleMenuView.rootView, "Handle Menu", t, ssg, x, y, menuWidth, menuHeight 283 ) 284 } 285 286 this.handleMenuView = handleMenuView 287 } 288 289 /** 290 * Updates handle menu's position variables to reflect its next position. 291 */ 292 private fun updateHandleMenuPillPositions(captionX: Int, captionY: Int) { 293 val menuX: Int 294 val menuY: Int 295 val taskBounds = taskInfo.getConfiguration().windowConfiguration.bounds 296 globalMenuPosition.set( 297 calculateMenuPosition( 298 splitScreenController, 299 taskInfo, 300 marginStart = marginMenuStart, 301 marginMenuTop, 302 captionX, 303 captionY, 304 captionWidth, 305 menuWidth, 306 context.isRtl() 307 ) 308 ) 309 if (layoutResId == R.layout.desktop_mode_app_header) { 310 // Align the handle menu to the start of the header. 311 menuX = if (context.isRtl()) { 312 taskBounds.width() - menuWidth - marginMenuStart 313 } else { 314 marginMenuStart 315 } 316 menuY = captionY + marginMenuTop 317 } else { 318 if (DesktopModeFlags.ENABLE_HANDLE_INPUT_FIX.isTrue()) { 319 // In a focused decor, we use global coordinates for handle menu. Therefore we 320 // need to account for other factors like split stage and menu/handle width to 321 // center the menu. 322 menuX = globalMenuPosition.x 323 menuY = globalMenuPosition.y 324 } else { 325 menuX = (taskBounds.width() / 2) - (menuWidth / 2) 326 menuY = captionY + marginMenuTop 327 } 328 } 329 // Handle Menu position setup. 330 handleMenuPosition.set(menuX.toFloat(), menuY.toFloat()) 331 } 332 333 /** 334 * Update pill layout, in case task changes have caused positioning to change. 335 */ 336 fun relayout( 337 t: SurfaceControl.Transaction, 338 captionX: Int, 339 captionY: Int, 340 ) { 341 handleMenuViewContainer?.let { container -> 342 updateHandleMenuPillPositions(captionX, captionY) 343 container.setPosition(t, handleMenuPosition.x, handleMenuPosition.y) 344 } 345 } 346 347 /** 348 * Check a passed MotionEvent if a click or hover has occurred on any button on this caption 349 * Note this should only be called when a regular onClick/onHover is not possible 350 * (i.e. the button was clicked through status bar layer) 351 * 352 * @param ev the MotionEvent to compare against. 353 */ 354 fun checkMotionEvent(ev: MotionEvent) { 355 // If the menu view is above status bar, we can let the views handle input directly. 356 if (isViewAboveStatusBar) return 357 val inputPoint = translateInputToLocalSpace(ev) 358 handleMenuView?.checkMotionEvent(ev, inputPoint) 359 } 360 361 // Translate the input point from display coordinates to the same space as the handle menu. 362 private fun translateInputToLocalSpace(ev: MotionEvent): PointF { 363 return PointF( 364 ev.x - handleMenuPosition.x, 365 ev.y - handleMenuPosition.y 366 ) 367 } 368 369 /** 370 * A valid menu input is one of the following: 371 * An input that happens in the menu views. 372 * Any input before the views have been laid out. 373 * 374 * @param inputPoint the input to compare against. 375 */ 376 fun isValidMenuInput(inputPoint: PointF): Boolean { 377 if (!viewsLaidOut()) return true 378 if (!isViewAboveStatusBar) { 379 return pointInView( 380 handleMenuViewContainer?.view, 381 inputPoint.x - handleMenuPosition.x, 382 inputPoint.y - handleMenuPosition.y 383 ) 384 } else { 385 // Handle menu exists in a different coordinate space when added to WindowManager. 386 // Therefore we must compare the provided input coordinates to global menu coordinates. 387 // This includes factoring for split stage as input coordinates are relative to split 388 // stage position, not relative to the display as a whole. 389 val inputRelativeToMenu = PointF( 390 inputPoint.x - globalMenuPosition.x, 391 inputPoint.y - globalMenuPosition.y 392 ) 393 if (splitScreenController.getSplitPosition(taskInfo.taskId) 394 == SplitScreenConstants.SPLIT_POSITION_BOTTOM_OR_RIGHT 395 ) { 396 val leftStageBounds = Rect() 397 splitScreenController.getStageBounds(leftStageBounds, Rect()) 398 inputRelativeToMenu.x += leftStageBounds.width().toFloat() 399 } 400 return pointInView( 401 handleMenuViewContainer?.view, 402 inputRelativeToMenu.x, 403 inputRelativeToMenu.y 404 ) 405 } 406 } 407 408 private fun pointInView(v: View?, x: Float, y: Float): Boolean { 409 return v != null && v.left <= x && v.right >= x && v.top <= y && v.bottom >= y 410 } 411 412 /** 413 * Check if the views for handle menu can be seen. 414 */ 415 private fun viewsLaidOut(): Boolean = handleMenuViewContainer?.view?.isLaidOut ?: false 416 417 /** 418 * Determines handle menu height based the max size and the visibility of pills. 419 */ 420 private fun getHandleMenuHeight(): Int { 421 var menuHeight = loadDimensionPixelSize(R.dimen.desktop_mode_handle_menu_height) 422 if (!shouldShowWindowingPill) { 423 menuHeight -= loadDimensionPixelSize( 424 R.dimen.desktop_mode_handle_menu_windowing_pill_height 425 ) 426 menuHeight -= pillTopMargin 427 } 428 if (!SHOULD_SHOW_SCREENSHOT_BUTTON) { 429 menuHeight -= loadDimensionPixelSize( 430 R.dimen.desktop_mode_handle_menu_screenshot_height 431 ) 432 } 433 if (!shouldShowNewWindowButton) { 434 menuHeight -= loadDimensionPixelSize( 435 R.dimen.desktop_mode_handle_menu_new_window_height 436 ) 437 } 438 if (!shouldShowManageWindowsButton) { 439 menuHeight -= loadDimensionPixelSize( 440 R.dimen.desktop_mode_handle_menu_manage_windows_height 441 ) 442 } 443 if (!shouldShowChangeAspectRatioButton) { 444 menuHeight -= loadDimensionPixelSize( 445 R.dimen.desktop_mode_handle_menu_change_aspect_ratio_height 446 ) 447 } 448 if (!shouldShowRestartButton) { 449 menuHeight -= loadDimensionPixelSize( 450 R.dimen.desktop_mode_handle_menu_restart_button_height) 451 } 452 if (!shouldShowMoreActionsPill) { 453 menuHeight -= pillTopMargin 454 } 455 if (!shouldShowBrowserPill) { 456 menuHeight -= loadDimensionPixelSize( 457 R.dimen.desktop_mode_handle_menu_open_in_browser_pill_height 458 ) 459 menuHeight -= pillTopMargin 460 } 461 return menuHeight 462 } 463 464 private fun loadDimensionPixelSize(@DimenRes resourceId: Int): Int { 465 if (resourceId == Resources.ID_NULL) { 466 return 0 467 } 468 return context.resources.getDimensionPixelSize(resourceId) 469 } 470 471 private fun Context.isRtl() = 472 resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL 473 474 fun close() { 475 loadAppInfoJob?.cancel() 476 handleMenuView?.animateCloseMenu { 477 handleMenuViewContainer?.releaseView() 478 handleMenuViewContainer = null 479 } 480 } 481 482 /** The view within the Handle Menu, with options to change the windowing mode and more. */ 483 @SuppressLint("ClickableViewAccessibility") 484 class HandleMenuView( 485 private val context: Context, 486 private val desktopModeUiEventLogger: DesktopModeUiEventLogger, 487 menuWidth: Int, 488 captionHeight: Int, 489 private val shouldShowWindowingPill: Boolean, 490 private val shouldShowBrowserPill: Boolean, 491 private val shouldShowNewWindowButton: Boolean, 492 private val shouldShowManageWindowsButton: Boolean, 493 private val shouldShowChangeAspectRatioButton: Boolean, 494 private val shouldShowDesktopModeButton: Boolean, 495 private val shouldShowRestartButton: Boolean, 496 private val isBrowserApp: Boolean 497 ) { 498 val rootView = LayoutInflater.from(context) 499 .inflate(R.layout.desktop_mode_window_decor_handle_menu, null /* root */) as View 500 501 // Insets for ripple effect of App Info Pill. and Windowing Pill. buttons 502 val iconButtondrawableShiftInset = context.resources.getDimensionPixelSize( 503 R.dimen.desktop_mode_handle_menu_icon_button_ripple_inset_shift 504 ) 505 val iconButtondrawableBaseInset = context.resources.getDimensionPixelSize( 506 R.dimen.desktop_mode_handle_menu_icon_button_ripple_inset_base 507 ) 508 private val iconButtonRippleRadius = context.resources.getDimensionPixelSize( 509 R.dimen.desktop_mode_handle_menu_icon_button_ripple_radius 510 ) 511 private val handleMenuCornerRadius = context.resources.getDimensionPixelSize( 512 R.dimen.desktop_mode_handle_menu_corner_radius 513 ) 514 private val iconButtonDrawableInsetsBase = DrawableInsets( 515 t = iconButtondrawableBaseInset, 516 b = iconButtondrawableBaseInset, l = iconButtondrawableBaseInset, 517 r = iconButtondrawableBaseInset 518 ) 519 private val iconButtonDrawableInsetsLeft = DrawableInsets( 520 t = iconButtondrawableBaseInset, 521 b = iconButtondrawableBaseInset, l = iconButtondrawableShiftInset, r = 0 522 ) 523 private val iconButtonDrawableInsetsRight = DrawableInsets( 524 t = iconButtondrawableBaseInset, 525 b = iconButtondrawableBaseInset, l = 0, r = iconButtondrawableShiftInset 526 ) 527 private val iconButtonDrawableInsetStart 528 get() = 529 if (context.isRtl) iconButtonDrawableInsetsRight else iconButtonDrawableInsetsLeft 530 private val iconButtonDrawableInsetEnd 531 get() = 532 if (context.isRtl) iconButtonDrawableInsetsLeft else iconButtonDrawableInsetsRight 533 534 // App Info Pill. 535 private val appInfoPill = rootView.requireViewById<View>(R.id.app_info_pill) 536 private val collapseMenuButton = appInfoPill.requireViewById<HandleMenuImageButton>( 537 R.id.collapse_menu_button 538 ) 539 540 @VisibleForTesting 541 val appIconView = appInfoPill.requireViewById<ImageView>(R.id.application_icon) 542 543 @VisibleForTesting 544 val appNameView = appInfoPill.requireViewById<MarqueedTextView>(R.id.application_name) 545 546 // Windowing Pill. 547 private val windowingPill = rootView.requireViewById<View>(R.id.windowing_pill) 548 private val fullscreenBtn = windowingPill.requireViewById<ImageButton>( 549 R.id.fullscreen_button 550 ) 551 private val splitscreenBtn = windowingPill.requireViewById<ImageButton>( 552 R.id.split_screen_button 553 ) 554 private val floatingBtn = windowingPill.requireViewById<ImageButton>(R.id.floating_button) 555 private val floatingBtnSpace = windowingPill.requireViewById<Space>( 556 R.id.floating_button_space 557 ) 558 559 private val desktopBtn = windowingPill.requireViewById<ImageButton>(R.id.desktop_button) 560 private val desktopBtnSpace = windowingPill.requireViewById<Space>( 561 R.id.desktop_button_space 562 ) 563 564 // More Actions Pill. 565 private val moreActionsPill = rootView.requireViewById<View>(R.id.more_actions_pill) 566 private val screenshotBtn = moreActionsPill.requireViewById<HandleMenuActionButton>( 567 R.id.screenshot_button 568 ) 569 private val newWindowBtn = moreActionsPill.requireViewById<HandleMenuActionButton>( 570 R.id.new_window_button 571 ) 572 private val manageWindowBtn = moreActionsPill 573 .requireViewById<HandleMenuActionButton>(R.id.manage_windows_button) 574 private val changeAspectRatioBtn = moreActionsPill 575 .requireViewById<HandleMenuActionButton>(R.id.change_aspect_ratio_button) 576 private val restartBtn = moreActionsPill 577 .requireViewById<HandleMenuActionButton>(R.id.handle_menu_restart_button) 578 579 // Open in Browser/App Pill. 580 private val openInAppOrBrowserPill = rootView.requireViewById<View>( 581 R.id.open_in_app_or_browser_pill 582 ) 583 private val openInAppOrBrowserBtn = openInAppOrBrowserPill 584 .requireViewById<HandleMenuActionButton>(R.id.open_in_app_or_browser_button) 585 private val openByDefaultBtn = openInAppOrBrowserPill.requireViewById<ImageButton>( 586 R.id.open_by_default_button 587 ) 588 private val decorThemeUtil = DecorThemeUtil(context) 589 private val animator = HandleMenuAnimator(rootView, menuWidth, captionHeight.toFloat()) 590 591 private lateinit var taskInfo: RunningTaskInfo 592 private lateinit var style: MenuStyle 593 594 var onToDesktopClickListener: (() -> Unit)? = null 595 var onToFullscreenClickListener: (() -> Unit)? = null 596 var onToSplitScreenClickListener: (() -> Unit)? = null 597 var onToFloatClickListener: (() -> Unit)? = null 598 var onNewWindowClickListener: (() -> Unit)? = null 599 var onManageWindowsClickListener: (() -> Unit)? = null 600 var onChangeAspectRatioClickListener: (() -> Unit)? = null 601 var onOpenInAppOrBrowserClickListener: (() -> Unit)? = null 602 var onOpenByDefaultClickListener: (() -> Unit)? = null 603 var onRestartClickListener: (() -> Unit)? = null 604 var onCloseMenuClickListener: (() -> Unit)? = null 605 var onOutsideTouchListener: (() -> Unit)? = null 606 607 init { 608 fullscreenBtn.setOnClickListener { onToFullscreenClickListener?.invoke() } 609 splitscreenBtn.setOnClickListener { onToSplitScreenClickListener?.invoke() } 610 desktopBtn.setOnClickListener { onToDesktopClickListener?.invoke() } 611 openInAppOrBrowserBtn.setOnClickListener { onOpenInAppOrBrowserClickListener?.invoke() } 612 floatingBtn.setOnClickListener { onToFloatClickListener?.invoke() } 613 openByDefaultBtn.setOnClickListener { 614 onOpenByDefaultClickListener?.invoke() 615 } 616 collapseMenuButton.setOnClickListener { onCloseMenuClickListener?.invoke() } 617 newWindowBtn.setOnClickListener { onNewWindowClickListener?.invoke() } 618 manageWindowBtn.setOnClickListener { onManageWindowsClickListener?.invoke() } 619 changeAspectRatioBtn.setOnClickListener { onChangeAspectRatioClickListener?.invoke() } 620 restartBtn.setOnClickListener { onRestartClickListener?.invoke() } 621 622 rootView.setOnTouchListener { _, event -> 623 if (event.actionMasked == ACTION_OUTSIDE) { 624 onOutsideTouchListener?.invoke() 625 return@setOnTouchListener false 626 } 627 return@setOnTouchListener true 628 } 629 630 desktopBtn.accessibilityDelegate = object : View.AccessibilityDelegate() { 631 override fun performAccessibilityAction( 632 host: View, 633 action: Int, 634 args: Bundle? 635 ): Boolean { 636 if (action == AccessibilityAction.ACTION_CLICK.id) { 637 desktopModeUiEventLogger.log(taskInfo, A11Y_APP_HANDLE_MENU_DESKTOP_VIEW) 638 } 639 return super.performAccessibilityAction(host, action, args) 640 } 641 } 642 643 fullscreenBtn.accessibilityDelegate = object : View.AccessibilityDelegate() { 644 override fun performAccessibilityAction( 645 host: View, 646 action: Int, 647 args: Bundle? 648 ): Boolean { 649 if (action == AccessibilityAction.ACTION_CLICK.id) { 650 desktopModeUiEventLogger.log(taskInfo, A11Y_APP_HANDLE_MENU_FULLSCREEN) 651 } 652 return super.performAccessibilityAction(host, action, args) 653 } 654 } 655 656 splitscreenBtn.accessibilityDelegate = object : View.AccessibilityDelegate() { 657 override fun performAccessibilityAction( 658 host: View, 659 action: Int, 660 args: Bundle? 661 ): Boolean { 662 if (action == AccessibilityAction.ACTION_CLICK.id) { 663 desktopModeUiEventLogger.log(taskInfo, A11Y_APP_HANDLE_MENU_SPLIT_SCREEN) 664 } 665 return super.performAccessibilityAction(host, action, args) 666 } 667 } 668 669 with(context) { 670 // Update a11y announcement out to say "double tap to enter Fullscreen" 671 ViewCompat.replaceAccessibilityAction( 672 fullscreenBtn, ACTION_CLICK, 673 getString( 674 R.string.app_handle_menu_accessibility_announce, 675 getString(R.string.fullscreen_text) 676 ), 677 null, 678 ) 679 680 // Update a11y announcement out to say "double tap to enter Desktop View" 681 ViewCompat.replaceAccessibilityAction( 682 desktopBtn, ACTION_CLICK, 683 getString( 684 R.string.app_handle_menu_accessibility_announce, 685 getString(R.string.desktop_text) 686 ), 687 null, 688 ) 689 690 // Update a11y announcement to say "double tap to enter Split Screen" 691 ViewCompat.replaceAccessibilityAction( 692 splitscreenBtn, ACTION_CLICK, 693 getString( 694 R.string.app_handle_menu_accessibility_announce, 695 getString(R.string.split_screen_text) 696 ), 697 null, 698 ) 699 } 700 } 701 702 /** Binds the menu views to the new data. */ 703 fun bind( 704 taskInfo: RunningTaskInfo, 705 shouldShowMoreActionsPill: Boolean 706 ) { 707 this.taskInfo = taskInfo 708 this.style = calculateMenuStyle(taskInfo) 709 710 bindAppInfoPill(style) 711 if (shouldShowWindowingPill) { 712 bindWindowingPill(style) 713 } 714 moreActionsPill.isGone = !shouldShowMoreActionsPill 715 if (shouldShowMoreActionsPill) { 716 bindMoreActionsPill(style) 717 } 718 bindOpenInAppOrBrowserPill(style) 719 } 720 721 /** Sets the app's name. */ 722 fun setAppName(name: CharSequence) { 723 appNameView.text = name 724 } 725 726 /** Sets the app's icon. */ 727 fun setAppIcon(icon: Bitmap) { 728 appIconView.setImageBitmap(icon) 729 } 730 731 /** Animates the menu openInAppOrBrowserg. */ 732 fun animateOpenMenu() { 733 if (taskInfo.isFullscreen || taskInfo.isMultiWindow) { 734 animator.animateCaptionHandleExpandToOpen() 735 } else { 736 animator.animateOpen() 737 } 738 } 739 740 /** Animates the menu closing. */ 741 fun animateCloseMenu(onAnimFinish: () -> Unit) { 742 if (taskInfo.isFullscreen || taskInfo.isMultiWindow) { 743 animator.animateCollapseIntoHandleClose(onAnimFinish) 744 } else { 745 animator.animateClose(onAnimFinish) 746 } 747 } 748 749 /** 750 * Checks whether a motion event falls inside this menu, and invokes a click of the 751 * collapse button if needed. 752 * Note: should only be called when regular click detection doesn't work because input is 753 * detected through the status bar layer with a global input monitor. 754 */ 755 fun checkMotionEvent(ev: MotionEvent, inputPointLocal: PointF) { 756 val inputInCollapseButton = pointInView( 757 collapseMenuButton, 758 inputPointLocal.x, 759 inputPointLocal.y 760 ) 761 val action = ev.actionMasked 762 collapseMenuButton.isHovered = inputInCollapseButton 763 && action != MotionEvent.ACTION_UP 764 collapseMenuButton.isPressed = inputInCollapseButton 765 && action == MotionEvent.ACTION_DOWN 766 if (action == MotionEvent.ACTION_UP && inputInCollapseButton) { 767 collapseMenuButton.performClick() 768 } 769 } 770 771 private fun pointInView(v: View?, x: Float, y: Float): Boolean { 772 return v != null && v.left <= x && v.right >= x && v.top <= y && v.bottom >= y 773 } 774 775 private fun calculateMenuStyle(taskInfo: RunningTaskInfo): MenuStyle { 776 val colorScheme = decorThemeUtil.getColorScheme(taskInfo) 777 return MenuStyle( 778 backgroundColor = colorScheme.surfaceBright.toArgb(), 779 textColor = colorScheme.onSurface.toArgb(), 780 windowingButtonColor = ColorStateList( 781 arrayOf( 782 intArrayOf(android.R.attr.state_pressed), 783 intArrayOf(android.R.attr.state_focused), 784 intArrayOf(android.R.attr.state_selected), 785 intArrayOf(), 786 ), 787 intArrayOf( 788 colorScheme.onSurface.toArgb(), 789 colorScheme.onSurface.toArgb(), 790 colorScheme.primary.toArgb(), 791 colorScheme.onSurface.toArgb(), 792 ) 793 ), 794 ) 795 } 796 797 private fun bindAppInfoPill(style: MenuStyle) { 798 appInfoPill.background.setTint(style.backgroundColor) 799 800 collapseMenuButton.apply { 801 imageTintList = ColorStateList.valueOf(style.textColor) 802 this.taskInfo = this@HandleMenuView.taskInfo 803 804 background = createBackgroundDrawable( 805 color = style.textColor, 806 cornerRadius = iconButtonRippleRadius, 807 drawableInsets = iconButtonDrawableInsetsBase 808 ) 809 } 810 appNameView.setTextColor(style.textColor) 811 appNameView.startMarquee() 812 } 813 814 private fun bindWindowingPill(style: MenuStyle) { 815 windowingPill.background.setTint(style.backgroundColor) 816 817 if (!BubbleAnythingFlagHelper.enableBubbleToFullscreen()) { 818 floatingBtn.visibility = View.GONE 819 floatingBtnSpace.visibility = View.GONE 820 } 821 822 fullscreenBtn.isSelected = taskInfo.isFullscreen 823 fullscreenBtn.isEnabled = !taskInfo.isFullscreen 824 fullscreenBtn.imageTintList = style.windowingButtonColor 825 splitscreenBtn.isSelected = taskInfo.isMultiWindow 826 splitscreenBtn.isEnabled = !taskInfo.isMultiWindow 827 splitscreenBtn.imageTintList = style.windowingButtonColor 828 floatingBtn.isSelected = taskInfo.isPinned 829 floatingBtn.isEnabled = !taskInfo.isPinned 830 floatingBtn.imageTintList = style.windowingButtonColor 831 desktopBtn.isGone = !shouldShowDesktopModeButton 832 desktopBtnSpace.isGone = !shouldShowDesktopModeButton 833 desktopBtn.isSelected = taskInfo.isFreeform 834 desktopBtn.isEnabled = !taskInfo.isFreeform 835 desktopBtn.imageTintList = style.windowingButtonColor 836 837 fullscreenBtn.apply { 838 background = createBackgroundDrawable( 839 color = style.textColor, 840 cornerRadius = iconButtonRippleRadius, 841 drawableInsets = iconButtonDrawableInsetStart 842 ) 843 } 844 845 splitscreenBtn.apply { 846 background = createBackgroundDrawable( 847 color = style.textColor, 848 cornerRadius = iconButtonRippleRadius, 849 drawableInsets = iconButtonDrawableInsetsBase 850 ) 851 } 852 853 floatingBtn.apply { 854 background = createBackgroundDrawable( 855 color = style.textColor, 856 cornerRadius = iconButtonRippleRadius, 857 drawableInsets = iconButtonDrawableInsetsBase 858 ) 859 } 860 861 desktopBtn.apply { 862 background = createBackgroundDrawable( 863 color = style.textColor, 864 cornerRadius = iconButtonRippleRadius, 865 drawableInsets = iconButtonDrawableInsetEnd 866 ) 867 } 868 } 869 870 private fun bindMoreActionsPill(style: MenuStyle) { 871 moreActionsPill.background.setTint(style.backgroundColor) 872 val buttons = arrayOf( 873 screenshotBtn to SHOULD_SHOW_SCREENSHOT_BUTTON, 874 newWindowBtn to shouldShowNewWindowButton, 875 manageWindowBtn to shouldShowManageWindowsButton, 876 changeAspectRatioBtn to shouldShowChangeAspectRatioButton, 877 restartBtn to shouldShowRestartButton, 878 ) 879 val firstVisible = buttons.find { it.second }?.first 880 val lastVisible = buttons.findLast { it.second }?.first 881 882 buttons.forEach { (button, shouldShow) -> 883 val topRadius = 884 if (button == firstVisible) handleMenuCornerRadius.toFloat() else 0f 885 val bottomRadius = 886 if (button == lastVisible) handleMenuCornerRadius.toFloat() else 0f 887 button.apply { 888 isGone = !shouldShow 889 textView.apply { 890 setTextColor(style.textColor) 891 startMarquee() 892 } 893 iconView.imageTintList = ColorStateList.valueOf(style.textColor) 894 background = createBackgroundDrawable( 895 color = style.textColor, 896 cornerRadius = floatArrayOf( 897 topRadius, topRadius, topRadius, topRadius, 898 bottomRadius, bottomRadius, bottomRadius, bottomRadius 899 ), 900 drawableInsets = DrawableInsets()) 901 } 902 } 903 } 904 905 private fun bindOpenInAppOrBrowserPill(style: MenuStyle) { 906 openInAppOrBrowserPill.apply { 907 isGone = !shouldShowBrowserPill 908 background.setTint(style.backgroundColor) 909 } 910 911 val btnText = if (isBrowserApp) { 912 getString(R.string.open_in_app_text) 913 } else { 914 getString(R.string.open_in_browser_text) 915 } 916 917 openInAppOrBrowserBtn.apply { 918 contentDescription = btnText 919 background = createBackgroundDrawable( 920 color = style.textColor, 921 cornerRadius = handleMenuCornerRadius, 922 drawableInsets = DrawableInsets()) 923 textView.apply { 924 text = btnText 925 setTextColor(style.textColor) 926 startMarquee() 927 } 928 iconView.imageTintList = ColorStateList.valueOf(style.textColor) 929 } 930 931 openByDefaultBtn.apply { 932 isGone = isBrowserApp 933 imageTintList = ColorStateList.valueOf(style.textColor) 934 background = createBackgroundDrawable( 935 color = style.textColor, 936 cornerRadius = iconButtonRippleRadius, 937 drawableInsets = iconButtonDrawableInsetEnd) 938 } 939 } 940 941 private fun getString(@StringRes resId: Int): String = context.resources.getString(resId) 942 943 private data class MenuStyle( 944 @ColorInt val backgroundColor: Int, 945 @ColorInt val textColor: Int, 946 val windowingButtonColor: ColorStateList, 947 ) 948 } 949 950 companion object { 951 private const val TAG = "HandleMenu" 952 private const val SHOULD_SHOW_SCREENSHOT_BUTTON = false 953 954 /** 955 * Returns whether the aspect ratio button should be shown for the task. It usually means 956 * that the task is on a large screen with ignore-orientation-request. 957 */ 958 fun shouldShowChangeAspectRatioButton(taskInfo: RunningTaskInfo): Boolean = 959 taskInfo.appCompatTaskInfo.eligibleForUserAspectRatioButton() && 960 taskInfo.windowingMode == WindowConfiguration.WINDOWING_MODE_FULLSCREEN 961 962 /** 963 * Returns whether the restart button should be shown for the task. It usually means that 964 * the task has moved to a different display. 965 */ 966 fun shouldShowRestartButton(taskInfo: RunningTaskInfo): Boolean = 967 taskInfo.appCompatTaskInfo.isRestartMenuEnabledForDisplayMove 968 } 969 } 970 971 /** A factory interface to create a [HandleMenu]. */ 972 interface HandleMenuFactory { createnull973 fun create( 974 @ShellMainThread mainDispatcher: MainCoroutineDispatcher, 975 @ShellBackgroundThread bgScope: CoroutineScope, 976 parentDecor: DesktopModeWindowDecoration, 977 windowManagerWrapper: WindowManagerWrapper, 978 taskResourceLoader: WindowDecorTaskResourceLoader, 979 layoutResId: Int, 980 splitScreenController: SplitScreenController, 981 shouldShowWindowingPill: Boolean, 982 shouldShowNewWindowButton: Boolean, 983 shouldShowManageWindowsButton: Boolean, 984 shouldShowChangeAspectRatioButton: Boolean, 985 shouldShowDesktopModeButton: Boolean, 986 shouldShowRestartButton: Boolean, 987 isBrowserApp: Boolean, 988 openInAppOrBrowserIntent: Intent?, 989 desktopModeUiEventLogger: DesktopModeUiEventLogger, 990 captionWidth: Int, 991 captionHeight: Int, 992 captionX: Int, 993 captionY: Int, 994 ): HandleMenu 995 } 996 997 /** A [HandleMenuFactory] implementation that creates a [HandleMenu]. */ 998 object DefaultHandleMenuFactory : HandleMenuFactory { 999 override fun create( 1000 @ShellMainThread mainDispatcher: MainCoroutineDispatcher, 1001 @ShellBackgroundThread bgScope: CoroutineScope, 1002 parentDecor: DesktopModeWindowDecoration, 1003 windowManagerWrapper: WindowManagerWrapper, 1004 taskResourceLoader: WindowDecorTaskResourceLoader, 1005 layoutResId: Int, 1006 splitScreenController: SplitScreenController, 1007 shouldShowWindowingPill: Boolean, 1008 shouldShowNewWindowButton: Boolean, 1009 shouldShowManageWindowsButton: Boolean, 1010 shouldShowChangeAspectRatioButton: Boolean, 1011 shouldShowDesktopModeButton: Boolean, 1012 shouldShowRestartButton: Boolean, 1013 isBrowserApp: Boolean, 1014 openInAppOrBrowserIntent: Intent?, 1015 desktopModeUiEventLogger: DesktopModeUiEventLogger, 1016 captionWidth: Int, 1017 captionHeight: Int, 1018 captionX: Int, 1019 captionY: Int, 1020 ): HandleMenu { 1021 return HandleMenu( 1022 mainDispatcher, 1023 bgScope, 1024 parentDecor, 1025 windowManagerWrapper, 1026 taskResourceLoader, 1027 layoutResId, 1028 splitScreenController, 1029 shouldShowWindowingPill, 1030 shouldShowNewWindowButton, 1031 shouldShowManageWindowsButton, 1032 shouldShowChangeAspectRatioButton, 1033 shouldShowDesktopModeButton, 1034 shouldShowRestartButton, 1035 isBrowserApp, 1036 openInAppOrBrowserIntent, 1037 desktopModeUiEventLogger, 1038 captionWidth, 1039 captionHeight, 1040 captionX, 1041 captionY, 1042 ) 1043 } 1044 } 1045