blob: 34a9585c1322fbdb716208ae451dc642eebce289 [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;
yuega235e132017-12-13 14:13:57 -080050import android.view.View.AccessibilityDelegate;
Eric Erfanian938468d2017-10-24 14:05:52 -070051import android.view.ViewGroup;
52import android.view.ViewPropertyAnimator;
53import android.view.ViewTreeObserver.OnPreDrawListener;
54import android.view.WindowManager;
55import android.view.WindowManager.LayoutParams;
yuega235e132017-12-13 14:13:57 -080056import android.view.accessibility.AccessibilityNodeInfo;
57import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
Eric Erfanian938468d2017-10-24 14:05:52 -070058import android.view.animation.AnticipateInterpolator;
59import android.view.animation.OvershootInterpolator;
60import android.widget.ImageView;
61import android.widget.TextView;
62import android.widget.ViewAnimator;
yueg81a77ff2017-12-05 10:29:03 -080063import com.android.dialer.common.LogUtil;
64import com.android.dialer.logging.DialerImpression;
65import com.android.dialer.logging.Logger;
yuega5a08d82017-10-31 14:11:53 -070066import com.android.dialer.util.DrawableConverter;
yueg81a77ff2017-12-05 10:29:03 -080067import com.android.incallui.call.CallList;
68import com.android.incallui.call.DialerCall;
Eric Erfanian938468d2017-10-24 14:05:52 -070069import com.android.newbubble.NewBubbleInfo.Action;
70import java.lang.annotation.Retention;
71import java.lang.annotation.RetentionPolicy;
72import java.util.List;
73
74/**
75 * Creates and manages a bubble window from information in a {@link NewBubbleInfo}. Before creating,
76 * be sure to check whether bubbles may be shown using {@link #canShowBubbles(Context)} and request
77 * permission if necessary ({@link #getRequestPermissionIntent(Context)} is provided for
78 * convenience)
79 */
80public class NewBubble {
81 // This class has some odd behavior that is not immediately obvious in order to avoid jank when
82 // resizing. See http://go/bubble-resize for details.
83
84 // How long text should show after showText(CharSequence) is called
85 private static final int SHOW_TEXT_DURATION_MILLIS = 3000;
86 // How long the new window should show before destroying the old one during resize operations.
87 // This ensures the new window has had time to draw first.
88 private static final int WINDOW_REDRAW_DELAY_MILLIS = 50;
89
90 private static Boolean canShowBubblesForTesting = null;
91
92 private final Context context;
93 private final WindowManager windowManager;
94
95 private final Handler handler;
96 private LayoutParams windowParams;
97
98 // Initialized in factory method
99 @SuppressWarnings("NullableProblems")
100 @NonNull
101 private NewBubbleInfo currentInfo;
102
103 @Visibility private int visibility;
104 private boolean expanded;
105 private boolean textShowing;
106 private boolean hideAfterText;
107 private CharSequence textAfterShow;
108 private int collapseEndAction;
109
yueg81a77ff2017-12-05 10:29:03 -0800110 ViewHolder viewHolder;
Eric Erfanian938468d2017-10-24 14:05:52 -0700111 private ViewPropertyAnimator collapseAnimation;
112 private Integer overrideGravity;
yueg87111362017-12-08 12:45:50 -0800113 @VisibleForTesting AnimatorSet exitAnimatorSet;
Eric Erfanian938468d2017-10-24 14:05:52 -0700114
yueg87111362017-12-08 12:45:50 -0800115 private final int primaryIconMoveDistance;
116 private final int leftBoundary;
yueg81a77ff2017-12-05 10:29:03 -0800117 private int savedYPosition = -1;
118
Eric Erfanian938468d2017-10-24 14:05:52 -0700119 private final Runnable collapseRunnable =
120 new Runnable() {
121 @Override
122 public void run() {
123 textShowing = false;
124 if (hideAfterText) {
125 // Always reset here since text shouldn't keep showing.
126 hideAndReset();
127 } else {
yueg81a77ff2017-12-05 10:29:03 -0800128 viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_AVATAR_AND_ICON);
Eric Erfanian938468d2017-10-24 14:05:52 -0700129 }
130 }
131 };
132
Eric Erfanian938468d2017-10-24 14:05:52 -0700133 /** Type of action after bubble collapse */
134 @Retention(RetentionPolicy.SOURCE)
135 @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
136 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
137 public @interface CollapseEnd {
138 int NOTHING = 0;
139 int HIDE = 1;
140 }
141
142 @Retention(RetentionPolicy.SOURCE)
143 @IntDef({Visibility.ENTERING, Visibility.SHOWING, Visibility.EXITING, Visibility.HIDDEN})
144 private @interface Visibility {
145 int HIDDEN = 0;
146 int ENTERING = 1;
147 int SHOWING = 2;
148 int EXITING = 3;
149 }
150
151 /** Indicate bubble expansion state. */
152 @Retention(RetentionPolicy.SOURCE)
153 @IntDef({ExpansionState.START_EXPANDING, ExpansionState.START_COLLAPSING})
154 public @interface ExpansionState {
155 // TODO(yueg): add more states when needed
156 int START_EXPANDING = 0;
157 int START_COLLAPSING = 1;
158 }
159
160 /**
161 * Determines whether bubbles can be shown based on permissions obtained. This should be checked
162 * before attempting to create a Bubble.
163 *
164 * @return true iff bubbles are able to be shown.
165 * @see Settings#canDrawOverlays(Context)
166 */
167 public static boolean canShowBubbles(@NonNull Context context) {
168 return canShowBubblesForTesting != null
169 ? canShowBubblesForTesting
170 : Settings.canDrawOverlays(context);
171 }
172
173 @VisibleForTesting(otherwise = VisibleForTesting.NONE)
174 public static void setCanShowBubblesForTesting(boolean canShowBubbles) {
175 canShowBubblesForTesting = canShowBubbles;
176 }
177
178 /** Returns an Intent to request permission to show overlays */
179 @NonNull
180 public static Intent getRequestPermissionIntent(@NonNull Context context) {
181 return new Intent(
182 Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
183 Uri.fromParts("package", context.getPackageName(), null));
184 }
185
186 /** Creates instances of Bubble. The default implementation just calls the constructor. */
187 @VisibleForTesting
188 public interface BubbleFactory {
189 NewBubble createBubble(@NonNull Context context, @NonNull Handler handler);
190 }
191
192 private static BubbleFactory bubbleFactory = NewBubble::new;
193
194 public static NewBubble createBubble(@NonNull Context context, @NonNull NewBubbleInfo info) {
195 NewBubble bubble = bubbleFactory.createBubble(context, new Handler());
196 bubble.setBubbleInfo(info);
197 return bubble;
198 }
199
200 @VisibleForTesting
201 public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) {
202 NewBubble.bubbleFactory = bubbleFactory;
203 }
204
205 @VisibleForTesting
206 public static void resetBubbleFactory() {
207 NewBubble.bubbleFactory = NewBubble::new;
208 }
209
210 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
211 NewBubble(@NonNull Context context, @NonNull Handler handler) {
212 context = new ContextThemeWrapper(context, R.style.Theme_AppCompat);
213 this.context = context;
214 this.handler = handler;
215 windowManager = context.getSystemService(WindowManager.class);
216
217 viewHolder = new ViewHolder(context);
yueg81a77ff2017-12-05 10:29:03 -0800218
219 leftBoundary =
220 context.getResources().getDimensionPixelOffset(R.dimen.bubble_off_screen_size_horizontal)
221 - context
222 .getResources()
223 .getDimensionPixelSize(R.dimen.bubble_shadow_padding_size_horizontal);
yueg87111362017-12-08 12:45:50 -0800224 primaryIconMoveDistance =
225 context.getResources().getDimensionPixelSize(R.dimen.bubble_size)
226 - context.getResources().getDimensionPixelSize(R.dimen.bubble_small_icon_size);
Eric Erfanian938468d2017-10-24 14:05:52 -0700227 }
228
229 /** Expands the main bubble menu. */
230 public void expand(boolean isUserAction) {
yueg81a77ff2017-12-05 10:29:03 -0800231 if (isUserAction) {
232 logBasicOrCallImpression(DialerImpression.Type.BUBBLE_PRIMARY_BUTTON_EXPAND);
Eric Erfanian938468d2017-10-24 14:05:52 -0700233 }
yuega235e132017-12-13 14:13:57 -0800234 setPrimaryButtonAccessibilityAction(
235 context.getString(R.string.a11y_bubble_primary_button_collapse_action));
yueg81a77ff2017-12-05 10:29:03 -0800236 viewHolder.setDrawerVisibility(View.INVISIBLE);
Eric Erfanian938468d2017-10-24 14:05:52 -0700237 View expandedView = viewHolder.getExpandedView();
238 expandedView
239 .getViewTreeObserver()
240 .addOnPreDrawListener(
241 new OnPreDrawListener() {
242 @Override
243 public boolean onPreDraw() {
yueg81a77ff2017-12-05 10:29:03 -0800244 // Move the whole bubble up so that expanded view is still in screen
245 int moveUpDistance = viewHolder.getMoveUpDistance();
246 if (moveUpDistance != 0) {
247 savedYPosition = windowParams.y;
248 }
249
250 // Calculate the move-to-middle distance
251 int deltaX =
252 (int) viewHolder.getRoot().findViewById(R.id.bubble_primary_container).getX();
253 float k = (float) moveUpDistance / deltaX;
254 if (isDrawingFromRight()) {
255 deltaX = -deltaX;
256 }
257
258 // Do X-move and Y-move together
259
260 final int startX = windowParams.x - deltaX;
261 final int startY = windowParams.y;
262 ValueAnimator animator = ValueAnimator.ofFloat(startX, windowParams.x);
263 animator.setInterpolator(new LinearOutSlowInInterpolator());
264 animator.addUpdateListener(
265 (valueAnimator) -> {
266 // Update windowParams and the root layout.
267 // We can't do ViewPropertyAnimation since it clips children.
268 float newX = (float) valueAnimator.getAnimatedValue();
269 if (moveUpDistance != 0) {
270 windowParams.y = startY - (int) (Math.abs(newX - (float) startX) * k);
271 }
272 windowParams.x = (int) newX;
273 windowManager.updateViewLayout(viewHolder.getRoot(), windowParams);
274 });
275 animator.addListener(
yueg87111362017-12-08 12:45:50 -0800276 new AnimatorListenerAdapter() {
yueg81a77ff2017-12-05 10:29:03 -0800277 @Override
278 public void onAnimationEnd(Animator animation) {
279 // Show expanded view
280 expandedView.setVisibility(View.VISIBLE);
281 expandedView.setTranslationY(-expandedView.getHeight());
yueg87111362017-12-08 12:45:50 -0800282 expandedView.setAlpha(0);
yueg81a77ff2017-12-05 10:29:03 -0800283 expandedView
284 .animate()
285 .setInterpolator(new LinearOutSlowInInterpolator())
yueg87111362017-12-08 12:45:50 -0800286 .translationY(0)
287 .alpha(1);
yueg81a77ff2017-12-05 10:29:03 -0800288 }
yueg81a77ff2017-12-05 10:29:03 -0800289 });
290 animator.start();
291
Eric Erfanian938468d2017-10-24 14:05:52 -0700292 expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
Eric Erfanian938468d2017-10-24 14:05:52 -0700293 return false;
294 }
295 });
296 setFocused(true);
297 expanded = true;
298 }
299
yueg81a77ff2017-12-05 10:29:03 -0800300 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
301 public void startCollapse(
302 @CollapseEnd int endAction, boolean isUserAction, boolean shouldRecoverYPosition) {
303 View expandedView = viewHolder.getExpandedView();
304 if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) {
305 // Drawer is already collapsed or animation is running.
306 return;
307 }
308
309 overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT;
310 setFocused(false);
311
312 if (collapseEndAction == CollapseEnd.NOTHING) {
313 collapseEndAction = endAction;
314 }
315 if (isUserAction && collapseEndAction == CollapseEnd.NOTHING) {
316 logBasicOrCallImpression(DialerImpression.Type.BUBBLE_COLLAPSE_BY_USER);
317 }
yuega235e132017-12-13 14:13:57 -0800318 setPrimaryButtonAccessibilityAction(
319 context.getString(R.string.a11y_bubble_primary_button_expand_action));
yueg81a77ff2017-12-05 10:29:03 -0800320 // Animate expanded view to move from its position to above primary button and hide
321 collapseAnimation =
322 expandedView
323 .animate()
324 .translationY(-expandedView.getHeight())
yueg87111362017-12-08 12:45:50 -0800325 .alpha(0)
yueg81a77ff2017-12-05 10:29:03 -0800326 .setInterpolator(new FastOutLinearInInterpolator())
327 .withEndAction(
328 () -> {
329 collapseAnimation = null;
330 expanded = false;
331
332 if (textShowing) {
333 // Will do resize once the text is done.
334 return;
335 }
336
337 // Set drawer visibility to INVISIBLE instead of GONE to keep primary button fixed
338 viewHolder.setDrawerVisibility(View.INVISIBLE);
339
340 // Do X-move and Y-move together
341 int deltaX =
342 (int) viewHolder.getRoot().findViewById(R.id.bubble_primary_container).getX();
343 int startX = windowParams.x;
344 int startY = windowParams.y;
345 float k =
346 (savedYPosition != -1 && shouldRecoverYPosition)
347 ? (savedYPosition - startY) / (float) deltaX
348 : 0;
349 Path path = new Path();
350 path.moveTo(windowParams.x, windowParams.y);
351 path.lineTo(
352 windowParams.x - deltaX,
353 (savedYPosition != -1 && shouldRecoverYPosition)
354 ? savedYPosition
355 : windowParams.y);
356 // The position is not useful after collapse
357 savedYPosition = -1;
358
359 ValueAnimator animator = ValueAnimator.ofFloat(startX, startX - deltaX);
360 animator.setInterpolator(new LinearOutSlowInInterpolator());
361 animator.addUpdateListener(
362 (valueAnimator) -> {
363 // Update windowParams and the root layout.
364 // We can't do ViewPropertyAnimation since it clips children.
365 float newX = (float) valueAnimator.getAnimatedValue();
366 if (k != 0) {
367 windowParams.y = startY + (int) (Math.abs(newX - (float) startX) * k);
368 }
369 windowParams.x = (int) newX;
370 windowManager.updateViewLayout(viewHolder.getRoot(), windowParams);
371 });
372 animator.addListener(
yueg87111362017-12-08 12:45:50 -0800373 new AnimatorListenerAdapter() {
yueg81a77ff2017-12-05 10:29:03 -0800374 @Override
375 public void onAnimationEnd(Animator animation) {
376 // If collapse on the right side, the primary button move left a bit after
377 // drawer
378 // visibility becoming GONE. To avoid it, we create a new ViewHolder.
379 replaceViewHolder();
380 }
yueg81a77ff2017-12-05 10:29:03 -0800381 });
382 animator.start();
383
384 // If this collapse was to come before a hide, do it now.
385 if (collapseEndAction == CollapseEnd.HIDE) {
386 hide();
387 }
388 collapseEndAction = CollapseEnd.NOTHING;
389
390 // Resume normal gravity after any resizing is done.
391 handler.postDelayed(
392 () -> {
393 overrideGravity = null;
394 if (!viewHolder.isMoving()) {
395 viewHolder.undoGravityOverride();
396 }
397 },
398 // Need to wait twice as long for resize and layout
399 WINDOW_REDRAW_DELAY_MILLIS * 2);
400 });
401 }
402
Eric Erfanian938468d2017-10-24 14:05:52 -0700403 /**
404 * Make the bubble visible. Will show a short entrance animation as it enters. If the bubble is
405 * already showing this method does nothing.
406 */
407 public void show() {
408 if (collapseEndAction == CollapseEnd.HIDE) {
409 // If show() was called while collapsing, make sure we don't hide after.
410 collapseEndAction = CollapseEnd.NOTHING;
411 }
412 if (visibility == Visibility.SHOWING || visibility == Visibility.ENTERING) {
413 return;
414 }
415
416 hideAfterText = false;
417
418 if (windowParams == null) {
419 // Apps targeting O+ must use TYPE_APPLICATION_OVERLAY, which is not available prior to O.
420 @SuppressWarnings("deprecation")
421 @SuppressLint("InlinedApi")
422 int type =
423 BuildCompat.isAtLeastO()
424 ? LayoutParams.TYPE_APPLICATION_OVERLAY
425 : LayoutParams.TYPE_PHONE;
426
427 windowParams =
428 new LayoutParams(
429 type,
430 LayoutParams.FLAG_NOT_TOUCH_MODAL
431 | LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
432 | LayoutParams.FLAG_NOT_FOCUSABLE
433 | LayoutParams.FLAG_LAYOUT_NO_LIMITS,
434 PixelFormat.TRANSLUCENT);
435 windowParams.gravity = Gravity.TOP | Gravity.LEFT;
yueg81a77ff2017-12-05 10:29:03 -0800436 windowParams.x = leftBoundary;
Eric Erfanian938468d2017-10-24 14:05:52 -0700437 windowParams.y = currentInfo.getStartingYPosition();
438 windowParams.height = LayoutParams.WRAP_CONTENT;
439 windowParams.width = LayoutParams.WRAP_CONTENT;
440 }
441
yueg87111362017-12-08 12:45:50 -0800442 if (exitAnimatorSet != null) {
443 exitAnimatorSet.removeAllListeners();
444 exitAnimatorSet.cancel();
445 exitAnimatorSet = null;
Eric Erfanian938468d2017-10-24 14:05:52 -0700446 } else {
447 windowManager.addView(viewHolder.getRoot(), windowParams);
yueg87111362017-12-08 12:45:50 -0800448 viewHolder.getPrimaryButton().setVisibility(View.VISIBLE);
Eric Erfanian938468d2017-10-24 14:05:52 -0700449 viewHolder.getPrimaryButton().setScaleX(0);
450 viewHolder.getPrimaryButton().setScaleY(0);
yueg87111362017-12-08 12:45:50 -0800451 viewHolder.getPrimaryAvatar().setAlpha(0f);
452 viewHolder.getPrimaryIcon().setAlpha(0f);
Eric Erfanian938468d2017-10-24 14:05:52 -0700453 }
454
455 viewHolder.setChildClickable(true);
456 visibility = Visibility.ENTERING;
yueg87111362017-12-08 12:45:50 -0800457
yuega235e132017-12-13 14:13:57 -0800458 setPrimaryButtonAccessibilityAction(
459 context.getString(R.string.a11y_bubble_primary_button_expand_action));
460
yueg87111362017-12-08 12:45:50 -0800461 // Show bubble animation: scale the whole bubble to 1, and change avatar+icon's alpha to 1
462 ObjectAnimator scaleXAnimator =
463 ObjectAnimator.ofFloat(viewHolder.getPrimaryButton(), "scaleX", 1);
464 ObjectAnimator scaleYAnimator =
465 ObjectAnimator.ofFloat(viewHolder.getPrimaryButton(), "scaleY", 1);
466 ObjectAnimator avatarAlphaAnimator =
467 ObjectAnimator.ofFloat(viewHolder.getPrimaryAvatar(), "alpha", 1);
468 ObjectAnimator iconAlphaAnimator =
469 ObjectAnimator.ofFloat(viewHolder.getPrimaryIcon(), "alpha", 1);
470 AnimatorSet enterAnimatorSet = new AnimatorSet();
471 enterAnimatorSet.playTogether(
472 scaleXAnimator, scaleYAnimator, avatarAlphaAnimator, iconAlphaAnimator);
473 enterAnimatorSet.setInterpolator(new OvershootInterpolator());
474 enterAnimatorSet.addListener(
475 new AnimatorListenerAdapter() {
476 @Override
477 public void onAnimationEnd(Animator animation) {
478 visibility = Visibility.SHOWING;
479 // Show the queued up text, if available.
480 if (textAfterShow != null) {
481 showText(textAfterShow);
482 textAfterShow = null;
483 }
484 }
485 });
486 enterAnimatorSet.start();
Eric Erfanian938468d2017-10-24 14:05:52 -0700487
488 updatePrimaryIconAnimation();
489 }
490
491 /** Hide the bubble. */
492 public void hide() {
493 if (hideAfterText) {
494 // hideAndReset() will be called after showing text, do nothing here.
495 return;
496 }
497 hideHelper(this::defaultAfterHidingAnimation);
498 }
499
500 /** Hide the bubble and reset {@viewHolder} to initial state */
501 public void hideAndReset() {
502 hideHelper(
503 () -> {
504 defaultAfterHidingAnimation();
505 reset();
506 });
507 }
508
509 /** Returns whether the bubble is currently visible */
510 public boolean isVisible() {
511 return visibility == Visibility.SHOWING
512 || visibility == Visibility.ENTERING
513 || visibility == Visibility.EXITING;
514 }
515
516 /**
517 * Set the info for this Bubble to display
518 *
519 * @param bubbleInfo the BubbleInfo to display in this Bubble.
520 */
521 public void setBubbleInfo(@NonNull NewBubbleInfo bubbleInfo) {
522 currentInfo = bubbleInfo;
523 update();
524 }
525
526 /**
527 * Update the state and behavior of actions.
528 *
529 * @param actions the new state of the bubble's actions
530 */
531 public void updateActions(@NonNull List<Action> actions) {
532 currentInfo = NewBubbleInfo.from(currentInfo).setActions(actions).build();
533 updateButtonStates();
534 }
535
yuega5a08d82017-10-31 14:11:53 -0700536 /**
537 * Update the avatar from photo.
538 *
539 * @param avatar the new photo avatar in the bubble's primary button
540 */
541 public void updatePhotoAvatar(@NonNull Drawable avatar) {
542 // Make it round
543 int bubbleSize = context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
544 Drawable roundAvatar =
545 DrawableConverter.getRoundedDrawable(context, avatar, bubbleSize, bubbleSize);
546
547 updateAvatar(roundAvatar);
548 }
549
550 /**
551 * Update the avatar.
552 *
553 * @param avatar the new avatar in the bubble's primary button
554 */
555 public void updateAvatar(@NonNull Drawable avatar) {
556 if (!avatar.equals(currentInfo.getAvatar())) {
557 currentInfo = NewBubbleInfo.from(currentInfo).setAvatar(avatar).build();
558 viewHolder.getPrimaryAvatar().setImageDrawable(currentInfo.getAvatar());
559 }
560 }
561
Eric Erfanian938468d2017-10-24 14:05:52 -0700562 /** Returns the currently displayed NewBubbleInfo */
563 public NewBubbleInfo getBubbleInfo() {
564 return currentInfo;
565 }
566
567 /**
568 * Display text in the main bubble. The bubble's drawer is not expandable while text is showing,
569 * and the drawer will be closed if already open.
570 *
571 * @param text the text to display to the user
572 */
573 public void showText(@NonNull CharSequence text) {
574 textShowing = true;
575 if (expanded) {
yueg81a77ff2017-12-05 10:29:03 -0800576 startCollapse(
577 CollapseEnd.NOTHING, false /* isUserAction */, false /* shouldRecoverYPosition */);
Eric Erfanian938468d2017-10-24 14:05:52 -0700578 doShowText(text);
579 } else {
580 // Need to transition from old bounds to new bounds manually
581 NewChangeOnScreenBounds transition = new NewChangeOnScreenBounds();
582 // Prepare and capture start values
583 TransitionValues startValues = new TransitionValues();
584 startValues.view = viewHolder.getPrimaryButton();
585 transition.addTarget(startValues.view);
586 transition.captureStartValues(startValues);
587
588 // If our view is not laid out yet, postpone showing the text.
589 if (startValues.values.isEmpty()) {
590 textAfterShow = text;
591 return;
592 }
593
yueg81a77ff2017-12-05 10:29:03 -0800594 doShowText(text);
595 // Hide the text so we can animate it in
596 viewHolder.getPrimaryText().setAlpha(0);
Eric Erfanian938468d2017-10-24 14:05:52 -0700597
yueg81a77ff2017-12-05 10:29:03 -0800598 ViewAnimator primaryButton = viewHolder.getPrimaryButton();
599 // Cancel the automatic transition scheduled in doShowText
600 TransitionManager.endTransitions((ViewGroup) primaryButton.getParent());
601 primaryButton
602 .getViewTreeObserver()
603 .addOnPreDrawListener(
604 new OnPreDrawListener() {
605 @Override
606 public boolean onPreDraw() {
607 primaryButton.getViewTreeObserver().removeOnPreDrawListener(this);
Eric Erfanian938468d2017-10-24 14:05:52 -0700608
yueg81a77ff2017-12-05 10:29:03 -0800609 // Prepare and capture end values, always use the size of primaryText since
610 // its invisibility makes primaryButton smaller than expected
611 TransitionValues endValues = new TransitionValues();
612 endValues.values.put(
613 NewChangeOnScreenBounds.PROPNAME_WIDTH,
614 viewHolder.getPrimaryText().getWidth());
615 endValues.values.put(
616 NewChangeOnScreenBounds.PROPNAME_HEIGHT,
617 viewHolder.getPrimaryText().getHeight());
618 endValues.view = primaryButton;
619 transition.addTarget(endValues.view);
620 transition.captureEndValues(endValues);
Eric Erfanian938468d2017-10-24 14:05:52 -0700621
yueg81a77ff2017-12-05 10:29:03 -0800622 // animate the primary button bounds change
623 Animator bounds =
624 transition.createAnimator(primaryButton, startValues, endValues);
Eric Erfanian938468d2017-10-24 14:05:52 -0700625
yueg81a77ff2017-12-05 10:29:03 -0800626 // Animate the text in
627 Animator alpha =
628 ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f);
Eric Erfanian938468d2017-10-24 14:05:52 -0700629
yueg81a77ff2017-12-05 10:29:03 -0800630 AnimatorSet set = new AnimatorSet();
631 set.play(bounds).before(alpha);
632 set.start();
633 return false;
634 }
635 });
Eric Erfanian938468d2017-10-24 14:05:52 -0700636 }
637 handler.removeCallbacks(collapseRunnable);
638 handler.postDelayed(collapseRunnable, SHOW_TEXT_DURATION_MILLIS);
639 }
640
Eric Erfanian938468d2017-10-24 14:05:52 -0700641 @Nullable
642 Integer getGravityOverride() {
643 return overrideGravity;
644 }
645
646 void onMoveStart() {
yueg81a77ff2017-12-05 10:29:03 -0800647 if (viewHolder.getExpandedView().getVisibility() == View.VISIBLE) {
648 viewHolder.setDrawerVisibility(View.INVISIBLE);
649 }
650 expanded = false;
651 savedYPosition = -1;
652
Eric Erfanian938468d2017-10-24 14:05:52 -0700653 viewHolder
654 .getPrimaryButton()
655 .animate()
656 .translationZ(
yuegc6deafc2017-11-06 16:42:13 -0800657 context
658 .getResources()
659 .getDimensionPixelOffset(R.dimen.bubble_dragging_elevation_change));
Eric Erfanian938468d2017-10-24 14:05:52 -0700660 }
661
662 void onMoveFinish() {
663 viewHolder.getPrimaryButton().animate().translationZ(0);
Eric Erfanian938468d2017-10-24 14:05:52 -0700664 }
665
666 void primaryButtonClick() {
667 if (textShowing || currentInfo.getActions().isEmpty()) {
668 return;
669 }
670 if (expanded) {
yueg81a77ff2017-12-05 10:29:03 -0800671 startCollapse(
672 CollapseEnd.NOTHING, true /* isUserAction */, true /* shouldRecoverYPosition */);
Eric Erfanian938468d2017-10-24 14:05:52 -0700673 } else {
674 expand(true);
675 }
676 }
677
yueg81a77ff2017-12-05 10:29:03 -0800678 void onLeftRightSwitch(boolean onRight) {
yueg87111362017-12-08 12:45:50 -0800679 // Move primary icon to the other side so it's not partially hiden
yueg81a77ff2017-12-05 10:29:03 -0800680 View primaryIcon = viewHolder.getPrimaryIcon();
yueg87111362017-12-08 12:45:50 -0800681 primaryIcon.animate().translationX(onRight ? -primaryIconMoveDistance : 0).start();
yueg81a77ff2017-12-05 10:29:03 -0800682 }
683
Eric Erfanian938468d2017-10-24 14:05:52 -0700684 LayoutParams getWindowParams() {
685 return windowParams;
686 }
687
688 View getRootView() {
689 return viewHolder.getRoot();
690 }
691
692 /**
693 * Hide the bubble if visible. Will run a short exit animation and before hiding, and {@code
694 * afterHiding} after hiding. If the bubble is currently showing text, will hide after the text is
695 * done displaying. If the bubble is not visible this method does nothing.
696 */
yueg87111362017-12-08 12:45:50 -0800697 @VisibleForTesting
698 void hideHelper(Runnable afterHiding) {
Eric Erfanian938468d2017-10-24 14:05:52 -0700699 if (visibility == Visibility.HIDDEN || visibility == Visibility.EXITING) {
700 return;
701 }
702
703 // Make bubble non clickable to prevent further buggy actions
704 viewHolder.setChildClickable(false);
705
706 if (textShowing) {
707 hideAfterText = true;
708 return;
709 }
710
711 if (collapseAnimation != null) {
712 collapseEndAction = CollapseEnd.HIDE;
713 return;
714 }
715
716 if (expanded) {
yueg81a77ff2017-12-05 10:29:03 -0800717 startCollapse(CollapseEnd.HIDE, false /* isUserAction */, false /* shouldRecoverYPosition */);
Eric Erfanian938468d2017-10-24 14:05:52 -0700718 return;
719 }
720
721 visibility = Visibility.EXITING;
yueg87111362017-12-08 12:45:50 -0800722
723 // Hide bubble animation: scale the whole bubble to 0, and change avatar+icon's alpha to 0
724 ObjectAnimator scaleXAnimator =
725 ObjectAnimator.ofFloat(viewHolder.getPrimaryButton(), "scaleX", 0);
726 ObjectAnimator scaleYAnimator =
727 ObjectAnimator.ofFloat(viewHolder.getPrimaryButton(), "scaleY", 0);
728 ObjectAnimator avatarAlphaAnimator =
729 ObjectAnimator.ofFloat(viewHolder.getPrimaryAvatar(), "alpha", 0);
730 ObjectAnimator iconAlphaAnimator =
731 ObjectAnimator.ofFloat(viewHolder.getPrimaryIcon(), "alpha", 0);
732 exitAnimatorSet = new AnimatorSet();
733 exitAnimatorSet.playTogether(
734 scaleXAnimator, scaleYAnimator, avatarAlphaAnimator, iconAlphaAnimator);
735 exitAnimatorSet.setInterpolator(new AnticipateInterpolator());
736 exitAnimatorSet.addListener(
737 new AnimatorListenerAdapter() {
738 @Override
yuega235e132017-12-13 14:13:57 -0800739 public void onAnimationStart(Animator animation) {
740 viewHolder.getPrimaryButton().setAccessibilityDelegate(null);
741 }
742
743 @Override
yueg87111362017-12-08 12:45:50 -0800744 public void onAnimationEnd(Animator animation) {
745 afterHiding.run();
746 }
747 });
748 exitAnimatorSet.start();
Eric Erfanian938468d2017-10-24 14:05:52 -0700749 }
750
751 private void reset() {
752 viewHolder = new ViewHolder(viewHolder.getRoot().getContext());
753 update();
754 }
755
756 private void update() {
yuega5a08d82017-10-31 14:11:53 -0700757 // Whole primary button background
yueg84ac49b2017-11-01 16:22:28 -0700758 Drawable backgroundCirle =
759 context.getResources().getDrawable(R.drawable.bubble_shape_circle, context.getTheme());
Eric Erfanian938468d2017-10-24 14:05:52 -0700760 int primaryTint =
761 ColorUtils.compositeColors(
762 context.getColor(R.color.bubble_primary_background_darken),
763 currentInfo.getPrimaryColor());
yueg84ac49b2017-11-01 16:22:28 -0700764 backgroundCirle.mutate().setTint(primaryTint);
765 viewHolder.getPrimaryButton().setBackground(backgroundCirle);
Eric Erfanian938468d2017-10-24 14:05:52 -0700766
yuega5a08d82017-10-31 14:11:53 -0700767 // Small icon
yueg84ac49b2017-11-01 16:22:28 -0700768 Drawable smallIconBackgroundCircle =
769 context
770 .getResources()
771 .getDrawable(R.drawable.bubble_shape_circle_small, context.getTheme());
772 smallIconBackgroundCircle.setTint(context.getColor(R.color.bubble_button_color_blue));
773 viewHolder.getPrimaryIcon().setBackground(smallIconBackgroundCircle);
Eric Erfanian938468d2017-10-24 14:05:52 -0700774 viewHolder.getPrimaryIcon().setImageIcon(currentInfo.getPrimaryIcon());
yuega5a08d82017-10-31 14:11:53 -0700775 viewHolder.getPrimaryAvatar().setImageDrawable(currentInfo.getAvatar());
Eric Erfanian938468d2017-10-24 14:05:52 -0700776
yuega5a08d82017-10-31 14:11:53 -0700777 updatePrimaryIconAnimation();
Eric Erfanian938468d2017-10-24 14:05:52 -0700778 updateButtonStates();
779 }
780
781 private void updatePrimaryIconAnimation() {
782 Drawable drawable = viewHolder.getPrimaryIcon().getDrawable();
783 if (drawable instanceof Animatable) {
784 if (isVisible()) {
785 ((Animatable) drawable).start();
786 } else {
787 ((Animatable) drawable).stop();
788 }
789 }
790 }
791
792 private void updateButtonStates() {
yueg84ac49b2017-11-01 16:22:28 -0700793 configureButton(currentInfo.getActions().get(0), viewHolder.getFullScreenButton());
794 configureButton(currentInfo.getActions().get(1), viewHolder.getMuteButton());
795 configureButton(currentInfo.getActions().get(2), viewHolder.getAudioRouteButton());
796 configureButton(currentInfo.getActions().get(3), viewHolder.getEndCallButton());
Eric Erfanian938468d2017-10-24 14:05:52 -0700797 }
798
yueg87111362017-12-08 12:45:50 -0800799 @VisibleForTesting
800 void doShowText(@NonNull CharSequence text) {
Eric Erfanian938468d2017-10-24 14:05:52 -0700801 TransitionManager.beginDelayedTransition((ViewGroup) viewHolder.getPrimaryButton().getParent());
802 viewHolder.getPrimaryText().setText(text);
803 viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_TEXT);
804 }
805
yueg84ac49b2017-11-01 16:22:28 -0700806 private void configureButton(Action action, NewCheckableButton button) {
807 button.setCompoundDrawablesWithIntrinsicBounds(action.getIconDrawable(), null, null, null);
Eric Erfanian938468d2017-10-24 14:05:52 -0700808 button.setChecked(action.isChecked());
809 button.setEnabled(action.isEnabled());
yueg84ac49b2017-11-01 16:22:28 -0700810 button.setText(action.getName());
yuega235e132017-12-13 14:13:57 -0800811 button.setContentDescription(action.getName());
Eric Erfanian938468d2017-10-24 14:05:52 -0700812 button.setOnClickListener(v -> doAction(action));
813 }
814
815 private void doAction(Action action) {
816 try {
817 action.getIntent().send();
818 } catch (CanceledException e) {
819 throw new RuntimeException(e);
820 }
821 }
822
yueg81a77ff2017-12-05 10:29:03 -0800823 /**
824 * Create a new ViewHolder object to replace the old one.It only happens when not moving and
825 * collapsed.
826 */
827 void replaceViewHolder() {
828 LogUtil.enterBlock("NewBubble.replaceViewHolder");
Eric Erfanian938468d2017-10-24 14:05:52 -0700829 ViewHolder oldViewHolder = viewHolder;
Eric Erfanian938468d2017-10-24 14:05:52 -0700830
yueg81a77ff2017-12-05 10:29:03 -0800831 // Create a new ViewHolder and copy needed info.
832 viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext());
833 viewHolder
834 .getPrimaryButton()
835 .setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild());
836 viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText());
yueg87111362017-12-08 12:45:50 -0800837 viewHolder.getPrimaryIcon().setX(isDrawingFromRight() ? 0 : primaryIconMoveDistance);
yueg81a77ff2017-12-05 10:29:03 -0800838 viewHolder
839 .getPrimaryIcon()
yueg87111362017-12-08 12:45:50 -0800840 .setTranslationX(isDrawingFromRight() ? -primaryIconMoveDistance : 0);
yuega235e132017-12-13 14:13:57 -0800841 setPrimaryButtonAccessibilityAction(
842 context.getString(R.string.a11y_bubble_primary_button_expand_action));
Eric Erfanian938468d2017-10-24 14:05:52 -0700843
yueg81a77ff2017-12-05 10:29:03 -0800844 update();
845
846 // Add new view at its horizontal boundary
Eric Erfanian938468d2017-10-24 14:05:52 -0700847 ViewGroup root = viewHolder.getRoot();
yueg81a77ff2017-12-05 10:29:03 -0800848 windowParams.x = leftBoundary;
849 windowParams.gravity = Gravity.TOP | (isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT);
Eric Erfanian938468d2017-10-24 14:05:52 -0700850 windowManager.addView(root, windowParams);
yueg81a77ff2017-12-05 10:29:03 -0800851
852 // Remove the old view after delay
Eric Erfanian938468d2017-10-24 14:05:52 -0700853 root.getViewTreeObserver()
854 .addOnPreDrawListener(
855 new OnPreDrawListener() {
856 @Override
857 public boolean onPreDraw() {
858 root.getViewTreeObserver().removeOnPreDrawListener(this);
859 // Wait a bit before removing the old view; make sure the new one has drawn over it.
860 handler.postDelayed(
861 () -> windowManager.removeView(oldViewHolder.getRoot()),
862 WINDOW_REDRAW_DELAY_MILLIS);
863 return true;
864 }
865 });
866 }
867
yueg81a77ff2017-12-05 10:29:03 -0800868 int getDrawerVisibility() {
869 return viewHolder.getExpandedView().getVisibility();
Eric Erfanian938468d2017-10-24 14:05:52 -0700870 }
871
872 private boolean isDrawingFromRight() {
873 return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
874 }
875
876 private void setFocused(boolean focused) {
877 if (focused) {
878 windowParams.flags &= ~LayoutParams.FLAG_NOT_FOCUSABLE;
879 } else {
880 windowParams.flags |= LayoutParams.FLAG_NOT_FOCUSABLE;
881 }
882 windowManager.updateViewLayout(getRootView(), windowParams);
883 }
884
885 private void defaultAfterHidingAnimation() {
yueg87111362017-12-08 12:45:50 -0800886 exitAnimatorSet = null;
887 viewHolder.getPrimaryButton().setVisibility(View.INVISIBLE);
Eric Erfanian938468d2017-10-24 14:05:52 -0700888 windowManager.removeView(viewHolder.getRoot());
889 visibility = Visibility.HIDDEN;
890
891 updatePrimaryIconAnimation();
892 }
893
yueg81a77ff2017-12-05 10:29:03 -0800894 private void logBasicOrCallImpression(DialerImpression.Type impressionType) {
895 DialerCall call = CallList.getInstance().getActiveOrBackgroundCall();
896 if (call != null) {
897 Logger.get(context)
898 .logCallImpression(impressionType, call.getUniqueCallId(), call.getTimeAddedMs());
899 } else {
900 Logger.get(context).logImpression(impressionType);
901 }
902 }
903
yuega235e132017-12-13 14:13:57 -0800904 private void setPrimaryButtonAccessibilityAction(String description) {
905 viewHolder
906 .getPrimaryButton()
907 .setAccessibilityDelegate(
908 new AccessibilityDelegate() {
909 @Override
910 public void onInitializeAccessibilityNodeInfo(View v, AccessibilityNodeInfo info) {
911 super.onInitializeAccessibilityNodeInfo(v, info);
912
913 AccessibilityAction clickAction =
914 new AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, description);
915 info.addAction(clickAction);
916 }
917 });
918 }
919
Eric Erfanian938468d2017-10-24 14:05:52 -0700920 @VisibleForTesting
921 class ViewHolder {
922
yuega5a08d82017-10-31 14:11:53 -0700923 public static final int CHILD_INDEX_AVATAR_AND_ICON = 0;
Eric Erfanian938468d2017-10-24 14:05:52 -0700924 public static final int CHILD_INDEX_TEXT = 1;
925
926 private final NewMoveHandler moveHandler;
927 private final NewWindowRoot root;
928 private final ViewAnimator primaryButton;
929 private final ImageView primaryIcon;
yuega5a08d82017-10-31 14:11:53 -0700930 private final ImageView primaryAvatar;
Eric Erfanian938468d2017-10-24 14:05:52 -0700931 private final TextView primaryText;
932
933 private final NewCheckableButton fullScreenButton;
934 private final NewCheckableButton muteButton;
935 private final NewCheckableButton audioRouteButton;
936 private final NewCheckableButton endCallButton;
937 private final View expandedView;
938
939 public ViewHolder(Context context) {
940 // Window root is not in the layout file so that the inflater has a view to inflate into
941 this.root = new NewWindowRoot(context);
942 LayoutInflater inflater = LayoutInflater.from(root.getContext());
943 View contentView = inflater.inflate(R.layout.new_bubble_base, root, true);
944 expandedView = contentView.findViewById(R.id.bubble_expanded_layout);
945 primaryButton = contentView.findViewById(R.id.bubble_button_primary);
yuega5a08d82017-10-31 14:11:53 -0700946 primaryAvatar = contentView.findViewById(R.id.bubble_icon_avatar);
Eric Erfanian938468d2017-10-24 14:05:52 -0700947 primaryIcon = contentView.findViewById(R.id.bubble_icon_primary);
948 primaryText = contentView.findViewById(R.id.bubble_text);
949
950 fullScreenButton = contentView.findViewById(R.id.bubble_button_full_screen);
951 muteButton = contentView.findViewById(R.id.bubble_button_mute);
952 audioRouteButton = contentView.findViewById(R.id.bubble_button_audio_route);
953 endCallButton = contentView.findViewById(R.id.bubble_button_end_call);
954
955 root.setOnBackPressedListener(
956 () -> {
957 if (visibility == Visibility.SHOWING && expanded) {
yueg81a77ff2017-12-05 10:29:03 -0800958 startCollapse(
959 CollapseEnd.NOTHING, true /* isUserAction */, true /* shouldRecoverYPosition */);
Eric Erfanian938468d2017-10-24 14:05:52 -0700960 return true;
961 }
962 return false;
963 });
964 root.setOnConfigurationChangedListener(
965 (configuration) -> {
966 // The values in the current MoveHandler may be stale, so replace it. Then ensure the
967 // Window is in bounds
968 moveHandler = new NewMoveHandler(primaryButton, NewBubble.this);
969 moveHandler.snapToBounds();
970 });
971 root.setOnTouchListener(
972 (v, event) -> {
973 if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
yueg81a77ff2017-12-05 10:29:03 -0800974 startCollapse(
975 CollapseEnd.NOTHING, true /* isUserAction */, true /* shouldRecoverYPosition */);
Eric Erfanian938468d2017-10-24 14:05:52 -0700976 return true;
977 }
978 return false;
979 });
980 moveHandler = new NewMoveHandler(primaryButton, NewBubble.this);
981 }
982
983 private void setChildClickable(boolean clickable) {
984 fullScreenButton.setClickable(clickable);
985 muteButton.setClickable(clickable);
986 audioRouteButton.setClickable(clickable);
987 endCallButton.setClickable(clickable);
988
989 // For primaryButton
990 moveHandler.setClickable(clickable);
991 }
992
yueg81a77ff2017-12-05 10:29:03 -0800993 public int getMoveUpDistance() {
994 int deltaAllowed =
995 expandedView.getHeight()
996 - context
997 .getResources()
998 .getDimensionPixelOffset(R.dimen.bubble_button_padding_vertical)
999 * 2;
1000 return moveHandler.getMoveUpDistance(deltaAllowed);
1001 }
1002
Eric Erfanian938468d2017-10-24 14:05:52 -07001003 public ViewGroup getRoot() {
1004 return root;
1005 }
1006
1007 public ViewAnimator getPrimaryButton() {
1008 return primaryButton;
1009 }
1010
1011 public ImageView getPrimaryIcon() {
1012 return primaryIcon;
1013 }
1014
yuega5a08d82017-10-31 14:11:53 -07001015 public ImageView getPrimaryAvatar() {
1016 return primaryAvatar;
1017 }
1018
Eric Erfanian938468d2017-10-24 14:05:52 -07001019 public TextView getPrimaryText() {
1020 return primaryText;
1021 }
1022
1023 public View getExpandedView() {
1024 return expandedView;
1025 }
1026
1027 public NewCheckableButton getFullScreenButton() {
1028 return fullScreenButton;
1029 }
1030
1031 public NewCheckableButton getMuteButton() {
1032 return muteButton;
1033 }
1034
1035 public NewCheckableButton getAudioRouteButton() {
1036 return audioRouteButton;
1037 }
1038
1039 public NewCheckableButton getEndCallButton() {
1040 return endCallButton;
1041 }
1042
1043 public void setDrawerVisibility(int visibility) {
1044 expandedView.setVisibility(visibility);
1045 }
1046
1047 public boolean isMoving() {
1048 return moveHandler.isMoving();
1049 }
1050
1051 public void undoGravityOverride() {
1052 moveHandler.undoGravityOverride();
1053 }
1054 }
Eric Erfanian938468d2017-10-24 14:05:52 -07001055}