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

Commit 087868c7 authored by Shivangi Dubey's avatar Shivangi Dubey
Browse files

Integrate Education UI with AppHandleEducationController

Integrate Education UI class WindowingEducationViewController with AppHandleEducationController.
Integrate WindowDecorCaptionHandleRepository with AppHandleEducationController to listen for triggers for all three tooltips.

Fixes: 365054977
Fixes: 365054970
Fixes: 365054137
Test: atest AppHandleEducationControllerTest
Test: atest AppHandleEducationFilterTest
Flag: com.android.window.flags.enable_desktop_windowing_app_handle_education
Change-Id: I78815e8627a446516b40dbc11e305f6bb415c95d
parent c700f1cc
Loading
Loading
Loading
Loading
+32 −4
Original line number Diff line number Diff line
@@ -28,6 +28,8 @@ import android.view.Choreographer;
import android.view.IWindowManager;
import android.view.WindowManager;

import androidx.annotation.OptIn;

import com.android.internal.jank.InteractionJankMonitor;
import com.android.internal.logging.UiEventLogger;
import com.android.internal.statusbar.IStatusBarService;
@@ -116,6 +118,8 @@ import com.android.wm.shell.unfold.qualifier.UnfoldTransition;
import com.android.wm.shell.windowdecor.CaptionWindowDecorViewModel;
import com.android.wm.shell.windowdecor.DesktopModeWindowDecorViewModel;
import com.android.wm.shell.windowdecor.WindowDecorViewModel;
import com.android.wm.shell.windowdecor.additionalviewcontainer.AdditionalSystemViewContainer;
import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController;

import dagger.Binds;
import dagger.Lazy;
@@ -123,6 +127,8 @@ import dagger.Module;
import dagger.Provides;

import kotlinx.coroutines.CoroutineScope;
import kotlinx.coroutines.ExperimentalCoroutinesApi;
import kotlinx.coroutines.MainCoroutineDispatcher;

import java.util.ArrayList;
import java.util.List;
@@ -307,6 +313,11 @@ public abstract class WMShellModule {
        return new AssistContentRequester(context, shellExecutor, bgExecutor);
    }

    @Provides
    static AdditionalSystemViewContainer.Factory provideAdditionalSystemViewContainerFactory() {
        return new AdditionalSystemViewContainer.Factory();
    }

    //
    // Freeform
    //
@@ -781,15 +792,32 @@ public abstract class WMShellModule {
        return new WindowDecorCaptionHandleRepository();
    }

    @WMSingleton
    @Provides
    static DesktopWindowingEducationTooltipController
            provideDesktopWindowingEducationTooltipController(
            Context context,
            AdditionalSystemViewContainer.Factory additionalSystemViewContainerFactory
    ) {
        return new DesktopWindowingEducationTooltipController(context,
                additionalSystemViewContainerFactory);
    }

    @OptIn(markerClass = ExperimentalCoroutinesApi.class)
    @WMSingleton
    @Provides
    static AppHandleEducationController provideAppHandleEducationController(
            Context context,
            AppHandleEducationFilter appHandleEducationFilter,
            ShellTaskOrganizer shellTaskOrganizer,
            AppHandleEducationDatastoreRepository appHandleEducationDatastoreRepository,
            @ShellMainThread CoroutineScope applicationScope) {
        return new AppHandleEducationController(appHandleEducationFilter,
                shellTaskOrganizer, appHandleEducationDatastoreRepository, applicationScope);
            WindowDecorCaptionHandleRepository windowDecorCaptionHandleRepository,
            DesktopWindowingEducationTooltipController desktopWindowingEducationTooltipController,
            @ShellMainThread CoroutineScope applicationScope, @ShellBackgroundThread
            MainCoroutineDispatcher backgroundDispatcher) {
        return new AppHandleEducationController(context, appHandleEducationFilter,
                appHandleEducationDatastoreRepository, windowDecorCaptionHandleRepository,
                desktopWindowingEducationTooltipController, applicationScope,
                backgroundDispatcher);
    }

    @WMSingleton
+193 −40
Original line number Diff line number Diff line
@@ -16,18 +16,29 @@

package com.android.wm.shell.desktopmode.education

import android.app.ActivityManager.RunningTaskInfo
import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
import android.annotation.DimenRes
import android.annotation.StringRes
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.ShellTaskOrganizer
import com.android.wm.shell.R
import com.android.wm.shell.desktopmode.CaptionState
import com.android.wm.shell.desktopmode.WindowDecorCaptionHandleRepository
import com.android.wm.shell.desktopmode.education.data.AppHandleEducationDatastoreRepository
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.windowdecor.education.DesktopWindowingEducationTooltipController
import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.EducationViewConfig
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -36,78 +47,220 @@ import kotlinx.coroutines.flow.filter
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

/**
 * Controls app handle education end to end.
 *
 * Listen to the user trigger for app handle education, calls an api to check if the education
 * should be shown and calls an api to show education.
 * should be shown and controls education UI.
 */
