• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 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.desktopmode.education
18 
19 import android.annotation.DimenRes
20 import android.annotation.StringRes
21 import android.content.Context
22 import android.content.res.Resources
23 import android.graphics.Point
24 import android.os.SystemProperties
25 import android.view.View.LAYOUT_DIRECTION_RTL
26 import com.android.window.flags.Flags
27 import com.android.wm.shell.R
28 import com.android.wm.shell.desktopmode.CaptionState
29 import com.android.wm.shell.desktopmode.DesktopModeUiEventLogger
30 import com.android.wm.shell.desktopmode.DesktopModeUiEventLogger.DesktopUiEventEnum
31 import com.android.wm.shell.desktopmode.WindowDecorCaptionHandleRepository
32 import com.android.wm.shell.desktopmode.education.data.AppHandleEducationDatastoreRepository
33 import com.android.wm.shell.shared.annotations.ShellBackgroundThread
34 import com.android.wm.shell.shared.annotations.ShellMainThread
35 import com.android.wm.shell.shared.desktopmode.DesktopModeStatus.canEnterDesktopMode
36 import com.android.wm.shell.shared.desktopmode.DesktopModeTransitionSource
37 import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController
38 import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.TooltipColorScheme
39 import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.TooltipEducationViewConfig
40 import kotlinx.coroutines.CoroutineScope
41 import kotlinx.coroutines.MainCoroutineDispatcher
42 import kotlinx.coroutines.delay
43 import kotlinx.coroutines.flow.collectLatest
44 import kotlinx.coroutines.flow.debounce
45 import kotlinx.coroutines.flow.filter
46 import kotlinx.coroutines.flow.first
47 import kotlinx.coroutines.flow.flowOn
48 import kotlinx.coroutines.flow.take
49 import kotlinx.coroutines.launch
50 
51 /**
52  * Controls app handle education end to end.
53  *
54  * Listen to the user trigger for app handle education, calls an api to check if the education
55  * should be shown and controls education UI.
56  */
57 @OptIn(kotlinx.coroutines.FlowPreview::class)
58 @kotlinx.coroutines.ExperimentalCoroutinesApi
59 class AppHandleEducationController(
60     private val context: Context,
61     private val appHandleEducationFilter: AppHandleEducationFilter,
62     private val appHandleEducationDatastoreRepository: AppHandleEducationDatastoreRepository,
63     private val windowDecorCaptionHandleRepository: WindowDecorCaptionHandleRepository,
64     private val windowingEducationViewController: DesktopWindowingEducationTooltipController,
65     @ShellMainThread private val applicationCoroutineScope: CoroutineScope,
66     @ShellBackgroundThread private val backgroundDispatcher: MainCoroutineDispatcher,
67     private val desktopModeUiEventLogger: DesktopModeUiEventLogger,
68 ) {
69     private lateinit var openHandleMenuCallback: (Int) -> Unit
70     private lateinit var toDesktopModeCallback: (Int, DesktopModeTransitionSource) -> Unit
71     private val onTertiaryFixedColor =
72         context.getColor(com.android.internal.R.color.materialColorOnTertiaryFixed)
73     private val tertiaryFixedColor =
74         context.getColor(com.android.internal.R.color.materialColorTertiaryFixed)
75 
76     init {
77         runIfEducationFeatureEnabled {
78             // Coroutine block for the first hint that appears on a full-screen app's app handle to
79             // encourage users to open the app handle menu.
80             applicationCoroutineScope.launch {
81                 if (isAppHandleHintViewed()) return@launch
82                 windowDecorCaptionHandleRepository.captionStateFlow
83                     .debounce(APP_HANDLE_EDUCATION_DELAY_MILLIS)
84                     .filter { captionState ->
85                         captionState is CaptionState.AppHandle &&
86                             !captionState.isHandleMenuExpanded &&
87                             !isAppHandleHintViewed() &&
88                             appHandleEducationFilter.shouldShowDesktopModeEducation(captionState)
89                     }
90                     .take(1)
91                     .flowOn(backgroundDispatcher)
92                     .collectLatest { captionState ->
93                         showEducation(captionState)
94                         appHandleEducationDatastoreRepository
95                             .updateAppHandleHintViewedTimestampMillis(true)
96                         delay(TOOLTIP_VISIBLE_DURATION_MILLIS)
97                         windowingEducationViewController.hideEducationTooltip()
98                     }
99             }
100 
101             // Coroutine block for the hint that appears when an app handle is expanded to
102             // encourage users to enter desktop mode.
103             applicationCoroutineScope.launch {
104                 if (isEnterDesktopModeHintViewed()) return@launch
105                 windowDecorCaptionHandleRepository.captionStateFlow
106                     .debounce(ENTER_DESKTOP_MODE_EDUCATION_DELAY_MILLIS)
107                     .filter { captionState ->
108                         captionState is CaptionState.AppHandle &&
109                             captionState.isHandleMenuExpanded &&
110                             !isEnterDesktopModeHintViewed() &&
111                             appHandleEducationFilter.shouldShowDesktopModeEducation(captionState)
112                     }
113                     .take(1)
114                     .flowOn(backgroundDispatcher)
115                     .collectLatest { captionState ->
116                         showWindowingImageButtonTooltip(captionState as CaptionState.AppHandle)
117                         appHandleEducationDatastoreRepository
118                             .updateEnterDesktopModeHintViewedTimestampMillis(true)
119                         delay(TOOLTIP_VISIBLE_DURATION_MILLIS)
120                         windowingEducationViewController.hideEducationTooltip()
121                     }
122             }
123 
124             // Coroutine block for the hint that appears on the window app header in freeform mode
125             // to let users know how to exit desktop mode.
126             applicationCoroutineScope.launch {
127                 if (isExitDesktopModeHintViewed()) return@launch
128                 windowDecorCaptionHandleRepository.captionStateFlow
129                     .debounce(APP_HANDLE_EDUCATION_DELAY_MILLIS)
130                     .filter { captionState ->
131                         captionState is CaptionState.AppHeader &&
132                             !captionState.isHeaderMenuExpanded &&
133                             !isExitDesktopModeHintViewed() &&
134                             appHandleEducationFilter.shouldShowDesktopModeEducation(captionState)
135                     }
136                     .take(1)
137                     .flowOn(backgroundDispatcher)
138                     .collectLatest { captionState ->
139                         showExitWindowingTooltip(captionState as CaptionState.AppHeader)
140                         appHandleEducationDatastoreRepository
141                             .updateExitDesktopModeHintViewedTimestampMillis(true)
142                         delay(TOOLTIP_VISIBLE_DURATION_MILLIS)
143                         windowingEducationViewController.hideEducationTooltip()
144                     }
145             }
146 
147             // Listens to a [NoCaption] state change to dismiss any tooltip if the app handle or app
148             // header is gone or de-focused (e.g. when a user swipes up to home, overview, or enters
149             // split screen)
150             applicationCoroutineScope.launch {
151                 if (
152                     isAppHandleHintViewed() &&
153                         isEnterDesktopModeHintViewed() &&
154                         isExitDesktopModeHintViewed()
155                 )
156                     return@launch
157                 windowDecorCaptionHandleRepository.captionStateFlow
158                     .filter { captionState ->
159                         captionState is CaptionState.NoCaption &&
160                             !isAppHandleHintViewed() &&
161                             !isEnterDesktopModeHintViewed() &&
162                             !isExitDesktopModeHintViewed()
163                     }
164                     .flowOn(backgroundDispatcher)
165                     .collectLatest { windowingEducationViewController.hideEducationTooltip() }
166             }
167         }
168     }
169 
170     private inline fun runIfEducationFeatureEnabled(block: () -> Unit) {
171         if (canEnterDesktopMode(context) && Flags.enableDesktopWindowingAppHandleEducation())
172             block()
173     }
174 
175     private fun showEducation(captionState: CaptionState) {
176         val appHandleBounds = (captionState as CaptionState.AppHandle).globalAppHandleBounds
177         val taskInfo = captionState.runningTaskInfo
178         val tooltipGlobalCoordinates =
179             Point(appHandleBounds.left + appHandleBounds.width() / 2, appHandleBounds.bottom)
180         // Populate information important to inflate app handle education tooltip.
181         val appHandleTooltipConfig =
182             TooltipEducationViewConfig(
183                 tooltipViewLayout = R.layout.desktop_windowing_education_top_arrow_tooltip,
184                 tooltipColorScheme =
185                     TooltipColorScheme(
186                         tertiaryFixedColor,
187                         onTertiaryFixedColor,
188                         onTertiaryFixedColor,
189                     ),
190                 tooltipViewGlobalCoordinates = tooltipGlobalCoordinates,
191                 tooltipText = getString(R.string.windowing_app_handle_education_tooltip),
192                 arrowDirection =
193                     DesktopWindowingEducationTooltipController.TooltipArrowDirection.UP,
194                 onEducationClickAction = {
195                     openHandleMenuCallback(taskInfo.taskId)
196                     desktopModeUiEventLogger.log(
197                         taskInfo,
198                         DesktopUiEventEnum.APP_HANDLE_EDUCATION_TOOLTIP_CLICKED,
199                     )
200                 },
201                 onDismissAction = {
202                     desktopModeUiEventLogger.log(
203                         taskInfo,
204                         DesktopUiEventEnum.APP_HANDLE_EDUCATION_TOOLTIP_DISMISSED,
205                     )
206                 },
207             )
208 
209         windowingEducationViewController.showEducationTooltip(
210             tooltipViewConfig = appHandleTooltipConfig,
211             taskId = taskInfo.taskId,
212         )
213         desktopModeUiEventLogger.log(
214             taskInfo,
215             DesktopUiEventEnum.APP_HANDLE_EDUCATION_TOOLTIP_SHOWN,
216         )
217     }
218 
219     /** Show tooltip that points to windowing image button in app handle menu */
220     private suspend fun showWindowingImageButtonTooltip(captionState: CaptionState.AppHandle) {
221         val appInfoPillHeight = getSize(R.dimen.desktop_mode_handle_menu_app_info_pill_height)
222         val taskInfo = captionState.runningTaskInfo
223         val windowingOptionPillHeight =
224             getSize(R.dimen.desktop_mode_handle_menu_windowing_pill_height)
225         val appHandleMenuWidth =
226             getSize(R.dimen.desktop_mode_handle_menu_width) +
227                 getSize(R.dimen.desktop_mode_handle_menu_pill_spacing_margin)
228         val appHandleMenuMargins =
229             getSize(R.dimen.desktop_mode_handle_menu_margin_top) +
230                 getSize(R.dimen.desktop_mode_handle_menu_pill_spacing_margin)
231 
232         val appHandleBounds = captionState.globalAppHandleBounds
233         val appHandleCenterX = appHandleBounds.left + appHandleBounds.width() / 2
234         val tooltipGlobalCoordinates =
235             Point(
236                 if (isRtl()) {
237                     appHandleCenterX - appHandleMenuWidth / 2
238                 } else {
239                     appHandleCenterX + appHandleMenuWidth / 2
240                 },
241                 appHandleBounds.top +
242                     appHandleMenuMargins +
243                     appInfoPillHeight +
244                     windowingOptionPillHeight / 2,
245             )
246         // Populate information important to inflate windowing image button education
247         // tooltip.
248         val windowingImageButtonTooltipConfig =
249             TooltipEducationViewConfig(
250                 tooltipViewLayout = R.layout.desktop_windowing_education_horizontal_arrow_tooltip,
251                 tooltipColorScheme =
252                     TooltipColorScheme(
253                         tertiaryFixedColor,
254                         onTertiaryFixedColor,
255                         onTertiaryFixedColor,
256                     ),
257                 tooltipViewGlobalCoordinates = tooltipGlobalCoordinates,
258                 tooltipText =
259                     getString(R.string.windowing_desktop_mode_image_button_education_tooltip),
260                 arrowDirection =
261                     DesktopWindowingEducationTooltipController.TooltipArrowDirection.HORIZONTAL,
262                 onEducationClickAction = {
263                     toDesktopModeCallback(
264                         taskInfo.taskId,
265                         DesktopModeTransitionSource.APP_HANDLE_MENU_BUTTON,
266                     )
267                     desktopModeUiEventLogger.log(
268                         taskInfo,
269                         DesktopUiEventEnum.ENTER_DESKTOP_MODE_EDUCATION_TOOLTIP_CLICKED,
270                     )
271                 },
272                 onDismissAction = {
273                     desktopModeUiEventLogger.log(
274                         taskInfo,
275                         DesktopUiEventEnum.ENTER_DESKTOP_MODE_EDUCATION_TOOLTIP_DISMISSED,
276                     )
277                 },
278             )
279 
280         windowingEducationViewController.showEducationTooltip(
281             taskId = taskInfo.taskId,
282             tooltipViewConfig = windowingImageButtonTooltipConfig,
283         )
284         desktopModeUiEventLogger.log(
285             taskInfo,
286             DesktopUiEventEnum.ENTER_DESKTOP_MODE_EDUCATION_TOOLTIP_SHOWN,
287         )
288     }
289 
290     /** Show tooltip that points to app chip button and educates user on how to exit desktop mode */
291     private suspend fun showExitWindowingTooltip(captionState: CaptionState.AppHeader) {
292         val globalAppChipBounds = captionState.globalAppChipBounds
293         val taskInfo = captionState.runningTaskInfo
294         val tooltipGlobalCoordinates =
295             Point(
296                 if (isRtl()) {
297                     globalAppChipBounds.left
298                 } else {
299                     globalAppChipBounds.right
300                 },
301                 globalAppChipBounds.top + globalAppChipBounds.height() / 2,
302             )
303         // Populate information important to inflate exit desktop mode education tooltip.
304         val exitWindowingTooltipConfig =
305             TooltipEducationViewConfig(
306                 tooltipViewLayout = R.layout.desktop_windowing_education_horizontal_arrow_tooltip,
307                 tooltipColorScheme =
308                     TooltipColorScheme(
309                         tertiaryFixedColor,
310                         onTertiaryFixedColor,
311                         onTertiaryFixedColor,
312                     ),
313                 tooltipViewGlobalCoordinates = tooltipGlobalCoordinates,
314                 tooltipText = getString(R.string.windowing_desktop_mode_exit_education_tooltip),
315                 arrowDirection =
316                     DesktopWindowingEducationTooltipController.TooltipArrowDirection.HORIZONTAL,
317                 onDismissAction = {
318                     desktopModeUiEventLogger.log(
319                         taskInfo,
320                         DesktopUiEventEnum.EXIT_DESKTOP_MODE_EDUCATION_TOOLTIP_DISMISSED,
321                     )
322                 },
323                 onEducationClickAction = {
324                     openHandleMenuCallback(taskInfo.taskId)
325                     desktopModeUiEventLogger.log(
326                         taskInfo,
327                         DesktopUiEventEnum.EXIT_DESKTOP_MODE_EDUCATION_TOOLTIP_CLICKED,
328                     )
329                 },
330             )
331         windowingEducationViewController.showEducationTooltip(
332             taskId = taskInfo.taskId,
333             tooltipViewConfig = exitWindowingTooltipConfig,
334         )
335         desktopModeUiEventLogger.log(
336             taskInfo,
337             DesktopUiEventEnum.EXIT_DESKTOP_MODE_EDUCATION_TOOLTIP_SHOWN,
338         )
339     }
340 
341     /**
342      * Setup callbacks for app handle education tooltips.
343      *
344      * @param openHandleMenuCallback callback invoked to open app handle menu or app chip menu.
345      * @param toDesktopModeCallback callback invoked to move task into desktop mode.
346      */
347     fun setAppHandleEducationTooltipCallbacks(
348         openHandleMenuCallback: (taskId: Int) -> Unit,
349         toDesktopModeCallback: (taskId: Int, DesktopModeTransitionSource) -> Unit,
350     ) {
351         this.openHandleMenuCallback = openHandleMenuCallback
352         this.toDesktopModeCallback = toDesktopModeCallback
353     }
354 
355     private suspend fun isAppHandleHintViewed(): Boolean =
356         appHandleEducationDatastoreRepository.dataStoreFlow
357             .first()
358             .hasAppHandleHintViewedTimestampMillis() && !FORCE_SHOW_DESKTOP_MODE_EDUCATION
359 
360     private suspend fun isEnterDesktopModeHintViewed(): Boolean =
361         appHandleEducationDatastoreRepository.dataStoreFlow
362             .first()
363             .hasEnterDesktopModeHintViewedTimestampMillis() && !FORCE_SHOW_DESKTOP_MODE_EDUCATION
364 
365     private suspend fun isExitDesktopModeHintViewed(): Boolean =
366         appHandleEducationDatastoreRepository.dataStoreFlow
367             .first()
368             .hasExitDesktopModeHintViewedTimestampMillis() && !FORCE_SHOW_DESKTOP_MODE_EDUCATION
369 
370     private fun getSize(@DimenRes resourceId: Int): Int {
371         if (resourceId == Resources.ID_NULL) return 0
372         return context.resources.getDimensionPixelSize(resourceId)
373     }
374 
375     private fun getString(@StringRes resId: Int): String = context.resources.getString(resId)
376 
377     private fun isRtl() = context.resources.configuration.layoutDirection == LAYOUT_DIRECTION_RTL
378 
379     companion object {
380         const val TAG = "AppHandleEducationController"
381         val APP_HANDLE_EDUCATION_DELAY_MILLIS: Long
382             get() = SystemProperties.getLong("persist.windowing_app_handle_education_delay", 3000L)
383 
384         val ENTER_DESKTOP_MODE_EDUCATION_DELAY_MILLIS: Long
385             get() =
386                 SystemProperties.getLong(
387                     "persist.windowing_enter_desktop_mode_education_timeout",
388                     400L,
389                 )
390 
391         val TOOLTIP_VISIBLE_DURATION_MILLIS: Long
392             get() = SystemProperties.getLong("persist.windowing_tooltip_visible_duration", 12000L)
393 
394         val FORCE_SHOW_DESKTOP_MODE_EDUCATION: Boolean
395             get() =
396                 SystemProperties.getBoolean(
397                     "persist.windowing_force_show_desktop_mode_education",
398                     false,
399                 )
400     }
401 }
402