blob: 23c4411cf1cbd035406403383f0010e3181393bc [file] [log] [blame]
Eric Erfanian938468d2017-10-24 14:05:52 -07001/*
2 * Copyright (C) 2017 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
17package com.android.newbubble;
18
19import android.animation.Animator;
yueg87111362017-12-08 12:45:50 -080020import android.animation.AnimatorListenerAdapter;
Eric Erfanian938468d2017-10-24 14:05:52 -070021import android.animation.AnimatorSet;
22import android.animation.ObjectAnimator;
yueg81a77ff2017-12-05 10:29:03 -080023import android.animation.ValueAnimator;
Eric Erfanian938468d2017-10-24 14:05:52 -070024import android.annotation.SuppressLint;
25import android.app.PendingIntent.CanceledException;
26import android.content.Context;
27import android.content.Intent;
yueg81a77ff2017-12-05 10:29:03 -080028import android.graphics.Path;
Eric Erfanian938468d2017-10-24 14:05:52 -070029import android.graphics.PixelFormat;
30import android.graphics.drawable.Animatable;
31import android.graphics.drawable.Drawable;
Eric Erfanian938468d2017-10-24 14:05:52 -070032import android.net.Uri;
33import android.os.Handler;
34import android.provider.Settings;
Eric Erfanian938468d2017-10-24 14:05:52 -070035import android.support.annotation.IntDef;
36import android.support.annotation.NonNull;
37import android.support.annotation.Nullable;
38import android.support.annotation.VisibleForTesting;
39import android.support.v4.graphics.ColorUtils;
Eric Erfanian938468d2017-10-24 14:05:52 -070040import android.support.v4.os.BuildCompat;
41import android.support.v4.view.animation.FastOutLinearInInterpolator;
42import android.support.v4.view.animation.LinearOutSlowInInterpolator;
43import android.transition.TransitionManager;
44import android.transition.TransitionValues;
45import android.view.ContextThemeWrapper;
46import android.view.Gravity;
47import android.view.LayoutInflater;
48import android.view.MotionEvent;
49import android.view.View;
50import android.view.ViewGroup;
51import android.view.ViewPropertyAnimator;
52import android.view.ViewTreeObserver.OnPreDrawListener;
53import android.view.WindowManager;
54import android.view.WindowManager.LayoutParams;
55import android.view.animation.AnticipateInterpolator;
56import android.view.animation.OvershootInterpolator;
57import android.widget.ImageView;
58import android.widget.TextView;
59import android.widget.ViewAnimator;
yueg81a77ff2017-12-05 10:29:03 -080060import com.android.dialer.common.LogUtil;
61import com.android.dialer.logging.DialerImpression;
62import com.android.dialer.logging.Logger;
yuega5a08d82017-10-31 14:11:53 -070063import com.android.dialer.util.DrawableConverter;
yueg81a77ff2017-12-05 10:29:03 -080064import com.android.incallui.call.CallList;
65import com.android.incallui.call.DialerCall;
Eric Erfanian938468d2017-10-24 14:05:52 -070066import com.android.newbubble.NewBubbleInfo.Action;
67import java.lang.annotation.Retention;
68import java.lang.annotation.RetentionPolicy;
69import java.util.List;
70
71/**
72 * Creates and manages a bubble window from information in a {@link NewBubbleInfo}. Before creating,
73 * be sure to check whether bubbles may be shown using {@link #canShowBubbles(Context)} and request
74 * permission if necessary ({@link #getRequestPermissionIntent(Context)} is provided for
75 * convenience)
76 */
77public class NewBubble {
78 // This class has some odd behavior that is not immediately obvious in order to avoid jank when
79 // resizing. See http://go/bubble-resize for details.
80
81 // How long text should show after showText(CharSequence) is called
82 private static final int SHOW_TEXT_DURATION_MILLIS = 3000;
83 // How long the new window should show before destroying the old one during resize operations.
84 // This ensures the new window has had time to draw first.
85 private static final int WINDOW_REDRAW_DELAY_MILLIS = 50;
86
87 private static Boolean canShowBubblesForTesting = null;
88
89 private final Context context;
90 private final WindowManager windowManager;
91
92 private final Handler handler;
93 private LayoutParams windowParams;
94
95 // Initialized in factory method
96 @SuppressWarnings("NullableProblems")
97 @NonNull
98 private NewBubbleInfo currentInfo;
99
100 @Visibility private int visibility;
101 private boolean expanded;
102 private boolean textShowing;
103 private boolean hideAfterText;
104 private CharSequence textAfterShow;
105 private int collapseEndAction;
106
yueg81a77ff2017-12-05 10:29:03 -0800107 ViewHolder viewHolder;
Eric Erfanian938468d2017-10-24 14:05:52 -0700108 private ViewPropertyAnimator collapseAnimation;
109 private Integer overrideGravity;
yueg87111362017-12-08 12:45:50 -0800110 @VisibleForTesting AnimatorSet exitAnimatorSet;
Eric Erfanian938468d2017-10-24 14:05:52 -0700111
yueg87111362017-12-08 12:45:50 -0800112 private final int primaryIconMoveDistance;
113 private final int leftBoundary;
yueg81a77ff2017-12-05 10:29:03 -0800114 private int savedYPosition = -1;
115
Eric Erfanian938468d2017-10-24 14:05:52 -0700116 private final Runnable collapseRunnable =
117 new Runnable() {
118 @Override
119 public void run() {
120 textShowing = false;
121 if (hideAfterText) {
122 // Always reset here since text shouldn't keep showing.
123 hideAndReset();
124 } else {
yueg81a77ff2017-12-05 10:29:03 -0800125 viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_AVATAR_AND_ICON);
Eric Erfanian938468d2017-10-24 14:05:52 -0700126 }
127 }
128 };
129
Eric Erfanian938468d2017-10-24 14:05:52 -0700130 /** Type of action after bubble collapse */
131 @Retention(RetentionPolicy.SOURCE)
132 @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
133 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
134 public @interface CollapseEnd {
135 int NOTHING = 0;
136 int HIDE = 1;
137 }
138
139 @Retention(RetentionPolicy.SOURCE)
140 @IntDef({Visibility.ENTERING, Visibility.SHOWING, Visibility.EXITING, Visibility.HIDDEN})
141 private @interface Visibility {
142 int HIDDEN = 0;
143 int ENTERING = 1;
144 int SHOWING = 2;
145 int EXITING = 3;
146 }
147
148 /** Indicate bubble expansion state. */
149 @Retention(RetentionPolicy.SOURCE)
150 @IntDef({ExpansionState.START_EXPANDING, ExpansionState.START_COLLAPSING})
151 public @interface ExpansionState {
152 // TODO(yueg): add more states when needed
153 int START_EXPANDING = 0;
154 int START_COLLAPSING = 1;
155 }
156
157 /**
158 * Determines whether bubbles can be shown based on permissions obtained. This should be checked
159 * before attempting to create a Bubble.
160 *
161 * @return true iff bubbles are able to be shown.
162 * @see Settings#canDrawOverlays(Context)
163 */
164 public static boolean canShowBubbles(@NonNull Context context) {
165 return canShowBubblesForTesting != null
166 ? canShowBubblesForTesting
167 : Settings.canDrawOverlays(context);
168 }
169
170 @VisibleForTesting(otherwise = VisibleForTesting.NONE)
171 public static void setCanShowBubblesForTesting(boolean canShowBubbles) {
172 canShowBubblesForTesting = canShowBubbles;
173 }
174
175 /** Returns an Intent to request permission to show overlays */
176 @NonNull
177 public static Intent getRequestPermissionIntent(@NonNull Context context) {
178 return new Intent(
179 Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
180 Uri.fromParts("package", context.getPackageName(), null));
181 }
182
183 /** Creates instances of Bubble. The default implementation just calls the constructor. */
184 @VisibleForTesting
185 public interface BubbleFactory {
186 NewBubble createBubble(@NonNull Context context, @NonNull Handler handler);
187 }
188
189 private static BubbleFactory bubbleFactory = NewBubble::new;
190
191 public static NewBubble createBubble(@NonNull Context context, @NonNull NewBubbleInfo info) {
192 NewBubble bubble = bubbleFactory.createBubble(context, new Handler());
193 bubble.setBubbleInfo(info);
194 return bubble;
195 }
196
197 @VisibleForTesting
198 public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) {
199 NewBubble.bubbleFactory = bubbleFactory;
200 }
201
202 @VisibleForTesting
203 public static void resetBubbleFactory() {
204 NewBubble.bubbleFactory = NewBubble::new;
205 }
206
207 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
208 NewBubble(@NonNull Context context, @NonNull Handler handler) {
209 context = new ContextThemeWrapper(context, R.style.Theme_AppCompat);
210 this.context = context;
211 this.handler = handler;
212 windowManager = context.getSystemService(WindowManager.class);
213
214 viewHolder = new ViewHolder(context);
yueg81a77ff2017-12-05 10:29:03 -0800215
216 leftBoundary =
217 context.getResources().getDimensionPixelOffset(R.dimen.bubble_off_screen_size_horizontal)
218 - context
219 .getResources()
220 .getDimensionPixelSize(R.dimen.bubble_shadow_padding_size_horizontal);
yueg87111362017-12-08 12:45:50 -0800221 primaryIconMoveDistance =
222 context.getResources().getDimensionPixelSize(R.dimen.bubble_size)
223 - context.getResources().getDimensionPixelSize(R.dimen.bubble_small_icon_size);
Eric Erfanian938468d2017-10-24 14:05:52 -0700224 }
225
226 /** Expands the main bubble menu. */
227 public void expand(boolean isUserAction) {
yueg81a77ff2017-12-05 10:29:03 -0800228 if (isUserAction) {
229 logBasicOrCallImpression(DialerImpression.Type.BUBBLE_PRIMARY_BUTTON_EXPAND);
Eric Erfanian938468d2017-10-24 14:05:52 -0700230 }
yueg81a77ff2017-12-05 10:29:03 -0800231 viewHolder.setDrawerVisibility(View.INVISIBLE);
Eric Erfanian938468d2017-10-24 14:05:52 -0700232 View expandedView = viewHolder.getExpandedView();
233 expandedView
234 .getViewTreeObserver()
235 .addOnPreDrawListener(
236 new OnPreDrawListener() {
237 @Override
238 public boolean onPreDraw() {
yueg81a77ff2017-12-05 10:29:03 -0800239 // Move the whole bubble up so that expanded view is still in screen
240 int moveUpDistance = viewHolder.getMoveUpDistance();
241 if (moveUpDistance != 0) {
242 savedYPosition = windowParams.y;
243 }
244
245 // Calculate the move-to-middle distance
246 int deltaX =
247 (int) viewHolder.getRoot().findViewById(R.id.bubble_primary_container).getX();
248 float k = (float) moveUpDistance / deltaX;
249 if (isDrawingFromRight()) {
250 deltaX = -deltaX;
251 }
252
253 // Do X-move and Y-move together
254
255 final int startX = windowParams.x - deltaX;
256 final int startY = windowParams.y;
257 ValueAnimator animator = ValueAnimator.ofFloat(startX, windowParams.x);
258 animator.setInterpolator(new LinearOutSlowInInterpolator());
259 animator.addUpdateListener(
260 (valueAnimator) -> {
261 // Update windowParams and the root layout.
262 // We can't do ViewPropertyAnimation since it clips children.
263 float newX = (float) valueAnimator.getAnimatedValue();
264 if (moveUpDistance != 0) {
265 windowParams.y = startY - (int) (Math.abs(newX - (float) startX) * k);
266 }
267 windowParams.x = (int) newX;
268 windowManager.updateViewLayout(viewHolder.getRoot(), windowParams);
269 });
270 animator.addListener(
yueg87111362017-12-08 12:45:50 -0800271 new AnimatorListenerAdapter() {
yueg81a77ff2017-12-05 10:29:03 -0800272 @Override
273 public void onAnimationEnd(Animator animation) {
274 // Show expanded view
275 expandedView.setVisibility(View.VISIBLE);
276 expandedView.setTranslationY(-expandedView.getHeight());
yueg87111362017-12-08 12:45:50 -0800277 expandedView.setAlpha(0);
yueg81a77ff2017-12-05 10:29:03 -0800278 expandedView
279 .animate()
280 .setInterpolator(new LinearOutSlowInInterpolator())
yueg87111362017-12-08 12:45:50 -0800281 .translationY(0)
282 .alpha(1);
yueg81a77ff2017-12-05 10:29:03 -0800283 }
yueg81a77ff2017-12-05 10:29:03 -0800284 });
285 animator.start();
286
Eric Erfanian938468d2017-10-24 14:05:52 -0700287 expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
Eric Erfanian938468d2017-10-24 14:05:52 -0700288 return false;
289 }
290 });
291 setFocused(true);
292 expanded = true;
293 }
294
yueg81a77ff2017-12-05 10:29:03 -0800295 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
296 public void startCollapse(
297 @CollapseEnd int endAction, boolean isUserAction, boolean shouldRecoverYPosition) {
298 View expandedView = viewHolder.getExpandedView();
299 if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) {
300 // Drawer is already collapsed or animation is running.
301 return;
302 }
303
304 overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT;
305 setFocused(false);
306
307 if (collapseEndAction == CollapseEnd.NOTHING) {
308 collapseEndAction = endAction;
309 }
310 if (isUserAction && collapseEndAction == CollapseEnd.NOTHING) {
311 logBasicOrCallImpression(DialerImpression.Type.BUBBLE_COLLAPSE_BY_USER);
312 }
313 // Animate expanded view to move from its position to above primary button and hide
314 collapseAnimation =
315 expandedView
316 .animate()
317 .translationY(-expandedView.getHeight())
yueg87111362017-12-08 12:45:50 -0800318 .alpha(0)
yueg81a77ff2017-12-05 10:29:03 -0800319 .setInterpolator(new FastOutLinearInInterpolator())
320 .withEndAction(
321 () -> {
322 collapseAnimation = null;
323 expanded = false;
324
325 if (textShowing) {
326 // Will do resize once the text is done.
327 return;
328 }
329
330 // Set drawer visibility to INVISIBLE instead of GONE to keep primary button fixed
331 viewHolder.setDrawerVisibility(View.INVISIBLE);
332
333 // Do X-move and Y-move together
334 int deltaX =
335 (int) viewHolder.getRoot().findViewById(R.id.bubble_primary_container).getX();
336 int startX = windowParams.x;
337 int startY = windowParams.y;
338 float k =
339 (savedYPosition != -1 && shouldRecoverYPosition)
340 ? (savedYPosition - startY) / (float) deltaX
341 : 0;
342 Path path = new Path();
343 path.moveTo(windowParams.x, windowParams.y);
344 path.lineTo(
345 windowParams.x - deltaX,
346 (savedYPosition != -1 && shouldRecoverYPosition)
347 ? savedYPosition
348 : windowParams.y);
349 // The position is not useful after collapse
350 savedYPosition = -1;
351
352 ValueAnimator animator = ValueAnimator.ofFloat(startX, startX - deltaX);
353 animator.setInterpolator(new LinearOutSlowInInterpolator());
354 animator.addUpdateListener(
355 (valueAnimator) -> {
356 // Update windowParams and the root layout.
357 // We can't do ViewPropertyAnimation since it clips children.
358 float newX = (float) valueAnimator.getAnimatedValue();
359 if (k != 0) {
360 windowParams.y = startY + (int) (Math.abs(newX - (float) startX) * k);
361 }
362 windowParams.x = (int) newX;
363 windowManager.updateViewLayout(viewHolder.getRoot(), windowParams);
364 });
365 animator.addListener(
yueg87111362017-12-08 12:45:50 -0800366 new AnimatorListenerAdapter() {
yueg81a77ff2017-12-05 10:29:03 -0800367 @Override
368 public void onAnimationEnd(Animator animation) {
369 // If collapse on the right side, the primary button move left a bit after
370 // drawer
371 // visibility becoming GONE. To avoid it, we create a new ViewHolder.
372 replaceViewHolder();
373 }
yueg81a77ff2017-12-05 10:29:03 -0800374 });
375 animator.start();
376
377 // If this collapse was to come before a hide, do it now.
378 if (collapseEndAction == CollapseEnd.HIDE) {
379 hide();
380 }
381 collapseEndAction = CollapseEnd.NOTHING;
382
383 // Resume normal gravity after any resizing is done.
384 handler.postDelayed(
385 () -> {
386 overrideGravity = null;
387 if (!viewHolder.isMoving()) {
388 viewHolder.undoGravityOverride();
389 }
390 },
391 // Need to wait twice as long for resize and layout
392 WINDOW_REDRAW_DELAY_MILLIS * 2);
393 });
394 }
395
Eric Erfanian938468d2017-10-24 14:05:52 -0700396 /**
397 * Make the bubble visible. Will show a short entrance animation as it enters. If the bubble is
398 * already showing this method does nothing.
399 */
400 public void show() {
401 if (collapseEndAction == CollapseEnd.HIDE) {
402 // If show() was called while collapsing, make sure we don't hide after.
403 collapseEndAction = CollapseEnd.NOTHING;
404 }
405 if (visibility == Visibility.SHOWING || visibility == Visibility.ENTERING) {
406 return;
407 }
408
409 hideAfterText = false;
410
411 if (windowParams == null) {
412 // Apps targeting O+ must use TYPE_APPLICATION_OVERLAY, which is not available prior to O.
413 @SuppressWarnings("deprecation")
414 @SuppressLint("InlinedApi")
415 int type =
416 BuildCompat.isAtLeastO()
417 ? LayoutParams.TYPE_APPLICATION_OVERLAY
418 : LayoutParams.TYPE_PHONE;
419
420 windowParams =
421 new LayoutParams(
422 type,
423 LayoutParams.FLAG_NOT_TOUCH_MODAL
424 | LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
425 | LayoutParams.FLAG_NOT_FOCUSABLE
426 | LayoutParams.FLAG_LAYOUT_NO_LIMITS,
427 PixelFormat.TRANSLUCENT);
428 windowParams.gravity = Gravity.TOP | Gravity.LEFT;
yueg81a77ff2017-12-05 10:29:03 -0800429 windowParams.x = leftBoundary;
Eric Erfanian938468d2017-10-24 14:05:52 -0700430 windowParams.y = currentInfo.getStartingYPosition();
431 windowParams.height = LayoutParams.WRAP_CONTENT;
432 windowParams.width = LayoutParams.WRAP_CONTENT;
433 }
434
yueg87111362017-12-08 12:45:50 -0800435 if (exitAnimatorSet != null) {
436 exitAnimatorSet.removeAllListeners();
437 exitAnimatorSet.cancel();
438 exitAnimatorSet = null;
Eric Erfanian938468d2017-10-24 14:05:52 -0700439 } else {
440 windowManager.addView(viewHolder.getRoot(), windowParams);
yueg87111362017-12-08 12:45:50 -0800441 viewHolder.getPrimaryButton().setVisibility(View.VISIBLE);
Eric Erfanian938468d2017-10-24 14:05:52 -0700442 viewHolder.getPrimaryButton().setScaleX(0);
443 viewHolder.getPrimaryButton().setScaleY(0);
yueg87111362017-12-08 12:45:50 -0800444 viewHolder.getPrimaryAvatar().setAlpha(0f);
445 viewHolder.getPrimaryIcon().setAlpha(0f);
Eric Erfanian938468d2017-10-24 14:05:52 -0700446 }
447
448 viewHolder.setChildClickable(true);
449 visibility = Visibility.ENTERING;
yueg87111362017-12-08 12:45:50 -0800450
451 // Show bubble animation: scale the whole bubble to 1, and change avatar+icon's alpha to 1
452 ObjectAnimator scaleXAnimator =
453 ObjectAnimator.ofFloat(viewHolder.getPrimaryButton(), "scaleX", 1);
454 ObjectAnimator scaleYAnimator =
455 ObjectAnimator.ofFloat(viewHolder.getPrimaryButton(), "scaleY", 1);
456 ObjectAnimator avatarAlphaAnimator =
457 ObjectAnimator.ofFloat(viewHolder.getPrimaryAvatar(), "alpha", 1);
458 ObjectAnimator iconAlphaAnimator =
459 ObjectAnimator.ofFloat(viewHolder.getPrimaryIcon(), "alpha", 1);
460 AnimatorSet enterAnimatorSet = new AnimatorSet();
461 enterAnimatorSet.playTogether(
462 scaleXAnimator, scaleYAnimator, avatarAlphaAnimator, iconAlphaAnimator);
463 enterAnimatorSet.setInterpolator(new OvershootInterpolator());
464 enterAnimatorSet.addListener(
465 new AnimatorListenerAdapter() {
466 @Override
467 public void onAnimationEnd(Animator animation) {
468 visibility = Visibility.SHOWING;
469 // Show the queued up text, if available.
470 if (textAfterShow != null) {
471 showText(textAfterShow);
472 textAfterShow = null;
473 }
474 }
475 });
476 enterAnimatorSet.start();
Eric Erfanian938468d2017-10-24 14:05:52 -0700477
478 updatePrimaryIconAnimation();
479 }
480
481 /** Hide the bubble. */
482 public void hide() {
483 if (hideAfterText) {
484 // hideAndReset() will be called after showing text, do nothing here.
485 return;
486 }
487 hideHelper(this::defaultAfterHidingAnimation);
488 }
489
490 /** Hide the bubble and reset {@viewHolder} to initial state */
491 public void hideAndReset() {
492 hideHelper(
493 () -> {
494 defaultAfterHidingAnimation();
495 reset();
496 });
497 }
498
499 /** Returns whether the bubble is currently visible */
500 public boolean isVisible() {
501 return visibility == Visibility.SHOWING
502 || visibility == Visibility.ENTERING
503 || visibility == Visibility.EXITING;
504 }
505
506 /**
507 * Set the info for this Bubble to display
508 *
509 * @param bubbleInfo the BubbleInfo to display in this Bubble.
510 */
511 public void setBubbleInfo(@NonNull NewBubbleInfo bubbleInfo) {
512 currentInfo = bubbleInfo;
513 update();
514 }
515
516 /**
517 * Update the state and behavior of actions.
518 *
519 * @param actions the new state of the bubble's actions
520 */
521 public void updateActions(@NonNull List<Action> actions) {
522 currentInfo = NewBubbleInfo.from(currentInfo).setActions(actions).build();
523 updateButtonStates();
524 }
525
yuega5a08d82017-10-31 14:11:53 -0700526 /**
527 * Update the avatar from photo.
528 *
529 * @param avatar the new photo avatar in the bubble's primary button
530 */
531 public void updatePhotoAvatar(@NonNull Drawable avatar) {
532 // Make it round
533 int bubbleSize = context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
534 Drawable roundAvatar =
535 DrawableConverter.getRoundedDrawable(context, avatar, bubbleSize, bubbleSize);
536
537 updateAvatar(roundAvatar);
538 }
539
540 /**
541 * Update the avatar.
542 *
543 * @param avatar the new avatar in the bubble's primary button
544 */
545 public void updateAvatar(@NonNull Drawable avatar) {
546 if (!avatar.equals(currentInfo.getAvatar())) {
547 currentInfo = NewBubbleInfo.from(currentInfo).setAvatar(avatar).build();
548 viewHolder.getPrimaryAvatar().setImageDrawable(currentInfo.getAvatar());
549 }
550 }
551
Eric Erfanian938468d2017-10-24 14:05:52 -0700552 /** Returns the currently displayed NewBubbleInfo */
553 public NewBubbleInfo getBubbleInfo() {
554 return currentInfo;
555 }
556
557 /**
558 * Display text in the main bubble. The bubble's drawer is not expandable while text is showing,
559 * and the drawer will be closed if already open.
560 *
561 * @param text the text to display to the user
562 */
563 public void showText(@NonNull CharSequence text) {
564 textShowing = true;
565 if (expanded) {
yueg81a77ff2017-12-05 10:29:03 -0800566 startCollapse(
567 CollapseEnd.NOTHING, false /* isUserAction */, false /* shouldRecoverYPosition */);
Eric Erfanian938468d2017-10-24 14:05:52 -0700568 doShowText(text);
569 } else {
570 // Need to transition from old bounds to new bounds manually
571 NewChangeOnScreenBounds transition = new NewChangeOnScreenBounds();
572 // Prepare and capture start values
573 TransitionValues startValues = new TransitionValues();
574 startValues.view = viewHolder.getPrimaryButton();
575 transition.addTarget(startValues.view);
576 transition.captureStartValues(startValues);
577
578 // If our view is not laid out yet, postpone showing the text.
579 if (startValues.values.isEmpty()) {
580 textAfterShow = text;
581 return;
582 }
583
yueg81a77ff2017-12-05 10:29:03 -0800584 doShowText(text);
585 // Hide the text so we can animate it in
586 viewHolder.getPrimaryText().setAlpha(0);
Eric Erfanian938468d2017-10-24 14:05:52 -0700587
yueg81a77ff2017-12-05 10:29:03 -0800588 ViewAnimator primaryButton = viewHolder.getPrimaryButton();
589 // Cancel the automatic transition scheduled in doShowText
590 TransitionManager.endTransitions((ViewGroup) primaryButton.getParent());
591 primaryButton
592 .getViewTreeObserver()
593 .addOnPreDrawListener(
594 new OnPreDrawListener() {
595 @Override
596 public boolean onPreDraw() {
597 primaryButton.getViewTreeObserver().removeOnPreDrawListener(this);
Eric Erfanian938468d2017-10-24 14:05:52 -0700598
yueg81a77ff2017-12-05 10:29:03 -0800599 // Prepare and capture end values, always use the size of primaryText since
600 // its invisibility makes primaryButton smaller than expected
601 TransitionValues endValues = new TransitionValues();
602 endValues.values.put(
603 NewChangeOnScreenBounds.PROPNAME_WIDTH,
604 viewHolder.getPrimaryText().getWidth());
605 endValues.values.put(
606 NewChangeOnScreenBounds.PROPNAME_HEIGHT,
607 viewHolder.getPrimaryText().getHeight());
608 endValues.view = primaryButton;
609 transition.addTarget(endValues.view);
610 transition.captureEndValues(endValues);
Eric Erfanian938468d2017-10-24 14:05:52 -0700611
yueg81a77ff2017-12-05 10:29:03 -0800612 // animate the primary button bounds change
613 Animator bounds =
614 transition.createAnimator(primaryButton, startValues, endValues);
Eric Erfanian938468d2017-10-24 14:05:52 -0700615
yueg81a77ff2017-12-05 10:29:03 -0800616 // Animate the text in
617 Animator alpha =
618 ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f);
Eric Erfanian938468d2017-10-24 14:05:52 -0700619
yueg81a77ff2017-12-05 10:29:03 -0800620 AnimatorSet set = new AnimatorSet();
621 set.play(bounds).before(alpha);
622 set.start();
623 return false;
624 }
625 });
Eric Erfanian938468d2017-10-24 14:05:52 -0700626 }
627 handler.removeCallbacks(collapseRunnable);
628 handler.postDelayed(collapseRunnable, SHOW_TEXT_DURATION_MILLIS);
629 }
630
Eric Erfanian938468d2017-10-24 14:05:52 -0700631 @Nullable
632 Integer getGravityOverride() {
633 return overrideGravity;
634 }
635
636 void onMoveStart() {
yueg81a77ff2017-12-05 10:29:03 -0800637 if (viewHolder.getExpandedView().getVisibility() == View.VISIBLE) {
638 viewHolder.setDrawerVisibility(View.INVISIBLE);
639 }
640 expanded = false;
641 savedYPosition = -1;
642
Eric Erfanian938468d2017-10-24 14:05:52 -0700643 viewHolder
644 .getPrimaryButton()
645 .animate()
646 .translationZ(
yuegc6deafc2017-11-06 16:42:13 -0800647 context
648 .getResources()
649 .getDimensionPixelOffset(R.dimen.bubble_dragging_elevation_change));
Eric Erfanian938468d2017-10-24 14:05:52 -0700650 }
651
652 void onMoveFinish() {
653 viewHolder.getPrimaryButton().animate().translationZ(0);
Eric Erfanian938468d2017-10-24 14:05:52 -0700654 }
655
656 void primaryButtonClick() {
657 if (textShowing || currentInfo.getActions().isEmpty()) {
658 return;
659 }
660 if (expanded) {
yueg81a77ff2017-12-05 10:29:03 -0800661 startCollapse(
662 CollapseEnd.NOTHING, true /* isUserAction */, true /* shouldRecoverYPosition */);
Eric Erfanian938468d2017-10-24 14:05:52 -0700663 } else {
664 expand(true);
665 }
666 }
667
yueg81a77ff2017-12-05 10:29:03 -0800668 void onLeftRightSwitch(boolean onRight) {
yueg87111362017-12-08 12:45:50 -0800669 // Move primary icon to the other side so it's not partially hiden
yueg81a77ff2017-12-05 10:29:03 -0800670 View primaryIcon = viewHolder.getPrimaryIcon();
yueg87111362017-12-08 12:45:50 -0800671 primaryIcon.animate().translationX(onRight ? -primaryIconMoveDistance : 0).start();
yueg81a77ff2017-12-05 10:29:03 -0800672 }
673
Eric Erfanian938468d2017-10-24 14:05:52 -0700674 LayoutParams getWindowParams() {
675 return windowParams;
676 }
677
678 View getRootView() {
679 return viewHolder.getRoot();
680 }
681
682 /**
683 * Hide the bubble if visible. Will run a short exit animation and before hiding, and {@code
684 * afterHiding} after hiding. If the bubble is currently showing text, will hide after the text is
685 * done displaying. If the bubble is not visible this method does nothing.
686 */
yueg87111362017-12-08 12:45:50 -0800687 @VisibleForTesting
688 void hideHelper(Runnable afterHiding) {
Eric Erfanian938468d2017-10-24 14:05:52 -0700689 if (visibility == Visibility.HIDDEN || visibility == Visibility.EXITING) {
690 return;
691 }
692
693 // Make bubble non clickable to prevent further buggy actions
694 viewHolder.setChildClickable(false);
695
696 if (textShowing) {
697 hideAfterText = true;
698 return;
699 }
700
701 if (collapseAnimation != null) {
702 collapseEndAction = CollapseEnd.HIDE;
703 return;
704 }
705
706 if (expanded) {
yueg81a77ff2017-12-05 10:29:03 -0800707 startCollapse(CollapseEnd.HIDE, false /* isUserAction */, false /* shouldRecoverYPosition */);
Eric Erfanian938468d2017-10-24 14:05:52 -0700708 return;
709 }
710
711 visibility = Visibility.EXITING;
yueg87111362017-12-08 12:45:50 -0800712
713 // Hide bubble animation: scale the whole bubble to 0, and change avatar+icon's alpha to 0
714 ObjectAnimator scaleXAnimator =
715 ObjectAnimator.ofFloat(viewHolder.getPrimaryButton(), "scaleX", 0);
716 ObjectAnimator scaleYAnimator =
717 ObjectAnimator.ofFloat(viewHolder.getPrimaryButton(), "scaleY", 0);
718 ObjectAnimator avatarAlphaAnimator =
719 ObjectAnimator.ofFloat(viewHolder.getPrimaryAvatar(), "alpha", 0);
720 ObjectAnimator iconAlphaAnimator =
721 ObjectAnimator.ofFloat(viewHolder.getPrimaryIcon(), "alpha", 0);
722 exitAnimatorSet = new AnimatorSet();
723 exitAnimatorSet.playTogether(
724 scaleXAnimator, scaleYAnimator, avatarAlphaAnimator, iconAlphaAnimator);
725 exitAnimatorSet.setInterpolator(new AnticipateInterpolator());
726 exitAnimatorSet.addListener(
727 new AnimatorListenerAdapter() {
728 @Override
729 public void onAnimationEnd(Animator animation) {
730 afterHiding.run();
731 }
732 });
733 exitAnimatorSet.start();
Eric Erfanian938468d2017-10-24 14:05:52 -0700734 }
735
736 private void reset() {
737 viewHolder = new ViewHolder(viewHolder.getRoot().getContext());
738 update();
739 }
740
741 private void update() {
yuega5a08d82017-10-31 14:11:53 -0700742 // Whole primary button background
yueg84ac49b2017-11-01 16:22:28 -0700743 Drawable backgroundCirle =
744 context.getResources().getDrawable(R.drawable.bubble_shape_circle, context.getTheme());
Eric Erfanian938468d2017-10-24 14:05:52 -0700745 int primaryTint =
746 ColorUtils.compositeColors(
747 context.getColor(R.color.bubble_primary_background_darken),
748 currentInfo.getPrimaryColor());
yueg84ac49b2017-11-01 16:22:28 -0700749 backgroundCirle.mutate().setTint(primaryTint);
750 viewHolder.getPrimaryButton().setBackground(backgroundCirle);
Eric Erfanian938468d2017-10-24 14:05:52 -0700751
yuega5a08d82017-10-31 14:11:53 -0700752 // Small icon
yueg84ac49b2017-11-01 16:22:28 -0700753 Drawable smallIconBackgroundCircle =
754 context
755 .getResources()
756 .getDrawable(R.drawable.bubble_shape_circle_small, context.getTheme());
757 smallIconBackgroundCircle.setTint(context.getColor(R.color.bubble_button_color_blue));
758 viewHolder.getPrimaryIcon().setBackground(smallIconBackgroundCircle);
Eric Erfanian938468d2017-10-24 14:05:52 -0700759 viewHolder.getPrimaryIcon().setImageIcon(currentInfo.getPrimaryIcon());
yuega5a08d82017-10-31 14:11:53 -0700760 viewHolder.getPrimaryAvatar().setImageDrawable(currentInfo.getAvatar());
Eric Erfanian938468d2017-10-24 14:05:52 -0700761
yuega5a08d82017-10-31 14:11:53 -0700762 updatePrimaryIconAnimation();
Eric Erfanian938468d2017-10-24 14:05:52 -0700763 updateButtonStates();
764 }
765
766 private void updatePrimaryIconAnimation() {
767 Drawable drawable = viewHolder.getPrimaryIcon().getDrawable();
768 if (drawable instanceof Animatable) {
769 if (isVisible()) {
770 ((Animatable) drawable).start();
771 } else {
772 ((Animatable) drawable).stop();
773 }
774 }
775 }
776
777 private void updateButtonStates() {
yueg84ac49b2017-11-01 16:22:28 -0700778 configureButton(currentInfo.getActions().get(0), viewHolder.getFullScreenButton());
779 configureButton(currentInfo.getActions().get(1), viewHolder.getMuteButton());
780 configureButton(currentInfo.getActions().get(2), viewHolder.getAudioRouteButton());
781 configureButton(currentInfo.getActions().get(3), viewHolder.getEndCallButton());
Eric Erfanian938468d2017-10-24 14:05:52 -0700782 }
783
yueg87111362017-12-08 12:45:50 -0800784 @VisibleForTesting
785 void doShowText(@NonNull CharSequence text) {
Eric Erfanian938468d2017-10-24 14:05:52 -0700786 TransitionManager.beginDelayedTransition((ViewGroup) viewHolder.getPrimaryButton().getParent());
787 viewHolder.getPrimaryText().setText(text);
788 viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_TEXT);
789 }
790
yueg84ac49b2017-11-01 16:22:28 -0700791 private void configureButton(Action action, NewCheckableButton button) {
792 button.setCompoundDrawablesWithIntrinsicBounds(action.getIconDrawable(), null, null, null);
Eric Erfanian938468d2017-10-24 14:05:52 -0700793 button.setChecked(action.isChecked());
794 button.setEnabled(action.isEnabled());
yueg84ac49b2017-11-01 16:22:28 -0700795 button.setText(action.getName());
Eric Erfanian938468d2017-10-24 14:05:52 -0700796 button.setOnClickListener(v -> doAction(action));
797 }
798
799 private void doAction(Action action) {
800 try {
801 action.getIntent().send();
802 } catch (CanceledException e) {
803 throw new RuntimeException(e);
804 }
805 }
806
yueg81a77ff2017-12-05 10:29:03 -0800807 /**
808 * Create a new ViewHolder object to replace the old one.It only happens when not moving and
809 * collapsed.
810 */
811 void replaceViewHolder() {
812 LogUtil.enterBlock("NewBubble.replaceViewHolder");
Eric Erfanian938468d2017-10-24 14:05:52 -0700813 ViewHolder oldViewHolder = viewHolder;
Eric Erfanian938468d2017-10-24 14:05:52 -0700814
yueg81a77ff2017-12-05 10:29:03 -0800815 // Create a new ViewHolder and copy needed info.
816 viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext());
817 viewHolder
818 .getPrimaryButton()
819 .setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild());
820 viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText());
yueg87111362017-12-08 12:45:50 -0800821 viewHolder.getPrimaryIcon().setX(isDrawingFromRight() ? 0 : primaryIconMoveDistance);
yueg81a77ff2017-12-05 10:29:03 -0800822 viewHolder
823 .getPrimaryIcon()
yueg87111362017-12-08 12:45:50 -0800824 .setTranslationX(isDrawingFromRight() ? -primaryIconMoveDistance : 0);
Eric Erfanian938468d2017-10-24 14:05:52 -0700825
yueg81a77ff2017-12-05 10:29:03 -0800826 update();
827
828 // Add new view at its horizontal boundary
Eric Erfanian938468d2017-10-24 14:05:52 -0700829 ViewGroup root = viewHolder.getRoot();
yueg81a77ff2017-12-05 10:29:03 -0800830 windowParams.x = leftBoundary;
831 windowParams.gravity = Gravity.TOP | (isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT);
Eric Erfanian938468d2017-10-24 14:05:52 -0700832 windowManager.addView(root, windowParams);
yueg81a77ff2017-12-05 10:29:03 -0800833
834 // Remove the old view after delay
Eric Erfanian938468d2017-10-24 14:05:52 -0700835 root.getViewTreeObserver()
836 .addOnPreDrawListener(
837 new OnPreDrawListener() {
838 @Override
839 public boolean onPreDraw() {
840 root.getViewTreeObserver().removeOnPreDrawListener(this);
841 // Wait a bit before removing the old view; make sure the new one has drawn over it.
842 handler.postDelayed(
843 () -> windowManager.removeView(oldViewHolder.getRoot()),
844 WINDOW_REDRAW_DELAY_MILLIS);
845 return true;
846 }
847 });
848 }
849
yueg81a77ff2017-12-05 10:29:03 -0800850 int getDrawerVisibility() {
851 return viewHolder.getExpandedView().getVisibility();
Eric Erfanian938468d2017-10-24 14:05:52 -0700852 }
853
854 private boolean isDrawingFromRight() {
855 return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
856 }
857
858 private void setFocused(boolean focused) {
859 if (focused) {
860 windowParams.flags &= ~LayoutParams.FLAG_NOT_FOCUSABLE;
861 } else {
862 windowParams.flags |= LayoutParams.FLAG_NOT_FOCUSABLE;
863 }
864 windowManager.updateViewLayout(getRootView(), windowParams);
865 }
866
867 private void defaultAfterHidingAnimation() {
yueg87111362017-12-08 12:45:50 -0800868 exitAnimatorSet = null;
869 viewHolder.getPrimaryButton().setVisibility(View.INVISIBLE);
Eric Erfanian938468d2017-10-24 14:05:52 -0700870 windowManager.removeView(viewHolder.getRoot());
871 visibility = Visibility.HIDDEN;
872
873 updatePrimaryIconAnimation();
874 }
875
yueg81a77ff2017-12-05 10:29:03 -0800876 private void logBasicOrCallImpression(DialerImpression.Type impressionType) {
877 DialerCall call = CallList.getInstance().getActiveOrBackgroundCall();
878 if (call != null) {
879 Logger.get(context)
880 .logCallImpression(impressionType, call.getUniqueCallId(), call.getTimeAddedMs());
881 } else {
882 Logger.get(context).logImpression(impressionType);
883 }
884 }
885
Eric Erfanian938468d2017-10-24 14:05:52 -0700886 @VisibleForTesting
887 class ViewHolder {
888
yuega5a08d82017-10-31 14:11:53 -0700889 public static final int CHILD_INDEX_AVATAR_AND_ICON = 0;
Eric Erfanian938468d2017-10-24 14:05:52 -0700890 public static final int CHILD_INDEX_TEXT = 1;
891
892 private final NewMoveHandler moveHandler;
893 private final NewWindowRoot root;
894 private final ViewAnimator primaryButton;
895 private final ImageView primaryIcon;
yuega5a08d82017-10-31 14:11:53 -0700896 private final ImageView primaryAvatar;
Eric Erfanian938468d2017-10-24 14:05:52 -0700897 private final TextView primaryText;
898
899 private final NewCheckableButton fullScreenButton;
900 private final NewCheckableButton muteButton;
901 private final NewCheckableButton audioRouteButton;
902 private final NewCheckableButton endCallButton;
903 private final View expandedView;
904
905 public ViewHolder(Context context) {
906 // Window root is not in the layout file so that the inflater has a view to inflate into
907 this.root = new NewWindowRoot(context);
908 LayoutInflater inflater = LayoutInflater.from(root.getContext());
909 View contentView = inflater.inflate(R.layout.new_bubble_base, root, true);
910 expandedView = contentView.findViewById(R.id.bubble_expanded_layout);
911 primaryButton = contentView.findViewById(R.id.bubble_button_primary);
yuega5a08d82017-10-31 14:11:53 -0700912 primaryAvatar = contentView.findViewById(R.id.bubble_icon_avatar);
Eric Erfanian938468d2017-10-24 14:05:52 -0700913 primaryIcon = contentView.findViewById(R.id.bubble_icon_primary);
914 primaryText = contentView.findViewById(R.id.bubble_text);
915
916 fullScreenButton = contentView.findViewById(R.id.bubble_button_full_screen);
917 muteButton = contentView.findViewById(R.id.bubble_button_mute);
918 audioRouteButton = contentView.findViewById(R.id.bubble_button_audio_route);
919 endCallButton = contentView.findViewById(R.id.bubble_button_end_call);
920
921 root.setOnBackPressedListener(
922 () -> {
923 if (visibility == Visibility.SHOWING && expanded) {
yueg81a77ff2017-12-05 10:29:03 -0800924 startCollapse(
925 CollapseEnd.NOTHING, true /* isUserAction */, true /* shouldRecoverYPosition */);
Eric Erfanian938468d2017-10-24 14:05:52 -0700926 return true;
927 }
928 return false;
929 });
930 root.setOnConfigurationChangedListener(
931 (configuration) -> {
932 // The values in the current MoveHandler may be stale, so replace it. Then ensure the
933 // Window is in bounds
934 moveHandler = new NewMoveHandler(primaryButton, NewBubble.this);
935 moveHandler.snapToBounds();
936 });
937 root.setOnTouchListener(
938 (v, event) -> {
939 if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
yueg81a77ff2017-12-05 10:29:03 -0800940 startCollapse(
941 CollapseEnd.NOTHING, true /* isUserAction */, true /* shouldRecoverYPosition */);
Eric Erfanian938468d2017-10-24 14:05:52 -0700942 return true;
943 }
944 return false;
945 });
946 moveHandler = new NewMoveHandler(primaryButton, NewBubble.this);
947 }
948
949 private void setChildClickable(boolean clickable) {
950 fullScreenButton.setClickable(clickable);
951 muteButton.setClickable(clickable);
952 audioRouteButton.setClickable(clickable);
953 endCallButton.setClickable(clickable);
954
955 // For primaryButton
956 moveHandler.setClickable(clickable);
957 }
958
yueg81a77ff2017-12-05 10:29:03 -0800959 public int getMoveUpDistance() {
960 int deltaAllowed =
961 expandedView.getHeight()
962 - context
963 .getResources()
964 .getDimensionPixelOffset(R.dimen.bubble_button_padding_vertical)
965 * 2;
966 return moveHandler.getMoveUpDistance(deltaAllowed);
967 }
968
Eric Erfanian938468d2017-10-24 14:05:52 -0700969 public ViewGroup getRoot() {
970 return root;
971 }
972
973 public ViewAnimator getPrimaryButton() {
974 return primaryButton;
975 }
976
977 public ImageView getPrimaryIcon() {
978 return primaryIcon;
979 }
980
yuega5a08d82017-10-31 14:11:53 -0700981 public ImageView getPrimaryAvatar() {
982 return primaryAvatar;
983 }
984
Eric Erfanian938468d2017-10-24 14:05:52 -0700985 public TextView getPrimaryText() {
986 return primaryText;
987 }
988
989 public View getExpandedView() {
990 return expandedView;
991 }
992
993 public NewCheckableButton getFullScreenButton() {
994 return fullScreenButton;
995 }
996
997 public NewCheckableButton getMuteButton() {
998 return muteButton;
999 }
1000
1001 public NewCheckableButton getAudioRouteButton() {
1002 return audioRouteButton;
1003 }
1004
1005 public NewCheckableButton getEndCallButton() {
1006 return endCallButton;
1007 }
1008
1009 public void setDrawerVisibility(int visibility) {
1010 expandedView.setVisibility(visibility);
1011 }
1012
1013 public boolean isMoving() {
1014 return moveHandler.isMoving();
1015 }
1016
1017 public void undoGravityOverride() {
1018 moveHandler.undoGravityOverride();
1019 }
1020 }
Eric Erfanian938468d2017-10-24 14:05:52 -07001021}