@OptIn(kotlinx.coroutines.FlowPreview::class)
@kotlinx.coroutines.ExperimentalCoroutinesApi
class AppHandleEducationController(
    private val context: Context,
    private val appHandleEducationFilter: AppHandleEducationFilter,
    shellTaskOrganizer: ShellTaskOrganizer,
    private val appHandleEducationDatastoreRepository: AppHandleEducationDatastoreRepository,
    @ShellMainThread private val applicationCoroutineScope: CoroutineScope
    private val windowDecorCaptionHandleRepository: WindowDecorCaptionHandleRepository,
    private val windowingEducationViewController: DesktopWindowingEducationTooltipController,
    @ShellMainThread private val applicationCoroutineScope: CoroutineScope,
    @ShellBackgroundThread private val backgroundDispatcher: MainCoroutineDispatcher,
) {
  init {
    runIfEducationFeatureEnabled {
      // TODO: b/361038716 - Use app handle state flow instead of focus task change flow
      val focusTaskChangeFlow = focusTaskChangeFlow(shellTaskOrganizer)
      applicationCoroutineScope.launch {
        // Central block handling the app's educational flow end-to-end.
        // This flow listens to the changes to the result of
        // [WindowingEducationProto#hasEducationViewedTimestampMillis()] in datastore proto object
        // Central block handling the app handle's educational flow end-to-end.
        isEducationViewedFlow()
            .flatMapLatest { isEducationViewed ->
              if (isEducationViewed) {
                // If the education is viewed then return emptyFlow() that completes immediately.
                // This will help us to not listen to focus task changes after the education has
                // been viewed already.
                // This will help us to not listen to [captionHandleStateFlow] after the education
                // has been viewed already.
                emptyFlow()
              } else {
                // This flow listens for focus task changes, which trigger the app handle education.
                focusTaskChangeFlow
                    .filter { runningTaskInfo ->
                      runningTaskInfo.topActivityInfo?.packageName?.let {
                        appHandleEducationFilter.shouldShowAppHandleEducation(it)
                      } ?: false && runningTaskInfo.windowingMode != WINDOWING_MODE_FREEFORM
                    }
                    .distinctUntilChanged()
                // Listen for changes to window decor's caption handle.
                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)
                    }
              }
            .debounce(
                APP_HANDLE_EDUCATION_DELAY) // Wait for few seconds, if the focus task changes.
            // During the delay then current emission will be cancelled.
            .flowOn(Dispatchers.IO)
            .collectLatest {
              // Fire and forget show education suspend function, manage entire lifecycle of
              // tooltip in UI class.
            }
            .flowOn(backgroundDispatcher)
            .collectLatest { captionState -> showEducation(captionState) }
      }
    }
  }

  private inline fun runIfEducationFeatureEnabled(block: () -> Unit) {
    if (Flags.enableDesktopWindowingAppHandleEducation()) block()
    if (canEnterDesktopMode(context) && Flags.enableDesktopWindowingAppHandleEducation()) block()
  }

  private fun showEducation(captionState: CaptionState) {
    val appHandleBounds = (captionState as CaptionState.AppHandle).globalAppHandleBounds
    val tooltipGlobalCoordinates =
        Point(appHandleBounds.left + appHandleBounds.width() / 2, appHandleBounds.bottom)
    // TODO: b/370546801 - Differentiate between user dismissing the tooltip vs following the cue.
    // Populate information important to inflate app handle education tooltip.
    val appHandleTooltipConfig =
        EducationViewConfig(
            tooltipViewLayout = R.layout.desktop_windowing_education_top_arrow_tooltip,
            tooltipViewGlobalCoordinates = tooltipGlobalCoordinates,
            tooltipText = getString(R.string.windowing_app_handle_education_tooltip),
            arrowDirection = DesktopWindowingEducationTooltipController.TooltipArrowDirection.UP,
            onEducationClickAction = {
              launchWithExceptionHandling { showWindowingImageButtonTooltip() }
            },
            onDismissAction = { launchWithExceptionHandling { showWindowingImageButtonTooltip() } },
        )

    windowingEducationViewController.showEducationTooltip(
        tooltipViewConfig = appHandleTooltipConfig, taskId = captionState.runningTaskInfo.taskId)
  }

  /** Show tooltip that points to windowing image button in app handle menu */
  private suspend fun showWindowingImageButtonTooltip() {
    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)
    val appHandleMenuWidth =
        getSize(R.dimen.desktop_mode_handle_menu_width) +
            getSize(R.dimen.desktop_mode_handle_menu_pill_spacing_margin)
    val appHandleMenuMargins =
        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(
                  appHandleBounds.left + appHandleBounds.width() / 2 + appHandleMenuWidth / 2,
                  appHandleBounds.top +
                      appHandleMenuMargins +
                      appInfoPillHeight +
                      windowingOptionPillHeight / 2)
          // Populate information important to inflate windowing image button education tooltip.
          val windowingImageButtonTooltipConfig =
              EducationViewConfig(
                  tooltipViewLayout = R.layout.desktop_windowing_education_left_arrow_tooltip,
                  tooltipViewGlobalCoordinates = tooltipGlobalCoordinates,
                  tooltipText =
                      getString(R.string.windowing_desktop_mode_image_button_education_tooltip),
                  arrowDirection =
                      DesktopWindowingEducationTooltipController.TooltipArrowDirection.LEFT,
                  onEducationClickAction = {
                    launchWithExceptionHandling { showExitWindowingTooltip() }
                  },
                  onDismissAction = { launchWithExceptionHandling { showExitWindowingTooltip() } },
              )

          windowingEducationViewController.showEducationTooltip(
              taskId = captionState.runningTaskInfo.taskId,
              tooltipViewConfig = windowingImageButtonTooltipConfig)
        }
  }

  /** Show tooltip that points to app chip button and educates user on how to exit desktop mode */
  private suspend fun showExitWindowingTooltip() {
    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
          val globalAppChipBounds = captionState.globalAppChipBounds
          val tooltipGlobalCoordinates =
              Point(
                  globalAppChipBounds.right,
                  globalAppChipBounds.top + globalAppChipBounds.height() / 2)
          // Populate information important to inflate exit desktop mode education tooltip.
          val exitWindowingTooltipConfig =
              EducationViewConfig(
                  tooltipViewLayout = R.layout.desktop_windowing_education_left_arrow_tooltip,
                  tooltipViewGlobalCoordinates = tooltipGlobalCoordinates,
                  tooltipText = getString(R.string.windowing_desktop_mode_exit_education_tooltip),
                  arrowDirection =
                      DesktopWindowingEducationTooltipController.TooltipArrowDirection.LEFT,
                  onDismissAction = {},
                  onEducationClickAction = {},
              )
          windowingEducationViewController.showEducationTooltip(
              taskId = captionState.runningTaskInfo.taskId,
              tooltipViewConfig = exitWindowingTooltipConfig,
          )
        }
  }

  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)
        }
      }

  /**
   * Listens to the changes to [WindowingEducationProto#hasEducationViewedTimestampMillis()] in
   * datastore proto object.
   */
  private fun isEducationViewedFlow(): Flow<Boolean> =
      appHandleEducationDatastoreRepository.dataStoreFlow
          .map { preferences -> preferences.hasEducationViewedTimestampMillis() }
          .distinctUntilChanged()

  private fun focusTaskChangeFlow(shellTaskOrganizer: ShellTaskOrganizer): Flow<RunningTaskInfo> =
      callbackFlow {
        val focusTaskChange = ShellTaskOrganizer.FocusListener { taskInfo -> trySend(taskInfo) }
        shellTaskOrganizer.addFocusListener(focusTaskChange)
        awaitClose { shellTaskOrganizer.removeFocusListener(focusTaskChange) }
  private fun getSize(@DimenRes resourceId: Int): Int {
    if (resourceId == Resources.ID_NULL) return 0
    return context.resources.getDimensionPixelSize(resourceId)
  }

  private companion object {
    val APP_HANDLE_EDUCATION_DELAY: Long
  private fun getString(@StringRes resId: Int): String = context.resources.getString(resId)

  companion object {
    const val TAG = "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)
  }
}
+6 −1
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ 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.data.AppHandleEducationDatastoreRepository
import com.android.wm.shell.desktopmode.education.data.WindowingEducationProto
import java.time.Duration
@@ -35,8 +36,12 @@ class AppHandleEducationFilter(
      context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager

  /** Returns true if conditions to show app handle education are met, returns false otherwise. */
  suspend fun shouldShowAppHandleEducation(focusAppPackageName: String): Boolean {
  suspend fun shouldShowAppHandleEducation(captionState: CaptionState): Boolean {
    if ((captionState as CaptionState.AppHandle).isHandleMenuExpanded) return false
    val focusAppPackageName =
        captionState.runningTaskInfo.topActivityInfo?.packageName ?: return false
    val windowingEducationProto = appHandleEducationDatastoreRepository.windowingEducationProto()

    return isFocusAppInAllowlist(focusAppPackageName) &&
        !isOtherEducationShowing() &&
        hasSufficientTimeSinceSetup() &&
+386 −0

File added.

Preview size limit exceeded, changes collapsed.

+1 −1
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import com.android.wm.shell.desktopmode.education.data.AppHandleEducationDatastoreRepository
import com.android.wm.shell.desktopmode.education.data.WindowingEducationProto
import com.android.wm.shell.util.GMAIL_PACKAGE_NAME
import com.android.wm.shell.util.createWindowingEducationProto
import com.google.common.truth.Truth.assertThat
import java.io.File
@@ -109,7 +110,6 @@ class AppHandleEducationDatastoreRepositoryTest {
      }

  companion object {
    private const val GMAIL_PACKAGE_NAME = "com.google.android.gm"
    private const val APP_HANDLE_EDUCATION_DATASTORE_TEST_FILE = "app_handle_education_test.pb"
  }
}
Loading