Switch Dialer to use new third_party bubble library

This CL switches Dialer to use the new common bubble libary. It also moves the
integration tests into the bubble libary.

Bug: 64797730
Test: BubbleIntegrationTest
PiperOrigin-RevId: 167439680
Change-Id: Ie2e9367cb6a6561efb8abd425b6a12f8c1e78138
diff --git a/java/com/android/bubble/AndroidManifest.xml b/java/com/android/bubble/AndroidManifest.xml
new file mode 100644
index 0000000..80efe5c
--- /dev/null
+++ b/java/com/android/bubble/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.bubble">
+
+  <uses-sdk android:minSdkVersion="21"/>
+  <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+</manifest>
diff --git a/java/com/android/bubble/Bubble.java b/java/com/android/bubble/Bubble.java
new file mode 100644
index 0000000..d83e284
--- /dev/null
+++ b/java/com/android/bubble/Bubble.java
@@ -0,0 +1,900 @@
+/*
+ * 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.bubble;
+
+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.content.res.ColorStateList;
+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.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+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.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.ViewGroup.MarginLayoutParams;
+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.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.ViewAnimator;
+import com.android.bubble.BubbleInfo.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 BubbleInfo}. 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 Bubble {
+  // 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 BubbleInfo currentInfo;
+
+  @Visibility private int visibility;
+  private boolean expanded;
+  private boolean textShowing;
+  private boolean hideAfterText;
+  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;
+
+  @Retention(RetentionPolicy.SOURCE)
+  @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
+  private @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
+        : VERSION.SDK_INT < VERSION_CODES.M || 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 {
+    Bubble createBubble(@NonNull Context context, @NonNull Handler handler);
+  }
+
+  private static BubbleFactory bubbleFactory = Bubble::new;
+
+  public static Bubble createBubble(@NonNull Context context, @NonNull BubbleInfo info) {
+    Bubble bubble = bubbleFactory.createBubble(context, new Handler());
+    bubble.setBubbleInfo(info);
+    return bubble;
+  }
+
+  @VisibleForTesting
+  public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) {
+    Bubble.bubbleFactory = bubbleFactory;
+  }
+
+  @VisibleForTesting
+  public static void resetBubbleFactory() {
+    Bubble.bubbleFactory = Bubble::new;
+  }
+
+  @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+  Bubble(@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() {
+    if (expanded || textShowing || currentInfo.getActions().isEmpty()) {
+      try {
+        currentInfo.getPrimaryIntent().send();
+      } catch (CanceledException e) {
+        throw new RuntimeException(e);
+      }
+      return;
+    }
+
+    if (bubbleExpansionStateListener != null) {
+      bubbleExpansionStateListener.onBubbleExpansionStateChanged(ExpansionState.START_EXPANDING);
+    }
+    doResize(
+        () -> {
+          onLeftRightSwitch(isDrawingFromRight());
+          viewHolder.setDrawerVisibility(View.VISIBLE);
+        });
+    View expandedView = viewHolder.getExpandedView();
+    expandedView
+        .getViewTreeObserver()
+        .addOnPreDrawListener(
+            new OnPreDrawListener() {
+              @Override
+              public boolean onPreDraw() {
+                expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
+                expandedView.setTranslationX(
+                    isDrawingFromRight() ? expandedView.getWidth() : -expandedView.getWidth());
+                expandedView
+                    .animate()
+                    .setInterpolator(new LinearOutSlowInInterpolator())
+                    .translationX(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_x);
+      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)
+        .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 BubbleInfo 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 = BubbleInfo.from(currentInfo).setActions(actions).build();
+    updateButtonStates();
+  }
+
+  /** Returns the currently displayed BubbleInfo */
+  public BubbleInfo 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);
+      doShowText(text);
+    } else {
+      // Need to transition from old bounds to new bounds manually
+      ChangeOnScreenBounds transition = new ChangeOnScreenBounds();
+      // Prepare and capture start values
+      TransitionValues startValues = new TransitionValues();
+      startValues.view = viewHolder.getPrimaryButton();
+      transition.addTarget(startValues.view);
+      transition.captureStartValues(startValues);
+
+      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(
+                            ChangeOnScreenBounds.PROPNAME_WIDTH,
+                            viewHolder.getPrimaryText().getWidth());
+                        endValues.values.put(
+                            ChangeOnScreenBounds.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);
+    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() {
+    expand();
+  }
+
+  void onLeftRightSwitch(boolean onRight) {
+    if (viewHolder.isMoving()) {
+      if (viewHolder.getExpandedView().getVisibility() == View.GONE) {
+        // If the drawer is not part of the layout we don't need to do anything. Layout flips will
+        // happen if necessary when opening the drawer.
+        return;
+      }
+    }
+
+    viewHolder
+        .getRoot()
+        .setLayoutDirection(onRight ? View.LAYOUT_DIRECTION_RTL : View.LAYOUT_DIRECTION_LTR);
+    View primaryContainer = viewHolder.getRoot().findViewById(R.id.bubble_primary_container);
+    ViewGroup.LayoutParams layoutParams = primaryContainer.getLayoutParams();
+    ((FrameLayout.LayoutParams) layoutParams).gravity = onRight ? Gravity.RIGHT : Gravity.LEFT;
+    primaryContainer.setLayoutParams(layoutParams);
+
+    viewHolder
+        .getExpandedView()
+        .setBackgroundResource(
+            onRight
+                ? R.drawable.bubble_background_pill_rtl
+                : R.drawable.bubble_background_pill_ltr);
+  }
+
+  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);
+      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);
+
+    setBackgroundDrawable(viewHolder.getFirstButton(), primaryTint);
+    setBackgroundDrawable(viewHolder.getSecondButton(), primaryTint);
+    setBackgroundDrawable(viewHolder.getThirdButton(), primaryTint);
+
+    int numButtons = currentInfo.getActions().size();
+    viewHolder.getThirdButton().setVisibility(numButtons < 3 ? View.GONE : View.VISIBLE);
+    viewHolder.getSecondButton().setVisibility(numButtons < 2 ? View.GONE : View.VISIBLE);
+
+    viewHolder.getPrimaryIcon().setImageIcon(currentInfo.getPrimaryIcon());
+    updatePrimaryIconAnimation();
+
+    viewHolder
+        .getExpandedView()
+        .setBackgroundTintList(ColorStateList.valueOf(currentInfo.getPrimaryColor()));
+
+    updateButtonStates();
+  }
+
+  private void updatePrimaryIconAnimation() {
+    Drawable drawable = viewHolder.getPrimaryIcon().getDrawable();
+    if (drawable instanceof Animatable) {
+      if (isVisible()) {
+        ((Animatable) drawable).start();
+      } else {
+        ((Animatable) drawable).stop();
+      }
+    }
+  }
+
+  private void setBackgroundDrawable(CheckableImageButton view, @ColorInt int color) {
+    RippleDrawable itemRipple =
+        (RippleDrawable)
+            context
+                .getResources()
+                .getDrawable(R.drawable.bubble_ripple_checkable_circle, context.getTheme());
+    itemRipple.getDrawable(0).setTint(color);
+    view.setBackground(itemRipple);
+  }
+
+  private void updateButtonStates() {
+    int numButtons = currentInfo.getActions().size();
+
+    if (numButtons >= 1) {
+      configureButton(currentInfo.getActions().get(0), viewHolder.getFirstButton());
+      if (numButtons >= 2) {
+        configureButton(currentInfo.getActions().get(1), viewHolder.getSecondButton());
+        if (numButtons >= 3) {
+          configureButton(currentInfo.getActions().get(2), viewHolder.getThirdButton());
+        }
+      }
+    }
+  }
+
+  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, CheckableImageButton button) {
+    action
+        .getIcon()
+        .loadDrawableAsync(
+            context,
+            d -> {
+              button.setImageIcon(action.getIcon());
+              button.setContentDescription(action.getName());
+              button.setChecked(action.isChecked());
+              button.setEnabled(action.isEnabled());
+            },
+            handler);
+    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) {
+    oldViewHolder.getShadowProvider().setVisibility(View.GONE);
+    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;
+              }
+            });
+  }
+
+  private void startCollapse(@CollapseEnd int endAction) {
+    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);
+    }
+    collapseAnimation =
+        expandedView
+            .animate()
+            .translationX(isDrawingFromRight() ? expandedView.getWidth() : -expandedView.getWidth())
+            .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 MoveHandler moveHandler;
+    private final WindowRoot root;
+    private final ViewAnimator primaryButton;
+    private final ImageView primaryIcon;
+    private final TextView primaryText;
+
+    private final CheckableImageButton firstButton;
+    private final CheckableImageButton secondButton;
+    private final CheckableImageButton thirdButton;
+    private final View expandedView;
+    private final View shadowProvider;
+
+    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 WindowRoot(context);
+      LayoutInflater inflater = LayoutInflater.from(root.getContext());
+      View contentView = inflater.inflate(R.layout.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);
+      shadowProvider = contentView.findViewById(R.id.bubble_drawer_shadow_provider);
+
+      firstButton = contentView.findViewById(R.id.bubble_icon_first);
+      secondButton = contentView.findViewById(R.id.bubble_icon_second);
+      thirdButton = contentView.findViewById(R.id.bubble_icon_third);
+
+      root.setOnBackPressedListener(
+          () -> {
+            if (visibility == Visibility.SHOWING && expanded) {
+              startCollapse(CollapseEnd.NOTHING);
+              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 MoveHandler(primaryButton, Bubble.this);
+            moveHandler.snapToBounds();
+          });
+      root.setOnTouchListener(
+          (v, event) -> {
+            if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
+              startCollapse(CollapseEnd.NOTHING);
+              return true;
+            }
+            return false;
+          });
+      expandedView
+          .getViewTreeObserver()
+          .addOnDrawListener(
+              () -> {
+                int translationX = (int) expandedView.getTranslationX();
+                int parentOffset =
+                    ((MarginLayoutParams) ((ViewGroup) expandedView.getParent()).getLayoutParams())
+                        .leftMargin;
+                if (isDrawingFromRight()) {
+                  int maxLeft =
+                      shadowProvider.getRight()
+                          - context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
+                  shadowProvider.setLeft(
+                      Math.min(maxLeft, expandedView.getLeft() + translationX + parentOffset));
+                } else {
+                  int minRight =
+                      shadowProvider.getLeft()
+                          + context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
+                  shadowProvider.setRight(
+                      Math.max(minRight, expandedView.getRight() + translationX + parentOffset));
+                }
+              });
+      moveHandler = new MoveHandler(primaryButton, Bubble.this);
+    }
+
+    private void setChildClickable(boolean clickable) {
+      firstButton.setClickable(clickable);
+      secondButton.setClickable(clickable);
+      thirdButton.setClickable(clickable);
+
+      primaryButton.setOnTouchListener(clickable ? moveHandler : null);
+    }
+
+    public ViewGroup getRoot() {
+      return root;
+    }
+
+    public ViewAnimator getPrimaryButton() {
+      return primaryButton;
+    }
+
+    public ImageView getPrimaryIcon() {
+      return primaryIcon;
+    }
+
+    public TextView getPrimaryText() {
+      return primaryText;
+    }
+
+    public CheckableImageButton getFirstButton() {
+      return firstButton;
+    }
+
+    public CheckableImageButton getSecondButton() {
+      return secondButton;
+    }
+
+    public CheckableImageButton getThirdButton() {
+      return thirdButton;
+    }
+
+    public View getExpandedView() {
+      return expandedView;
+    }
+
+    public View getShadowProvider() {
+      return shadowProvider;
+    }
+
+    public void setDrawerVisibility(int visibility) {
+      expandedView.setVisibility(visibility);
+      shadowProvider.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);
+  }
+}
diff --git a/java/com/android/bubble/BubbleInfo.java b/java/com/android/bubble/BubbleInfo.java
new file mode 100644
index 0000000..b4f81b3
--- /dev/null
+++ b/java/com/android/bubble/BubbleInfo.java
@@ -0,0 +1,123 @@
+/*
+ * 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.bubble;
+
+import android.app.PendingIntent;
+import android.graphics.drawable.Icon;
+import android.support.annotation.ColorInt;
+import android.support.annotation.NonNull;
+import android.support.annotation.Px;
+import com.google.auto.value.AutoValue;
+import java.util.Collections;
+import java.util.List;
+
+/** Info for displaying a {@link Bubble} */
+@AutoValue
+public abstract class BubbleInfo {
+  @ColorInt
+  public abstract int getPrimaryColor();
+
+  @NonNull
+  public abstract Icon getPrimaryIcon();
+
+  @NonNull
+  public abstract PendingIntent getPrimaryIntent();
+
+  @Px
+  public abstract int getStartingYPosition();
+
+  @NonNull
+  public abstract List<Action> getActions();
+
+  public static Builder builder() {
+    return new AutoValue_BubbleInfo.Builder().setActions(Collections.emptyList());
+  }
+
+  public static Builder from(@NonNull BubbleInfo bubbleInfo) {
+    return builder()
+        .setPrimaryIntent(bubbleInfo.getPrimaryIntent())
+        .setPrimaryColor(bubbleInfo.getPrimaryColor())
+        .setPrimaryIcon(bubbleInfo.getPrimaryIcon())
+        .setStartingYPosition(bubbleInfo.getStartingYPosition())
+        .setActions(bubbleInfo.getActions());
+  }
+
+  /** Builder for {@link BubbleInfo} */
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder setPrimaryColor(@ColorInt int primaryColor);
+
+    public abstract Builder setPrimaryIcon(@NonNull Icon primaryIcon);
+
+    public abstract Builder setPrimaryIntent(@NonNull PendingIntent primaryIntent);
+
+    public abstract Builder setStartingYPosition(@Px int startingYPosition);
+
+    public abstract Builder setActions(List<Action> actions);
+
+    public abstract BubbleInfo build();
+  }
+
+  /** Represents actions to be shown in the bubble when expanded */
+  @AutoValue
+  public abstract static class Action {
+
+    @NonNull
+    public abstract Icon getIcon();
+
+    @NonNull
+    public abstract CharSequence getName();
+
+    @NonNull
+    public abstract PendingIntent getIntent();
+
+    public abstract boolean isEnabled();
+
+    public abstract boolean isChecked();
+
+    public static Builder builder() {
+      return new AutoValue_BubbleInfo_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())
+          .setIcon(action.getIcon());
+    }
+
+    /** Builder for {@link Action} */
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      public abstract Builder setIcon(@NonNull Icon icon);
+
+      public abstract Builder setName(@NonNull 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/bubble/ChangeOnScreenBounds.java b/java/com/android/bubble/ChangeOnScreenBounds.java
new file mode 100644
index 0000000..0a7adf6
--- /dev/null
+++ b/java/com/android/bubble/ChangeOnScreenBounds.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.bubble;
+
+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 ChangeOnScreenBounds 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/bubble/CheckableImageButton.java b/java/com/android/bubble/CheckableImageButton.java
new file mode 100644
index 0000000..dd9acce
--- /dev/null
+++ b/java/com/android/bubble/CheckableImageButton.java
@@ -0,0 +1,101 @@
+/*
+ * 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.bubble;
+
+import android.content.Context;
+import android.support.v4.view.AccessibilityDelegateCompat;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityEventCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.support.v7.widget.AppCompatImageButton;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.Checkable;
+
+/**
+ * An {@link android.widget.ImageButton ImageButton} that implements {@link Checkable} and
+ * propagates the checkable state
+ */
+public class CheckableImageButton extends AppCompatImageButton implements Checkable {
+
+  // Copied without modification from AppCompat library
+
+  private static final int[] DRAWABLE_STATE_CHECKED = new int[] {android.R.attr.state_checked};
+
+  private boolean mChecked;
+
+  public CheckableImageButton(Context context) {
+    this(context, null);
+  }
+
+  public CheckableImageButton(Context context, AttributeSet attrs) {
+    this(context, attrs, android.R.attr.imageButtonStyle);
+  }
+
+  public CheckableImageButton(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;
+      refreshDrawableState();
+      sendAccessibilityEvent(AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED);
+    }
+  }
+
+  @Override
+  public boolean isChecked() {
+    return mChecked;
+  }
+
+  @Override
+  public void toggle() {
+    setChecked(!mChecked);
+  }
+
+  @Override
+  public int[] onCreateDrawableState(int extraSpace) {
+    if (mChecked) {
+      return mergeDrawableStates(
+          super.onCreateDrawableState(extraSpace + DRAWABLE_STATE_CHECKED.length),
+          DRAWABLE_STATE_CHECKED);
+    } else {
+      return super.onCreateDrawableState(extraSpace);
+    }
+  }
+}
diff --git a/java/com/android/bubble/MoveHandler.java b/java/com/android/bubble/MoveHandler.java
new file mode 100644
index 0000000..06efbd4
--- /dev/null
+++ b/java/com/android/bubble/MoveHandler.java
@@ -0,0 +1,280 @@
+/*
+ * 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.bubble;
+
+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 MoveHandler 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 Bubble bubble;
+  private final int minX;
+  private final int minY;
+  private final int maxX;
+  private final int maxY;
+  private final int bubbleSize;
+  private final int shadowPaddingSize;
+  private final float touchSlopSquared;
+
+  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;
+          realX = realX + shadowPaddingSize;
+          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) {
+          boolean wasOnRight = (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
+          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 + shadowPaddingSize;
+          windowParams.x =
+              (int) (onRight ? (displayWidth - value - centeringOffset) : value - centeringOffset);
+          windowParams.gravity = Gravity.TOP | (onRight ? Gravity.RIGHT : Gravity.LEFT);
+          if (wasOnRight != onRight) {
+            bubble.onLeftRightSwitch(onRight);
+          }
+          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 + shadowPaddingSize, minY, maxY);
+        }
+
+        @Override
+        public void setValue(LayoutParams object, float value) {
+          object.y = (int) value - bubbleSize - shadowPaddingSize;
+          if (bubble.isVisible()) {
+            windowManager.updateViewLayout(bubble.getRootView(), object);
+          }
+        }
+      };
+
+  public MoveHandler(@NonNull View targetView, @NonNull Bubble bubble) {
+    this.bubble = bubble;
+    context = targetView.getContext();
+    windowManager = context.getSystemService(WindowManager.class);
+
+    bubbleSize = context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
+    shadowPaddingSize =
+        context.getResources().getDimensionPixelOffset(R.dimen.bubble_shadow_padding_size);
+    minX =
+        context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_x)
+            + bubbleSize / 2;
+    minY =
+        context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_y)
+            + 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 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();
+          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/bubble/WindowRoot.java b/java/com/android/bubble/WindowRoot.java
new file mode 100644
index 0000000..b9024c4
--- /dev/null
+++ b/java/com/android/bubble/WindowRoot.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.bubble;
+
+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 WindowRoot 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 WindowRoot(@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/bubble/res/color/bubble_checkable_mask.xml b/java/com/android/bubble/res/color/bubble_checkable_mask.xml
new file mode 100644
index 0000000..f9416ab
--- /dev/null
+++ b/java/com/android/bubble/res/color/bubble_checkable_mask.xml
@@ -0,0 +1,21 @@
+<?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
+  -->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:color="@android:color/white" android:state_checked="true"/>
+  <item android:color="@android:color/transparent"/>
+</selector>
diff --git a/java/com/android/bubble/res/color/bubble_icon_tint_states.xml b/java/com/android/bubble/res/color/bubble_icon_tint_states.xml
new file mode 100644
index 0000000..33ca1fd
--- /dev/null
+++ b/java/com/android/bubble/res/color/bubble_icon_tint_states.xml
@@ -0,0 +1,21 @@
+<?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
+  -->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:color="#80FFFFFF" android:state_enabled="false"/>
+  <item android:color="@android:color/white"/>
+</selector>
diff --git a/java/com/android/bubble/res/drawable/bubble_background_pill_ltr.xml b/java/com/android/bubble/res/drawable/bubble_background_pill_ltr.xml
new file mode 100644
index 0000000..77c813a
--- /dev/null
+++ b/java/com/android/bubble/res/drawable/bubble_background_pill_ltr.xml
@@ -0,0 +1,23 @@
+<?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_size"
+      android:topRightRadius="@dimen/bubble_size"/>
+  <solid android:color="@android:color/white"/>
+</shape>
diff --git a/java/com/android/bubble/res/drawable/bubble_background_pill_rtl.xml b/java/com/android/bubble/res/drawable/bubble_background_pill_rtl.xml
new file mode 100644
index 0000000..9e25421
--- /dev/null
+++ b/java/com/android/bubble/res/drawable/bubble_background_pill_rtl.xml
@@ -0,0 +1,23 @@
+<?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:bottomLeftRadius="@dimen/bubble_size"
+      android:topLeftRadius="@dimen/bubble_size"/>
+  <solid android:color="@android:color/white"/>
+</shape>
diff --git a/java/com/android/bubble/res/drawable/bubble_ripple_checkable_circle.xml b/java/com/android/bubble/res/drawable/bubble_ripple_checkable_circle.xml
new file mode 100644
index 0000000..85e0b24
--- /dev/null
+++ b/java/com/android/bubble/res/drawable/bubble_ripple_checkable_circle.xml
@@ -0,0 +1,30 @@
+<?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 android:shape="oval">
+      <solid android:color="@color/bubble_checkable_mask"/>
+    </shape>
+  </item>
+  <item android:id="@android:id/mask">
+    <shape android:shape="oval">
+      <solid android:color="@android:color/white"/>
+    </shape>
+  </item>
+</ripple>
diff --git a/java/com/android/bubble/res/drawable/bubble_ripple_circle.xml b/java/com/android/bubble/res/drawable/bubble_ripple_circle.xml
new file mode 100644
index 0000000..8d5cf0b
--- /dev/null
+++ b/java/com/android/bubble/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/bubble/res/layout/bubble_base.xml b/java/com/android/bubble/res/layout/bubble_base.xml
new file mode 100644
index 0000000..3b5735c
--- /dev/null
+++ b/java/com/android/bubble/res/layout/bubble_base.xml
@@ -0,0 +1,127 @@
+<?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
+  -->
+
+<FrameLayout 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"
+    android:clipToPadding="false"
+    tools:theme="@style/Theme.AppCompat">
+  <View
+      android:id="@+id/bubble_drawer_shadow_provider"
+      android:layout_width="@dimen/bubble_size"
+      android:layout_height="@dimen/bubble_size"
+      android:layout_marginTop="@dimen/bubble_shadow_padding_size"
+      android:layout_marginBottom="@dimen/bubble_shadow_padding_size"
+      android:layout_marginStart="@dimen/bubble_shadow_padding_size"
+      android:background="@drawable/bubble_ripple_circle"
+      android:backgroundTint="@android:color/transparent"
+      android:elevation="10dp"
+      android:visibility="invisible"
+      />
+  <FrameLayout
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:layout_marginStart="48dp"
+      android:elevation="10dp"
+      android:paddingTop="@dimen/bubble_shadow_padding_size"
+      android:paddingBottom="@dimen/bubble_shadow_padding_size"
+      android:paddingEnd="@dimen/bubble_shadow_padding_size">
+
+    <LinearLayout
+        android:id="@+id/bubble_expanded_layout"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:paddingStart="32dp"
+        android:paddingEnd="8dp"
+        android:background="@drawable/bubble_background_pill_ltr"
+        android:layoutDirection="inherit"
+        android:orientation="horizontal"
+        android:visibility="gone"
+        tools:backgroundTint="#FF0000FF"
+        tools:visibility="visible">
+      <com.android.bubble.CheckableImageButton
+          android:id="@+id/bubble_icon_first"
+          android:layout_width="@dimen/bubble_size"
+          android:layout_height="@dimen/bubble_size"
+          android:layout_marginStart="4dp"
+          android:padding="@dimen/bubble_icon_padding"
+          android:tint="@color/bubble_icon_tint_states"
+          android:tintMode="src_in"
+          tools:background="@drawable/bubble_ripple_checkable_circle"
+          tools:src="@android:drawable/ic_lock_idle_lock"/>
+      <com.android.bubble.CheckableImageButton
+          android:id="@+id/bubble_icon_second"
+          android:layout_width="@dimen/bubble_size"
+          android:layout_height="@dimen/bubble_size"
+          android:layout_marginStart="4dp"
+          android:padding="@dimen/bubble_icon_padding"
+          android:tint="@color/bubble_icon_tint_states"
+          android:tintMode="src_in"
+          tools:background="@drawable/bubble_ripple_checkable_circle"
+          tools:src="@android:drawable/ic_input_add"/>
+      <com.android.bubble.CheckableImageButton
+          android:id="@+id/bubble_icon_third"
+          android:layout_width="@dimen/bubble_size"
+          android:layout_height="@dimen/bubble_size"
+          android:layout_marginStart="4dp"
+          android:padding="@dimen/bubble_icon_padding"
+          android:tint="@color/bubble_icon_tint_states"
+          android:tintMode="src_in"
+          tools:background="@drawable/bubble_ripple_checkable_circle"
+          tools:src="@android:drawable/ic_menu_call"/>
+    </LinearLayout>
+  </FrameLayout>
+  <FrameLayout
+      android:id="@+id/bubble_primary_container"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:layout_gravity="start"
+      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:layout_margin="@dimen/bubble_shadow_padding_size"
+        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>
+  </FrameLayout>
+
+</FrameLayout>
diff --git a/java/com/android/bubble/res/values/colors.xml b/java/com/android/bubble/res/values/colors.xml
new file mode 100644
index 0000000..97545fa
--- /dev/null
+++ b/java/com/android/bubble/res/values/colors.xml
@@ -0,0 +1,20 @@
+<?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>
+</resources>
diff --git a/java/com/android/bubble/res/values/values.xml b/java/com/android/bubble/res/values/values.xml
new file mode 100644
index 0000000..f581617
--- /dev/null
+++ b/java/com/android/bubble/res/values/values.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>
+  <dimen name="bubble_safe_margin_x">16dp</dimen>
+  <dimen name="bubble_safe_margin_y">64dp</dimen>
+  <dimen name="bubble_size">56dp</dimen>
+  <dimen name="bubble_icon_padding">16dp</dimen>
+  <dimen name="bubble_move_elevation_change">4dp</dimen>
+  <dimen name="bubble_shadow_padding_size">16dp</dimen>
+</resources>