1 /* 2 * Copyright (C) 2020 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.quickstep.interaction; 17 18 import static android.view.View.GONE; 19 20 import android.content.Context; 21 import android.content.pm.PackageManager; 22 import android.graphics.drawable.Animatable2; 23 import android.graphics.drawable.AnimatedVectorDrawable; 24 import android.graphics.drawable.ColorDrawable; 25 import android.graphics.drawable.Drawable; 26 import android.graphics.drawable.RippleDrawable; 27 import android.util.Log; 28 import android.view.View; 29 import android.view.ViewGroup; 30 import android.view.accessibility.AccessibilityEvent; 31 import android.widget.Button; 32 import android.widget.ImageView; 33 import android.widget.RelativeLayout; 34 import android.widget.TextView; 35 36 import androidx.annotation.CallSuper; 37 import androidx.annotation.DrawableRes; 38 import androidx.annotation.NonNull; 39 import androidx.annotation.Nullable; 40 import androidx.annotation.StringRes; 41 import androidx.appcompat.app.AlertDialog; 42 import androidx.appcompat.content.res.AppCompatResources; 43 44 import com.android.launcher3.R; 45 import com.android.launcher3.Utilities; 46 import com.android.launcher3.anim.AnimatorListeners; 47 import com.android.launcher3.views.ClipIconView; 48 import com.android.quickstep.interaction.EdgeBackGestureHandler.BackGestureAttemptCallback; 49 import com.android.quickstep.interaction.NavBarGestureHandler.NavBarGestureAttemptCallback; 50 51 abstract class TutorialController implements BackGestureAttemptCallback, 52 NavBarGestureAttemptCallback { 53 54 private static final String TAG = "TutorialController"; 55 56 private static final String PIXEL_TIPS_APP_PACKAGE_NAME = "com.google.android.apps.tips"; 57 private static final CharSequence DEFAULT_PIXEL_TIPS_APP_NAME = "Pixel Tips"; 58 59 private static final int FEEDBACK_ANIMATION_MS = 250; 60 private static final int RIPPLE_VISIBLE_MS = 300; 61 private static final int GESTURE_ANIMATION_DELAY_MS = 1500; 62 private static final int ADVANCE_TUTORIAL_TIMEOUT_MS = 4000; 63 64 final TutorialFragment mTutorialFragment; 65 TutorialType mTutorialType; 66 final Context mContext; 67 68 final TextView mCloseButton; 69 final ViewGroup mFeedbackView; 70 final TextView mFeedbackTitleView; 71 final ImageView mFeedbackVideoView; 72 final ImageView mGestureVideoView; 73 final RelativeLayout mFakeLauncherView; 74 final ImageView mFakeHotseatView; 75 final ClipIconView mFakeIconView; 76 final View mFakeTaskView; 77 final View mFakePreviousTaskView; 78 final View mRippleView; 79 final RippleDrawable mRippleDrawable; 80 final Button mActionButton; 81 final TutorialStepIndicator mTutorialStepView; 82 private final AlertDialog mSkipTutorialDialog; 83 84 protected boolean mGestureCompleted = false; 85 86 // These runnables should be used when posting callbacks to their views and cleared from their 87 // views before posting new callbacks. 88 private final Runnable mTitleViewCallback; 89 @Nullable private Runnable mFeedbackViewCallback; 90 @Nullable private Runnable mFeedbackVideoViewCallback; 91 TutorialController(TutorialFragment tutorialFragment, TutorialType tutorialType)92 TutorialController(TutorialFragment tutorialFragment, TutorialType tutorialType) { 93 mTutorialFragment = tutorialFragment; 94 mTutorialType = tutorialType; 95 mContext = mTutorialFragment.getContext(); 96 97 RootSandboxLayout rootView = tutorialFragment.getRootView(); 98 mCloseButton = rootView.findViewById(R.id.gesture_tutorial_fragment_close_button); 99 mCloseButton.setOnClickListener(button -> showSkipTutorialDialog()); 100 mFeedbackView = rootView.findViewById(R.id.gesture_tutorial_fragment_feedback_view); 101 mFeedbackTitleView = mFeedbackView.findViewById( 102 R.id.gesture_tutorial_fragment_feedback_title); 103 mFeedbackVideoView = rootView.findViewById(R.id.gesture_tutorial_feedback_video); 104 mGestureVideoView = rootView.findViewById(R.id.gesture_tutorial_gesture_video); 105 mFakeLauncherView = rootView.findViewById(R.id.gesture_tutorial_fake_launcher_view); 106 mFakeHotseatView = rootView.findViewById(R.id.gesture_tutorial_fake_hotseat_view); 107 mFakeIconView = rootView.findViewById(R.id.gesture_tutorial_fake_icon_view); 108 mFakeTaskView = rootView.findViewById(R.id.gesture_tutorial_fake_task_view); 109 mFakePreviousTaskView = 110 rootView.findViewById(R.id.gesture_tutorial_fake_previous_task_view); 111 mRippleView = rootView.findViewById(R.id.gesture_tutorial_ripple_view); 112 mRippleDrawable = (RippleDrawable) mRippleView.getBackground(); 113 mActionButton = rootView.findViewById(R.id.gesture_tutorial_fragment_action_button); 114 mTutorialStepView = 115 rootView.findViewById(R.id.gesture_tutorial_fragment_feedback_tutorial_step); 116 mSkipTutorialDialog = createSkipTutorialDialog(); 117 118 mTitleViewCallback = () -> mFeedbackTitleView.sendAccessibilityEvent( 119 AccessibilityEvent.TYPE_VIEW_FOCUSED); 120 } 121 showSkipTutorialDialog()122 private void showSkipTutorialDialog() { 123 if (mSkipTutorialDialog != null) { 124 mSkipTutorialDialog.show(); 125 } 126 } 127 setTutorialType(TutorialType tutorialType)128 void setTutorialType(TutorialType tutorialType) { 129 mTutorialType = tutorialType; 130 } 131 132 @DrawableRes getMockHotseatResId()133 protected int getMockHotseatResId() { 134 return R.drawable.default_sandbox_mock_launcher; 135 } 136 137 @DrawableRes getMockAppTaskThumbnailResId(boolean forDarkMode)138 protected int getMockAppTaskThumbnailResId(boolean forDarkMode) { 139 return R.drawable.default_sandbox_app_task_thumbnail; 140 } 141 142 @DrawableRes getMockPreviousAppTaskThumbnailResId()143 protected int getMockPreviousAppTaskThumbnailResId() { 144 return R.drawable.default_sandbox_app_previous_task_thumbnail; 145 } 146 147 @DrawableRes getMockAppIconResId()148 public int getMockAppIconResId() { 149 return R.drawable.default_sandbox_app_icon; 150 } 151 152 @DrawableRes getMockWallpaperResId()153 public int getMockWallpaperResId() { 154 return R.drawable.default_sandbox_wallpaper; 155 } 156 fadeTaskViewAndRun(Runnable r)157 void fadeTaskViewAndRun(Runnable r) { 158 mFakeTaskView.animate().alpha(0).setListener(AnimatorListeners.forSuccessCallback(r)); 159 } 160 161 @StringRes getIntroductionTitle()162 public Integer getIntroductionTitle() { 163 return null; 164 } 165 166 @StringRes getIntroductionSubtitle()167 public Integer getIntroductionSubtitle() { 168 return null; 169 } 170 showFeedback()171 void showFeedback() { 172 if (mGestureCompleted) { 173 mFeedbackView.setTranslationY(0); 174 return; 175 } 176 AnimatedVectorDrawable tutorialAnimation = mTutorialFragment.getTutorialAnimation(); 177 AnimatedVectorDrawable gestureAnimation = mTutorialFragment.getGestureAnimation(); 178 179 if (tutorialAnimation != null && gestureAnimation != null) { 180 TextView title = mFeedbackView.findViewById( 181 R.id.gesture_tutorial_fragment_feedback_title); 182 183 playFeedbackVideo(tutorialAnimation, gestureAnimation, () -> { 184 mFeedbackView.setTranslationY(0); 185 title.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED); 186 }, true); 187 } 188 } 189 190 /** 191 * Show feedback reflecting a failed gesture attempt. 192 * 193 * @param subtitleResId Resource of the text to display. 194 **/ showFeedback(int subtitleResId)195 void showFeedback(int subtitleResId) { 196 showFeedback(subtitleResId, false); 197 } 198 199 /** 200 * Show feedback reflecting the result of a gesture attempt. 201 * 202 * @param isGestureSuccessful Whether the tutorial feedback's action button should be shown. 203 **/ showFeedback(int subtitleResId, boolean isGestureSuccessful)204 void showFeedback(int subtitleResId, boolean isGestureSuccessful) { 205 showFeedback( 206 isGestureSuccessful 207 ? R.string.gesture_tutorial_nice : R.string.gesture_tutorial_try_again, 208 subtitleResId, 209 isGestureSuccessful, 210 false); 211 } 212 showFeedback( int titleResId, int subtitleResId, boolean isGestureSuccessful, boolean useGestureAnimationDelay)213 void showFeedback( 214 int titleResId, 215 int subtitleResId, 216 boolean isGestureSuccessful, 217 boolean useGestureAnimationDelay) { 218 mFeedbackTitleView.setText(titleResId); 219 mFeedbackTitleView.removeCallbacks(mTitleViewCallback); 220 TextView subtitle = 221 mFeedbackView.findViewById(R.id.gesture_tutorial_fragment_feedback_subtitle); 222 subtitle.setText(subtitleResId); 223 if (isGestureSuccessful) { 224 hideCloseButton(); 225 if (mTutorialFragment.isAtFinalStep()) { 226 showActionButton(); 227 } 228 229 if (mFeedbackVideoViewCallback != null) { 230 mFeedbackVideoView.removeCallbacks(mFeedbackVideoViewCallback); 231 mFeedbackVideoViewCallback = null; 232 } 233 } 234 mGestureCompleted = isGestureSuccessful; 235 236 AnimatedVectorDrawable tutorialAnimation = mTutorialFragment.getTutorialAnimation(); 237 AnimatedVectorDrawable gestureAnimation = mTutorialFragment.getGestureAnimation(); 238 if (tutorialAnimation != null && gestureAnimation != null) { 239 if (!isGestureSuccessful) { 240 playFeedbackVideo(tutorialAnimation, gestureAnimation, () -> { 241 mFeedbackView.setTranslationY( 242 -mFeedbackView.getHeight() - mFeedbackView.getTop()); 243 mFeedbackView.setVisibility(View.VISIBLE); 244 mFeedbackView.animate() 245 .setDuration(FEEDBACK_ANIMATION_MS) 246 .translationY(0) 247 .start(); 248 mFeedbackTitleView.postDelayed(mTitleViewCallback, FEEDBACK_ANIMATION_MS); 249 }, useGestureAnimationDelay); 250 return; 251 } else { 252 mTutorialFragment.releaseFeedbackVideoView(); 253 } 254 } 255 mFeedbackView.setTranslationY(-mFeedbackView.getHeight() - mFeedbackView.getTop()); 256 mFeedbackView.setVisibility(View.VISIBLE); 257 mFeedbackView.animate() 258 .setDuration(FEEDBACK_ANIMATION_MS) 259 .translationY(0) 260 .withEndAction(() -> { 261 if (isGestureSuccessful && !mTutorialFragment.isAtFinalStep()) { 262 if (mFeedbackViewCallback != null) { 263 mFeedbackView.removeCallbacks(mFeedbackViewCallback); 264 } 265 mFeedbackViewCallback = mTutorialFragment::continueTutorial; 266 mFeedbackView.postDelayed(mFeedbackViewCallback, 267 ADVANCE_TUTORIAL_TIMEOUT_MS); 268 } 269 }) 270 .start(); 271 mFeedbackTitleView.postDelayed(mTitleViewCallback, FEEDBACK_ANIMATION_MS); 272 } 273 hideFeedback(boolean releaseFeedbackVideo)274 void hideFeedback(boolean releaseFeedbackVideo) { 275 mFeedbackView.clearAnimation(); 276 mFeedbackView.setVisibility(View.INVISIBLE); 277 if (releaseFeedbackVideo) { 278 mTutorialFragment.releaseFeedbackVideoView(); 279 } 280 } 281 playFeedbackVideo( @onNull AnimatedVectorDrawable tutorialAnimation, @NonNull AnimatedVectorDrawable gestureAnimation, @NonNull Runnable onStartRunnable, boolean useGestureAnimationDelay)282 private void playFeedbackVideo( 283 @NonNull AnimatedVectorDrawable tutorialAnimation, 284 @NonNull AnimatedVectorDrawable gestureAnimation, 285 @NonNull Runnable onStartRunnable, 286 boolean useGestureAnimationDelay) { 287 288 if (tutorialAnimation.isRunning()) { 289 tutorialAnimation.reset(); 290 } 291 tutorialAnimation.registerAnimationCallback(new Animatable2.AnimationCallback() { 292 293 @Override 294 public void onAnimationStart(Drawable drawable) { 295 super.onAnimationStart(drawable); 296 297 mGestureVideoView.setVisibility(GONE); 298 if (gestureAnimation.isRunning()) { 299 gestureAnimation.stop(); 300 } 301 302 if (!useGestureAnimationDelay) { 303 onStartRunnable.run(); 304 } 305 } 306 307 @Override 308 public void onAnimationEnd(Drawable drawable) { 309 super.onAnimationEnd(drawable); 310 311 mGestureVideoView.setVisibility(View.VISIBLE); 312 gestureAnimation.start(); 313 314 tutorialAnimation.unregisterAnimationCallback(this); 315 } 316 }); 317 318 if (mFeedbackViewCallback != null) { 319 mFeedbackVideoView.removeCallbacks(mFeedbackViewCallback); 320 mFeedbackViewCallback = null; 321 } 322 if (mFeedbackVideoViewCallback != null) { 323 mFeedbackVideoView.removeCallbacks(mFeedbackVideoViewCallback); 324 mFeedbackVideoViewCallback = null; 325 } 326 if (useGestureAnimationDelay) { 327 mFeedbackViewCallback = onStartRunnable; 328 mFeedbackVideoViewCallback = () -> { 329 mFeedbackVideoView.setVisibility(View.VISIBLE); 330 tutorialAnimation.start(); 331 }; 332 333 mFeedbackVideoView.setVisibility(View.GONE); 334 mFeedbackView.post(mFeedbackViewCallback); 335 mFeedbackVideoView.postDelayed(mFeedbackVideoViewCallback, GESTURE_ANIMATION_DELAY_MS); 336 } else { 337 mFeedbackVideoView.setVisibility(View.VISIBLE); 338 tutorialAnimation.start(); 339 } 340 } 341 setRippleHotspot(float x, float y)342 void setRippleHotspot(float x, float y) { 343 mRippleDrawable.setHotspot(x, y); 344 } 345 showRippleEffect(@ullable Runnable onCompleteRunnable)346 void showRippleEffect(@Nullable Runnable onCompleteRunnable) { 347 mRippleDrawable.setState( 348 new int[] {android.R.attr.state_pressed, android.R.attr.state_enabled}); 349 mRippleView.postDelayed(() -> { 350 mRippleDrawable.setState(new int[] {}); 351 if (onCompleteRunnable != null) { 352 onCompleteRunnable.run(); 353 } 354 }, RIPPLE_VISIBLE_MS); 355 } 356 onActionButtonClicked(View button)357 void onActionButtonClicked(View button) { 358 mTutorialFragment.continueTutorial(); 359 } 360 361 @CallSuper transitToController()362 void transitToController() { 363 hideFeedback(false); 364 hideActionButton(); 365 updateSubtext(); 366 updateDrawables(); 367 368 mGestureCompleted = false; 369 if (mFakeHotseatView != null) { 370 mFakeHotseatView.setVisibility(View.INVISIBLE); 371 } 372 } 373 hideCloseButton()374 void hideCloseButton() { 375 mCloseButton.setVisibility(GONE); 376 } 377 showCloseButton()378 void showCloseButton() { 379 mCloseButton.setVisibility(View.VISIBLE); 380 mCloseButton.setTextAppearance(Utilities.isDarkTheme(mContext) 381 ? R.style.TextAppearance_GestureTutorial_Feedback_Subtext 382 : R.style.TextAppearance_GestureTutorial_Feedback_Subtext_Dark); 383 } 384 hideActionButton()385 void hideActionButton() { 386 showCloseButton(); 387 // Invisible to maintain the layout. 388 mActionButton.setVisibility(View.INVISIBLE); 389 mActionButton.setOnClickListener(null); 390 } 391 showActionButton()392 void showActionButton() { 393 hideCloseButton(); 394 mActionButton.setVisibility(View.VISIBLE); 395 mActionButton.setOnClickListener(this::onActionButtonClicked); 396 } 397 updateSubtext()398 private void updateSubtext() { 399 mTutorialStepView.setTutorialProgress( 400 mTutorialFragment.getCurrentStep(), mTutorialFragment.getNumSteps()); 401 } 402 updateDrawables()403 private void updateDrawables() { 404 if (mContext != null) { 405 mTutorialFragment.getRootView().setBackground(AppCompatResources.getDrawable( 406 mContext, getMockWallpaperResId())); 407 mTutorialFragment.updateFeedbackVideo(); 408 mFakeLauncherView.setBackgroundColor( 409 mContext.getColor(Utilities.isDarkTheme(mContext) 410 ? R.color.fake_wallpaper_color_dark_mode 411 : R.color.fake_wallpaper_color_light_mode)); 412 mFakeHotseatView.setImageDrawable(AppCompatResources.getDrawable( 413 mContext, getMockHotseatResId())); 414 mFakeTaskView.setBackground(AppCompatResources.getDrawable( 415 mContext, getMockAppTaskThumbnailResId(Utilities.isDarkTheme(mContext)))); 416 mFakeTaskView.animate().alpha(1).setListener( 417 AnimatorListeners.forSuccessCallback(() -> mFakeTaskView.animate().cancel())); 418 mFakePreviousTaskView.setBackground(AppCompatResources.getDrawable( 419 mContext, getMockPreviousAppTaskThumbnailResId())); 420 mFakeIconView.setBackground(AppCompatResources.getDrawable( 421 mContext, getMockAppIconResId())); 422 } 423 } 424 createSkipTutorialDialog()425 private AlertDialog createSkipTutorialDialog() { 426 if (mContext instanceof GestureSandboxActivity) { 427 GestureSandboxActivity sandboxActivity = (GestureSandboxActivity) mContext; 428 View contentView = View.inflate( 429 sandboxActivity, R.layout.gesture_tutorial_dialog, null); 430 AlertDialog tutorialDialog = new AlertDialog 431 .Builder(sandboxActivity, R.style.Theme_AppCompat_Dialog_Alert) 432 .setView(contentView) 433 .create(); 434 435 PackageManager packageManager = mContext.getPackageManager(); 436 CharSequence tipsAppName = DEFAULT_PIXEL_TIPS_APP_NAME; 437 438 try { 439 tipsAppName = packageManager.getApplicationLabel( 440 packageManager.getApplicationInfo( 441 PIXEL_TIPS_APP_PACKAGE_NAME, PackageManager.GET_META_DATA)); 442 } catch (PackageManager.NameNotFoundException e) { 443 Log.e(TAG, 444 "Could not find app label for package name: " 445 + PIXEL_TIPS_APP_PACKAGE_NAME 446 + ". Defaulting to 'Pixel Tips.'", 447 e); 448 } 449 450 TextView subtitleTextView = (TextView) contentView.findViewById( 451 R.id.gesture_tutorial_dialog_subtitle); 452 if (subtitleTextView != null) { 453 subtitleTextView.setText( 454 mContext.getString(R.string.skip_tutorial_dialog_subtitle, tipsAppName)); 455 } else { 456 Log.w(TAG, "No subtitle view in the skip tutorial dialog to update."); 457 } 458 459 Button cancelButton = (Button) contentView.findViewById( 460 R.id.gesture_tutorial_dialog_cancel_button); 461 if (cancelButton != null) { 462 cancelButton.setOnClickListener( 463 v -> tutorialDialog.dismiss()); 464 } else { 465 Log.w(TAG, "No cancel button in the skip tutorial dialog to update."); 466 } 467 468 Button confirmButton = contentView.findViewById( 469 R.id.gesture_tutorial_dialog_confirm_button); 470 if (confirmButton != null) { 471 confirmButton.setOnClickListener(v -> { 472 sandboxActivity.closeTutorial(); 473 tutorialDialog.dismiss(); 474 }); 475 } else { 476 Log.w(TAG, "No confirm button in the skip tutorial dialog to update."); 477 } 478 479 tutorialDialog.getWindow().setBackgroundDrawable( 480 new ColorDrawable(sandboxActivity.getColor(android.R.color.transparent))); 481 482 return tutorialDialog; 483 } 484 485 return null; 486 } 487 488 /** Denotes the type of the tutorial. */ 489 enum TutorialType { 490 BACK_NAVIGATION, 491 BACK_NAVIGATION_COMPLETE, 492 HOME_NAVIGATION, 493 HOME_NAVIGATION_COMPLETE, 494 OVERVIEW_NAVIGATION, 495 OVERVIEW_NAVIGATION_COMPLETE, 496 ASSISTANT, 497 ASSISTANT_COMPLETE, 498 SANDBOX_MODE 499 } 500 } 501