1 /*
<lambda>null2 * Copyright (C) 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16 package com.android.launcher3.taskbar
17
18 import android.content.Context
19 import android.content.Intent
20 import android.net.Uri
21 import android.os.Bundle
22 import android.text.SpannableString
23 import android.text.method.LinkMovementMethod
24 import android.text.style.URLSpan
25 import android.view.Gravity
26 import android.view.View
27 import android.view.View.GONE
28 import android.view.View.VISIBLE
29 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
30 import android.view.ViewGroup.MarginLayoutParams
31 import android.view.accessibility.AccessibilityEvent
32 import android.view.accessibility.AccessibilityNodeInfo
33 import android.widget.TextView
34 import androidx.annotation.IntDef
35 import androidx.annotation.LayoutRes
36 import androidx.core.text.HtmlCompat
37 import androidx.core.view.updateLayoutParams
38 import com.airbnb.lottie.LottieAnimationView
39 import com.android.launcher3.LauncherPrefs
40 import com.android.launcher3.R
41 import com.android.launcher3.Utilities
42 import com.android.launcher3.config.FeatureFlags.enableTaskbarPinning
43 import com.android.launcher3.taskbar.TaskbarAutohideSuspendController.FLAG_AUTOHIDE_SUSPEND_EDU_OPEN
44 import com.android.launcher3.taskbar.TaskbarControllers.LoggableTaskbarController
45 import com.android.launcher3.util.DisplayController
46 import com.android.launcher3.util.OnboardingPrefs.TASKBAR_EDU_TOOLTIP_STEP
47 import com.android.launcher3.util.OnboardingPrefs.TASKBAR_SEARCH_EDU_SEEN
48 import com.android.launcher3.util.ResourceBasedOverride
49 import com.android.launcher3.views.ActivityContext
50 import com.android.launcher3.views.BaseDragLayer
51 import com.android.quickstep.util.LottieAnimationColorUtils
52 import java.io.PrintWriter
53
54 /** First EDU step for swiping up to show transient Taskbar. */
55 const val TOOLTIP_STEP_SWIPE = 0
56 /** Second EDU step for explaining Taskbar functionality when unstashed. */
57 const val TOOLTIP_STEP_FEATURES = 1
58 /** Third EDU step for explaining Taskbar pinning. */
59 const val TOOLTIP_STEP_PINNING = 2
60
61 /**
62 * EDU is completed.
63 *
64 * This value should match the maximum count for [TASKBAR_EDU_TOOLTIP_STEP].
65 */
66 const val TOOLTIP_STEP_NONE = 3
67 /** The base URL for the Privacy Policy that will later be localized. */
68 private const val PRIVACY_POLICY_BASE_URL = "https://policies.google.com/privacy/embedded?hl="
69 /** The base URL for the Terms of Service that will later be localized. */
70 private const val TOS_BASE_URL = "https://policies.google.com/terms?hl="
71
72 /** Current step in the tooltip EDU flow. */
73 @Retention(AnnotationRetention.SOURCE)
74 @IntDef(TOOLTIP_STEP_SWIPE, TOOLTIP_STEP_FEATURES, TOOLTIP_STEP_PINNING, TOOLTIP_STEP_NONE)
75 annotation class TaskbarEduTooltipStep
76
77 /** Controls stepping through the Taskbar tooltip EDU. */
78 open class TaskbarEduTooltipController(context: Context) :
79 ResourceBasedOverride, LoggableTaskbarController {
80
81 protected val activityContext: TaskbarActivityContext = ActivityContext.lookupContext(context)
82 open val shouldShowSearchEdu = false
83 private val isTooltipEnabled: Boolean
84 get() {
85 return !Utilities.isRunningInTestHarness() &&
86 !activityContext.isPhoneMode &&
87 !activityContext.isTinyTaskbar
88 }
89
90 private val isOpen: Boolean
91 get() = tooltip?.isOpen ?: false
92
93 val isBeforeTooltipFeaturesStep: Boolean
94 get() = isTooltipEnabled && tooltipStep <= TOOLTIP_STEP_FEATURES
95
96 private lateinit var controllers: TaskbarControllers
97
98 // Keep track of whether the user has seen the Search Edu
99 private var userHasSeenSearchEdu: Boolean
100 get() {
101 return TASKBAR_SEARCH_EDU_SEEN.get(activityContext)
102 }
103 private set(seen) {
104 LauncherPrefs.get(activityContext).put(TASKBAR_SEARCH_EDU_SEEN, seen)
105 }
106
107 @TaskbarEduTooltipStep
108 var tooltipStep: Int
109 get() {
110 return TASKBAR_EDU_TOOLTIP_STEP.get(activityContext)
111 }
112 private set(step) {
113 TASKBAR_EDU_TOOLTIP_STEP.set(step, activityContext)
114 }
115
116 private var tooltip: TaskbarEduTooltip? = null
117
118 fun init(controllers: TaskbarControllers) {
119 this.controllers = controllers
120 // We want to show the Search Edu right after pinning the taskbar, so we post it here
121 activityContext.dragLayer.post { maybeShowSearchEdu() }
122 }
123
124 /** Shows swipe EDU tooltip if it is the current [tooltipStep]. */
125 fun maybeShowSwipeEdu() {
126 if (
127 !isTooltipEnabled ||
128 !DisplayController.isTransientTaskbar(activityContext) ||
129 tooltipStep > TOOLTIP_STEP_SWIPE
130 ) {
131 return
132 }
133
134 tooltipStep = TOOLTIP_STEP_FEATURES
135 inflateTooltip(R.layout.taskbar_edu_swipe)
136 tooltip?.run {
137 requireViewById<LottieAnimationView>(R.id.swipe_animation).supportLightTheme()
138 show()
139 }
140 }
141
142 /**
143 * Shows feature EDU tooltip if this step has not been seen.
144 *
145 * If [TOOLTIP_STEP_SWIPE] has not been seen at this point, the first step is skipped because a
146 * swipe up is necessary to show this step.
147 */
148 fun maybeShowFeaturesEdu() {
149 if (!isTooltipEnabled || tooltipStep > TOOLTIP_STEP_FEATURES) {
150 maybeShowPinningEdu()
151 maybeShowSearchEdu()
152 return
153 }
154
155 tooltipStep = TOOLTIP_STEP_NONE
156 inflateTooltip(R.layout.taskbar_edu_features)
157 tooltip?.run {
158 allowTouchDismissal = false
159 val splitscreenAnim = requireViewById<LottieAnimationView>(R.id.splitscreen_animation)
160 val suggestionsAnim = requireViewById<LottieAnimationView>(R.id.suggestions_animation)
161 val pinningAnim = requireViewById<LottieAnimationView>(R.id.pinning_animation)
162 val pinningEdu = requireViewById<View>(R.id.pinning_edu)
163 splitscreenAnim.supportLightTheme()
164 suggestionsAnim.supportLightTheme()
165 pinningAnim.supportLightTheme()
166 if (DisplayController.isTransientTaskbar(activityContext)) {
167 splitscreenAnim.setAnimation(R.raw.taskbar_edu_splitscreen_transient)
168 suggestionsAnim.setAnimation(R.raw.taskbar_edu_suggestions_transient)
169 pinningEdu.visibility = if (enableTaskbarPinning()) VISIBLE else GONE
170 } else {
171 splitscreenAnim.setAnimation(R.raw.taskbar_edu_splitscreen_persistent)
172 suggestionsAnim.setAnimation(R.raw.taskbar_edu_suggestions_persistent)
173 pinningEdu.visibility = GONE
174 }
175
176 // Set up layout parameters.
177 content.updateLayoutParams { width = MATCH_PARENT }
178 updateLayoutParams<MarginLayoutParams> {
179 if (DisplayController.isTransientTaskbar(activityContext)) {
180 width =
181 resources.getDimensionPixelSize(
182 if (enableTaskbarPinning())
183 R.dimen.taskbar_edu_features_tooltip_width_with_three_features
184 else R.dimen.taskbar_edu_features_tooltip_width_with_two_features
185 )
186
187 bottomMargin += activityContext.deviceProfile.taskbarHeight
188 } else {
189 width =
190 resources.getDimensionPixelSize(
191 R.dimen.taskbar_edu_features_tooltip_width_with_two_features
192 )
193 }
194 }
195
196 findViewById<View>(R.id.done_button)?.setOnClickListener { hide() }
197 show()
198 }
199 }
200
201 /**
202 * Shows standalone Pinning EDU tooltip if this EDU has not been seen.
203 *
204 * We show this standalone edu if users have seen the previous version of taskbar education,
205 * which did not include the pinning feature.
206 */
207 private fun maybeShowPinningEdu() {
208 // use old value of tooltipStep that was set to the previous value of TOOLTIP_STEP_NONE (2
209 // for the original 2 edu steps) as a proxy to needing to show the separate pinning edu
210 if (
211 !enableTaskbarPinning() ||
212 !DisplayController.isTransientTaskbar(activityContext) ||
213 !isTooltipEnabled ||
214 tooltipStep > TOOLTIP_STEP_PINNING ||
215 tooltipStep < TOOLTIP_STEP_FEATURES
216 ) {
217 return
218 }
219 tooltipStep = TOOLTIP_STEP_NONE
220 inflateTooltip(R.layout.taskbar_edu_pinning)
221
222 tooltip?.run {
223 allowTouchDismissal = true
224 requireViewById<LottieAnimationView>(R.id.standalone_pinning_animation)
225 .supportLightTheme()
226
227 updateLayoutParams<BaseDragLayer.LayoutParams> {
228 if (DisplayController.isTransientTaskbar(activityContext)) {
229 bottomMargin += activityContext.deviceProfile.taskbarHeight
230 }
231 // Unlike other tooltips, we want to align with taskbar divider rather than center.
232 gravity = Gravity.BOTTOM
233 marginStart = 0
234 width =
235 resources.getDimensionPixelSize(
236 R.dimen.taskbar_edu_features_tooltip_width_with_one_feature
237 )
238 }
239
240 // Calculate the amount the tooltip must be shifted by to align with the taskbar divider
241 val taskbarDividerView = controllers.taskbarViewController.taskbarDividerView
242 val dividerLocation = taskbarDividerView.x + taskbarDividerView.width / 2
243 x = dividerLocation - layoutParams.width / 2
244
245 show()
246 }
247 }
248
249 /**
250 * Shows standalone Search EDU tooltip if this EDU has not been seen.
251 *
252 * We show this standalone edu for users to learn to how to trigger Search from the pinned
253 * taskbar
254 */
255 fun maybeShowSearchEdu() {
256 if (
257 !enableTaskbarPinning() ||
258 !DisplayController.isPinnedTaskbar(activityContext) ||
259 !isTooltipEnabled ||
260 !shouldShowSearchEdu ||
261 userHasSeenSearchEdu
262 ) {
263 return
264 }
265 userHasSeenSearchEdu = true
266 inflateTooltip(R.layout.taskbar_edu_search)
267 tooltip?.run {
268 allowTouchDismissal = true
269 requireViewById<LottieAnimationView>(R.id.search_edu_animation).supportLightTheme()
270 val eduSubtitle: TextView = requireViewById(R.id.search_edu_text)
271 showDisclosureText(eduSubtitle)
272 updateLayoutParams<BaseDragLayer.LayoutParams> {
273 if (DisplayController.isTransientTaskbar(activityContext)) {
274 bottomMargin += activityContext.deviceProfile.taskbarHeight
275 }
276 // Unlike other tooltips, we want to align with the all apps button rather than
277 // center.
278 gravity = Gravity.BOTTOM
279 marginStart = 0
280 width =
281 resources.getDimensionPixelSize(
282 R.dimen.taskbar_edu_features_tooltip_width_with_one_feature
283 )
284 }
285
286 // Calculate the amount the tooltip must be shifted by to align with the action key
287 val allAppsButtonView = controllers.taskbarViewController.allAppsButtonView
288 if (allAppsButtonView != null) {
289 val allAppsIconLocation = allAppsButtonView.x + allAppsButtonView.width / 2
290 x = allAppsIconLocation - layoutParams.width / 2
291 }
292
293 show()
294 }
295 }
296
297 /**
298 * Set up the provided TextView to display legal disclosures. The method takes locale into
299 * account to show the appropriate links to regional disclosures.
300 */
301 private fun TaskbarEduTooltip.showDisclosureText(
302 textView: TextView,
303 stringId: Int = R.string.taskbar_edu_search_disclosure,
304 ) {
305 val locale = resources.configuration.locales[0]
306 val text =
307 SpannableString(
308 HtmlCompat.fromHtml(
309 resources.getString(
310 stringId,
311 PRIVACY_POLICY_BASE_URL + locale.language,
312 TOS_BASE_URL + locale.language,
313 ),
314 HtmlCompat.FROM_HTML_MODE_COMPACT,
315 )
316 )
317 // Directly process URLSpan clicks
318 text.getSpans(0, text.length, URLSpan::class.java).forEach { urlSpan ->
319 val url: URLSpan =
320 object : URLSpan(urlSpan.url) {
321 override fun onClick(widget: View) {
322 val uri = Uri.parse(urlSpan.url)
323 val context = widget.context
324 val intent =
325 Intent(Intent.ACTION_VIEW, uri).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
326 context.startActivity(intent)
327 }
328 }
329
330 val spanStart = text.getSpanStart(urlSpan)
331 val spanEnd = text.getSpanEnd(urlSpan)
332 val spanFlags = text.getSpanFlags(urlSpan)
333 text.removeSpan(urlSpan)
334 text.setSpan(url, spanStart, spanEnd, spanFlags)
335 }
336 textView.text = text
337 textView.movementMethod = LinkMovementMethod.getInstance()
338 }
339
340 /** Closes the current [tooltip]. */
341 fun hide() {
342 tooltip?.close(true)
343 }
344
345 /** Initializes [tooltip] with content from [contentResId]. */
346 private fun inflateTooltip(@LayoutRes contentResId: Int) {
347 val overlayContext = controllers.taskbarOverlayController.requestWindow()
348 val tooltip =
349 overlayContext.layoutInflater.inflate(
350 R.layout.taskbar_edu_tooltip,
351 overlayContext.dragLayer,
352 false
353 ) as TaskbarEduTooltip
354
355 controllers.taskbarAutohideSuspendController.updateFlag(
356 FLAG_AUTOHIDE_SUSPEND_EDU_OPEN,
357 true
358 )
359
360 tooltip.onCloseCallback = {
361 this.tooltip = null
362 controllers.taskbarAutohideSuspendController.updateFlag(
363 FLAG_AUTOHIDE_SUSPEND_EDU_OPEN,
364 false
365 )
366 controllers.taskbarStashController.updateAndAnimateTransientTaskbar(true)
367 }
368 tooltip.accessibilityDelegate = createAccessibilityDelegate()
369
370 overlayContext.layoutInflater.inflate(contentResId, tooltip.content, true)
371 this.tooltip = tooltip
372 }
373
374 private fun createAccessibilityDelegate() =
375 object : View.AccessibilityDelegate() {
376 override fun performAccessibilityAction(
377 host: View,
378 action: Int,
379 args: Bundle?
380 ): Boolean {
381 if (action == R.id.close) {
382 hide()
383 return true
384 }
385 return super.performAccessibilityAction(host, action, args)
386 }
387
388 override fun onPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) {
389 super.onPopulateAccessibilityEvent(host, event)
390 if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
391 event.text.add(host.context?.getText(R.string.taskbar_edu_a11y_title))
392 }
393 }
394
395 override fun onInitializeAccessibilityNodeInfo(
396 host: View,
397 info: AccessibilityNodeInfo
398 ) {
399 super.onInitializeAccessibilityNodeInfo(host, info)
400 info.addAction(
401 AccessibilityNodeInfo.AccessibilityAction(
402 R.id.close,
403 host.context?.getText(R.string.taskbar_edu_close)
404 )
405 )
406 }
407 }
408
409 override fun dumpLogs(prefix: String?, pw: PrintWriter?) {
410 pw?.println(prefix + "TaskbarEduTooltipController:")
411 pw?.println("$prefix\tisTooltipEnabled=$isTooltipEnabled")
412 pw?.println("$prefix\tisOpen=$isOpen")
413 pw?.println("$prefix\ttooltipStep=$tooltipStep")
414 }
415
416 companion object {
417 @JvmStatic
418 fun newInstance(context: Context): TaskbarEduTooltipController {
419 return ResourceBasedOverride.Overrides.getObject(
420 TaskbarEduTooltipController::class.java,
421 context,
422 R.string.taskbar_edu_tooltip_controller_class
423 )
424 }
425 }
426 }
427
428 /**
429 * Maps colors in the dark-themed Lottie assets to their light-themed equivalents.
430 *
431 * For instance, `".blue100" to R.color.lottie_blue400` means objects that are material blue100 in
432 * dark theme should be changed to material blue400 in light theme.
433 */
434 private val DARK_TO_LIGHT_COLORS =
435 mapOf(
436 ".blue100" to R.color.lottie_blue400,
437 ".blue400" to R.color.lottie_blue600,
438 ".green100" to R.color.lottie_green400,
439 ".green400" to R.color.lottie_green600,
440 ".grey300" to R.color.lottie_grey600,
441 ".grey400" to R.color.lottie_grey700,
442 ".grey800" to R.color.lottie_grey200,
443 ".red400" to R.color.lottie_red600,
444 ".yellow100" to R.color.lottie_yellow400,
445 ".yellow400" to R.color.lottie_yellow600,
446 )
447
LottieAnimationViewnull448 private fun LottieAnimationView.supportLightTheme() {
449 if (Utilities.isDarkTheme(context)) {
450 return
451 }
452
453 LottieAnimationColorUtils.updateToColorResources(this, DARK_TO_LIGHT_COLORS, context.theme)
454 }
455