Loading libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationController.kt +144 −205 Original line number Diff line number Diff line Loading @@ -22,7 +22,6 @@ import android.content.Context import android.content.res.Resources import android.graphics.Point import android.os.SystemProperties import android.util.Slog import com.android.window.flags.Flags import com.android.wm.shell.R import com.android.wm.shell.desktopmode.CaptionState Loading @@ -32,27 +31,17 @@ import com.android.wm.shell.shared.annotations.ShellBackgroundThread import com.android.wm.shell.shared.annotations.ShellMainThread import com.android.wm.shell.shared.desktopmode.DesktopModeStatus.canEnterDesktopMode import com.android.wm.shell.shared.desktopmode.DesktopModeTransitionSource import com.android.wm.shell.windowdecor.common.DecorThemeUtil import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.TooltipColorScheme import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.TooltipEducationViewConfig import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainCoroutineDispatcher import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.launch /** Loading @@ -72,59 +61,75 @@ class AppHandleEducationController( @ShellMainThread private val applicationCoroutineScope: CoroutineScope, @ShellBackgroundThread private val backgroundDispatcher: MainCoroutineDispatcher, ) { private val decorThemeUtil = DecorThemeUtil(context) private lateinit var openHandleMenuCallback: (Int) -> Unit private lateinit var toDesktopModeCallback: (Int, DesktopModeTransitionSource) -> Unit private val onTertiaryFixedColor = context.getColor(com.android.internal.R.color.materialColorOnTertiaryFixed) private val tertiaryFixedColor = context.getColor(com.android.internal.R.color.materialColorTertiaryFixed) init { runIfEducationFeatureEnabled { // Coroutine block for the first hint that appears on a full-screen app's app handle to // encourage users to open the app handle menu. applicationCoroutineScope.launch { // Central block handling the app handle's educational flow end-to-end. isAppHandleHintViewedFlow() .flatMapLatest { isAppHandleHintViewed -> if (isAppHandleHintViewed) { // If the education is viewed then return emptyFlow() that completes // immediately. // This will help us to not listen to [captionHandleStateFlow] after the // education // has been viewed already. emptyFlow() } else { // Listen for changes to window decor's caption handle. if (isAppHandleHintViewed()) return@launch windowDecorCaptionHandleRepository.captionStateFlow // Wait for few seconds before emitting the latest state. .debounce(APP_HANDLE_EDUCATION_DELAY_MILLIS) .filter { captionState -> captionState is CaptionState.AppHandle && appHandleEducationFilter.shouldShowAppHandleEducation( captionState ) !captionState.isHandleMenuExpanded && !isAppHandleHintViewed() && appHandleEducationFilter.shouldShowDesktopModeEducation(captionState) } .take(1) .flowOn(backgroundDispatcher) .collectLatest { captionState -> showEducation(captionState) appHandleEducationDatastoreRepository .updateAppHandleHintViewedTimestampMillis(true) } } // Coroutine block for the hint that appears when an app handle is expanded to // encourage users to enter desktop mode. applicationCoroutineScope.launch { if (isEnterDesktopModeHintViewed()) return@launch windowDecorCaptionHandleRepository.captionStateFlow .debounce(ENTER_DESKTOP_MODE_EDUCATION_DELAY_MILLIS) .filter { captionState -> captionState is CaptionState.AppHandle && captionState.isHandleMenuExpanded && !isEnterDesktopModeHintViewed() && appHandleEducationFilter.shouldShowDesktopModeEducation(captionState) } .take(1) .flowOn(backgroundDispatcher) .collectLatest { captionState -> val tooltipColorScheme = tooltipColorScheme(captionState) showEducation(captionState, tooltipColorScheme) // After showing first tooltip, mark education as viewed showWindowingImageButtonTooltip(captionState as CaptionState.AppHandle) appHandleEducationDatastoreRepository .updateAppHandleHintViewedTimestampMillis(true) .updateEnterDesktopModeHintViewedTimestampMillis(true) } } // Coroutine block for the hint that appears on the window app header in freeform mode // to let users know how to exit desktop mode. applicationCoroutineScope.launch { if (isAppHandleHintUsed()) return@launch if (isExitDesktopModeHintViewed()) return@launch windowDecorCaptionHandleRepository.captionStateFlow .debounce(APP_HANDLE_EDUCATION_DELAY_MILLIS) .filter { captionState -> captionState is CaptionState.AppHandle && captionState.isHandleMenuExpanded captionState is CaptionState.AppHeader && !captionState.isHeaderMenuExpanded && !isExitDesktopModeHintViewed() && appHandleEducationFilter.shouldShowDesktopModeEducation(captionState) } .take(1) .flowOn(backgroundDispatcher) .collect { // If user expands app handle, mark user has used the app handle hint .collectLatest { captionState -> showExitWindowingTooltip(captionState as CaptionState.AppHeader) appHandleEducationDatastoreRepository .updateAppHandleHintUsedTimestampMillis(true) .updateExitDesktopModeHintViewedTimestampMillis(true) } } } Loading @@ -135,7 +140,7 @@ class AppHandleEducationController( block() } private fun showEducation(captionState: CaptionState, tooltipColorScheme: TooltipColorScheme) { private fun showEducation(captionState: CaptionState) { val appHandleBounds = (captionState as CaptionState.AppHandle).globalAppHandleBounds val tooltipGlobalCoordinates = Point(appHandleBounds.left + appHandleBounds.width() / 2, appHandleBounds.bottom) Loading @@ -145,21 +150,21 @@ class AppHandleEducationController( val appHandleTooltipConfig = TooltipEducationViewConfig( tooltipViewLayout = R.layout.desktop_windowing_education_top_arrow_tooltip, tooltipColorScheme = tooltipColorScheme, tooltipColorScheme = TooltipColorScheme( tertiaryFixedColor, onTertiaryFixedColor, onTertiaryFixedColor, ), tooltipViewGlobalCoordinates = tooltipGlobalCoordinates, tooltipText = getString(R.string.windowing_app_handle_education_tooltip), arrowDirection = DesktopWindowingEducationTooltipController.TooltipArrowDirection.UP, onEducationClickAction = { launchWithExceptionHandling { showWindowingImageButtonTooltip(tooltipColorScheme) } openHandleMenuCallback(captionState.runningTaskInfo.taskId) }, onDismissAction = { launchWithExceptionHandling { showWindowingImageButtonTooltip(tooltipColorScheme) } // TODO: b/341320146 - Log previous tooltip was dismissed }, ) Loading @@ -170,7 +175,7 @@ class AppHandleEducationController( } /** Show tooltip that points to windowing image button in app handle menu */ private suspend fun showWindowingImageButtonTooltip(tooltipColorScheme: TooltipColorScheme) { private suspend fun showWindowingImageButtonTooltip(captionState: CaptionState.AppHandle) { val appInfoPillHeight = getSize(R.dimen.desktop_mode_handle_menu_app_info_pill_height) val windowingOptionPillHeight = getSize(R.dimen.desktop_mode_handle_menu_windowing_pill_height) Loading @@ -181,26 +186,6 @@ class AppHandleEducationController( getSize(R.dimen.desktop_mode_handle_menu_margin_top) + getSize(R.dimen.desktop_mode_handle_menu_pill_spacing_margin) windowDecorCaptionHandleRepository.captionStateFlow // After the first tooltip was dismissed, wait for 400 ms and see if the app handle menu // has been expanded. .timeout(APP_HANDLE_EDUCATION_TIMEOUT_MILLIS.milliseconds) .catchTimeoutAndLog { // TODO: b/341320146 - Log previous tooltip was dismissed } // Wait for few milliseconds before emitting the latest state. .debounce(APP_HANDLE_EDUCATION_DELAY_MILLIS) .filter { captionState -> // Filter out states when app handle is not visible or not expanded. captionState is CaptionState.AppHandle && captionState.isHandleMenuExpanded } // Before showing this tooltip, stop listening to further emissions to avoid // accidentally // showing the same tooltip on future emissions. .take(1) .flowOn(backgroundDispatcher) .collectLatest { captionState -> captionState as CaptionState.AppHandle val appHandleBounds = captionState.globalAppHandleBounds val tooltipGlobalCoordinates = Point( Loading @@ -215,27 +200,25 @@ class AppHandleEducationController( val windowingImageButtonTooltipConfig = TooltipEducationViewConfig( tooltipViewLayout = R.layout.desktop_windowing_education_left_arrow_tooltip, tooltipColorScheme = tooltipColorScheme, tooltipColorScheme = TooltipColorScheme( tertiaryFixedColor, onTertiaryFixedColor, onTertiaryFixedColor, ), tooltipViewGlobalCoordinates = tooltipGlobalCoordinates, tooltipText = getString( R.string.windowing_desktop_mode_image_button_education_tooltip ), getString(R.string.windowing_desktop_mode_image_button_education_tooltip), arrowDirection = DesktopWindowingEducationTooltipController.TooltipArrowDirection.LEFT, onEducationClickAction = { launchWithExceptionHandling { showExitWindowingTooltip(tooltipColorScheme) } toDesktopModeCallback( captionState.runningTaskInfo.taskId, DesktopModeTransitionSource.APP_HANDLE_MENU_BUTTON, ) }, onDismissAction = { launchWithExceptionHandling { showExitWindowingTooltip(tooltipColorScheme) } // TODO: b/341320146 - Log previous tooltip was dismissed }, ) Loading @@ -244,30 +227,9 @@ class AppHandleEducationController( tooltipViewConfig = windowingImageButtonTooltipConfig, ) } } /** Show tooltip that points to app chip button and educates user on how to exit desktop mode */ private suspend fun showExitWindowingTooltip(tooltipColorScheme: TooltipColorScheme) { windowDecorCaptionHandleRepository.captionStateFlow // After the previous tooltip was dismissed, wait for 400 ms and see if the user entered // desktop mode. .timeout(APP_HANDLE_EDUCATION_TIMEOUT_MILLIS.milliseconds) .catchTimeoutAndLog { // TODO: b/341320146 - Log previous tooltip was dismissed } // Wait for few milliseconds before emitting the latest state. .debounce(APP_HANDLE_EDUCATION_DELAY_MILLIS) .filter { captionState -> // Filter out states when app header is not visible or expanded. captionState is CaptionState.AppHeader && !captionState.isHeaderMenuExpanded } // Before showing this tooltip, stop listening to further emissions to avoid // accidentally // showing the same tooltip on future emissions. .take(1) .flowOn(backgroundDispatcher) .collectLatest { captionState -> captionState as CaptionState.AppHeader private suspend fun showExitWindowingTooltip(captionState: CaptionState.AppHeader) { val globalAppChipBounds = captionState.globalAppChipBounds val tooltipGlobalCoordinates = Point( Loading @@ -278,13 +240,19 @@ class AppHandleEducationController( val exitWindowingTooltipConfig = TooltipEducationViewConfig( tooltipViewLayout = R.layout.desktop_windowing_education_left_arrow_tooltip, tooltipColorScheme = tooltipColorScheme, tooltipColorScheme = TooltipColorScheme( tertiaryFixedColor, onTertiaryFixedColor, onTertiaryFixedColor, ), tooltipViewGlobalCoordinates = tooltipGlobalCoordinates, tooltipText = getString(R.string.windowing_desktop_mode_exit_education_tooltip), tooltipText = getString(R.string.windowing_desktop_mode_exit_education_tooltip), arrowDirection = DesktopWindowingEducationTooltipController.TooltipArrowDirection.LEFT, onDismissAction = {}, onDismissAction = { // TODO: b/341320146 - Log previous tooltip was dismissed }, onEducationClickAction = { openHandleMenuCallback(captionState.runningTaskInfo.taskId) }, Loading @@ -294,16 +262,6 @@ class AppHandleEducationController( tooltipViewConfig = exitWindowingTooltipConfig, ) } } private fun tooltipColorScheme(captionState: CaptionState): TooltipColorScheme { val onTertiaryFixed = context.getColor(com.android.internal.R.color.materialColorOnTertiaryFixed) val tertiaryFixed = context.getColor(com.android.internal.R.color.materialColorTertiaryFixed) return TooltipColorScheme(tertiaryFixed, onTertiaryFixed, onTertiaryFixed) } /** * Setup callbacks for app handle education tooltips. Loading @@ -319,43 +277,20 @@ class AppHandleEducationController( this.toDesktopModeCallback = toDesktopModeCallback } private inline fun <T> Flow<T>.catchTimeoutAndLog(crossinline block: () -> Unit) = catch { exception -> if (exception is TimeoutCancellationException) block() else throw exception } private fun launchWithExceptionHandling(block: suspend () -> Unit) = applicationCoroutineScope.launch { try { block() } catch (e: Throwable) { Slog.e(TAG, "Error: ", e) } } private suspend fun isAppHandleHintViewed(): Boolean = appHandleEducationDatastoreRepository.dataStoreFlow .first() .hasAppHandleHintViewedTimestampMillis() && !FORCE_SHOW_DESKTOP_MODE_EDUCATION /** * Listens to the changes to [WindowingEducationProto#hasAppHandleHintViewedTimestampMillis()] * in datastore proto object. * * If [SHOULD_OVERRIDE_EDUCATION_CONDITIONS] is true, this flow will always emit false. That * means it will always emit app handle hint has not been viewed yet. */ private fun isAppHandleHintViewedFlow(): Flow<Boolean> = private suspend fun isEnterDesktopModeHintViewed(): Boolean = appHandleEducationDatastoreRepository.dataStoreFlow .map { preferences -> preferences.hasAppHandleHintViewedTimestampMillis() && !SHOULD_OVERRIDE_EDUCATION_CONDITIONS } .distinctUntilChanged() .first() .hasEnterDesktopModeHintViewedTimestampMillis() && !FORCE_SHOW_DESKTOP_MODE_EDUCATION /** * Listens to the changes to [WindowingEducationProto#hasAppHandleHintUsedTimestampMillis()] in * datastore proto object. */ private suspend fun isAppHandleHintUsed(): Boolean = private suspend fun isExitDesktopModeHintViewed(): Boolean = appHandleEducationDatastoreRepository.dataStoreFlow .first() .hasAppHandleHintUsedTimestampMillis() .hasExitDesktopModeHintViewedTimestampMillis() && !FORCE_SHOW_DESKTOP_MODE_EDUCATION private fun getSize(@DimenRes resourceId: Int): Int { if (resourceId == Resources.ID_NULL) return 0 Loading @@ -369,13 +304,17 @@ class AppHandleEducationController( val APP_HANDLE_EDUCATION_DELAY_MILLIS: Long get() = SystemProperties.getLong("persist.windowing_app_handle_education_delay", 3000L) val APP_HANDLE_EDUCATION_TIMEOUT_MILLIS: Long get() = SystemProperties.getLong("persist.windowing_app_handle_education_timeout", 400L) val ENTER_DESKTOP_MODE_EDUCATION_DELAY_MILLIS: Long get() = SystemProperties.getLong( "persist.windowing_enter_desktop_mode_education_timeout", 400L, ) val SHOULD_OVERRIDE_EDUCATION_CONDITIONS: Boolean val FORCE_SHOW_DESKTOP_MODE_EDUCATION: Boolean get() = SystemProperties.getBoolean( "persist.desktop_windowing_app_handle_education_override_conditions", "persist.windowing_force_show_desktop_mode_education", false, ) } Loading libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationFilter.kt +14 −19 Original line number Diff line number Diff line Loading @@ -17,13 +17,14 @@ package com.android.wm.shell.desktopmode.education import android.annotation.IntegerRes import android.app.ActivityManager.RunningTaskInfo import android.app.usage.UsageStatsManager import android.content.Context import android.os.SystemClock import android.provider.Settings.Secure import com.android.wm.shell.R import com.android.wm.shell.desktopmode.CaptionState import com.android.wm.shell.desktopmode.education.AppHandleEducationController.Companion.SHOULD_OVERRIDE_EDUCATION_CONDITIONS import com.android.wm.shell.desktopmode.education.AppHandleEducationController.Companion.FORCE_SHOW_DESKTOP_MODE_EDUCATION import com.android.wm.shell.desktopmode.education.data.AppHandleEducationDatastoreRepository import com.android.wm.shell.desktopmode.education.data.WindowingEducationProto import java.time.Duration Loading @@ -37,26 +38,28 @@ class AppHandleEducationFilter( private val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager suspend fun shouldShowDesktopModeEducation(captionState: CaptionState.AppHeader): Boolean = shouldShowDesktopModeEducation(captionState.runningTaskInfo) suspend fun shouldShowDesktopModeEducation(captionState: CaptionState.AppHandle): Boolean = shouldShowDesktopModeEducation(captionState.runningTaskInfo) /** * Returns true if conditions to show app handle education are met, returns false otherwise. * Returns true if conditions to show app handle, enter desktop mode and exit desktop mode * education are met based on the app info and usage, returns false otherwise. * * If [SHOULD_OVERRIDE_EDUCATION_CONDITIONS] is true, this method will always return * ![captionState.isHandleMenuExpanded]. * If [FORCE_SHOW_DESKTOP_MODE_EDUCATION] is true, this method will always return true. */ suspend fun shouldShowAppHandleEducation(captionState: CaptionState): Boolean { if ((captionState as CaptionState.AppHandle).isHandleMenuExpanded) return false if (SHOULD_OVERRIDE_EDUCATION_CONDITIONS) return true private suspend fun shouldShowDesktopModeEducation(taskInfo: RunningTaskInfo): Boolean { if (FORCE_SHOW_DESKTOP_MODE_EDUCATION) return true val focusAppPackageName = captionState.runningTaskInfo.topActivityInfo?.packageName ?: return false val focusAppPackageName = taskInfo.topActivityInfo?.packageName ?: return false val windowingEducationProto = appHandleEducationDatastoreRepository.windowingEducationProto() return isFocusAppInAllowlist(focusAppPackageName) && !isOtherEducationShowing() && hasSufficientTimeSinceSetup() && !isAppHandleHintViewedBefore(windowingEducationProto) && !isAppHandleHintUsedBefore(windowingEducationProto) && hasMinAppUsage(windowingEducationProto, focusAppPackageName) } Loading @@ -79,14 +82,6 @@ class AppHandleEducationFilter( R.integer.desktop_windowing_education_required_time_since_setup_seconds ) private fun isAppHandleHintViewedBefore( windowingEducationProto: WindowingEducationProto ): Boolean = windowingEducationProto.hasAppHandleHintViewedTimestampMillis() private fun isAppHandleHintUsedBefore( windowingEducationProto: WindowingEducationProto ): Boolean = windowingEducationProto.hasAppHandleHintUsedTimestampMillis() private suspend fun hasMinAppUsage( windowingEducationProto: WindowingEducationProto, focusAppPackageName: String, Loading libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/AppHandleEducationDatastoreRepository.kt +34 −0 Original line number Diff line number Diff line Loading @@ -90,6 +90,40 @@ constructor(private val dataStore: DataStore<WindowingEducationProto>) { } } /** * Updates [WindowingEducationProto.enterDesktopModeHintViewedTimestampMillis_] field in * datastore with current timestamp if [isViewed] is true, if not then clears the field. */ suspend fun updateEnterDesktopModeHintViewedTimestampMillis(isViewed: Boolean) { dataStore.updateData { preferences -> if (isViewed) { preferences .toBuilder() .setEnterDesktopModeHintViewedTimestampMillis(System.currentTimeMillis()) .build() } else { preferences.toBuilder().clearEnterDesktopModeHintViewedTimestampMillis().build() } } } /** * Updates [WindowingEducationProto.exitDesktopModeHintViewedTimestampMillis_] field in * datastore with current timestamp if [isViewed] is true, if not then clears the field. */ suspend fun updateExitDesktopModeHintViewedTimestampMillis(isViewed: Boolean) { dataStore.updateData { preferences -> if (isViewed) { preferences .toBuilder() .setExitDesktopModeHintViewedTimestampMillis(System.currentTimeMillis()) .build() } else { preferences.toBuilder().clearExitDesktopModeHintViewedTimestampMillis().build() } } } /** * Updates [WindowingEducationProto.appHandleHintUsedTimestampMillis_] field in datastore with * current timestamp if [isViewed] is true, if not then clears the field. Loading libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java +3 −0 Original line number Diff line number Diff line Loading @@ -542,6 +542,9 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin if (appHeader != null) { appHeader.setAppName(name); appHeader.setAppIcon(icon); if (canEnterDesktopMode(mContext) && isEducationEnabled()) { notifyCaptionStateChanged(); } } }); } Loading libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHeaderViewHolder.kt +0 −5 Original line number Diff line number Diff line Loading @@ -329,11 +329,6 @@ class AppHeaderViewHolder( } fun runOnAppChipGlobalLayout(runnable: () -> Unit) { if (openMenuButton.isAttachedToWindow) { // App chip is already inflated. runnable() return } // Wait for app chip to be inflated before notifying repository. openMenuButton.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener { Loading Loading
libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationController.kt +144 −205 Original line number Diff line number Diff line Loading @@ -22,7 +22,6 @@ import android.content.Context import android.content.res.Resources import android.graphics.Point import android.os.SystemProperties import android.util.Slog import com.android.window.flags.Flags import com.android.wm.shell.R import com.android.wm.shell.desktopmode.CaptionState Loading @@ -32,27 +31,17 @@ import com.android.wm.shell.shared.annotations.ShellBackgroundThread import com.android.wm.shell.shared.annotations.ShellMainThread import com.android.wm.shell.shared.desktopmode.DesktopModeStatus.canEnterDesktopMode import com.android.wm.shell.shared.desktopmode.DesktopModeTransitionSource import com.android.wm.shell.windowdecor.common.DecorThemeUtil import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.TooltipColorScheme import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.TooltipEducationViewConfig import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainCoroutineDispatcher import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.launch /** Loading @@ -72,59 +61,75 @@ class AppHandleEducationController( @ShellMainThread private val applicationCoroutineScope: CoroutineScope, @ShellBackgroundThread private val backgroundDispatcher: MainCoroutineDispatcher, ) { private val decorThemeUtil = DecorThemeUtil(context) private lateinit var openHandleMenuCallback: (Int) -> Unit private lateinit var toDesktopModeCallback: (Int, DesktopModeTransitionSource) -> Unit private val onTertiaryFixedColor = context.getColor(com.android.internal.R.color.materialColorOnTertiaryFixed) private val tertiaryFixedColor = context.getColor(com.android.internal.R.color.materialColorTertiaryFixed) init { runIfEducationFeatureEnabled { // Coroutine block for the first hint that appears on a full-screen app's app handle to // encourage users to open the app handle menu. applicationCoroutineScope.launch { // Central block handling the app handle's educational flow end-to-end. isAppHandleHintViewedFlow() .flatMapLatest { isAppHandleHintViewed -> if (isAppHandleHintViewed) { // If the education is viewed then return emptyFlow() that completes // immediately. // This will help us to not listen to [captionHandleStateFlow] after the // education // has been viewed already. emptyFlow() } else { // Listen for changes to window decor's caption handle. if (isAppHandleHintViewed()) return@launch windowDecorCaptionHandleRepository.captionStateFlow // Wait for few seconds before emitting the latest state. .debounce(APP_HANDLE_EDUCATION_DELAY_MILLIS) .filter { captionState -> captionState is CaptionState.AppHandle && appHandleEducationFilter.shouldShowAppHandleEducation( captionState ) !captionState.isHandleMenuExpanded && !isAppHandleHintViewed() && appHandleEducationFilter.shouldShowDesktopModeEducation(captionState) } .take(1) .flowOn(backgroundDispatcher) .collectLatest { captionState -> showEducation(captionState) appHandleEducationDatastoreRepository .updateAppHandleHintViewedTimestampMillis(true) } } // Coroutine block for the hint that appears when an app handle is expanded to // encourage users to enter desktop mode. applicationCoroutineScope.launch { if (isEnterDesktopModeHintViewed()) return@launch windowDecorCaptionHandleRepository.captionStateFlow .debounce(ENTER_DESKTOP_MODE_EDUCATION_DELAY_MILLIS) .filter { captionState -> captionState is CaptionState.AppHandle && captionState.isHandleMenuExpanded && !isEnterDesktopModeHintViewed() && appHandleEducationFilter.shouldShowDesktopModeEducation(captionState) } .take(1) .flowOn(backgroundDispatcher) .collectLatest { captionState -> val tooltipColorScheme = tooltipColorScheme(captionState) showEducation(captionState, tooltipColorScheme) // After showing first tooltip, mark education as viewed showWindowingImageButtonTooltip(captionState as CaptionState.AppHandle) appHandleEducationDatastoreRepository .updateAppHandleHintViewedTimestampMillis(true) .updateEnterDesktopModeHintViewedTimestampMillis(true) } } // Coroutine block for the hint that appears on the window app header in freeform mode // to let users know how to exit desktop mode. applicationCoroutineScope.launch { if (isAppHandleHintUsed()) return@launch if (isExitDesktopModeHintViewed()) return@launch windowDecorCaptionHandleRepository.captionStateFlow .debounce(APP_HANDLE_EDUCATION_DELAY_MILLIS) .filter { captionState -> captionState is CaptionState.AppHandle && captionState.isHandleMenuExpanded captionState is CaptionState.AppHeader && !captionState.isHeaderMenuExpanded && !isExitDesktopModeHintViewed() && appHandleEducationFilter.shouldShowDesktopModeEducation(captionState) } .take(1) .flowOn(backgroundDispatcher) .collect { // If user expands app handle, mark user has used the app handle hint .collectLatest { captionState -> showExitWindowingTooltip(captionState as CaptionState.AppHeader) appHandleEducationDatastoreRepository .updateAppHandleHintUsedTimestampMillis(true) .updateExitDesktopModeHintViewedTimestampMillis(true) } } } Loading @@ -135,7 +140,7 @@ class AppHandleEducationController( block() } private fun showEducation(captionState: CaptionState, tooltipColorScheme: TooltipColorScheme) { private fun showEducation(captionState: CaptionState) { val appHandleBounds = (captionState as CaptionState.AppHandle).globalAppHandleBounds val tooltipGlobalCoordinates = Point(appHandleBounds.left + appHandleBounds.width() / 2, appHandleBounds.bottom) Loading @@ -145,21 +150,21 @@ class AppHandleEducationController( val appHandleTooltipConfig = TooltipEducationViewConfig( tooltipViewLayout = R.layout.desktop_windowing_education_top_arrow_tooltip, tooltipColorScheme = tooltipColorScheme, tooltipColorScheme = TooltipColorScheme( tertiaryFixedColor, onTertiaryFixedColor, onTertiaryFixedColor, ), tooltipViewGlobalCoordinates = tooltipGlobalCoordinates, tooltipText = getString(R.string.windowing_app_handle_education_tooltip), arrowDirection = DesktopWindowingEducationTooltipController.TooltipArrowDirection.UP, onEducationClickAction = { launchWithExceptionHandling { showWindowingImageButtonTooltip(tooltipColorScheme) } openHandleMenuCallback(captionState.runningTaskInfo.taskId) }, onDismissAction = { launchWithExceptionHandling { showWindowingImageButtonTooltip(tooltipColorScheme) } // TODO: b/341320146 - Log previous tooltip was dismissed }, ) Loading @@ -170,7 +175,7 @@ class AppHandleEducationController( } /** Show tooltip that points to windowing image button in app handle menu */ private suspend fun showWindowingImageButtonTooltip(tooltipColorScheme: TooltipColorScheme) { private suspend fun showWindowingImageButtonTooltip(captionState: CaptionState.AppHandle) { val appInfoPillHeight = getSize(R.dimen.desktop_mode_handle_menu_app_info_pill_height) val windowingOptionPillHeight = getSize(R.dimen.desktop_mode_handle_menu_windowing_pill_height) Loading @@ -181,26 +186,6 @@ class AppHandleEducationController( getSize(R.dimen.desktop_mode_handle_menu_margin_top) + getSize(R.dimen.desktop_mode_handle_menu_pill_spacing_margin) windowDecorCaptionHandleRepository.captionStateFlow // After the first tooltip was dismissed, wait for 400 ms and see if the app handle menu // has been expanded. .timeout(APP_HANDLE_EDUCATION_TIMEOUT_MILLIS.milliseconds) .catchTimeoutAndLog { // TODO: b/341320146 - Log previous tooltip was dismissed } // Wait for few milliseconds before emitting the latest state. .debounce(APP_HANDLE_EDUCATION_DELAY_MILLIS) .filter { captionState -> // Filter out states when app handle is not visible or not expanded. captionState is CaptionState.AppHandle && captionState.isHandleMenuExpanded } // Before showing this tooltip, stop listening to further emissions to avoid // accidentally // showing the same tooltip on future emissions. .take(1) .flowOn(backgroundDispatcher) .collectLatest { captionState -> captionState as CaptionState.AppHandle val appHandleBounds = captionState.globalAppHandleBounds val tooltipGlobalCoordinates = Point( Loading @@ -215,27 +200,25 @@ class AppHandleEducationController( val windowingImageButtonTooltipConfig = TooltipEducationViewConfig( tooltipViewLayout = R.layout.desktop_windowing_education_left_arrow_tooltip, tooltipColorScheme = tooltipColorScheme, tooltipColorScheme = TooltipColorScheme( tertiaryFixedColor, onTertiaryFixedColor, onTertiaryFixedColor, ), tooltipViewGlobalCoordinates = tooltipGlobalCoordinates, tooltipText = getString( R.string.windowing_desktop_mode_image_button_education_tooltip ), getString(R.string.windowing_desktop_mode_image_button_education_tooltip), arrowDirection = DesktopWindowingEducationTooltipController.TooltipArrowDirection.LEFT, onEducationClickAction = { launchWithExceptionHandling { showExitWindowingTooltip(tooltipColorScheme) } toDesktopModeCallback( captionState.runningTaskInfo.taskId, DesktopModeTransitionSource.APP_HANDLE_MENU_BUTTON, ) }, onDismissAction = { launchWithExceptionHandling { showExitWindowingTooltip(tooltipColorScheme) } // TODO: b/341320146 - Log previous tooltip was dismissed }, ) Loading @@ -244,30 +227,9 @@ class AppHandleEducationController( tooltipViewConfig = windowingImageButtonTooltipConfig, ) } } /** Show tooltip that points to app chip button and educates user on how to exit desktop mode */ private suspend fun showExitWindowingTooltip(tooltipColorScheme: TooltipColorScheme) { windowDecorCaptionHandleRepository.captionStateFlow // After the previous tooltip was dismissed, wait for 400 ms and see if the user entered // desktop mode. .timeout(APP_HANDLE_EDUCATION_TIMEOUT_MILLIS.milliseconds) .catchTimeoutAndLog { // TODO: b/341320146 - Log previous tooltip was dismissed } // Wait for few milliseconds before emitting the latest state. .debounce(APP_HANDLE_EDUCATION_DELAY_MILLIS) .filter { captionState -> // Filter out states when app header is not visible or expanded. captionState is CaptionState.AppHeader && !captionState.isHeaderMenuExpanded } // Before showing this tooltip, stop listening to further emissions to avoid // accidentally // showing the same tooltip on future emissions. .take(1) .flowOn(backgroundDispatcher) .collectLatest { captionState -> captionState as CaptionState.AppHeader private suspend fun showExitWindowingTooltip(captionState: CaptionState.AppHeader) { val globalAppChipBounds = captionState.globalAppChipBounds val tooltipGlobalCoordinates = Point( Loading @@ -278,13 +240,19 @@ class AppHandleEducationController( val exitWindowingTooltipConfig = TooltipEducationViewConfig( tooltipViewLayout = R.layout.desktop_windowing_education_left_arrow_tooltip, tooltipColorScheme = tooltipColorScheme, tooltipColorScheme = TooltipColorScheme( tertiaryFixedColor, onTertiaryFixedColor, onTertiaryFixedColor, ), tooltipViewGlobalCoordinates = tooltipGlobalCoordinates, tooltipText = getString(R.string.windowing_desktop_mode_exit_education_tooltip), tooltipText = getString(R.string.windowing_desktop_mode_exit_education_tooltip), arrowDirection = DesktopWindowingEducationTooltipController.TooltipArrowDirection.LEFT, onDismissAction = {}, onDismissAction = { // TODO: b/341320146 - Log previous tooltip was dismissed }, onEducationClickAction = { openHandleMenuCallback(captionState.runningTaskInfo.taskId) }, Loading @@ -294,16 +262,6 @@ class AppHandleEducationController( tooltipViewConfig = exitWindowingTooltipConfig, ) } } private fun tooltipColorScheme(captionState: CaptionState): TooltipColorScheme { val onTertiaryFixed = context.getColor(com.android.internal.R.color.materialColorOnTertiaryFixed) val tertiaryFixed = context.getColor(com.android.internal.R.color.materialColorTertiaryFixed) return TooltipColorScheme(tertiaryFixed, onTertiaryFixed, onTertiaryFixed) } /** * Setup callbacks for app handle education tooltips. Loading @@ -319,43 +277,20 @@ class AppHandleEducationController( this.toDesktopModeCallback = toDesktopModeCallback } private inline fun <T> Flow<T>.catchTimeoutAndLog(crossinline block: () -> Unit) = catch { exception -> if (exception is TimeoutCancellationException) block() else throw exception } private fun launchWithExceptionHandling(block: suspend () -> Unit) = applicationCoroutineScope.launch { try { block() } catch (e: Throwable) { Slog.e(TAG, "Error: ", e) } } private suspend fun isAppHandleHintViewed(): Boolean = appHandleEducationDatastoreRepository.dataStoreFlow .first() .hasAppHandleHintViewedTimestampMillis() && !FORCE_SHOW_DESKTOP_MODE_EDUCATION /** * Listens to the changes to [WindowingEducationProto#hasAppHandleHintViewedTimestampMillis()] * in datastore proto object. * * If [SHOULD_OVERRIDE_EDUCATION_CONDITIONS] is true, this flow will always emit false. That * means it will always emit app handle hint has not been viewed yet. */ private fun isAppHandleHintViewedFlow(): Flow<Boolean> = private suspend fun isEnterDesktopModeHintViewed(): Boolean = appHandleEducationDatastoreRepository.dataStoreFlow .map { preferences -> preferences.hasAppHandleHintViewedTimestampMillis() && !SHOULD_OVERRIDE_EDUCATION_CONDITIONS } .distinctUntilChanged() .first() .hasEnterDesktopModeHintViewedTimestampMillis() && !FORCE_SHOW_DESKTOP_MODE_EDUCATION /** * Listens to the changes to [WindowingEducationProto#hasAppHandleHintUsedTimestampMillis()] in * datastore proto object. */ private suspend fun isAppHandleHintUsed(): Boolean = private suspend fun isExitDesktopModeHintViewed(): Boolean = appHandleEducationDatastoreRepository.dataStoreFlow .first() .hasAppHandleHintUsedTimestampMillis() .hasExitDesktopModeHintViewedTimestampMillis() && !FORCE_SHOW_DESKTOP_MODE_EDUCATION private fun getSize(@DimenRes resourceId: Int): Int { if (resourceId == Resources.ID_NULL) return 0 Loading @@ -369,13 +304,17 @@ class AppHandleEducationController( val APP_HANDLE_EDUCATION_DELAY_MILLIS: Long get() = SystemProperties.getLong("persist.windowing_app_handle_education_delay", 3000L) val APP_HANDLE_EDUCATION_TIMEOUT_MILLIS: Long get() = SystemProperties.getLong("persist.windowing_app_handle_education_timeout", 400L) val ENTER_DESKTOP_MODE_EDUCATION_DELAY_MILLIS: Long get() = SystemProperties.getLong( "persist.windowing_enter_desktop_mode_education_timeout", 400L, ) val SHOULD_OVERRIDE_EDUCATION_CONDITIONS: Boolean val FORCE_SHOW_DESKTOP_MODE_EDUCATION: Boolean get() = SystemProperties.getBoolean( "persist.desktop_windowing_app_handle_education_override_conditions", "persist.windowing_force_show_desktop_mode_education", false, ) } Loading
libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationFilter.kt +14 −19 Original line number Diff line number Diff line Loading @@ -17,13 +17,14 @@ package com.android.wm.shell.desktopmode.education import android.annotation.IntegerRes import android.app.ActivityManager.RunningTaskInfo import android.app.usage.UsageStatsManager import android.content.Context import android.os.SystemClock import android.provider.Settings.Secure import com.android.wm.shell.R import com.android.wm.shell.desktopmode.CaptionState import com.android.wm.shell.desktopmode.education.AppHandleEducationController.Companion.SHOULD_OVERRIDE_EDUCATION_CONDITIONS import com.android.wm.shell.desktopmode.education.AppHandleEducationController.Companion.FORCE_SHOW_DESKTOP_MODE_EDUCATION import com.android.wm.shell.desktopmode.education.data.AppHandleEducationDatastoreRepository import com.android.wm.shell.desktopmode.education.data.WindowingEducationProto import java.time.Duration Loading @@ -37,26 +38,28 @@ class AppHandleEducationFilter( private val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager suspend fun shouldShowDesktopModeEducation(captionState: CaptionState.AppHeader): Boolean = shouldShowDesktopModeEducation(captionState.runningTaskInfo) suspend fun shouldShowDesktopModeEducation(captionState: CaptionState.AppHandle): Boolean = shouldShowDesktopModeEducation(captionState.runningTaskInfo) /** * Returns true if conditions to show app handle education are met, returns false otherwise. * Returns true if conditions to show app handle, enter desktop mode and exit desktop mode * education are met based on the app info and usage, returns false otherwise. * * If [SHOULD_OVERRIDE_EDUCATION_CONDITIONS] is true, this method will always return * ![captionState.isHandleMenuExpanded]. * If [FORCE_SHOW_DESKTOP_MODE_EDUCATION] is true, this method will always return true. */ suspend fun shouldShowAppHandleEducation(captionState: CaptionState): Boolean { if ((captionState as CaptionState.AppHandle).isHandleMenuExpanded) return false if (SHOULD_OVERRIDE_EDUCATION_CONDITIONS) return true private suspend fun shouldShowDesktopModeEducation(taskInfo: RunningTaskInfo): Boolean { if (FORCE_SHOW_DESKTOP_MODE_EDUCATION) return true val focusAppPackageName = captionState.runningTaskInfo.topActivityInfo?.packageName ?: return false val focusAppPackageName = taskInfo.topActivityInfo?.packageName ?: return false val windowingEducationProto = appHandleEducationDatastoreRepository.windowingEducationProto() return isFocusAppInAllowlist(focusAppPackageName) && !isOtherEducationShowing() && hasSufficientTimeSinceSetup() && !isAppHandleHintViewedBefore(windowingEducationProto) && !isAppHandleHintUsedBefore(windowingEducationProto) && hasMinAppUsage(windowingEducationProto, focusAppPackageName) } Loading @@ -79,14 +82,6 @@ class AppHandleEducationFilter( R.integer.desktop_windowing_education_required_time_since_setup_seconds ) private fun isAppHandleHintViewedBefore( windowingEducationProto: WindowingEducationProto ): Boolean = windowingEducationProto.hasAppHandleHintViewedTimestampMillis() private fun isAppHandleHintUsedBefore( windowingEducationProto: WindowingEducationProto ): Boolean = windowingEducationProto.hasAppHandleHintUsedTimestampMillis() private suspend fun hasMinAppUsage( windowingEducationProto: WindowingEducationProto, focusAppPackageName: String, Loading
libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/AppHandleEducationDatastoreRepository.kt +34 −0 Original line number Diff line number Diff line Loading @@ -90,6 +90,40 @@ constructor(private val dataStore: DataStore<WindowingEducationProto>) { } } /** * Updates [WindowingEducationProto.enterDesktopModeHintViewedTimestampMillis_] field in * datastore with current timestamp if [isViewed] is true, if not then clears the field. */ suspend fun updateEnterDesktopModeHintViewedTimestampMillis(isViewed: Boolean) { dataStore.updateData { preferences -> if (isViewed) { preferences .toBuilder() .setEnterDesktopModeHintViewedTimestampMillis(System.currentTimeMillis()) .build() } else { preferences.toBuilder().clearEnterDesktopModeHintViewedTimestampMillis().build() } } } /** * Updates [WindowingEducationProto.exitDesktopModeHintViewedTimestampMillis_] field in * datastore with current timestamp if [isViewed] is true, if not then clears the field. */ suspend fun updateExitDesktopModeHintViewedTimestampMillis(isViewed: Boolean) { dataStore.updateData { preferences -> if (isViewed) { preferences .toBuilder() .setExitDesktopModeHintViewedTimestampMillis(System.currentTimeMillis()) .build() } else { preferences.toBuilder().clearExitDesktopModeHintViewedTimestampMillis().build() } } } /** * Updates [WindowingEducationProto.appHandleHintUsedTimestampMillis_] field in datastore with * current timestamp if [isViewed] is true, if not then clears the field. Loading
libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java +3 −0 Original line number Diff line number Diff line Loading @@ -542,6 +542,9 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin if (appHeader != null) { appHeader.setAppName(name); appHeader.setAppIcon(icon); if (canEnterDesktopMode(mContext) && isEducationEnabled()) { notifyCaptionStateChanged(); } } }); } Loading
libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHeaderViewHolder.kt +0 −5 Original line number Diff line number Diff line Loading @@ -329,11 +329,6 @@ class AppHeaderViewHolder( } fun runOnAppChipGlobalLayout(runnable: () -> Unit) { if (openMenuButton.isAttachedToWindow) { // App chip is already inflated. runnable() return } // Wait for app chip to be inflated before notifying repository. openMenuButton.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener { Loading