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