• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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