Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit af72c8c5 authored by Vania Desmonda's avatar Vania Desmonda Committed by Android (Google) Code Review
Browse files

Merge "Enable 3 desktop mode education hints to launch independently." into main

parents 7844c82d 96a9c32f
Loading
Loading
Loading
Loading
+144 −205
Original line number Diff line number Diff line
@@ -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
@@ -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

/**
@@ -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)
                    }
            }
        }
@@ -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)
@@ -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
                },
            )

@@ -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)
@@ -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(
@@ -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
                },
            )

@@ -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(
@@ -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)
                },
@@ -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.
@@ -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
@@ -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,
                )
    }
+14 −19
Original line number Diff line number Diff line
@@ -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
@@ -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)
    }

@@ -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,
+34 −0
Original line number Diff line number Diff line
@@ -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.
+3 −0
Original line number Diff line number Diff line
@@ -542,6 +542,9 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin
                if (appHeader != null) {
                    appHeader.setAppName(name);
                    appHeader.setAppIcon(icon);
                    if (canEnterDesktopMode(mContext) && isEducationEnabled()) {
                        notifyCaptionStateChanged();
                    }
                }
            });
        }
+0 −5
Original line number Diff line number Diff line
@@ -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