/* * Copyright (C) 2023 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.launcher3.taskbar import android.content.Intent import android.net.Uri import android.os.Bundle import android.text.SpannableString import android.text.method.LinkMovementMethod import android.text.style.URLSpan import android.view.Gravity import android.view.View import android.view.View.GONE import android.view.View.VISIBLE import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.MarginLayoutParams import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import android.widget.TextView import androidx.annotation.IntDef import androidx.annotation.LayoutRes import androidx.annotation.VisibleForTesting import androidx.core.text.HtmlCompat import androidx.core.view.updateLayoutParams import com.airbnb.lottie.LottieAnimationView import com.android.launcher3.Flags.refactorTaskbarUiState import com.android.launcher3.LauncherPrefs import com.android.launcher3.R import com.android.launcher3.RemoveAnimationSettingsTracker import com.android.launcher3.Utilities import com.android.launcher3.config.FeatureFlags.enableTaskbarPinning import com.android.launcher3.taskbar.TaskbarAutohideSuspendController.FLAG_AUTOHIDE_SUSPEND_EDU_OPEN import com.android.launcher3.taskbar.TaskbarControllers.LoggableTaskbarController import com.android.launcher3.util.OnboardingPrefs.TASKBAR_EDU_TOOLTIP_STEP import com.android.launcher3.util.OnboardingPrefs.TASKBAR_SEARCH_EDU_SEEN import com.android.launcher3.views.ActivityContext import com.android.launcher3.views.BaseDragLayer import com.android.quickstep.util.ContextualSearchInvoker import com.android.quickstep.util.LottieAnimationColorUtils import com.android.wm.shell.shared.TypefaceUtils import com.android.wm.shell.shared.TypefaceUtils.FontFamily import java.io.PrintWriter import javax.inject.Inject /** First EDU step for swiping up to show transient Taskbar. */ const val TOOLTIP_STEP_SWIPE = 0 /** Second EDU step for explaining Taskbar functionality when unstashed. */ const val TOOLTIP_STEP_FEATURES = 1 /** Third EDU step for explaining Taskbar pinning. */ const val TOOLTIP_STEP_PINNING = 2 /** * EDU is completed. * * This value should match the maximum count for [TASKBAR_EDU_TOOLTIP_STEP]. */ const val TOOLTIP_STEP_NONE = 3 /** The base URL for the Privacy Policy that will later be localized. */ private const val PRIVACY_POLICY_BASE_URL = "https://policies.google.com/privacy/embedded?hl=" /** The base URL for the Terms of Service that will later be localized. */ private const val TOS_BASE_URL = "https://policies.google.com/terms?hl=" /** Current step in the tooltip EDU flow. */ @Retention(AnnotationRetention.SOURCE) @IntDef(TOOLTIP_STEP_SWIPE, TOOLTIP_STEP_FEATURES, TOOLTIP_STEP_PINNING, TOOLTIP_STEP_NONE) annotation class TaskbarEduTooltipStep /** Controls stepping through the Taskbar tooltip EDU. */ open class TaskbarEduTooltipController @Inject constructor( activityContext: ActivityContext, private val removeAnimationSettingsTracker: RemoveAnimationSettingsTracker, ) : LoggableTaskbarController { protected val activityContext = activityContext as TaskbarActivityContext open val shouldShowSearchEdu: Boolean get() = ContextualSearchInvoker(activityContext) .runContextualSearchInvocationChecksAndLogFailures() private val isTooltipEnabled: Boolean get() { return !Utilities.isRunningInTestHarness() && !activityContext.isPhoneMode && !activityContext.isTinyTaskbar } val isTooltipOpen: Boolean get() = tooltip?.isOpen ?: false val isBeforeTooltipFeaturesStep: Boolean get() = isTooltipEnabled && tooltipStep <= TOOLTIP_STEP_FEATURES private lateinit var controllers: TaskbarControllers private lateinit var taskbarUIState: TaskbarUiState // Keep track of whether the user has seen the Search Edu @VisibleForTesting var userHasSeenSearchEdu: Boolean get() { return TASKBAR_SEARCH_EDU_SEEN.get(activityContext) } private set(seen) { LauncherPrefs.get(activityContext).put(TASKBAR_SEARCH_EDU_SEEN, seen) } @TaskbarEduTooltipStep var tooltipStep: Int get() { return TASKBAR_EDU_TOOLTIP_STEP.get(activityContext) } private set(step) { TASKBAR_EDU_TOOLTIP_STEP.set(step, activityContext) updateShouldShowEduOnAppLaunch() } private var tooltip: TaskbarEduTooltip? = null fun init(controllers: TaskbarControllers, taskbarUiState: TaskbarUiState) { this.controllers = controllers this.taskbarUIState = taskbarUiState updateShouldShowEduOnAppLaunch() // We want to show the Search Edu right after pinning the taskbar, so we post it here activityContext.dragLayer.post { maybeShowSearchEdu() } } /** * Should be called whenever [TaskbarUIController], [DeviceProfile] or * "taskbar_edu_tooltip_step" counter is changed. */ fun updateShouldShowEduOnAppLaunch() { if (!refactorTaskbarUiState()) { return } val uiController = controllers.uiController if (uiController is LauncherTaskbarUIController) { taskbarUIState.setShouldShowEduOnAppLaunch(uiController.shouldShowEduOnAppLaunch()) } } /** * Turns off auto play of lottie animations if user has opted to remove animation else attaches * click listener to allow user to play or pause animations. */ fun handleEduAnimations(animationViews: List) { for (animationView in animationViews) { if (removeAnimationSettingsTracker.isRemoveAnimationEnabled()) { animationView.pauseAnimation() } else { animationView.setOnClickListener { if (animationView.isAnimating) animationView.pauseAnimation() else animationView.playAnimation() } } } } /** Shows swipe EDU tooltip if it is the current [tooltipStep]. */ fun maybeShowSwipeEdu() { if ( !isTooltipEnabled || !activityContext.isTransientTaskbar || tooltipStep > TOOLTIP_STEP_SWIPE ) { return } tooltipStep = TOOLTIP_STEP_FEATURES inflateTooltip(R.layout.taskbar_edu_swipe) tooltip?.run { TypefaceUtils.setTypeface( requireViewById(R.id.taskbar_edu_title), FontFamily.GSF_HEADLINE_SMALL_EMPHASIZED, ) val swipeAnimation = requireViewById(R.id.swipe_animation) swipeAnimation.contentDescription = context.getString(R.string.taskbar_edu_swipe_animation_description) swipeAnimation.supportLightTheme() handleEduAnimations(listOf(swipeAnimation)) show() } } /** * Shows feature EDU tooltip if this step has not been seen. * * If [TOOLTIP_STEP_SWIPE] has not been seen at this point, the first step is skipped because a * swipe up is necessary to show this step. */ fun maybeShowFeaturesEdu() { if (!isTooltipEnabled || tooltipStep > TOOLTIP_STEP_FEATURES) { maybeShowPinningEdu() maybeShowSearchEdu() return } tooltipStep = TOOLTIP_STEP_NONE inflateTooltip(R.layout.taskbar_edu_features) tooltip?.run { allowTouchDismissal = false val splitscreenAnim = requireViewById(R.id.splitscreen_animation) splitscreenAnim.contentDescription = context.getString(R.string.taskbar_edu_split_screen_animation_description) val suggestionsAnim = requireViewById(R.id.suggestions_animation) suggestionsAnim.contentDescription = context.getString(R.string.taskbar_edu_suggested_app_animation_description) val pinningAnim = requireViewById(R.id.pinning_animation) pinningAnim.contentDescription = context.getString(R.string.taskbar_edu_pinning_animation_description) val pinningEdu = requireViewById(R.id.pinning_edu) splitscreenAnim.supportLightTheme() suggestionsAnim.supportLightTheme() pinningAnim.supportLightTheme() handleEduAnimations(listOf(splitscreenAnim, suggestionsAnim, pinningAnim)) if (activityContext.isTransientTaskbar) { splitscreenAnim.setAnimation(R.raw.taskbar_edu_splitscreen_transient) suggestionsAnim.setAnimation(R.raw.taskbar_edu_suggestions_transient) pinningEdu.visibility = if (enableTaskbarPinning()) VISIBLE else GONE } else { splitscreenAnim.setAnimation(R.raw.taskbar_edu_splitscreen_persistent) suggestionsAnim.setAnimation(R.raw.taskbar_edu_suggestions_persistent) pinningEdu.visibility = GONE } TypefaceUtils.setTypeface( requireViewById(R.id.taskbar_edu_title), FontFamily.GSF_HEADLINE_SMALL_EMPHASIZED, ) TypefaceUtils.setTypeface( requireViewById(R.id.splitscreen_text), FontFamily.GSF_BODY_MEDIUM, ) TypefaceUtils.setTypeface( requireViewById(R.id.suggestions_text), FontFamily.GSF_BODY_MEDIUM, ) TypefaceUtils.setTypeface( requireViewById(R.id.pinning_text), FontFamily.GSF_BODY_MEDIUM, ) // Set up layout parameters. content.updateLayoutParams { width = MATCH_PARENT } updateLayoutParams { if (activityContext.isTransientTaskbar) { width = resources.getDimensionPixelSize( if (enableTaskbarPinning()) R.dimen.taskbar_edu_features_tooltip_width_with_three_features else R.dimen.taskbar_edu_features_tooltip_width_with_two_features ) bottomMargin += activityContext.deviceProfile.taskbarProfile.height } else { width = resources.getDimensionPixelSize( R.dimen.taskbar_edu_features_tooltip_width_with_two_features ) } } findViewById(R.id.done_button)?.setOnClickListener { hide() } show() } } /** * Shows standalone Pinning EDU tooltip if this EDU has not been seen. * * We show this standalone edu if users have seen the previous version of taskbar education, * which did not include the pinning feature. */ private fun maybeShowPinningEdu() { // use old value of tooltipStep that was set to the previous value of TOOLTIP_STEP_NONE (2 // for the original 2 edu steps) as a proxy to needing to show the separate pinning edu if ( !enableTaskbarPinning() || !activityContext.isTransientTaskbar || !isTooltipEnabled || tooltipStep > TOOLTIP_STEP_PINNING || tooltipStep < TOOLTIP_STEP_FEATURES ) { return } tooltipStep = TOOLTIP_STEP_NONE inflateTooltip(R.layout.taskbar_edu_pinning) tooltip?.run { allowTouchDismissal = true TypefaceUtils.setTypeface( requireViewById(R.id.taskbar_edu_title), FontFamily.GSF_HEADLINE_SMALL_EMPHASIZED, ) TypefaceUtils.setTypeface( requireViewById(R.id.pinning_text), FontFamily.GSF_BODY_MEDIUM, ) val pinningAnim = requireViewById(R.id.standalone_pinning_animation) pinningAnim.contentDescription = context.getString(R.string.taskbar_edu_pinning_animation_description) pinningAnim.supportLightTheme() handleEduAnimations(listOf(pinningAnim)) updateLayoutParams { if (activityContext.isTransientTaskbar) { bottomMargin += activityContext.deviceProfile.taskbarProfile.height } // Unlike other tooltips, we want to align with taskbar divider rather than center. gravity = Gravity.BOTTOM marginStart = 0 width = resources.getDimensionPixelSize( R.dimen.taskbar_edu_features_tooltip_width_with_one_feature ) } // Calculate the amount the tooltip must be shifted by to align with the taskbar divider val taskbarDividerView = controllers.taskbarViewController.taskbarDividerView val dividerLocation = taskbarDividerView.x + taskbarDividerView.width / 2 x = dividerLocation - layoutParams.width / 2 show() } } /** * Shows standalone Search EDU tooltip if this EDU has not been seen. * * We show this standalone edu for users to learn to how to trigger Search from the pinned * taskbar */ fun maybeShowSearchEdu() { if ( !enableTaskbarPinning() || !activityContext.isPinnedTaskbar || !isTooltipEnabled || !shouldShowSearchEdu || userHasSeenSearchEdu || !controllers.taskbarStashController.isTaskbarVisibleAndNotStashing ) { return } userHasSeenSearchEdu = true inflateTooltip(R.layout.taskbar_edu_search) tooltip?.run { allowTouchDismissal = true val searchEdu = requireViewById(R.id.search_edu_animation) searchEdu.contentDescription = context.getString(R.string.taskbar_edu_suggested_search_animation_description) searchEdu.supportLightTheme() handleEduAnimations(listOf(searchEdu)) val eduSubtitle: TextView = requireViewById(R.id.search_edu_text) TypefaceUtils.setTypeface( requireViewById(R.id.taskbar_edu_title), FontFamily.GSF_HEADLINE_SMALL_EMPHASIZED, ) TypefaceUtils.setTypeface(eduSubtitle, FontFamily.GSF_BODY_SMALL) showDisclosureText(eduSubtitle) updateLayoutParams { if (activityContext.isTransientTaskbar) { bottomMargin += activityContext.deviceProfile.taskbarProfile.height } // Unlike other tooltips, we want to align with the all apps button rather than // center. gravity = Gravity.BOTTOM marginStart = 0 width = resources.getDimensionPixelSize( R.dimen.taskbar_edu_features_tooltip_width_with_one_feature ) } // Calculate the amount the tooltip must be shifted by to align with the action key val allAppsButtonView = controllers.taskbarViewController.allAppsButtonView if (allAppsButtonView != null) { val allAppsIconLocation = allAppsButtonView.x + allAppsButtonView.width / 2 x = allAppsIconLocation - layoutParams.width / 2 } show() } } /** * Set up the provided TextView to display legal disclosures. The method takes locale into * account to show the appropriate links to regional disclosures. */ private fun TaskbarEduTooltip.showDisclosureText( textView: TextView, stringId: Int = R.string.taskbar_edu_search_disclosure, ) { val locale = resources.configuration.locales[0] val text = SpannableString( HtmlCompat.fromHtml( resources.getString( stringId, PRIVACY_POLICY_BASE_URL + locale.language, TOS_BASE_URL + locale.language, ), HtmlCompat.FROM_HTML_MODE_COMPACT, ) ) // Directly process URLSpan clicks text.getSpans(0, text.length, URLSpan::class.java).forEach { urlSpan -> val url: URLSpan = object : URLSpan(urlSpan.url) { override fun onClick(widget: View) { val uri = Uri.parse(urlSpan.url) val context = widget.context val intent = Intent(Intent.ACTION_VIEW, uri).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } } val spanStart = text.getSpanStart(urlSpan) val spanEnd = text.getSpanEnd(urlSpan) val spanFlags = text.getSpanFlags(urlSpan) text.removeSpan(urlSpan) text.setSpan(url, spanStart, spanEnd, spanFlags) } textView.text = text textView.movementMethod = LinkMovementMethod.getInstance() } /** Closes the current [tooltip]. */ fun hide() { tooltip?.close(true) } /** Initializes [tooltip] with content from [contentResId]. */ private fun inflateTooltip(@LayoutRes contentResId: Int) { val overlayContext = controllers.taskbarOverlayController.requestWindow() val tooltip = overlayContext.layoutInflater.inflate( R.layout.taskbar_edu_tooltip, overlayContext.dragLayer, false, ) as TaskbarEduTooltip controllers.taskbarAutohideSuspendController.updateFlag( FLAG_AUTOHIDE_SUSPEND_EDU_OPEN, true, ) tooltip.onCloseCallback = { this.tooltip = null controllers.taskbarAutohideSuspendController.updateFlag( FLAG_AUTOHIDE_SUSPEND_EDU_OPEN, false, ) controllers.taskbarStashController.updateAndAnimateTransientTaskbar(true) } tooltip.accessibilityDelegate = createAccessibilityDelegate() overlayContext.layoutInflater.inflate(contentResId, tooltip.content, true) this.tooltip = tooltip } private fun createAccessibilityDelegate() = object : View.AccessibilityDelegate() { override fun performAccessibilityAction( host: View, action: Int, args: Bundle?, ): Boolean { if (action == R.id.close) { hide() return true } return super.performAccessibilityAction(host, action, args) } override fun onPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) { super.onPopulateAccessibilityEvent(host, event) if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { event.text.add(host.context?.getText(R.string.taskbar_edu_a11y_title)) } } override fun onInitializeAccessibilityNodeInfo( host: View, info: AccessibilityNodeInfo, ) { super.onInitializeAccessibilityNodeInfo(host, info) info.addAction( AccessibilityNodeInfo.AccessibilityAction( R.id.close, host.context?.getText(R.string.taskbar_edu_close), ) ) } } override fun dumpLogs(prefix: String?, pw: PrintWriter?) { pw?.println(prefix + "TaskbarEduTooltipController:") pw?.println("$prefix\tisTooltipEnabled=$isTooltipEnabled") pw?.println("$prefix\tisOpen=$isTooltipOpen") pw?.println("$prefix\ttooltipStep=$tooltipStep") } companion object { @JvmStatic fun newInstance(activityContext: ActivityContext): TaskbarEduTooltipController = activityContext.activityComponent.createTaskbarEduTooltipController() } } /** * Maps colors in the dark-themed Lottie assets to their light-themed equivalents. * * For instance, `".blue100" to R.color.lottie_blue400` means objects that are material blue100 in * dark theme should be changed to material blue400 in light theme. */ private val DARK_TO_LIGHT_COLORS = mapOf( ".blue100" to R.color.lottie_blue400, ".blue400" to R.color.lottie_blue600, ".green100" to R.color.lottie_green400, ".green400" to R.color.lottie_green600, ".grey300" to R.color.lottie_grey600, ".grey400" to R.color.lottie_grey700, ".grey800" to R.color.lottie_grey200, ".red400" to R.color.lottie_red600, ".yellow100" to R.color.lottie_yellow400, ".yellow400" to R.color.lottie_yellow600, ) private fun LottieAnimationView.supportLightTheme() { if (Utilities.isDarkTheme(context)) { return } LottieAnimationColorUtils.updateToColorResources(this, DARK_TO_LIGHT_COLORS, context.theme) }