summaryrefslogtreecommitdiffstats
path: root/java/com/android/newbubble
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/android/newbubble')
-rw-r--r--java/com/android/newbubble/AndroidManifest.xml22
-rw-r--r--java/com/android/newbubble/NewBubble.java837
-rw-r--r--java/com/android/newbubble/NewBubbleInfo.java117
-rw-r--r--java/com/android/newbubble/NewChangeOnScreenBounds.java191
-rw-r--r--java/com/android/newbubble/NewCheckableButton.java87
-rw-r--r--java/com/android/newbubble/NewMoveHandler.java279
-rw-r--r--java/com/android/newbubble/NewWindowRoot.java74
-rw-r--r--java/com/android/newbubble/res/drawable/bubble_background_with_radius.xml25
-rw-r--r--java/com/android/newbubble/res/drawable/bubble_ripple_circle.xml26
-rw-r--r--java/com/android/newbubble/res/layout/new_bubble_base.xml137
-rw-r--r--java/com/android/newbubble/res/values/colors.xml25
-rw-r--r--java/com/android/newbubble/res/values/values.xml32
12 files changed, 1852 insertions, 0 deletions
diff --git a/java/com/android/newbubble/AndroidManifest.xml b/java/com/android/newbubble/AndroidManifest.xml
new file mode 100644
index 000000000..048f8cf1b
--- /dev/null
+++ b/java/com/android/newbubble/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<!--
+ ~ Copyright (C) 2017 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.newbubble">
+
+ <uses-sdk android:minSdkVersion="23"/>
+ <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+</manifest>
diff --git a/java/com/android/newbubble/NewBubble.java b/java/com/android/newbubble/NewBubble.java
new file mode 100644
index 000000000..d9b9ae2ad
--- /dev/null
+++ b/java/com/android/newbubble/NewBubble.java
@@ -0,0 +1,837 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.newbubble;
+
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.annotation.SuppressLint;
+import android.app.PendingIntent.CanceledException;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.PixelFormat;
+import android.graphics.drawable.Animatable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.RippleDrawable;
+import android.net.Uri;
+import android.os.Handler;
+import android.provider.Settings;
+import android.support.annotation.ColorInt;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.support.v4.graphics.ColorUtils;
+import android.support.v4.graphics.drawable.DrawableCompat;
+import android.support.v4.os.BuildCompat;
+import android.support.v4.view.animation.FastOutLinearInInterpolator;
+import android.support.v4.view.animation.LinearOutSlowInInterpolator;
+import android.transition.TransitionManager;
+import android.transition.TransitionValues;
+import android.view.ContextThemeWrapper;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewPropertyAnimator;
+import android.view.ViewTreeObserver.OnPreDrawListener;
+import android.view.WindowManager;
+import android.view.WindowManager.LayoutParams;
+import android.view.animation.AnticipateInterpolator;
+import android.view.animation.OvershootInterpolator;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.ViewAnimator;
+import com.android.newbubble.NewBubbleInfo.Action;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.List;
+
+/**
+ * Creates and manages a bubble window from information in a {@link NewBubbleInfo}. Before creating,
+ * be sure to check whether bubbles may be shown using {@link #canShowBubbles(Context)} and request
+ * permission if necessary ({@link #getRequestPermissionIntent(Context)} is provided for
+ * convenience)
+ */
+public class NewBubble {
+ // This class has some odd behavior that is not immediately obvious in order to avoid jank when
+ // resizing. See http://go/bubble-resize for details.
+
+ // How long text should show after showText(CharSequence) is called
+ private static final int SHOW_TEXT_DURATION_MILLIS = 3000;
+ // How long the new window should show before destroying the old one during resize operations.
+ // This ensures the new window has had time to draw first.
+ private static final int WINDOW_REDRAW_DELAY_MILLIS = 50;
+
+ private static Boolean canShowBubblesForTesting = null;
+
+ private final Context context;
+ private final WindowManager windowManager;
+
+ private final Handler handler;
+ private LayoutParams windowParams;
+
+ // Initialized in factory method
+ @SuppressWarnings("NullableProblems")
+ @NonNull
+ private NewBubbleInfo currentInfo;
+
+ @Visibility private int visibility;
+ private boolean expanded;
+ private boolean textShowing;
+ private boolean hideAfterText;
+ private CharSequence textAfterShow;
+ private int collapseEndAction;
+
+ @VisibleForTesting ViewHolder viewHolder;
+ private ViewPropertyAnimator collapseAnimation;
+ private Integer overrideGravity;
+ private ViewPropertyAnimator exitAnimator;
+
+ private final Runnable collapseRunnable =
+ new Runnable() {
+ @Override
+ public void run() {
+ textShowing = false;
+ if (hideAfterText) {
+ // Always reset here since text shouldn't keep showing.
+ hideAndReset();
+ } else {
+ doResize(
+ () -> viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_ICON));
+ }
+ }
+ };
+
+ private BubbleExpansionStateListener bubbleExpansionStateListener;
+
+ /** Type of action after bubble collapse */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ public @interface CollapseEnd {
+ int NOTHING = 0;
+ int HIDE = 1;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Visibility.ENTERING, Visibility.SHOWING, Visibility.EXITING, Visibility.HIDDEN})
+ private @interface Visibility {
+ int HIDDEN = 0;
+ int ENTERING = 1;
+ int SHOWING = 2;
+ int EXITING = 3;
+ }
+
+ /** Indicate bubble expansion state. */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ExpansionState.START_EXPANDING, ExpansionState.START_COLLAPSING})
+ public @interface ExpansionState {
+ // TODO(yueg): add more states when needed
+ int START_EXPANDING = 0;
+ int START_COLLAPSING = 1;
+ }
+
+ /**
+ * Determines whether bubbles can be shown based on permissions obtained. This should be checked
+ * before attempting to create a Bubble.
+ *
+ * @return true iff bubbles are able to be shown.
+ * @see Settings#canDrawOverlays(Context)
+ */
+ public static boolean canShowBubbles(@NonNull Context context) {
+ return canShowBubblesForTesting != null
+ ? canShowBubblesForTesting
+ : Settings.canDrawOverlays(context);
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+ public static void setCanShowBubblesForTesting(boolean canShowBubbles) {
+ canShowBubblesForTesting = canShowBubbles;
+ }
+
+ /** Returns an Intent to request permission to show overlays */
+ @NonNull
+ public static Intent getRequestPermissionIntent(@NonNull Context context) {
+ return new Intent(
+ Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ Uri.fromParts("package", context.getPackageName(), null));
+ }
+
+ /** Creates instances of Bubble. The default implementation just calls the constructor. */
+ @VisibleForTesting
+ public interface BubbleFactory {
+ NewBubble createBubble(@NonNull Context context, @NonNull Handler handler);
+ }
+
+ private static BubbleFactory bubbleFactory = NewBubble::new;
+
+ public static NewBubble createBubble(@NonNull Context context, @NonNull NewBubbleInfo info) {
+ NewBubble bubble = bubbleFactory.createBubble(context, new Handler());
+ bubble.setBubbleInfo(info);
+ return bubble;
+ }
+
+ @VisibleForTesting
+ public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) {
+ NewBubble.bubbleFactory = bubbleFactory;
+ }
+
+ @VisibleForTesting
+ public static void resetBubbleFactory() {
+ NewBubble.bubbleFactory = NewBubble::new;
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ NewBubble(@NonNull Context context, @NonNull Handler handler) {
+ context = new ContextThemeWrapper(context, R.style.Theme_AppCompat);
+ this.context = context;
+ this.handler = handler;
+ windowManager = context.getSystemService(WindowManager.class);
+
+ viewHolder = new ViewHolder(context);
+ }
+
+ /** Expands the main bubble menu. */
+ public void expand(boolean isUserAction) {
+ if (bubbleExpansionStateListener != null) {
+ bubbleExpansionStateListener.onBubbleExpansionStateChanged(
+ ExpansionState.START_EXPANDING, isUserAction);
+ }
+ doResize(() -> viewHolder.setDrawerVisibility(View.VISIBLE));
+ View expandedView = viewHolder.getExpandedView();
+ expandedView
+ .getViewTreeObserver()
+ .addOnPreDrawListener(
+ new OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ // Animate expanded view to move from above primary button to its final position
+ expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
+ expandedView.setTranslationY(-viewHolder.getRoot().getHeight());
+ expandedView
+ .animate()
+ .setInterpolator(new LinearOutSlowInInterpolator())
+ .translationY(0);
+ return false;
+ }
+ });
+ setFocused(true);
+ expanded = true;
+ }
+
+ /**
+ * Make the bubble visible. Will show a short entrance animation as it enters. If the bubble is
+ * already showing this method does nothing.
+ */
+ public void show() {
+ if (collapseEndAction == CollapseEnd.HIDE) {
+ // If show() was called while collapsing, make sure we don't hide after.
+ collapseEndAction = CollapseEnd.NOTHING;
+ }
+ if (visibility == Visibility.SHOWING || visibility == Visibility.ENTERING) {
+ return;
+ }
+
+ hideAfterText = false;
+
+ if (windowParams == null) {
+ // Apps targeting O+ must use TYPE_APPLICATION_OVERLAY, which is not available prior to O.
+ @SuppressWarnings("deprecation")
+ @SuppressLint("InlinedApi")
+ int type =
+ BuildCompat.isAtLeastO()
+ ? LayoutParams.TYPE_APPLICATION_OVERLAY
+ : LayoutParams.TYPE_PHONE;
+
+ windowParams =
+ new LayoutParams(
+ type,
+ LayoutParams.FLAG_NOT_TOUCH_MODAL
+ | LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
+ | LayoutParams.FLAG_NOT_FOCUSABLE
+ | LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ PixelFormat.TRANSLUCENT);
+ windowParams.gravity = Gravity.TOP | Gravity.LEFT;
+ windowParams.x =
+ context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_horizontal);
+ windowParams.y = currentInfo.getStartingYPosition();
+ windowParams.height = LayoutParams.WRAP_CONTENT;
+ windowParams.width = LayoutParams.WRAP_CONTENT;
+ }
+
+ if (exitAnimator != null) {
+ exitAnimator.cancel();
+ exitAnimator = null;
+ } else {
+ windowManager.addView(viewHolder.getRoot(), windowParams);
+ viewHolder.getPrimaryButton().setScaleX(0);
+ viewHolder.getPrimaryButton().setScaleY(0);
+ }
+
+ viewHolder.setChildClickable(true);
+ visibility = Visibility.ENTERING;
+ viewHolder
+ .getPrimaryButton()
+ .animate()
+ .setInterpolator(new OvershootInterpolator())
+ .scaleX(1)
+ .scaleY(1)
+ .withEndAction(
+ () -> {
+ visibility = Visibility.SHOWING;
+ // Show the queued up text, if available.
+ if (textAfterShow != null) {
+ showText(textAfterShow);
+ textAfterShow = null;
+ }
+ })
+ .start();
+
+ updatePrimaryIconAnimation();
+ }
+
+ /** Hide the bubble. */
+ public void hide() {
+ if (hideAfterText) {
+ // hideAndReset() will be called after showing text, do nothing here.
+ return;
+ }
+ hideHelper(this::defaultAfterHidingAnimation);
+ }
+
+ /** Hide the bubble and reset {@viewHolder} to initial state */
+ public void hideAndReset() {
+ hideHelper(
+ () -> {
+ defaultAfterHidingAnimation();
+ reset();
+ });
+ }
+
+ /** Returns whether the bubble is currently visible */
+ public boolean isVisible() {
+ return visibility == Visibility.SHOWING
+ || visibility == Visibility.ENTERING
+ || visibility == Visibility.EXITING;
+ }
+
+ /**
+ * Set the info for this Bubble to display
+ *
+ * @param bubbleInfo the BubbleInfo to display in this Bubble.
+ */
+ public void setBubbleInfo(@NonNull NewBubbleInfo bubbleInfo) {
+ currentInfo = bubbleInfo;
+ update();
+ }
+
+ /**
+ * Update the state and behavior of actions.
+ *
+ * @param actions the new state of the bubble's actions
+ */
+ public void updateActions(@NonNull List<Action> actions) {
+ currentInfo = NewBubbleInfo.from(currentInfo).setActions(actions).build();
+ updateButtonStates();
+ }
+
+ /** Returns the currently displayed NewBubbleInfo */
+ public NewBubbleInfo getBubbleInfo() {
+ return currentInfo;
+ }
+
+ /**
+ * Display text in the main bubble. The bubble's drawer is not expandable while text is showing,
+ * and the drawer will be closed if already open.
+ *
+ * @param text the text to display to the user
+ */
+ public void showText(@NonNull CharSequence text) {
+ textShowing = true;
+ if (expanded) {
+ startCollapse(CollapseEnd.NOTHING, false);
+ doShowText(text);
+ } else {
+ // Need to transition from old bounds to new bounds manually
+ NewChangeOnScreenBounds transition = new NewChangeOnScreenBounds();
+ // Prepare and capture start values
+ TransitionValues startValues = new TransitionValues();
+ startValues.view = viewHolder.getPrimaryButton();
+ transition.addTarget(startValues.view);
+ transition.captureStartValues(startValues);
+
+ // If our view is not laid out yet, postpone showing the text.
+ if (startValues.values.isEmpty()) {
+ textAfterShow = text;
+ return;
+ }
+
+ doResize(
+ () -> {
+ doShowText(text);
+ // Hide the text so we can animate it in
+ viewHolder.getPrimaryText().setAlpha(0);
+
+ ViewAnimator primaryButton = viewHolder.getPrimaryButton();
+ // Cancel the automatic transition scheduled in doShowText
+ TransitionManager.endTransitions((ViewGroup) primaryButton.getParent());
+ primaryButton
+ .getViewTreeObserver()
+ .addOnPreDrawListener(
+ new OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ primaryButton.getViewTreeObserver().removeOnPreDrawListener(this);
+
+ // Prepare and capture end values, always use the size of primaryText since
+ // its invisibility makes primaryButton smaller than expected
+ TransitionValues endValues = new TransitionValues();
+ endValues.values.put(
+ NewChangeOnScreenBounds.PROPNAME_WIDTH,
+ viewHolder.getPrimaryText().getWidth());
+ endValues.values.put(
+ NewChangeOnScreenBounds.PROPNAME_HEIGHT,
+ viewHolder.getPrimaryText().getHeight());
+ endValues.view = primaryButton;
+ transition.addTarget(endValues.view);
+ transition.captureEndValues(endValues);
+
+ // animate the primary button bounds change
+ Animator bounds =
+ transition.createAnimator(primaryButton, startValues, endValues);
+
+ // Animate the text in
+ Animator alpha =
+ ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f);
+
+ AnimatorSet set = new AnimatorSet();
+ set.play(bounds).before(alpha);
+ set.start();
+ return false;
+ }
+ });
+ });
+ }
+ handler.removeCallbacks(collapseRunnable);
+ handler.postDelayed(collapseRunnable, SHOW_TEXT_DURATION_MILLIS);
+ }
+
+ public void setBubbleExpansionStateListener(
+ BubbleExpansionStateListener bubbleExpansionStateListener) {
+ this.bubbleExpansionStateListener = bubbleExpansionStateListener;
+ }
+
+ @Nullable
+ Integer getGravityOverride() {
+ return overrideGravity;
+ }
+
+ void onMoveStart() {
+ startCollapse(CollapseEnd.NOTHING, true);
+ viewHolder
+ .getPrimaryButton()
+ .animate()
+ .translationZ(
+ context.getResources().getDimensionPixelOffset(R.dimen.bubble_move_elevation_change));
+ }
+
+ void onMoveFinish() {
+ viewHolder.getPrimaryButton().animate().translationZ(0);
+ // If it's GONE, no resize is necessary. If it's VISIBLE, it will get cleaned up when the
+ // collapse animation finishes
+ if (viewHolder.getExpandedView().getVisibility() == View.INVISIBLE) {
+ doResize(null);
+ }
+ }
+
+ void primaryButtonClick() {
+ if (textShowing || currentInfo.getActions().isEmpty()) {
+ return;
+ }
+ if (expanded) {
+ startCollapse(CollapseEnd.NOTHING, true);
+ } else {
+ expand(true);
+ }
+ }
+
+ LayoutParams getWindowParams() {
+ return windowParams;
+ }
+
+ View getRootView() {
+ return viewHolder.getRoot();
+ }
+
+ /**
+ * Hide the bubble if visible. Will run a short exit animation and before hiding, and {@code
+ * afterHiding} after hiding. If the bubble is currently showing text, will hide after the text is
+ * done displaying. If the bubble is not visible this method does nothing.
+ */
+ private void hideHelper(Runnable afterHiding) {
+ if (visibility == Visibility.HIDDEN || visibility == Visibility.EXITING) {
+ return;
+ }
+
+ // Make bubble non clickable to prevent further buggy actions
+ viewHolder.setChildClickable(false);
+
+ if (textShowing) {
+ hideAfterText = true;
+ return;
+ }
+
+ if (collapseAnimation != null) {
+ collapseEndAction = CollapseEnd.HIDE;
+ return;
+ }
+
+ if (expanded) {
+ startCollapse(CollapseEnd.HIDE, false);
+ return;
+ }
+
+ visibility = Visibility.EXITING;
+ exitAnimator =
+ viewHolder
+ .getPrimaryButton()
+ .animate()
+ .setInterpolator(new AnticipateInterpolator())
+ .scaleX(0)
+ .scaleY(0)
+ .withEndAction(afterHiding);
+ exitAnimator.start();
+ }
+
+ private void reset() {
+ viewHolder = new ViewHolder(viewHolder.getRoot().getContext());
+ update();
+ }
+
+ private void update() {
+ RippleDrawable backgroundRipple =
+ (RippleDrawable)
+ context.getResources().getDrawable(R.drawable.bubble_ripple_circle, context.getTheme());
+ int primaryTint =
+ ColorUtils.compositeColors(
+ context.getColor(R.color.bubble_primary_background_darken),
+ currentInfo.getPrimaryColor());
+ backgroundRipple.getDrawable(0).setTint(primaryTint);
+ viewHolder.getPrimaryButton().setBackground(backgroundRipple);
+
+ viewHolder.getPrimaryIcon().setImageIcon(currentInfo.getPrimaryIcon());
+ updatePrimaryIconAnimation();
+
+ updateButtonStates();
+ }
+
+ private void updatePrimaryIconAnimation() {
+ Drawable drawable = viewHolder.getPrimaryIcon().getDrawable();
+ if (drawable instanceof Animatable) {
+ if (isVisible()) {
+ ((Animatable) drawable).start();
+ } else {
+ ((Animatable) drawable).stop();
+ }
+ }
+ }
+
+ private void updateButtonStates() {
+ int colorBlue = context.getColor(R.color.bubble_button_text_color_blue);
+ int colorWhite = context.getColor(R.color.bubble_button_text_color_white);
+
+ configureButton(currentInfo.getActions().get(0), viewHolder.getFullScreenButton(), colorBlue);
+ configureButton(currentInfo.getActions().get(1), viewHolder.getMuteButton(), colorBlue);
+ configureButton(currentInfo.getActions().get(2), viewHolder.getAudioRouteButton(), colorBlue);
+ configureButton(currentInfo.getActions().get(3), viewHolder.getEndCallButton(), colorWhite);
+ }
+
+ private void doShowText(@NonNull CharSequence text) {
+ TransitionManager.beginDelayedTransition((ViewGroup) viewHolder.getPrimaryButton().getParent());
+ viewHolder.getPrimaryText().setText(text);
+ viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_TEXT);
+ }
+
+ private void configureButton(Action action, NewCheckableButton button, @ColorInt int iconColor) {
+ Drawable iconDrawable = DrawableCompat.wrap(action.getIconDrawable());
+ DrawableCompat.setTint(iconDrawable.mutate(), iconColor);
+
+ button.setCompoundDrawablesWithIntrinsicBounds(iconDrawable, null, null, null);
+ button.setChecked(action.isChecked());
+ button.setEnabled(action.isEnabled());
+ if (action.getName() != null) {
+ button.setText(action.getName());
+ }
+ button.setOnClickListener(v -> doAction(action));
+ }
+
+ private void doAction(Action action) {
+ try {
+ action.getIntent().send();
+ } catch (CanceledException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private void doResize(@Nullable Runnable operation) {
+ // If we're resizing on the right side of the screen, there is an implicit move operation
+ // necessary. The WindowManager does not sync the move and resize operations, so serious jank
+ // would occur. To fix this, instead of resizing the window, we create a new one and destroy
+ // the old one. There is a short delay before destroying the old view to ensure the new one has
+ // had time to draw.
+ ViewHolder oldViewHolder = viewHolder;
+ if (isDrawingFromRight()) {
+ viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext());
+ update();
+ viewHolder
+ .getPrimaryButton()
+ .setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild());
+ viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText());
+ }
+
+ if (operation != null) {
+ operation.run();
+ }
+
+ if (isDrawingFromRight()) {
+ swapViewHolders(oldViewHolder);
+ }
+ }
+
+ private void swapViewHolders(ViewHolder oldViewHolder) {
+ ViewGroup root = viewHolder.getRoot();
+ windowManager.addView(root, windowParams);
+ root.getViewTreeObserver()
+ .addOnPreDrawListener(
+ new OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ root.getViewTreeObserver().removeOnPreDrawListener(this);
+ // Wait a bit before removing the old view; make sure the new one has drawn over it.
+ handler.postDelayed(
+ () -> windowManager.removeView(oldViewHolder.getRoot()),
+ WINDOW_REDRAW_DELAY_MILLIS);
+ return true;
+ }
+ });
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ public void startCollapse(@CollapseEnd int endAction, boolean isUserAction) {
+ View expandedView = viewHolder.getExpandedView();
+ if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) {
+ // Drawer is already collapsed or animation is running.
+ return;
+ }
+
+ overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT;
+ setFocused(false);
+
+ if (collapseEndAction == CollapseEnd.NOTHING) {
+ collapseEndAction = endAction;
+ }
+ if (bubbleExpansionStateListener != null && collapseEndAction == CollapseEnd.NOTHING) {
+ bubbleExpansionStateListener.onBubbleExpansionStateChanged(
+ ExpansionState.START_COLLAPSING, isUserAction);
+ }
+ // Animate expanded view to move from its position to above primary button and hide
+ collapseAnimation =
+ expandedView
+ .animate()
+ .translationY(-viewHolder.getRoot().getHeight())
+ .setInterpolator(new FastOutLinearInInterpolator())
+ .withEndAction(
+ () -> {
+ collapseAnimation = null;
+ expanded = false;
+
+ if (textShowing) {
+ // Will do resize once the text is done.
+ return;
+ }
+
+ // Hide the drawer and resize if possible.
+ viewHolder.setDrawerVisibility(View.INVISIBLE);
+ if (!viewHolder.isMoving() || !isDrawingFromRight()) {
+ doResize(() -> viewHolder.setDrawerVisibility(View.GONE));
+ }
+
+ // If this collapse was to come before a hide, do it now.
+ if (collapseEndAction == CollapseEnd.HIDE) {
+ hide();
+ }
+ collapseEndAction = CollapseEnd.NOTHING;
+
+ // Resume normal gravity after any resizing is done.
+ handler.postDelayed(
+ () -> {
+ overrideGravity = null;
+ if (!viewHolder.isMoving()) {
+ viewHolder.undoGravityOverride();
+ }
+ },
+ // Need to wait twice as long for resize and layout
+ WINDOW_REDRAW_DELAY_MILLIS * 2);
+ });
+ }
+
+ private boolean isDrawingFromRight() {
+ return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
+ }
+
+ private void setFocused(boolean focused) {
+ if (focused) {
+ windowParams.flags &= ~LayoutParams.FLAG_NOT_FOCUSABLE;
+ } else {
+ windowParams.flags |= LayoutParams.FLAG_NOT_FOCUSABLE;
+ }
+ windowManager.updateViewLayout(getRootView(), windowParams);
+ }
+
+ private void defaultAfterHidingAnimation() {
+ exitAnimator = null;
+ windowManager.removeView(viewHolder.getRoot());
+ visibility = Visibility.HIDDEN;
+
+ updatePrimaryIconAnimation();
+ }
+
+ @VisibleForTesting
+ class ViewHolder {
+
+ public static final int CHILD_INDEX_ICON = 0;
+ public static final int CHILD_INDEX_TEXT = 1;
+
+ private final NewMoveHandler moveHandler;
+ private final NewWindowRoot root;
+ private final ViewAnimator primaryButton;
+ private final ImageView primaryIcon;
+ private final TextView primaryText;
+
+ private final NewCheckableButton fullScreenButton;
+ private final NewCheckableButton muteButton;
+ private final NewCheckableButton audioRouteButton;
+ private final NewCheckableButton endCallButton;
+ private final View expandedView;
+
+ public ViewHolder(Context context) {
+ // Window root is not in the layout file so that the inflater has a view to inflate into
+ this.root = new NewWindowRoot(context);
+ LayoutInflater inflater = LayoutInflater.from(root.getContext());
+ View contentView = inflater.inflate(R.layout.new_bubble_base, root, true);
+ expandedView = contentView.findViewById(R.id.bubble_expanded_layout);
+ primaryButton = contentView.findViewById(R.id.bubble_button_primary);
+ primaryIcon = contentView.findViewById(R.id.bubble_icon_primary);
+ primaryText = contentView.findViewById(R.id.bubble_text);
+
+ fullScreenButton = contentView.findViewById(R.id.bubble_button_full_screen);
+ muteButton = contentView.findViewById(R.id.bubble_button_mute);
+ audioRouteButton = contentView.findViewById(R.id.bubble_button_audio_route);
+ endCallButton = contentView.findViewById(R.id.bubble_button_end_call);
+
+ root.setOnBackPressedListener(
+ () -> {
+ if (visibility == Visibility.SHOWING && expanded) {
+ startCollapse(CollapseEnd.NOTHING, true);
+ return true;
+ }
+ return false;
+ });
+ root.setOnConfigurationChangedListener(
+ (configuration) -> {
+ // The values in the current MoveHandler may be stale, so replace it. Then ensure the
+ // Window is in bounds
+ moveHandler = new NewMoveHandler(primaryButton, NewBubble.this);
+ moveHandler.snapToBounds();
+ });
+ root.setOnTouchListener(
+ (v, event) -> {
+ if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
+ startCollapse(CollapseEnd.NOTHING, true);
+ return true;
+ }
+ return false;
+ });
+ moveHandler = new NewMoveHandler(primaryButton, NewBubble.this);
+ }
+
+ private void setChildClickable(boolean clickable) {
+ fullScreenButton.setClickable(clickable);
+ muteButton.setClickable(clickable);
+ audioRouteButton.setClickable(clickable);
+ endCallButton.setClickable(clickable);
+
+ // For primaryButton
+ moveHandler.setClickable(clickable);
+ }
+
+ public ViewGroup getRoot() {
+ return root;
+ }
+
+ public ViewAnimator getPrimaryButton() {
+ return primaryButton;
+ }
+
+ public ImageView getPrimaryIcon() {
+ return primaryIcon;
+ }
+
+ public TextView getPrimaryText() {
+ return primaryText;
+ }
+
+ public View getExpandedView() {
+ return expandedView;
+ }
+
+ public NewCheckableButton getFullScreenButton() {
+ return fullScreenButton;
+ }
+
+ public NewCheckableButton getMuteButton() {
+ return muteButton;
+ }
+
+ public NewCheckableButton getAudioRouteButton() {
+ return audioRouteButton;
+ }
+
+ public NewCheckableButton getEndCallButton() {
+ return endCallButton;
+ }
+
+ public void setDrawerVisibility(int visibility) {
+ expandedView.setVisibility(visibility);
+ }
+
+ public boolean isMoving() {
+ return moveHandler.isMoving();
+ }
+
+ public void undoGravityOverride() {
+ moveHandler.undoGravityOverride();
+ }
+ }
+
+ /** Listener for bubble expansion state change. */
+ public interface BubbleExpansionStateListener {
+ void onBubbleExpansionStateChanged(@ExpansionState int expansionState, boolean isUserAction);
+ }
+}
diff --git a/java/com/android/newbubble/NewBubbleInfo.java b/java/com/android/newbubble/NewBubbleInfo.java
new file mode 100644
index 000000000..f615929e3
--- /dev/null
+++ b/java/com/android/newbubble/NewBubbleInfo.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.newbubble;
+
+import android.app.PendingIntent;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+import android.support.annotation.ColorInt;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.Px;
+import com.google.auto.value.AutoValue;
+import java.util.Collections;
+import java.util.List;
+
+/** Info for displaying a {@link NewBubble} */
+@AutoValue
+public abstract class NewBubbleInfo {
+ @ColorInt
+ public abstract int getPrimaryColor();
+
+ public abstract Icon getPrimaryIcon();
+
+ @Px
+ public abstract int getStartingYPosition();
+
+ @NonNull
+ public abstract List<Action> getActions();
+
+ public static Builder builder() {
+ return new AutoValue_NewBubbleInfo.Builder().setActions(Collections.emptyList());
+ }
+
+ public static Builder from(@NonNull NewBubbleInfo bubbleInfo) {
+ return builder()
+ .setPrimaryColor(bubbleInfo.getPrimaryColor())
+ .setPrimaryIcon(bubbleInfo.getPrimaryIcon())
+ .setStartingYPosition(bubbleInfo.getStartingYPosition())
+ .setActions(bubbleInfo.getActions());
+ }
+
+ /** Builder for {@link NewBubbleInfo} */
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public abstract Builder setPrimaryColor(@ColorInt int primaryColor);
+
+ public abstract Builder setPrimaryIcon(@NonNull Icon primaryIcon);
+
+ public abstract Builder setStartingYPosition(@Px int startingYPosition);
+
+ public abstract Builder setActions(List<Action> actions);
+
+ public abstract NewBubbleInfo build();
+ }
+
+ /** Represents actions to be shown in the bubble when expanded */
+ @AutoValue
+ public abstract static class Action {
+
+ public abstract Drawable getIconDrawable();
+
+ @Nullable
+ public abstract CharSequence getName();
+
+ @NonNull
+ public abstract PendingIntent getIntent();
+
+ public abstract boolean isEnabled();
+
+ public abstract boolean isChecked();
+
+ public static Builder builder() {
+ return new AutoValue_NewBubbleInfo_Action.Builder().setEnabled(true).setChecked(false);
+ }
+
+ public static Builder from(@NonNull Action action) {
+ return builder()
+ .setIntent(action.getIntent())
+ .setChecked(action.isChecked())
+ .setEnabled(action.isEnabled())
+ .setName(action.getName())
+ .setIconDrawable(action.getIconDrawable());
+ }
+
+ /** Builder for {@link Action} */
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public abstract Builder setIconDrawable(Drawable iconDrawable);
+
+ public abstract Builder setName(@Nullable CharSequence name);
+
+ public abstract Builder setIntent(@NonNull PendingIntent intent);
+
+ public abstract Builder setEnabled(boolean enabled);
+
+ public abstract Builder setChecked(boolean checked);
+
+ public abstract Action build();
+ }
+ }
+}
diff --git a/java/com/android/newbubble/NewChangeOnScreenBounds.java b/java/com/android/newbubble/NewChangeOnScreenBounds.java
new file mode 100644
index 000000000..0653d3a4b
--- /dev/null
+++ b/java/com/android/newbubble/NewChangeOnScreenBounds.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.newbubble;
+
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.graphics.Path;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.support.annotation.VisibleForTesting;
+import android.transition.Transition;
+import android.transition.TransitionValues;
+import android.util.Property;
+import android.view.View;
+import android.view.ViewGroup;
+
+/** Similar to {@link android.transition.ChangeBounds ChangeBounds} but works across windows */
+public class NewChangeOnScreenBounds extends Transition {
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ static final String PROPNAME_BOUNDS = "bubble:changeScreenBounds:bounds";
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ static final String PROPNAME_SCREEN_X = "bubble:changeScreenBounds:screenX";
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ static final String PROPNAME_SCREEN_Y = "bubble:changeScreenBounds:screenY";
+
+ static final String PROPNAME_WIDTH = "bubble:changeScreenBounds:width";
+ static final String PROPNAME_HEIGHT = "bubble:changeScreenBounds:height";
+
+ private static final Property<ViewBounds, PointF> TOP_LEFT_PROPERTY =
+ new Property<ViewBounds, PointF>(PointF.class, "topLeft") {
+ @Override
+ public void set(ViewBounds viewBounds, PointF topLeft) {
+ viewBounds.setTopLeft(topLeft);
+ }
+
+ @Override
+ public PointF get(ViewBounds viewBounds) {
+ return null;
+ }
+ };
+
+ private static final Property<ViewBounds, PointF> BOTTOM_RIGHT_PROPERTY =
+ new Property<ViewBounds, PointF>(PointF.class, "bottomRight") {
+ @Override
+ public void set(ViewBounds viewBounds, PointF bottomRight) {
+ viewBounds.setBottomRight(bottomRight);
+ }
+
+ @Override
+ public PointF get(ViewBounds viewBounds) {
+ return null;
+ }
+ };
+ private final int[] tempLocation = new int[2];
+
+ @Override
+ public void captureStartValues(TransitionValues transitionValues) {
+ captureValuesWithSize(transitionValues);
+ }
+
+ @Override
+ public void captureEndValues(TransitionValues transitionValues) {
+ captureValuesWithSize(transitionValues);
+ }
+
+ /**
+ * Capture location (left and top) from {@code values.view} and size (width and height) from
+ * {@code values.values}. If size is not set, use the size of {@code values.view}.
+ */
+ private void captureValuesWithSize(TransitionValues values) {
+ View view = values.view;
+
+ if (view.isLaidOut() || view.getWidth() != 0 || view.getHeight() != 0) {
+ Integer width = (Integer) values.values.get(PROPNAME_WIDTH);
+ Integer height = (Integer) values.values.get(PROPNAME_HEIGHT);
+
+ values.values.put(
+ PROPNAME_BOUNDS,
+ new Rect(
+ view.getLeft(),
+ view.getTop(),
+ width == null ? view.getRight() : view.getLeft() + width,
+ height == null ? view.getBottom() : view.getTop() + height));
+ values.view.getLocationOnScreen(tempLocation);
+ values.values.put(PROPNAME_SCREEN_X, tempLocation[0]);
+ values.values.put(PROPNAME_SCREEN_Y, tempLocation[1]);
+ }
+ }
+
+ @Override
+ public Animator createAnimator(
+ ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) {
+ Rect startBounds = (Rect) startValues.values.get(PROPNAME_BOUNDS);
+ Rect endBounds = (Rect) endValues.values.get(PROPNAME_BOUNDS);
+
+ if (startBounds == null || endBounds == null) {
+ // start or end values were not captured, so don't animate.
+ return null;
+ }
+
+ // Offset the startBounds by the difference in screen position
+ int startScreenX = (Integer) startValues.values.get(PROPNAME_SCREEN_X);
+ int startScreenY = (Integer) startValues.values.get(PROPNAME_SCREEN_Y);
+ int endScreenX = (Integer) endValues.values.get(PROPNAME_SCREEN_X);
+ int endScreenY = (Integer) endValues.values.get(PROPNAME_SCREEN_Y);
+ startBounds.offset(startScreenX - endScreenX, startScreenY - endScreenY);
+
+ final int startLeft = startBounds.left;
+ final int endLeft = endBounds.left;
+ final int startTop = startBounds.top;
+ final int endTop = endBounds.top;
+ final int startRight = startBounds.right;
+ final int endRight = endBounds.right;
+ final int startBottom = startBounds.bottom;
+ final int endBottom = endBounds.bottom;
+ ViewBounds viewBounds = new ViewBounds(endValues.view);
+ viewBounds.setTopLeft(new PointF(startLeft, startTop));
+ viewBounds.setBottomRight(new PointF(startRight, startBottom));
+
+ // Animate the top left and bottom right corners along a path
+ Path topLeftPath = getPathMotion().getPath(startLeft, startTop, endLeft, endTop);
+ ObjectAnimator topLeftAnimator =
+ ObjectAnimator.ofObject(viewBounds, TOP_LEFT_PROPERTY, null, topLeftPath);
+
+ Path bottomRightPath = getPathMotion().getPath(startRight, startBottom, endRight, endBottom);
+ ObjectAnimator bottomRightAnimator =
+ ObjectAnimator.ofObject(viewBounds, BOTTOM_RIGHT_PROPERTY, null, bottomRightPath);
+ AnimatorSet set = new AnimatorSet();
+ set.playTogether(topLeftAnimator, bottomRightAnimator);
+ return set;
+ }
+
+ private static class ViewBounds {
+ private int left;
+ private int top;
+ private int right;
+ private int bottom;
+ private final View view;
+ private int topLeftCalls;
+ private int bottomRightCalls;
+
+ public ViewBounds(View view) {
+ this.view = view;
+ }
+
+ public void setTopLeft(PointF topLeft) {
+ left = Math.round(topLeft.x);
+ top = Math.round(topLeft.y);
+ topLeftCalls++;
+ if (topLeftCalls == bottomRightCalls) {
+ updateLeftTopRightBottom();
+ }
+ }
+
+ public void setBottomRight(PointF bottomRight) {
+ right = Math.round(bottomRight.x);
+ bottom = Math.round(bottomRight.y);
+ bottomRightCalls++;
+ if (topLeftCalls == bottomRightCalls) {
+ updateLeftTopRightBottom();
+ }
+ }
+
+ private void updateLeftTopRightBottom() {
+ view.setLeft(left);
+ view.setTop(top);
+ view.setRight(right);
+ view.setBottom(bottom);
+ topLeftCalls = 0;
+ bottomRightCalls = 0;
+ }
+ }
+}
diff --git a/java/com/android/newbubble/NewCheckableButton.java b/java/com/android/newbubble/NewCheckableButton.java
new file mode 100644
index 000000000..63525a4a1
--- /dev/null
+++ b/java/com/android/newbubble/NewCheckableButton.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.newbubble;
+
+import android.content.Context;
+import android.support.v4.view.AccessibilityDelegateCompat;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.support.v7.widget.AppCompatButton;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.Checkable;
+
+/**
+ * A {@link android.widget.Button Button} that implements {@link Checkable} and propagates the
+ * checkable state
+ */
+public class NewCheckableButton extends AppCompatButton implements Checkable {
+
+ private boolean mChecked;
+
+ public NewCheckableButton(Context context) {
+ this(context, null);
+ }
+
+ public NewCheckableButton(Context context, AttributeSet attrs) {
+ this(context, attrs, android.R.attr.imageButtonStyle);
+ }
+
+ public NewCheckableButton(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ ViewCompat.setAccessibilityDelegate(
+ this,
+ new AccessibilityDelegateCompat() {
+ @Override
+ public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
+ super.onInitializeAccessibilityEvent(host, event);
+ event.setChecked(isChecked());
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(
+ View host, AccessibilityNodeInfoCompat info) {
+ super.onInitializeAccessibilityNodeInfo(host, info);
+ info.setCheckable(true);
+ info.setChecked(isChecked());
+ }
+ });
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ if (mChecked != checked) {
+ mChecked = checked;
+ setTextColor(
+ checked
+ ? getContext().getColor(R.color.bubble_button_text_color_blue)
+ : getContext().getColor(R.color.bubble_button_text_color_black));
+ }
+ }
+
+ @Override
+ public boolean isChecked() {
+ return mChecked;
+ }
+
+ @Override
+ public void toggle() {
+ setChecked(!mChecked);
+ }
+}
diff --git a/java/com/android/newbubble/NewMoveHandler.java b/java/com/android/newbubble/NewMoveHandler.java
new file mode 100644
index 000000000..189ad8472
--- /dev/null
+++ b/java/com/android/newbubble/NewMoveHandler.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.newbubble;
+
+import android.content.Context;
+import android.graphics.Point;
+import android.support.animation.FloatPropertyCompat;
+import android.support.animation.SpringAnimation;
+import android.support.animation.SpringForce;
+import android.support.annotation.NonNull;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.View.OnTouchListener;
+import android.view.ViewConfiguration;
+import android.view.WindowManager;
+import android.view.WindowManager.LayoutParams;
+import android.widget.Scroller;
+
+/** Handles touches and manages moving the bubble in response */
+class NewMoveHandler implements OnTouchListener {
+
+ // Amount the ViewConfiguration's minFlingVelocity will be scaled by for our own minVelocity
+ private static final int MIN_FLING_VELOCITY_FACTOR = 8;
+ // The friction multiplier to control how slippery the bubble is when flung
+ private static final float SCROLL_FRICTION_MULTIPLIER = 4f;
+
+ private final Context context;
+ private final WindowManager windowManager;
+ private final NewBubble bubble;
+ private final int minX;
+ private final int minY;
+ private final int maxX;
+ private final int maxY;
+ private final int bubbleSize;
+ private final float touchSlopSquared;
+
+ private boolean clickable = true;
+ private boolean isMoving;
+ private float firstX;
+ private float firstY;
+
+ private SpringAnimation moveXAnimation;
+ private SpringAnimation moveYAnimation;
+ private VelocityTracker velocityTracker;
+ private Scroller scroller;
+
+ private static float clamp(float value, float min, float max) {
+ return Math.min(max, Math.max(min, value));
+ }
+
+ // Handles the left/right gravity conversion and centering
+ private final FloatPropertyCompat<WindowManager.LayoutParams> xProperty =
+ new FloatPropertyCompat<LayoutParams>("xProperty") {
+ @Override
+ public float getValue(LayoutParams windowParams) {
+ int realX = windowParams.x;
+ realX = realX + bubbleSize / 2;
+ if (relativeToRight(windowParams)) {
+ int displayWidth = context.getResources().getDisplayMetrics().widthPixels;
+ realX = displayWidth - realX;
+ }
+ return clamp(realX, minX, maxX);
+ }
+
+ @Override
+ public void setValue(LayoutParams windowParams, float value) {
+ int displayWidth = context.getResources().getDisplayMetrics().widthPixels;
+ boolean onRight;
+ Integer gravityOverride = bubble.getGravityOverride();
+ if (gravityOverride == null) {
+ onRight = value > displayWidth / 2;
+ } else {
+ onRight = (gravityOverride & Gravity.RIGHT) == Gravity.RIGHT;
+ }
+ int centeringOffset = bubbleSize / 2;
+ windowParams.x =
+ (int) (onRight ? (displayWidth - value - centeringOffset) : value - centeringOffset);
+ windowParams.gravity = Gravity.TOP | (onRight ? Gravity.RIGHT : Gravity.LEFT);
+ if (bubble.isVisible()) {
+ windowManager.updateViewLayout(bubble.getRootView(), windowParams);
+ }
+ }
+ };
+
+ private final FloatPropertyCompat<WindowManager.LayoutParams> yProperty =
+ new FloatPropertyCompat<LayoutParams>("yProperty") {
+ @Override
+ public float getValue(LayoutParams object) {
+ return clamp(object.y + bubbleSize, minY, maxY);
+ }
+
+ @Override
+ public void setValue(LayoutParams object, float value) {
+ object.y = (int) value - bubbleSize;
+ if (bubble.isVisible()) {
+ windowManager.updateViewLayout(bubble.getRootView(), object);
+ }
+ }
+ };
+
+ public NewMoveHandler(@NonNull View targetView, @NonNull NewBubble bubble) {
+ this.bubble = bubble;
+ context = targetView.getContext();
+ windowManager = context.getSystemService(WindowManager.class);
+
+ bubbleSize = context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
+ minX =
+ context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_horizontal)
+ + bubbleSize / 2;
+ minY =
+ context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_vertical)
+ + bubbleSize / 2;
+ maxX = context.getResources().getDisplayMetrics().widthPixels - minX;
+ maxY = context.getResources().getDisplayMetrics().heightPixels - minY;
+
+ // Squared because it will be compared against the square of the touch delta. This is more
+ // efficient than needing to take a square root.
+ touchSlopSquared = (float) Math.pow(ViewConfiguration.get(context).getScaledTouchSlop(), 2);
+
+ targetView.setOnTouchListener(this);
+ }
+
+ public void setClickable(boolean clickable) {
+ this.clickable = clickable;
+ }
+
+ public boolean isMoving() {
+ return isMoving;
+ }
+
+ public void undoGravityOverride() {
+ LayoutParams windowParams = bubble.getWindowParams();
+ xProperty.setValue(windowParams, xProperty.getValue(windowParams));
+ }
+
+ public void snapToBounds() {
+ ensureSprings();
+
+ moveXAnimation.animateToFinalPosition(relativeToRight(bubble.getWindowParams()) ? maxX : minX);
+ moveYAnimation.animateToFinalPosition(yProperty.getValue(bubble.getWindowParams()));
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ float eventX = event.getRawX();
+ float eventY = event.getRawY();
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ firstX = eventX;
+ firstY = eventY;
+ velocityTracker = VelocityTracker.obtain();
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (isMoving || hasExceededTouchSlop(event)) {
+ if (!isMoving) {
+ isMoving = true;
+ bubble.onMoveStart();
+ }
+
+ ensureSprings();
+
+ moveXAnimation.animateToFinalPosition(clamp(eventX, minX, maxX));
+ moveYAnimation.animateToFinalPosition(clamp(eventY, minY, maxY));
+ }
+
+ velocityTracker.addMovement(event);
+ break;
+ case MotionEvent.ACTION_UP:
+ if (isMoving) {
+ ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
+ velocityTracker.computeCurrentVelocity(
+ 1000, viewConfiguration.getScaledMaximumFlingVelocity());
+ float xVelocity = velocityTracker.getXVelocity();
+ float yVelocity = velocityTracker.getYVelocity();
+ boolean isFling = isFling(xVelocity, yVelocity);
+
+ if (isFling) {
+ Point target =
+ findTarget(
+ xVelocity,
+ yVelocity,
+ (int) xProperty.getValue(bubble.getWindowParams()),
+ (int) yProperty.getValue(bubble.getWindowParams()));
+
+ moveXAnimation.animateToFinalPosition(target.x);
+ moveYAnimation.animateToFinalPosition(target.y);
+ } else {
+ snapX();
+ }
+ isMoving = false;
+ bubble.onMoveFinish();
+ } else {
+ v.performClick();
+ if (clickable) {
+ bubble.primaryButtonClick();
+ }
+ }
+ break;
+ default: // fall out
+ }
+ return true;
+ }
+
+ private void ensureSprings() {
+ if (moveXAnimation == null) {
+ moveXAnimation = new SpringAnimation(bubble.getWindowParams(), xProperty);
+ moveXAnimation.setSpring(new SpringForce());
+ moveXAnimation.getSpring().setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY);
+ }
+
+ if (moveYAnimation == null) {
+ moveYAnimation = new SpringAnimation(bubble.getWindowParams(), yProperty);
+ moveYAnimation.setSpring(new SpringForce());
+ moveYAnimation.getSpring().setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY);
+ }
+ }
+
+ private Point findTarget(float xVelocity, float yVelocity, int startX, int startY) {
+ if (scroller == null) {
+ scroller = new Scroller(context);
+ scroller.setFriction(ViewConfiguration.getScrollFriction() * SCROLL_FRICTION_MULTIPLIER);
+ }
+
+ // Find where a fling would end vertically
+ scroller.fling(startX, startY, (int) xVelocity, (int) yVelocity, minX, maxX, minY, maxY);
+ int targetY = scroller.getFinalY();
+ scroller.abortAnimation();
+
+ // If the x component of the velocity is above the minimum fling velocity, use velocity to
+ // determine edge. Otherwise use its starting position
+ boolean pullRight = isFling(xVelocity, 0) ? xVelocity > 0 : isOnRightHalf(startX);
+ return new Point(pullRight ? maxX : minX, targetY);
+ }
+
+ private boolean isFling(float xVelocity, float yVelocity) {
+ int minFlingVelocity =
+ ViewConfiguration.get(context).getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_FACTOR;
+ return getMagnitudeSquared(xVelocity, yVelocity) > minFlingVelocity * minFlingVelocity;
+ }
+
+ private boolean isOnRightHalf(float currentX) {
+ return currentX > (minX + maxX) / 2;
+ }
+
+ private void snapX() {
+ // Check if x value is closer to min or max
+ boolean pullRight = isOnRightHalf(xProperty.getValue(bubble.getWindowParams()));
+ moveXAnimation.animateToFinalPosition(pullRight ? maxX : minX);
+ }
+
+ private boolean relativeToRight(LayoutParams windowParams) {
+ return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
+ }
+
+ private boolean hasExceededTouchSlop(MotionEvent event) {
+ return getMagnitudeSquared(event.getRawX() - firstX, event.getRawY() - firstY)
+ > touchSlopSquared;
+ }
+
+ private float getMagnitudeSquared(float deltaX, float deltaY) {
+ return deltaX * deltaX + deltaY * deltaY;
+ }
+}
diff --git a/java/com/android/newbubble/NewWindowRoot.java b/java/com/android/newbubble/NewWindowRoot.java
new file mode 100644
index 000000000..da24b7143
--- /dev/null
+++ b/java/com/android/newbubble/NewWindowRoot.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.newbubble;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.support.annotation.NonNull;
+import android.view.KeyEvent;
+import android.widget.FrameLayout;
+
+/**
+ * ViewGroup that handles some overlay window concerns. Allows back button and configuration change
+ * events to be listened for via interfaces.
+ */
+public class NewWindowRoot extends FrameLayout {
+
+ /** Callback for when the back button is pressed while this window is in focus */
+ public interface OnBackPressedListener {
+ boolean onBackPressed();
+ }
+
+ /** Callback for when the Configuration changes for this window */
+ public interface OnConfigurationChangedListener {
+ void onConfigurationChanged(Configuration newConfiguration);
+ }
+
+ private OnBackPressedListener backPressedListener;
+ private OnConfigurationChangedListener configurationChangedListener;
+
+ public NewWindowRoot(@NonNull Context context) {
+ super(context);
+ }
+
+ public void setOnBackPressedListener(OnBackPressedListener listener) {
+ backPressedListener = listener;
+ }
+
+ public void setOnConfigurationChangedListener(OnConfigurationChangedListener listener) {
+ configurationChangedListener = listener;
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_BACK && backPressedListener != null) {
+ if (event.getAction() == KeyEvent.ACTION_UP) {
+ return backPressedListener.onBackPressed();
+ }
+ return true;
+ }
+ return super.dispatchKeyEvent(event);
+ }
+
+ @Override
+ public void dispatchConfigurationChanged(Configuration newConfig) {
+ super.dispatchConfigurationChanged(newConfig);
+ if (configurationChangedListener != null) {
+ configurationChangedListener.onConfigurationChanged(newConfig);
+ }
+ }
+}
diff --git a/java/com/android/newbubble/res/drawable/bubble_background_with_radius.xml b/java/com/android/newbubble/res/drawable/bubble_background_with_radius.xml
new file mode 100644
index 000000000..4fd871c47
--- /dev/null
+++ b/java/com/android/newbubble/res/drawable/bubble_background_with_radius.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2017 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+ <corners
+ android:bottomRightRadius="@dimen/bubble_radius"
+ android:topRightRadius="@dimen/bubble_radius"
+ android:bottomLeftRadius="@dimen/bubble_radius"
+ android:topLeftRadius="@dimen/bubble_radius"/>
+ <solid android:color="@android:color/white"/>
+</shape>
diff --git a/java/com/android/newbubble/res/drawable/bubble_ripple_circle.xml b/java/com/android/newbubble/res/drawable/bubble_ripple_circle.xml
new file mode 100644
index 000000000..8d5cf0bb5
--- /dev/null
+++ b/java/com/android/newbubble/res/drawable/bubble_ripple_circle.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2017 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+
+<ripple xmlns:android="http://schemas.android.com/apk/res/android"
+ android:color="?android:colorControlHighlight">
+ <item>
+ <shape>
+ <corners android:radius="@dimen/bubble_size"/>
+ <solid android:color="@android:color/white"/>
+ </shape>
+ </item>
+</ripple>
diff --git a/java/com/android/newbubble/res/layout/new_bubble_base.xml b/java/com/android/newbubble/res/layout/new_bubble_base.xml
new file mode 100644
index 000000000..ef35d7426
--- /dev/null
+++ b/java/com/android/newbubble/res/layout/new_bubble_base.xml
@@ -0,0 +1,137 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2017 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ tools:theme="@style/Theme.AppCompat">
+ <RelativeLayout
+ android:id="@+id/bubble_primary_container"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_centerHorizontal="true"
+ android:animateLayoutChanges="true"
+ android:clipChildren="false"
+ android:clipToPadding="false"
+ android:elevation="12dp">
+ <ViewAnimator
+ android:id="@+id/bubble_button_primary"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:background="@drawable/bubble_ripple_circle"
+ android:measureAllChildren="false"
+ tools:backgroundTint="#FF0000AA">
+ <ImageView
+ android:id="@+id/bubble_icon_primary"
+ android:layout_width="@dimen/bubble_size"
+ android:layout_height="@dimen/bubble_size"
+ android:padding="@dimen/bubble_icon_padding"
+ android:tint="@android:color/white"
+ android:tintMode="src_in"
+ tools:src="@android:drawable/ic_btn_speak_now"/>
+ <TextView
+ android:id="@+id/bubble_text"
+ android:layout_width="wrap_content"
+ android:layout_height="@dimen/bubble_size"
+ android:paddingStart="@dimen/bubble_icon_padding"
+ android:paddingEnd="@dimen/bubble_icon_padding"
+ android:gravity="center"
+ android:minWidth="@dimen/bubble_size"
+ android:textAppearance="@style/TextAppearance.AppCompat"
+ tools:text="Call ended"/>
+ </ViewAnimator>
+ </RelativeLayout>
+ <RelativeLayout
+ android:id="@+id/bubble_expanded_layout"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_below="@id/bubble_primary_container"
+ android:paddingTop="@dimen/bubble_shadow_padding_size_vertical"
+ android:paddingBottom="@dimen/bubble_shadow_padding_size_vertical"
+ android:paddingStart="@dimen/bubble_shadow_padding_size_horizontal"
+ android:paddingEnd="@dimen/bubble_shadow_padding_size_horizontal"
+ android:clipToPadding="false"
+ android:visibility="gone"
+ tools:visibility="visible">
+ <RelativeLayout
+ android:id="@+id/bubble_expanded_layout_part_one"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:background="@drawable/bubble_background_with_radius"
+ android:elevation="@dimen/bubble_elevation"
+ android:layoutDirection="inherit">
+ <com.android.newbubble.NewCheckableButton
+ android:id="@+id/bubble_button_full_screen"
+ android:layout_width="@dimen/bubble_expanded_width"
+ android:layout_height="@dimen/bubble_size"
+ android:padding="@dimen/bubble_icon_padding"
+ android:tint="@color/bubble_button_text_color_blue"
+ android:tintMode="src_in"
+ android:text="Full screen"
+ android:textColor="@color/bubble_button_text_color_black"
+ android:background="@android:color/transparent"
+ android:drawablePadding="@dimen/bubble_icon_padding"/>
+ <com.android.newbubble.NewCheckableButton
+ android:id="@+id/bubble_button_mute"
+ android:layout_width="@dimen/bubble_expanded_width"
+ android:layout_height="@dimen/bubble_size"
+ android:layout_below="@id/bubble_button_full_screen"
+ android:padding="@dimen/bubble_icon_padding"
+ android:tint="@color/bubble_button_text_color_blue"
+ android:tintMode="src_in"
+ android:text="Mute"
+ android:textColor="@color/bubble_button_text_color_black"
+ android:background="@android:color/transparent"
+ android:drawablePadding="@dimen/bubble_icon_padding"/>
+ <com.android.newbubble.NewCheckableButton
+ android:id="@+id/bubble_button_audio_route"
+ android:layout_width="@dimen/bubble_expanded_width"
+ android:layout_height="@dimen/bubble_size"
+ android:layout_below="@id/bubble_button_mute"
+ android:padding="@dimen/bubble_icon_padding"
+ android:tint="@color/bubble_button_text_color_blue"
+ android:tintMode="src_in"
+ android:text="Speakerphone"
+ android:textColor="@color/bubble_button_text_color_black"
+ android:background="@android:color/transparent"
+ android:drawablePadding="@dimen/bubble_icon_padding"/>
+ </RelativeLayout>
+ <RelativeLayout
+ android:id="@+id/bubble_expanded_layout_part_two"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/bubble_expanded_separator_height"
+ android:layout_below="@id/bubble_expanded_layout_part_one"
+ android:background="@drawable/bubble_ripple_circle"
+ android:backgroundTint="@color/bubble_end_call_button_background"
+ android:elevation="@dimen/bubble_elevation"
+ android:layoutDirection="inherit">
+ <com.android.newbubble.NewCheckableButton
+ android:id="@+id/bubble_button_end_call"
+ android:layout_width="@dimen/bubble_expanded_width"
+ android:layout_height="@dimen/bubble_size"
+ android:padding="@dimen/bubble_icon_padding"
+ android:tint="@color/bubble_button_text_color_white"
+ android:tintMode="src_in"
+ android:text="End Call"
+ android:textColor="@color/bubble_button_text_color_white"
+ android:background="@android:color/transparent"
+ android:drawablePadding="@dimen/bubble_icon_padding"/>
+ </RelativeLayout>
+ </RelativeLayout>
+</RelativeLayout>
diff --git a/java/com/android/newbubble/res/values/colors.xml b/java/com/android/newbubble/res/values/colors.xml
new file mode 100644
index 000000000..556d8bd95
--- /dev/null
+++ b/java/com/android/newbubble/res/values/colors.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2017 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+
+<resources>
+ <color name="bubble_primary_background_darken">#33000000</color>
+
+ <color name="bubble_button_text_color_black">@color/dialer_primary_text_color</color>
+ <color name="bubble_button_text_color_white">@color/dialer_primary_text_color_white</color>
+ <color name="bubble_button_text_color_blue">@color/dialer_theme_color</color>
+ <color name="bubble_end_call_button_background">@color/dialer_end_call_button_color</color>
+</resources>
diff --git a/java/com/android/newbubble/res/values/values.xml b/java/com/android/newbubble/res/values/values.xml
new file mode 100644
index 000000000..4bb90aff0
--- /dev/null
+++ b/java/com/android/newbubble/res/values/values.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2017 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+
+<resources>
+ <dimen name="bubble_size">56dp</dimen>
+ <dimen name="bubble_icon_padding">16dp</dimen>
+ <dimen name="bubble_move_elevation_change">4dp</dimen>
+
+ <dimen name="bubble_safe_margin_horizontal">-4dp</dimen>
+ <dimen name="bubble_safe_margin_vertical">64dp</dimen>
+ <dimen name="bubble_shadow_padding_size_vertical">16dp</dimen>
+ <dimen name="bubble_shadow_padding_size_horizontal">12dp</dimen>
+
+ <dimen name="bubble_elevation">10dp</dimen>
+ <dimen name="bubble_expanded_width">160dp</dimen>
+ <dimen name="bubble_radius">20dp</dimen>
+ <dimen name="bubble_expanded_separator_height">4dp</dimen>
+</resources>