diff options
22 files changed, 856 insertions, 32 deletions
diff --git a/libs/WindowManager/Shell/res/drawable/desktop_windowing_education_promo_background.xml b/libs/WindowManager/Shell/res/drawable/desktop_windowing_education_promo_background.xml new file mode 100644 index 000000000000..645d24df7c26 --- /dev/null +++ b/libs/WindowManager/Shell/res/drawable/desktop_windowing_education_promo_background.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 2024 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<shape android:shape="rectangle" + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"> + <solid android:color="?androidprv:attr/materialColorSurfaceContainerLow" /> + <corners android:radius="@dimen/desktop_windowing_education_promo_corner_radius" /> +</shape> diff --git a/libs/WindowManager/Shell/res/layout/desktop_windowing_education_promo.xml b/libs/WindowManager/Shell/res/layout/desktop_windowing_education_promo.xml new file mode 100644 index 000000000000..eebfd4bb595e --- /dev/null +++ b/libs/WindowManager/Shell/res/layout/desktop_windowing_education_promo.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 2024 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/education_container" + android:layout_width="@dimen/desktop_windowing_education_promo_width" + android:layout_height="@dimen/desktop_windowing_education_promo_height" + android:elevation="1dp" + android:orientation="vertical" + android:paddingHorizontal="32dp" + android:paddingVertical="24dp" + android:background="@drawable/desktop_windowing_education_promo_background"> + <TextView + android:id="@+id/education_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="24sp" + android:lineHeight="32dp" + android:textFontWeight="400" + android:fontFamily="google-sans-text" + android:layout_gravity="center_horizontal" + android:gravity="center"/> +</LinearLayout> diff --git a/libs/WindowManager/Shell/res/values/config.xml b/libs/WindowManager/Shell/res/values/config.xml index a14461a57a95..59e6c7786cb8 100644 --- a/libs/WindowManager/Shell/res/values/config.xml +++ b/libs/WindowManager/Shell/res/values/config.xml @@ -186,4 +186,6 @@ <!-- Apps that can trigger Desktop Windowing App handle Education --> <string-array name="desktop_windowing_app_handle_education_allowlist_apps"></string-array> + <!-- Apps that can trigger Desktop Windowing App-To-Web Education --> + <string-array name="desktop_windowing_app_to_web_education_allowlist_apps"></string-array> </resources> diff --git a/libs/WindowManager/Shell/res/values/dimen.xml b/libs/WindowManager/Shell/res/values/dimen.xml index fa1aa193e1e3..34f950c0a326 100644 --- a/libs/WindowManager/Shell/res/values/dimen.xml +++ b/libs/WindowManager/Shell/res/values/dimen.xml @@ -650,4 +650,11 @@ <dimen name="open_by_default_settings_dialog_radio_button_height">64dp</dimen> <!-- The width of radio buttons in the open by default settings dialog. --> <dimen name="open_by_default_settings_dialog_radio_button_width">316dp</dimen> + + <!-- The width of the desktop windowing education promo. --> + <dimen name="desktop_windowing_education_promo_width">412dp</dimen> + <!-- The height of the desktop windowing education promo. --> + <dimen name="desktop_windowing_education_promo_height">352dp</dimen> + <!-- The corner radius of the desktop windowing education promo. --> + <dimen name="desktop_windowing_education_promo_corner_radius">28dp</dimen> </resources> diff --git a/libs/WindowManager/Shell/res/values/strings.xml b/libs/WindowManager/Shell/res/values/strings.xml index ef0386a1b4dd..52585d4ead35 100644 --- a/libs/WindowManager/Shell/res/values/strings.xml +++ b/libs/WindowManager/Shell/res/values/strings.xml @@ -339,4 +339,7 @@ <string name="open_by_default_dialog_in_browser_text">In your browser</string> <!-- Text for open by default settings dialog dismiss button. --> <string name="open_by_default_dialog_dismiss_button_text">OK</string> + + <!-- Text for the App-to-Web education promo. --> + <string name="desktop_windowing_app_to_web_education_text">Quickly open apps in your browser here</string> </resources> diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java index 817be3b1fe0d..fec4c16992a5 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java @@ -86,7 +86,10 @@ import com.android.wm.shell.desktopmode.ToggleResizeDesktopTaskTransitionHandler import com.android.wm.shell.desktopmode.WindowDecorCaptionHandleRepository; import com.android.wm.shell.desktopmode.education.AppHandleEducationController; import com.android.wm.shell.desktopmode.education.AppHandleEducationFilter; +import com.android.wm.shell.desktopmode.education.AppToWebEducationController; +import com.android.wm.shell.desktopmode.education.AppToWebEducationFilter; import com.android.wm.shell.desktopmode.education.data.AppHandleEducationDatastoreRepository; +import com.android.wm.shell.desktopmode.education.data.AppToWebEducationDatastoreRepository; import com.android.wm.shell.desktopmode.persistence.DesktopPersistentRepository; import com.android.wm.shell.desktopmode.persistence.DesktopRepositoryInitializer; import com.android.wm.shell.desktopmode.persistence.DesktopRepositoryInitializerImpl; @@ -132,6 +135,7 @@ 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.DesktopWindowingEducationPromoController; import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController; import com.android.wm.shell.windowdecor.tiling.DesktopTilingDecorViewModel; @@ -288,6 +292,7 @@ public abstract class WMShellModule { MultiInstanceHelper multiInstanceHelper, Optional<DesktopTasksLimiter> desktopTasksLimiter, AppHandleEducationController appHandleEducationController, + AppToWebEducationController appToWebEducationController, WindowDecorCaptionHandleRepository windowDecorCaptionHandleRepository, Optional<DesktopActivityOrientationChangeHandler> desktopActivityOrientationHandler, FocusTransitionObserver focusTransitionObserver, @@ -317,6 +322,7 @@ public abstract class WMShellModule { multiInstanceHelper, desktopTasksLimiter, appHandleEducationController, + appToWebEducationController, windowDecorCaptionHandleRepository, desktopActivityOrientationHandler, focusTransitionObserver, @@ -1040,6 +1046,20 @@ public abstract class WMShellModule { context, additionalSystemViewContainerFactory, displayController); } + @WMSingleton + @Provides + static DesktopWindowingEducationPromoController provideDesktopWindowingEducationPromoController( + Context context, + AdditionalSystemViewContainer.Factory additionalSystemViewContainerFactory, + DisplayController displayController + ) { + return new DesktopWindowingEducationPromoController( + context, + additionalSystemViewContainerFactory, + displayController + ); + } + @OptIn(markerClass = ExperimentalCoroutinesApi.class) @WMSingleton @Provides @@ -1063,6 +1083,38 @@ public abstract class WMShellModule { @WMSingleton @Provides + static AppToWebEducationDatastoreRepository provideAppToWebEducationDatastoreRepository( + Context context) { + return new AppToWebEducationDatastoreRepository(context); + } + + @WMSingleton + @Provides + static AppToWebEducationFilter provideAppToWebEducationFilter( + Context context, + AppToWebEducationDatastoreRepository appToWebEducationDatastoreRepository) { + return new AppToWebEducationFilter(context, appToWebEducationDatastoreRepository); + } + + @OptIn(markerClass = ExperimentalCoroutinesApi.class) + @WMSingleton + @Provides + static AppToWebEducationController provideAppToWebEducationController( + Context context, + AppToWebEducationFilter appToWebEducationFilter, + AppToWebEducationDatastoreRepository appToWebEducationDatastoreRepository, + WindowDecorCaptionHandleRepository windowDecorCaptionHandleRepository, + DesktopWindowingEducationPromoController desktopWindowingEducationPromoController, + @ShellMainThread CoroutineScope applicationScope, + @ShellBackgroundThread MainCoroutineDispatcher backgroundDispatcher) { + return new AppToWebEducationController(context, appToWebEducationFilter, + appToWebEducationDatastoreRepository, windowDecorCaptionHandleRepository, + desktopWindowingEducationPromoController, applicationScope, + backgroundDispatcher); + } + + @WMSingleton + @Provides static DesktopPersistentRepository provideDesktopPersistentRepository( Context context, @ShellBackgroundThread CoroutineScope bgScope) { return new DesktopPersistentRepository(context, bgScope); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/WindowDecorCaptionHandleRepository.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/WindowDecorCaptionHandleRepository.kt index 7ae537088832..8bfcca093855 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/WindowDecorCaptionHandleRepository.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/WindowDecorCaptionHandleRepository.kt @@ -18,6 +18,10 @@ package com.android.wm.shell.desktopmode import android.app.ActivityManager.RunningTaskInfo import android.graphics.Rect +import com.android.wm.shell.desktopmode.CaptionState.AppHandle +import com.android.wm.shell.desktopmode.CaptionState.AppHeader +import com.android.wm.shell.desktopmode.CaptionState.NoCaption +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,11 +30,20 @@ class WindowDecorCaptionHandleRepository { private val _captionStateFlow = MutableStateFlow<CaptionState>(CaptionState.NoCaption) /** Observer for app handle state changes. */ val captionStateFlow: StateFlow<CaptionState> = _captionStateFlow + private val _appToWebUsageFlow = MutableSharedFlow<Unit>() + /** Observer for App-to-Web usage. */ + val appToWebUsageFlow = _appToWebUsageFlow + /** Notifies [captionStateFlow] if there is a change to caption state. */ fun notifyCaptionChanged(captionState: CaptionState) { _captionStateFlow.value = captionState } + + /** Notifies [appToWebUsageFlow] if App-to-Web feature is used. */ + fun onAppToWebUsage() { + _appToWebUsageFlow.tryEmit(Unit) + } } /** @@ -41,17 +54,19 @@ class WindowDecorCaptionHandleRepository { * * [AppHeader]: Indicating that there is at least one visible app chip on the screen. * * [NoCaption]: Signifying that no caption handle is currently visible on the device. */ -sealed class CaptionState { +sealed class CaptionState{ data class AppHandle( - val runningTaskInfo: RunningTaskInfo, - val isHandleMenuExpanded: Boolean, - val globalAppHandleBounds: Rect + val runningTaskInfo: RunningTaskInfo, + val isHandleMenuExpanded: Boolean, + val globalAppHandleBounds: Rect, + val isCapturedLinkAvailable: Boolean ) : CaptionState() data class AppHeader( - val runningTaskInfo: RunningTaskInfo, - val isHeaderMenuExpanded: Boolean, - val globalAppChipBounds: Rect + val runningTaskInfo: RunningTaskInfo, + val isHeaderMenuExpanded: Boolean, + val globalAppChipBounds: Rect, + val isCapturedLinkAvailable: Boolean ) : CaptionState() data object NoCaption : CaptionState() diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationController.kt index f21a124f0b8b..e01c448be8e5 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationController.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationController.kt @@ -36,7 +36,7 @@ import com.android.wm.shell.shared.desktopmode.DesktopModeTransitionSource import com.android.wm.shell.windowdecor.common.DecorThemeUtil import com.android.wm.shell.windowdecor.common.Theme import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController -import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.EducationViewConfig +import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.TooltipEducationViewConfig import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.TooltipColorScheme import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope @@ -137,7 +137,7 @@ class AppHandleEducationController( // 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( + TooltipEducationViewConfig( tooltipViewLayout = R.layout.desktop_windowing_education_top_arrow_tooltip, tooltipColorScheme = tooltipColorScheme, tooltipViewGlobalCoordinates = tooltipGlobalCoordinates, @@ -196,7 +196,7 @@ class AppHandleEducationController( windowingOptionPillHeight / 2) // Populate information important to inflate windowing image button education tooltip. val windowingImageButtonTooltipConfig = - EducationViewConfig( + TooltipEducationViewConfig( tooltipViewLayout = R.layout.desktop_windowing_education_left_arrow_tooltip, tooltipColorScheme = tooltipColorScheme, tooltipViewGlobalCoordinates = tooltipGlobalCoordinates, @@ -249,7 +249,7 @@ class AppHandleEducationController( globalAppChipBounds.top + globalAppChipBounds.height() / 2) // Populate information important to inflate exit desktop mode education tooltip. val exitWindowingTooltipConfig = - EducationViewConfig( + TooltipEducationViewConfig( tooltipViewLayout = R.layout.desktop_windowing_education_left_arrow_tooltip, tooltipColorScheme = tooltipColorScheme, tooltipViewGlobalCoordinates = tooltipGlobalCoordinates, diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppToWebEducationController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppToWebEducationController.kt new file mode 100644 index 000000000000..693da81ec4a0 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppToWebEducationController.kt @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.desktopmode.education + +import android.annotation.DimenRes +import android.annotation.StringRes +import android.app.ActivityManager.RunningTaskInfo +import android.content.Context +import android.content.res.Resources +import android.graphics.Point +import android.os.SystemProperties +import androidx.compose.ui.graphics.toArgb +import com.android.window.flags.Flags +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.AppToWebEducationDatastoreRepository +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.common.DecorThemeUtil +import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationPromoController +import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationPromoController.EducationColorScheme +import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationPromoController.EducationViewConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.flow.Flow +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.launch + +/** + * Controls App-to-Web education end to end. + * + * Listen to usages of App-to-Web, calls an api to check if the education + * should be shown and controls education UI. + */ +@OptIn(kotlinx.coroutines.FlowPreview::class) +@kotlinx.coroutines.ExperimentalCoroutinesApi +class AppToWebEducationController( + private val context: Context, + private val appToWebEducationFilter: AppToWebEducationFilter, + private val appToWebEducationDatastoreRepository: AppToWebEducationDatastoreRepository, + private val windowDecorCaptionHandleRepository: WindowDecorCaptionHandleRepository, + private val windowingEducationViewController: DesktopWindowingEducationPromoController, + @ShellMainThread private val applicationCoroutineScope: CoroutineScope, + @ShellBackgroundThread private val backgroundDispatcher: MainCoroutineDispatcher, +) { + private val decorThemeUtil = DecorThemeUtil(context) + + init { + runIfEducationFeatureEnabled { + applicationCoroutineScope.launch { + // Central block handling the App-to-Web's educational flow end-to-end. + isEducationViewLimitReachedFlow() + .flatMapLatest { countExceedsMaximum -> + if (countExceedsMaximum) { + // If the education has been viewed the maximum amount of times then + // return emptyFlow() that completes immediately. This will help us to + // not listen to [captionHandleStateFlow] after the education should + // not be shown. + emptyFlow() + } else { + // Listen for changes to window decor's caption. + windowDecorCaptionHandleRepository.captionStateFlow + // Wait for few seconds before emitting the latest state. + .debounce(APP_TO_WEB_EDUCATION_DELAY_MILLIS) + .filter { captionState -> + captionState !is CaptionState.NoCaption && + appToWebEducationFilter + .shouldShowAppToWebEducation(captionState) + } + } + } + .flowOn(backgroundDispatcher) + .collectLatest { captionState -> + val educationColorScheme = educationColorScheme(captionState) + showEducation(captionState, educationColorScheme!!) + // After showing first tooltip, increase count of education views + appToWebEducationDatastoreRepository.updateEducationShownCount() + } + } + + applicationCoroutineScope.launch { + if (isFeatureUsed()) return@launch + windowDecorCaptionHandleRepository.appToWebUsageFlow + .collect { + // If user utilizes App-to-Web, mark user has used the feature + appToWebEducationDatastoreRepository + .updateFeatureUsedTimestampMillis(isViewed = true) + } + } + } + } + + private inline fun runIfEducationFeatureEnabled(block: () -> Unit) { + if (canEnterDesktopMode(context) && Flags.enableDesktopWindowingAppToWebEducation()) block() + } + + private fun showEducation(captionState: CaptionState, colorScheme: EducationColorScheme) { + val educationGlobalCoordinates: Point + val taskId: Int + when (captionState) { + is CaptionState.AppHandle -> { + val appHandleBounds = captionState.globalAppHandleBounds + val educationWidth = + loadDimensionPixelSize(R.dimen.desktop_windowing_education_promo_width) + educationGlobalCoordinates = Point( + appHandleBounds.centerX() - educationWidth / 2, + appHandleBounds.bottom + ) + taskId = captionState.runningTaskInfo.taskId + } + + is CaptionState.AppHeader -> { + val taskBounds = + captionState.runningTaskInfo.configuration.windowConfiguration.bounds + educationGlobalCoordinates = + Point(taskBounds.left, captionState.globalAppChipBounds.bottom) + taskId = captionState.runningTaskInfo.taskId + } + + else -> return + } + + // Populate information important to inflate education promo. + val educationConfig = + EducationViewConfig( + viewLayout = R.layout.desktop_windowing_education_promo, + educationColorScheme = colorScheme, + viewGlobalCoordinates = educationGlobalCoordinates, + educationText = getString(R.string.desktop_windowing_app_to_web_education_text), + widthId = R.dimen.desktop_windowing_education_promo_width, + heightId = R.dimen.desktop_windowing_education_promo_height + ) + + windowingEducationViewController.showEducation( + viewConfig = educationConfig, taskId = taskId) + } + + private fun educationColorScheme(captionState: CaptionState): EducationColorScheme? { + val taskInfo: RunningTaskInfo = when (captionState) { + is CaptionState.AppHandle -> captionState.runningTaskInfo + is CaptionState.AppHeader -> captionState.runningTaskInfo + else -> return null + } + + val colorScheme = decorThemeUtil.getColorScheme(taskInfo) + val tooltipContainerColor = colorScheme.surfaceBright.toArgb() + val tooltipTextColor = colorScheme.onSurface.toArgb() + return EducationColorScheme(tooltipContainerColor, tooltipTextColor) + } + + /** + * Listens to changes in the number of times the education has been viewed, mapping the count to + * true if the education has been viewed the maximum amount of times. + */ + private fun isEducationViewLimitReachedFlow(): Flow<Boolean> = + appToWebEducationDatastoreRepository.dataStoreFlow + .map { preferences -> + appToWebEducationFilter.isEducationViewLimitReached(preferences)} + .distinctUntilChanged() + + /** + * Listens to the changes to [WindowingEducationProto#hasFeatureUsedTimestampMillis()] in + * datastore proto object. + */ + private suspend fun isFeatureUsed(): Boolean = + appToWebEducationDatastoreRepository.dataStoreFlow.first().hasFeatureUsedTimestampMillis() + + private fun loadDimensionPixelSize(@DimenRes resourceId: Int): Int { + if (resourceId == Resources.ID_NULL) return 0 + return context.resources.getDimensionPixelSize(resourceId) + } + + private fun getString(@StringRes resId: Int): String = context.resources.getString(resId) + + companion object { + const val TAG = "AppToWebEducationController" + val APP_TO_WEB_EDUCATION_DELAY_MILLIS: Long + get() = SystemProperties.getLong( + "persist.windowing_app_handle_education_delay", + 3000L + ) + } +} diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppToWebEducationFilter.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppToWebEducationFilter.kt new file mode 100644 index 000000000000..feee6ed86da1 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppToWebEducationFilter.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.desktopmode.education + +import android.annotation.IntegerRes +import android.app.ActivityManager.RunningTaskInfo +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.AppToWebEducationDatastoreRepository +import com.android.wm.shell.desktopmode.education.data.WindowingEducationProto +import java.time.Duration + +/** Filters incoming App-to-Web education triggers based on set conditions. */ +class AppToWebEducationFilter( + private val context: Context, + private val appToWebEducationDatastoreRepository: AppToWebEducationDatastoreRepository +) { + + /** Returns true if conditions to show App-to-web education are met, returns false otherwise. */ + suspend fun shouldShowAppToWebEducation(captionState: CaptionState): Boolean { + val (taskInfo: RunningTaskInfo, isCapturedLinkAvailable: Boolean) = when (captionState) { + is CaptionState.AppHandle -> + Pair(captionState.runningTaskInfo, captionState.isCapturedLinkAvailable) + is CaptionState.AppHeader -> + Pair(captionState.runningTaskInfo, captionState.isCapturedLinkAvailable) + else -> return false + } + + val focusAppPackageName = taskInfo.topActivityInfo?.packageName ?: return false + val windowingEducationProto = appToWebEducationDatastoreRepository.windowingEducationProto() + + return !isOtherEducationShowing() && + !isEducationViewLimitReached(windowingEducationProto) && + hasSufficientTimeSinceSetup() && + !isFeatureUsedBefore(windowingEducationProto) && + isCapturedLinkAvailable && + isFocusAppInAllowlist(focusAppPackageName) + } + + private fun isFocusAppInAllowlist(focusAppPackageName: String): Boolean = + focusAppPackageName in + context.resources.getStringArray( + R.array.desktop_windowing_app_to_web_education_allowlist_apps) + + // TODO: b/350953004 - Add checks based on App compat + // TODO: b/350951797 - Add checks based on PKT tips education + private fun isOtherEducationShowing(): Boolean = isTaskbarEducationShowing() || + isCompatUiEducationShowing() + + private fun isTaskbarEducationShowing(): Boolean = + Secure.getInt(context.contentResolver, Secure.LAUNCHER_TASKBAR_EDUCATION_SHOWING, 0) == 1 + + private fun isCompatUiEducationShowing(): Boolean = + Secure.getInt(context.contentResolver, Secure.COMPAT_UI_EDUCATION_SHOWING, 0) == 1 + + private fun hasSufficientTimeSinceSetup(): Boolean = + Duration.ofMillis(SystemClock.elapsedRealtime()) > + convertIntegerResourceToDuration( + R.integer.desktop_windowing_education_required_time_since_setup_seconds) + + /** Returns true if education is viewed maximum amount of times it should be shown. */ + fun isEducationViewLimitReached(windowingEducationProto: WindowingEducationProto): Boolean = + windowingEducationProto.getAppToWebEducation().getEducationShownCount() >= + MAXIMUM_TIMES_EDUCATION_SHOWN + + private fun isFeatureUsedBefore(windowingEducationProto: WindowingEducationProto): Boolean = + windowingEducationProto.hasFeatureUsedTimestampMillis() + + private fun convertIntegerResourceToDuration(@IntegerRes resourceId: Int): Duration = + Duration.ofSeconds(context.resources.getInteger(resourceId).toLong()) + + companion object { + private const val MAXIMUM_TIMES_EDUCATION_SHOWN = 100 + } +} diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/AppToWebEducationDatastoreRepository.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/AppToWebEducationDatastoreRepository.kt new file mode 100644 index 000000000000..8be6e6dff6fe --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/AppToWebEducationDatastoreRepository.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.desktopmode.education.data + +import android.content.Context +import android.util.Slog +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.Serializer +import androidx.datastore.dataStoreFile +import com.android.framework.protobuf.InvalidProtocolBufferException +import com.android.internal.annotations.VisibleForTesting +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** Updates data in App-to-Web's education datastore. */ +class AppToWebEducationDatastoreRepository +@VisibleForTesting +constructor(private val dataStore: DataStore<WindowingEducationProto>) { + constructor( + context: Context + ) : this( + DataStoreFactory.create( + serializer = WindowingEducationProtoSerializer, + produceFile = { context.dataStoreFile(APP_TO_WEB_EDUCATION_DATASTORE_FILEPATH) })) + + /** Provides dataStore.data flow and handles exceptions thrown during collection */ + val dataStoreFlow: Flow<WindowingEducationProto> = + dataStore.data.catch { exception -> + // dataStore.data throws an IOException when an error is encountered when reading data + if (exception is IOException) { + Slog.e( + TAG, + "Error in reading App-to-Web education related data from datastore," + + "data is stored in a file named" + + "$APP_TO_WEB_EDUCATION_DATASTORE_FILEPATH", exception) + } else { + throw exception + } + } + + /** + * Reads and returns the [WindowingEducationProto] Proto object from the DataStore. If the + * DataStore is empty or there's an error reading, it returns the default value of Proto. + */ + suspend fun windowingEducationProto(): WindowingEducationProto = dataStoreFlow.first() + + /** + * Updates [WindowingEducationProto.featureUsedTimestampMillis_] field in datastore with current + * timestamp if [isViewed] is true, if not then clears the field. + */ + suspend fun updateFeatureUsedTimestampMillis(isViewed: Boolean) { + dataStore.updateData { preferences -> + if (isViewed) { + preferences + .toBuilder().setFeatureUsedTimestampMillis(System.currentTimeMillis()).build() + } else { + preferences.toBuilder().clearFeatureUsedTimestampMillis().build() + } + } + } + + /** + * Increases [AppToWebEducation.educationShownCount] field by one. + */ + suspend fun updateEducationShownCount() { + val currentAppHandleProto = windowingEducationProto().appToWebEducation.toBuilder() + currentAppHandleProto + .setEducationShownCount(currentAppHandleProto.getEducationShownCount() + 1) + dataStore.updateData { preferences -> + preferences.toBuilder().setAppToWebEducation(currentAppHandleProto).build() + } + } + + + companion object { + private const val TAG = "AppToWebEducationDatastoreRepository" + private const val APP_TO_WEB_EDUCATION_DATASTORE_FILEPATH = "app_to_web_education.pb" + + object WindowingEducationProtoSerializer : Serializer<WindowingEducationProto> { + + override val defaultValue: WindowingEducationProto = + WindowingEducationProto.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): WindowingEducationProto = + try { + WindowingEducationProto.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + + override suspend fun writeTo( + windowingProto: WindowingEducationProto, + output: OutputStream + ) = windowingProto.writeTo(output) + } + } +} diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/proto/windowing_education.proto b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/proto/windowing_education.proto index d29ec53d9c61..4cddd01ee96b 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/proto/windowing_education.proto +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/proto/windowing_education.proto @@ -28,6 +28,8 @@ message WindowingEducationProto { oneof education_data { // Fields specific to app handle education AppHandleEducation app_handle_education = 3; + // Fields specific to App-to-Web education + AppToWebEducation app_to_web_education = 4; } message AppHandleEducation { @@ -36,4 +38,9 @@ message WindowingEducationProto { // Timestamp of when app_usage_stats was last cached optional int64 app_usage_stats_last_update_timestamp_millis = 2; } + + message AppToWebEducation { + // Number of times education is shown + optional int64 education_shown_count = 1; + } }
\ No newline at end of file diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java index 29b8ddd03970..67775f700be4 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java @@ -113,6 +113,7 @@ import com.android.wm.shell.desktopmode.DesktopTasksLimiter; import com.android.wm.shell.desktopmode.DesktopWallpaperActivity; import com.android.wm.shell.desktopmode.WindowDecorCaptionHandleRepository; import com.android.wm.shell.desktopmode.education.AppHandleEducationController; +import com.android.wm.shell.desktopmode.education.AppToWebEducationController; import com.android.wm.shell.freeform.FreeformTaskTransitionStarter; import com.android.wm.shell.shared.FocusTransitionListener; import com.android.wm.shell.shared.annotations.ShellBackgroundThread; @@ -176,6 +177,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel, private final WindowDecorCaptionHandleRepository mWindowDecorCaptionHandleRepository; private final Optional<DesktopTasksLimiter> mDesktopTasksLimiter; private final AppHandleEducationController mAppHandleEducationController; + private final AppToWebEducationController mAppToWebEducationController; private final AppHeaderViewHolder.Factory mAppHeaderViewHolderFactory; private boolean mTransitionDragActive; @@ -249,6 +251,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel, MultiInstanceHelper multiInstanceHelper, Optional<DesktopTasksLimiter> desktopTasksLimiter, AppHandleEducationController appHandleEducationController, + AppToWebEducationController appToWebEducationController, WindowDecorCaptionHandleRepository windowDecorCaptionHandleRepository, Optional<DesktopActivityOrientationChangeHandler> activityOrientationChangeHandler, FocusTransitionObserver focusTransitionObserver, @@ -282,6 +285,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel, interactionJankMonitor, desktopTasksLimiter, appHandleEducationController, + appToWebEducationController, windowDecorCaptionHandleRepository, activityOrientationChangeHandler, new TaskPositionerFactory(), @@ -319,6 +323,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel, InteractionJankMonitor interactionJankMonitor, Optional<DesktopTasksLimiter> desktopTasksLimiter, AppHandleEducationController appHandleEducationController, + AppToWebEducationController appToWebEducationController, WindowDecorCaptionHandleRepository windowDecorCaptionHandleRepository, Optional<DesktopActivityOrientationChangeHandler> activityOrientationChangeHandler, TaskPositionerFactory taskPositionerFactory, @@ -354,6 +359,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel, mInteractionJankMonitor = interactionJankMonitor; mDesktopTasksLimiter = desktopTasksLimiter; mAppHandleEducationController = appHandleEducationController; + mAppToWebEducationController = appToWebEducationController; mWindowDecorCaptionHandleRepository = windowDecorCaptionHandleRepository; mActivityOrientationChangeHandler = activityOrientationChangeHandler; mAssistContentRequester = assistContentRequester; diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java index c88ac3d19635..af7cd0584c17 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java @@ -509,7 +509,6 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin boolean applyStartTransactionOnDraw, boolean shouldSetTaskVisibilityPositionAndCrop, boolean hasGlobalFocus) { Trace.beginSection("DesktopModeWindowDecoration#updateRelayoutParamsAndSurfaces"); - if (Flags.enableDesktopWindowingAppToWeb()) { setCapturedLink(taskInfo.capturedLink, taskInfo.capturedLinkTimestamp); } @@ -551,7 +550,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin if (mResult.mRootView == null) { // This means something blocks the window decor from showing, e.g. the task is hidden. // Nothing is set up in this case including the decoration surface. - if (canEnterDesktopMode(mContext) && Flags.enableDesktopWindowingAppHandleEducation()) { + if (canEnterDesktopMode(mContext) && isEducationEnabled()) { notifyNoCaptionHandle(); } mExclusionRegionListener.onExclusionRegionDismissed(mTaskInfo.taskId); @@ -570,7 +569,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin if (isAppHandle(mWindowDecorViewHolder)) { position.set(determineHandlePosition()); } - if (canEnterDesktopMode(mContext) && Flags.enableDesktopWindowingAppHandleEducation()) { + if (canEnterDesktopMode(mContext) && isEducationEnabled()) { notifyCaptionStateChanged(); } @@ -710,7 +709,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin private void notifyCaptionStateChanged() { // TODO: b/366159408 - Ensure bounds sent with notification account for RTL mode. - if (!canEnterDesktopMode(mContext) || !Flags.enableDesktopWindowingAppHandleEducation()) { + if (!canEnterDesktopMode(mContext) || !isEducationEnabled()) { return; } if (!isCaptionVisible()) { @@ -719,7 +718,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin // App handle is visible since `mWindowDecorViewHolder` is of type // [AppHandleViewHolder]. final CaptionState captionState = new CaptionState.AppHandle(mTaskInfo, - isHandleMenuActive(), getCurrentAppHandleBounds()); + isHandleMenuActive(), getCurrentAppHandleBounds(), isCapturedLinkAvailable()); mWindowDecorCaptionHandleRepository.notifyCaptionChanged(captionState); } else { // App header is visible since `mWindowDecorViewHolder` is of type @@ -732,8 +731,12 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin } } + private boolean isCapturedLinkAvailable() { + return mCapturedLink != null && !mCapturedLink.mExpired; + } + private void notifyNoCaptionHandle() { - if (!canEnterDesktopMode(mContext) || !Flags.enableDesktopWindowingAppHandleEducation()) { + if (!canEnterDesktopMode(mContext) || !isEducationEnabled()) { return; } mWindowDecorCaptionHandleRepository.notifyCaptionChanged( @@ -760,7 +763,8 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin final CaptionState captionState = new CaptionState.AppHeader( mTaskInfo, isHandleMenuActive(), - appChipGlobalPosition); + appChipGlobalPosition, + isCapturedLinkAvailable()); mWindowDecorCaptionHandleRepository.notifyCaptionChanged(captionState); } @@ -1389,6 +1393,9 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin /* openInBrowserClickListener= */ (intent) -> { mOpenInBrowserClickListener.accept(intent); onCapturedLinkExpired(); + if (Flags.enableDesktopWindowingAppToWebEducation()) { + mWindowDecorCaptionHandleRepository.onAppToWebUsage(); + } return Unit.INSTANCE; }, /* onOpenByDefaultClickListener= */ () -> { @@ -1407,7 +1414,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin }, /* forceShowSystemBars= */ inDesktopImmersive ); - if (canEnterDesktopMode(mContext) && Flags.enableDesktopWindowingAppHandleEducation()) { + if (canEnterDesktopMode(mContext) && isEducationEnabled()) { notifyCaptionStateChanged(); } mMinimumInstancesFound = false; @@ -1478,7 +1485,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin mWindowDecorViewHolder.onHandleMenuClosed(); mHandleMenu.close(); mHandleMenu = null; - if (canEnterDesktopMode(mContext) && Flags.enableDesktopWindowingAppHandleEducation()) { + if (canEnterDesktopMode(mContext) && isEducationEnabled()) { notifyCaptionStateChanged(); } } @@ -1633,6 +1640,12 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin && v.getTop() <= y && v.getBottom() >= y; } + /** Returns true if at least one education flag is enabled. */ + private boolean isEducationEnabled() { + return Flags.enableDesktopWindowingAppHandleEducation() + || Flags.enableDesktopWindowingAppToWebEducation(); + } + @Override public void close() { closeDragResizeListener(); @@ -1642,7 +1655,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin disposeResizeVeil(); disposeStatusBarInputLayer(); clearCurrentViewHostRunnable(); - if (canEnterDesktopMode(mContext) && Flags.enableDesktopWindowingAppHandleEducation()) { + if (canEnterDesktopMode(mContext) && isEducationEnabled()) { notifyNoCaptionHandle(); } super.close(); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationPromoController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationPromoController.kt new file mode 100644 index 000000000000..b3489a4b4348 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationPromoController.kt @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.windowdecor.education + +import android.annotation.ColorInt +import android.annotation.DimenRes +import android.annotation.LayoutRes +import android.content.Context +import android.content.res.Resources +import android.graphics.Point +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import android.widget.LinearLayout +import android.widget.TextView +import android.window.DisplayAreaInfo +import android.window.WindowContainerTransaction +import androidx.dynamicanimation.animation.DynamicAnimation +import androidx.dynamicanimation.animation.SpringForce +import com.android.wm.shell.R +import com.android.wm.shell.common.DisplayChangeController.OnDisplayChangingListener +import com.android.wm.shell.common.DisplayController +import com.android.wm.shell.shared.animation.PhysicsAnimator +import com.android.wm.shell.windowdecor.WindowManagerWrapper +import com.android.wm.shell.windowdecor.additionalviewcontainer.AdditionalSystemViewContainer + +/** + * Controls the lifecycle of an education promo, including showing and hiding it. + */ +class DesktopWindowingEducationPromoController( + private val context: Context, + private val additionalSystemViewContainerFactory: AdditionalSystemViewContainer.Factory, + private val displayController: DisplayController, +) : OnDisplayChangingListener { + private var educationView: View? = null + private var animator: PhysicsAnimator<View>? = null + private val springConfig by lazy { + PhysicsAnimator.SpringConfig( + SpringForce.STIFFNESS_MEDIUM, + SpringForce.DAMPING_RATIO_LOW_BOUNCY + ) + } + private var popupWindow: AdditionalSystemViewContainer? = null + + override fun onDisplayChange( + displayId: Int, + fromRotation: Int, + toRotation: Int, + newDisplayAreaInfo: DisplayAreaInfo?, + t: WindowContainerTransaction? + ) { + // Exit if the rotation hasn't changed or is changed by 180 degrees. [fromRotation] and + // [toRotation] can be one of the [@Surface.Rotation] values. + if ((fromRotation % 2 == toRotation % 2)) return + hideEducation() + } + + /** + * Shows education promo. + * + * @param viewConfig features of the education. + * @param taskId is used in the title of popup window created for the education view. + */ + fun showEducation( + viewConfig: EducationViewConfig, + taskId: Int + ) { + hideEducation() + educationView = createEducationView(viewConfig, taskId) + animator = createAnimator() + animateShowEducationTransition() + displayController.addDisplayChangingController(this) + } + + /** Hide the current education view if visible */ + private fun hideEducation() = animateHideEducationTransition { cleanUp() } + + /** Create education view by inflating layout provided. */ + private fun createEducationView( + viewConfig: EducationViewConfig, + taskId: Int + ): View { + val educationView = + LayoutInflater.from(context) + .inflate( + viewConfig.viewLayout, /* root= */ null, /* attachToRoot= */ false) + .apply { + alpha = 0f + scaleX = 0f + scaleY = 0f + + requireViewById<TextView>(R.id.education_text).apply { + text = viewConfig.educationText + } + setOnTouchListener { _, motionEvent -> + if (motionEvent.action == MotionEvent.ACTION_OUTSIDE) { + hideEducation() + true + } else { + false + } + } + setOnClickListener { + hideEducation() + } + setEducationColorScheme(viewConfig.educationColorScheme) + } + + createEducationPopupWindow( + taskId, + viewConfig.viewGlobalCoordinates, + loadDimensionPixelSize(viewConfig.widthId), + loadDimensionPixelSize(viewConfig.heightId), + educationView = educationView) + + return educationView + } + + /** Create animator for education transitions */ + private fun createAnimator(): PhysicsAnimator<View>? = + educationView?.let { + PhysicsAnimator.getInstance(it).apply { setDefaultSpringConfig(springConfig) } + } + + /** Animate show transition for the education view */ + private fun animateShowEducationTransition() { + animator + ?.spring(DynamicAnimation.ALPHA, 1f) + ?.spring(DynamicAnimation.SCALE_X, 1f) + ?.spring(DynamicAnimation.SCALE_Y, 1f) + ?.start() + } + + /** Animate hide transition for the education view */ + private fun animateHideEducationTransition(endActions: () -> Unit) { + animator + ?.spring(DynamicAnimation.ALPHA, 0f) + ?.spring(DynamicAnimation.SCALE_X, 0f) + ?.spring(DynamicAnimation.SCALE_Y, 0f) + ?.start() + endActions() + } + + /** Remove education promo and clean up all relative properties */ + private fun cleanUp() { + educationView = null + animator = null + popupWindow?.releaseView() + popupWindow = null + displayController.removeDisplayChangingController(this) + } + + private fun createEducationPopupWindow( + taskId: Int, + educationViewGlobalCoordinates: Point, + width: Int, + height: Int, + educationView: View, + ) { + popupWindow = + additionalSystemViewContainerFactory.create( + windowManagerWrapper = + WindowManagerWrapper(context.getSystemService(WindowManager::class.java)), + taskId = taskId, + x = educationViewGlobalCoordinates.x, + y = educationViewGlobalCoordinates.y, + width = width, + height = height, + flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, + view = educationView) + } + + private fun View.setEducationColorScheme(educationColorScheme: EducationColorScheme) { + requireViewById<LinearLayout>(R.id.education_container).apply { + background.setTint(educationColorScheme.container) + } + requireViewById<TextView>(R.id.education_text).apply { + setTextColor(educationColorScheme.text) + } + } + + private fun loadDimensionPixelSize(@DimenRes resourceId: Int): Int { + if (resourceId == Resources.ID_NULL) return 0 + return context.resources.getDimensionPixelSize(resourceId) + } + + /** + * The configuration for education view features: + * + * @property viewLayout Layout resource ID of the view to be used for education promo. + * @property viewGlobalCoordinates Global (screen) coordinates of the education. + * @property educationText Text to be added to the TextView of the promo. + * @property widthId res Id for education width + * @property heightId res Id for education height + */ + data class EducationViewConfig( + @LayoutRes val viewLayout: Int, + val educationColorScheme: EducationColorScheme, + val viewGlobalCoordinates: Point, + val educationText: String, + @DimenRes val widthId: Int, + @DimenRes val heightId: Int + ) + + /** + * Color scheme of education view: + * + * @property container Color of the container of the education. + * @property text Text color of the [TextView] of education promo. + */ + data class EducationColorScheme( + @ColorInt val container: Int, + @ColorInt val text: Int, + ) +} diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipController.kt index c61b31e7ba01..4fa2744b4c12 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipController.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipController.kt @@ -80,7 +80,7 @@ class DesktopWindowingEducationTooltipController( * @param tooltipViewConfig features of tooltip. * @param taskId is used in the title of popup window created for the tooltip view. */ - fun showEducationTooltip(tooltipViewConfig: EducationViewConfig, taskId: Int) { + fun showEducationTooltip(tooltipViewConfig: TooltipEducationViewConfig, taskId: Int) { hideEducationTooltip() tooltipView = createEducationTooltipView(tooltipViewConfig, taskId) animator = createAnimator() @@ -93,7 +93,7 @@ class DesktopWindowingEducationTooltipController( /** Create education view by inflating layout provided. */ private fun createEducationTooltipView( - tooltipViewConfig: EducationViewConfig, + tooltipViewConfig: TooltipEducationViewConfig, taskId: Int, ): View { val tooltipView = @@ -271,7 +271,7 @@ class DesktopWindowingEducationTooltipController( * @property onEducationClickAction Lambda to be executed when the tooltip is clicked. * @property onDismissAction Lambda to be executed when the tooltip is dismissed. */ - data class EducationViewConfig( + data class TooltipEducationViewConfig( @LayoutRes val tooltipViewLayout: Int, val tooltipColorScheme: TooltipColorScheme, val tooltipViewGlobalCoordinates: Point, @@ -299,4 +299,4 @@ class DesktopWindowingEducationTooltipController( UP, LEFT, } -} +}
\ No newline at end of file diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/WindowDecorCaptionHandleRepositoryTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/WindowDecorCaptionHandleRepositoryTest.kt index e3caf2ede99d..38c6ed90241c 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/WindowDecorCaptionHandleRepositoryTest.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/WindowDecorCaptionHandleRepositoryTest.kt @@ -49,7 +49,10 @@ class WindowDecorCaptionHandleRepositoryTest { val taskInfo = createTaskInfo(WINDOWING_MODE_FULLSCREEN, GMAIL_PACKAGE_NAME) val appHandleCaptionState = CaptionState.AppHandle( - taskInfo, false, Rect(/* left= */ 0, /* top= */ 1, /* right= */ 2, /* bottom= */ 3)) + runningTaskInfo = taskInfo, + isHandleMenuExpanded = false, + globalAppHandleBounds = Rect(/* left= */ 0, /* top= */ 1, /* right= */ 2, /* bottom= */ 3), + isCapturedLinkAvailable = false) captionHandleRepository.notifyCaptionChanged(appHandleCaptionState) @@ -61,7 +64,10 @@ class WindowDecorCaptionHandleRepositoryTest { val taskInfo = createTaskInfo(WINDOWING_MODE_FREEFORM, GMAIL_PACKAGE_NAME) val appHeaderCaptionState = CaptionState.AppHeader( - taskInfo, true, Rect(/* left= */ 0, /* top= */ 1, /* right= */ 2, /* bottom= */ 3)) + runningTaskInfo = taskInfo, + isHeaderMenuExpanded = true, + globalAppChipBounds = Rect(/* left= */ 0, /* top= */ 1, /* right= */ 2, /* bottom= */ 3), + isCapturedLinkAvailable = false) captionHandleRepository.notifyCaptionChanged(appHeaderCaptionState) diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationControllerTest.kt index 7dbadc9d9083..d94186c8284e 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationControllerTest.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationControllerTest.kt @@ -84,7 +84,7 @@ class AppHandleEducationControllerTest : ShellTestCase() { private val testDataStoreFlow = MutableStateFlow(createWindowingEducationProto()) private val testCaptionStateFlow = MutableStateFlow<CaptionState>(CaptionState.NoCaption) private val educationConfigCaptor = - argumentCaptor<DesktopWindowingEducationTooltipController.EducationViewConfig>() + argumentCaptor<DesktopWindowingEducationTooltipController.TooltipEducationViewConfig>() @Mock private lateinit var mockEducationFilter: AppHandleEducationFilter @Mock private lateinit var mockDataStoreRepository: AppHandleEducationDatastoreRepository @Mock private lateinit var mockCaptionHandleRepository: WindowDecorCaptionHandleRepository diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/util/WindowingEducationTestUtils.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/util/WindowingEducationTestUtils.kt index 708fadb7fbe2..99e82959fcd6 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/util/WindowingEducationTestUtils.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/util/WindowingEducationTestUtils.kt @@ -32,11 +32,13 @@ fun createAppHandleState( runningTaskInfo: RunningTaskInfo = createTaskInfo(), isHandleMenuExpanded: Boolean = false, globalAppHandleBounds: Rect = Rect(), + isCapturedLinkAvailable: Boolean = false ): CaptionState.AppHandle = CaptionState.AppHandle( runningTaskInfo = runningTaskInfo, isHandleMenuExpanded = isHandleMenuExpanded, - globalAppHandleBounds = globalAppHandleBounds) + globalAppHandleBounds = globalAppHandleBounds, + isCapturedLinkAvailable = isCapturedLinkAvailable) /** * Create an instance of [CaptionState.AppHeader] with parameters as properties. @@ -47,11 +49,13 @@ fun createAppHeaderState( runningTaskInfo: RunningTaskInfo = createTaskInfo(), isHeaderMenuExpanded: Boolean = false, globalAppChipBounds: Rect = Rect(), + isCapturedLinkAvailable: Boolean = false ): CaptionState.AppHeader = CaptionState.AppHeader( runningTaskInfo = runningTaskInfo, isHeaderMenuExpanded = isHeaderMenuExpanded, - globalAppChipBounds = globalAppChipBounds) + globalAppChipBounds = globalAppChipBounds, + isCapturedLinkAvailable = isCapturedLinkAvailable) /** * Create an instance of [RunningTaskInfo] with parameters as properties. diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt index 36c5be1d7191..03aab18d8d87 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt @@ -95,6 +95,7 @@ import com.android.wm.shell.desktopmode.DesktopTasksController.SnapPosition import com.android.wm.shell.desktopmode.DesktopTasksLimiter import com.android.wm.shell.desktopmode.WindowDecorCaptionHandleRepository import com.android.wm.shell.desktopmode.education.AppHandleEducationController +import com.android.wm.shell.desktopmode.education.AppToWebEducationController import com.android.wm.shell.freeform.FreeformTaskTransitionStarter import com.android.wm.shell.shared.desktopmode.DesktopModeStatus import com.android.wm.shell.shared.desktopmode.DesktopModeTransitionSource @@ -195,6 +196,7 @@ class DesktopModeWindowDecorViewModelTests : ShellTestCase() { DesktopModeWindowDecorViewModel.TaskPositionerFactory @Mock private lateinit var mockTaskPositioner: TaskPositioner @Mock private lateinit var mockAppHandleEducationController: AppHandleEducationController + @Mock private lateinit var mockAppToWebEducationController: AppToWebEducationController @Mock private lateinit var mockFocusTransitionObserver: FocusTransitionObserver @Mock private lateinit var mockCaptionHandleRepository: WindowDecorCaptionHandleRepository @Mock private lateinit var motionEvent: MotionEvent @@ -261,6 +263,7 @@ class DesktopModeWindowDecorViewModelTests : ShellTestCase() { mockInteractionJankMonitor, Optional.of(mockTasksLimiter), mockAppHandleEducationController, + mockAppToWebEducationController, mockCaptionHandleRepository, Optional.of(mockActivityOrientationChangeHandler), mockTaskPositionerFactory, diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java index f6fed29bbbe8..86ec6753a1bd 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java @@ -1299,7 +1299,8 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { } @Test - @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_APP_HANDLE_EDUCATION) + @DisableFlags({Flags.FLAG_ENABLE_DESKTOP_WINDOWING_APP_HANDLE_EDUCATION, + Flags.FLAG_ENABLE_DESKTOP_WINDOWING_APP_TO_WEB_EDUCATION}) public void notifyCaptionStateChanged_flagDisabled_doNoNotify() { when(DesktopModeStatus.canEnterDesktopMode(mContext)).thenReturn(true); final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true); diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipControllerTest.kt index 741dfb8dd885..15f2c7be38b7 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipControllerTest.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipControllerTest.kt @@ -285,7 +285,7 @@ class DesktopWindowingEducationTooltipControllerTest : ShellTestCase() { onEducationClickAction: () -> Unit = {}, onDismissAction: () -> Unit = {} ) = - DesktopWindowingEducationTooltipController.EducationViewConfig( + DesktopWindowingEducationTooltipController.TooltipEducationViewConfig( tooltipViewLayout = tooltipViewLayout, tooltipColorScheme = tooltipColorScheme, tooltipViewGlobalCoordinates = tooltipViewGlobalCoordinates, |